diff --git a/src/app/(main)/bagPrint/page.tsx b/src/app/(main)/bagPrint/page.tsx
new file mode 100644
index 0000000..e935cee
--- /dev/null
+++ b/src/app/(main)/bagPrint/page.tsx
@@ -0,0 +1,23 @@
+import BagPrintSearch from "@/components/BagPrint/BagPrintSearch";
+import { Stack, Typography } from "@mui/material";
+import { Metadata } from "next";
+import React from "react";
+
+export const metadata: Metadata = {
+ title: "打袋機",
+};
+
+const BagPrintPage: React.FC = () => {
+ return (
+ <>
+
+
+ 打袋機
+
+
+
+ >
+ );
+};
+
+export default BagPrintPage;
diff --git a/src/app/api/bagPrint/actions.ts b/src/app/api/bagPrint/actions.ts
new file mode 100644
index 0000000..b6bf3e1
--- /dev/null
+++ b/src/app/api/bagPrint/actions.ts
@@ -0,0 +1,82 @@
+"use client";
+
+import { NEXT_PUBLIC_API_URL } from "@/config/api";
+import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
+
+export interface JobOrderListItem {
+ id: number;
+ code: string | null;
+ planStart: string | null;
+ itemCode: string | null;
+ itemName: string | null;
+ reqQty: number | null;
+ stockInLineId: number | null;
+ itemId: number | null;
+ lotNo: string | null;
+}
+
+export interface PrinterStatusRequest {
+ printerType: "dataflex" | "laser";
+ printerIp?: string;
+ printerPort?: number;
+}
+
+export interface PrinterStatusResponse {
+ connected: boolean;
+ message: string;
+}
+
+export interface OnPackQrDownloadRequest {
+ jobOrders: {
+ jobOrderId: number;
+ itemCode: string;
+ }[];
+}
+
+/**
+ * Fetch job orders by plan date from GET /py/job-orders.
+ * Client-side only; uses auth token from localStorage.
+ */
+export async function fetchJobOrders(planStart: string): Promise {
+ const url = `${NEXT_PUBLIC_API_URL}/py/job-orders?planStart=${encodeURIComponent(planStart)}`;
+ const res = await clientAuthFetch(url, { method: "GET" });
+ if (!res.ok) {
+ throw new Error(`Failed to fetch job orders: ${res.status}`);
+ }
+ return res.json();
+}
+
+export async function checkPrinterStatus(
+ request: PrinterStatusRequest,
+): Promise {
+ const url = `${NEXT_PUBLIC_API_URL}/plastic/check-printer`;
+ const res = await clientAuthFetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(request),
+ });
+
+ const data = (await res.json()) as PrinterStatusResponse;
+ if (!res.ok) {
+ return data;
+ }
+
+ return data;
+}
+
+export async function downloadOnPackQrZip(
+ request: OnPackQrDownloadRequest,
+): Promise {
+ const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr`;
+ const res = await clientAuthFetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(request),
+ });
+
+ if (!res.ok) {
+ throw new Error((await res.text()) || "Download failed");
+ }
+
+ return res.blob();
+}
diff --git a/src/app/api/settings/bomWeighting/client.ts b/src/app/api/settings/bomWeighting/client.ts
index a502763..e8480b2 100644
--- a/src/app/api/settings/bomWeighting/client.ts
+++ b/src/app/api/settings/bomWeighting/client.ts
@@ -12,6 +12,13 @@ export interface UpdateBomWeightingScoreInputs {
remarks?: string;
}
+export const fetchBomWeightingScoresClient = async (): Promise => {
+ const response = await axiosInstance.get(
+ `${NEXT_PUBLIC_API_URL}/bomWeightingScores`
+ );
+ return response.data;
+};
+
export const updateBomWeightingScoreClient = async (
data: UpdateBomWeightingScoreInputs
): Promise => {
diff --git a/src/components/BagPrint/BagPrintSearch.tsx b/src/components/BagPrint/BagPrintSearch.tsx
new file mode 100644
index 0000000..3792540
--- /dev/null
+++ b/src/components/BagPrint/BagPrintSearch.tsx
@@ -0,0 +1,445 @@
+"use client";
+
+import React, { useCallback, useEffect, useState } from "react";
+import {
+ Box,
+ Button,
+ FormControl,
+ InputLabel,
+ MenuItem,
+ Select,
+ Stack,
+ Typography,
+ Paper,
+ CircularProgress,
+ SelectChangeEvent,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ TextField,
+ Snackbar,
+} from "@mui/material";
+import ChevronLeft from "@mui/icons-material/ChevronLeft";
+import ChevronRight from "@mui/icons-material/ChevronRight";
+import Settings from "@mui/icons-material/Settings";
+import Print from "@mui/icons-material/Print";
+import Download from "@mui/icons-material/Download";
+import { checkPrinterStatus, downloadOnPackQrZip, fetchJobOrders, JobOrderListItem } from "@/app/api/bagPrint/actions";
+import dayjs from "dayjs";
+
+// Light blue theme (matching Python Bag1)
+const BG_TOP = "#E8F4FC";
+const BG_LIST = "#D4E8F7";
+const BG_ROW = "#C5E1F5";
+const BG_ROW_SELECTED = "#6BB5FF";
+const BG_STATUS_ERROR = "#FFCCCB";
+const BG_STATUS_OK = "#90EE90";
+const FG_STATUS_ERROR = "#B22222";
+const FG_STATUS_OK = "#006400";
+
+const PRINTER_OPTIONS = [
+ { value: "dataflex", label: "打袋機 DataFlex" },
+ { value: "laser", label: "激光機" },
+];
+
+const REFRESH_MS = 60 * 1000;
+const PRINTER_CHECK_MS = 60 * 1000;
+const PRINTER_RETRY_MS = 30 * 1000;
+const SETTINGS_KEY = "bagPrint_settings";
+
+const DEFAULT_SETTINGS = {
+ dabag_ip: "",
+ dabag_port: "3008",
+ laser_ip: "192.168.17.10",
+ laser_port: "45678",
+};
+
+function loadSettings(): typeof DEFAULT_SETTINGS {
+ if (typeof window === "undefined") return DEFAULT_SETTINGS;
+ try {
+ const s = localStorage.getItem(SETTINGS_KEY);
+ if (s) return { ...DEFAULT_SETTINGS, ...JSON.parse(s) };
+ } catch {}
+ return DEFAULT_SETTINGS;
+}
+
+function saveSettings(s: typeof DEFAULT_SETTINGS) {
+ if (typeof window === "undefined") return;
+ try {
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(s));
+ } catch {}
+}
+
+function formatQty(val: number | null | undefined): string {
+ if (val == null) return "—";
+ try {
+ const n = Number(val);
+ if (Number.isInteger(n)) return n.toLocaleString();
+ return n.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 }).replace(/\.?0+$/, "");
+ } catch {
+ return String(val);
+ }
+}
+
+function getBatch(jo: JobOrderListItem): string {
+ return (jo.lotNo || "—").trim() || "—";
+}
+
+const BagPrintSearch: React.FC = () => {
+ const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD"));
+ const [jobOrders, setJobOrders] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [connected, setConnected] = useState(false);
+ const [printer, setPrinter] = useState("dataflex");
+ const [selectedId, setSelectedId] = useState(null);
+ const [settingsOpen, setSettingsOpen] = useState(false);
+ const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: "success" | "info" | "error" }>({ open: false, message: "" });
+ const [settings, setSettings] = useState(DEFAULT_SETTINGS);
+ const [printerConnected, setPrinterConnected] = useState(false);
+ const [printerMessage, setPrinterMessage] = useState("列印機未連接");
+ const [downloadingOnPack, setDownloadingOnPack] = useState(false);
+
+ useEffect(() => {
+ setSettings(loadSettings());
+ }, []);
+
+ const loadJobOrders = useCallback(async (fromUserChange = false) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await fetchJobOrders(planDate);
+ setJobOrders(data);
+ setConnected(true);
+ if (fromUserChange) setSelectedId(null);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "連接不到服務器");
+ setConnected(false);
+ setJobOrders([]);
+ } finally {
+ setLoading(false);
+ }
+ }, [planDate]);
+
+ useEffect(() => {
+ loadJobOrders(true);
+ }, [planDate]);
+
+ useEffect(() => {
+ if (!connected) return;
+ const id = setInterval(() => loadJobOrders(false), REFRESH_MS);
+ return () => clearInterval(id);
+ }, [connected, loadJobOrders]);
+
+ const checkCurrentPrinter = useCallback(async () => {
+ try {
+ const request =
+ printer === "dataflex"
+ ? {
+ printerType: "dataflex" as const,
+ printerIp: settings.dabag_ip,
+ printerPort: Number(settings.dabag_port || 3008),
+ }
+ : {
+ printerType: "laser" as const,
+ printerIp: settings.laser_ip,
+ printerPort: Number(settings.laser_port || 45678),
+ };
+
+ const result = await checkPrinterStatus(request);
+ setPrinterConnected(result.connected);
+ setPrinterMessage(result.message);
+ } catch (e) {
+ setPrinterConnected(false);
+ setPrinterMessage(e instanceof Error ? e.message : "列印機狀態檢查失敗");
+ }
+ }, [printer, settings]);
+
+ useEffect(() => {
+ checkCurrentPrinter();
+ }, [checkCurrentPrinter]);
+
+ useEffect(() => {
+ const intervalMs = printerConnected ? PRINTER_CHECK_MS : PRINTER_RETRY_MS;
+ const id = setInterval(() => {
+ checkCurrentPrinter();
+ }, intervalMs);
+
+ return () => clearInterval(id);
+ }, [printerConnected, checkCurrentPrinter]);
+
+ const goPrevDay = () => {
+ setPlanDate((d) => dayjs(d).subtract(1, "day").format("YYYY-MM-DD"));
+ };
+
+ const goNextDay = () => {
+ setPlanDate((d) => dayjs(d).add(1, "day").format("YYYY-MM-DD"));
+ };
+
+ const handlePrinterChange = (e: SelectChangeEvent) => {
+ setPrinter(e.target.value);
+ };
+
+ const handleRowClick = (jo: JobOrderListItem) => {
+ setSelectedId(jo.id);
+ const batch = getBatch(jo);
+ const itemCode = jo.itemCode || "—";
+ const itemName = jo.itemName || "—";
+ setSnackbar({ open: true, message: `已點選:批次 ${batch} 品號 ${itemCode} ${itemName}`, severity: "info" });
+ // TODO: Actual printing would require backend API to proxy to printer (TCP/serial)
+ // For now, show info. Backend could add /py/print-dataflex, /py/print-label, /py/print-laser
+ };
+
+ const handleDownloadOnPackQr = async () => {
+ const onPackJobOrders = jobOrders
+ .map((jobOrder) => ({
+ jobOrderId: jobOrder.id,
+ itemCode: jobOrder.itemCode?.trim() || "",
+ }))
+ .filter((jobOrder) => jobOrder.itemCode.length > 0);
+
+ if (onPackJobOrders.length === 0) {
+ setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" });
+ return;
+ }
+
+ setDownloadingOnPack(true);
+ try {
+ const blob = await downloadOnPackQrZip({
+ jobOrders: onPackJobOrders,
+ });
+
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.setAttribute("download", `onpack_qr_${planDate}.zip`);
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ window.URL.revokeObjectURL(url);
+
+ setSnackbar({ open: true, message: "OnPack QR code ZIP 已下載", severity: "success" });
+ } catch (e) {
+ setSnackbar({
+ open: true,
+ message: e instanceof Error ? e.message : "下載 OnPack QR code 失敗",
+ severity: "error",
+ });
+ } finally {
+ setDownloadingOnPack(false);
+ }
+ };
+
+ return (
+
+ {/* Top: date nav + printer + settings */}
+
+
+
+ } onClick={goPrevDay}>
+ 前一天
+
+ setPlanDate(e.target.value)}
+ size="small"
+ sx={{ width: 160 }}
+ InputLabelProps={{ shrink: true }}
+ />
+ } onClick={goNextDay}>
+ 後一天
+
+
+
+ } onClick={() => setSettingsOpen(true)}>
+ 設定
+
+
+ 列印機:
+
+
+ 列印機
+
+
+
+
+
+ {printerMessage}
+
+
+ }
+ onClick={handleDownloadOnPackQr}
+ disabled={loading || downloadingOnPack || jobOrders.length === 0}
+ >
+ {downloadingOnPack ? "下載中..." : "下載 OnPack 汁水機 QR code"}
+
+
+
+
+ {/* Job orders list */}
+
+ {loading ? (
+
+
+
+ ) : jobOrders.length === 0 ? (
+
+ 當日無工單
+
+ ) : (
+
+
+ {jobOrders.map((jo) => {
+ const batch = getBatch(jo);
+ const qtyStr = formatQty(jo.reqQty);
+ const isSelected = selectedId === jo.id;
+ return (
+ handleRowClick(jo)}
+ >
+
+
+ {batch}
+
+ {qtyStr !== "—" && (
+
+ 數量:{qtyStr}
+
+ )}
+
+
+
+ {jo.code || "—"}
+
+
+
+
+ {jo.itemCode || "—"}
+
+
+
+
+ {jo.itemName || "—"}
+
+
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ handleRowClick(jo);
+ }}
+ >
+ 列印
+
+
+ );
+ })}
+
+
+ )}
+
+
+ {/* Settings dialog */}
+
+
+ setSnackbar((s) => ({ ...s, open: false }))}
+ message={snackbar.message}
+ anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
+ />
+
+ );
+};
+
+export default BagPrintSearch;
diff --git a/src/components/BomWeightingScoreTable/BomWeightingScoreTable.tsx b/src/components/BomWeightingScoreTable/BomWeightingScoreTable.tsx
index 200357d..6767949 100644
--- a/src/components/BomWeightingScoreTable/BomWeightingScoreTable.tsx
+++ b/src/components/BomWeightingScoreTable/BomWeightingScoreTable.tsx
@@ -16,9 +16,10 @@ import Stack from "@mui/material/Stack";
interface Props {
bomWeightingScores: BomWeightingScoreResult[];
+ onWeightingUpdated?: () => void;
}
-const BomWeightingScoreTable: React.FC & { Loading?: React.FC } = ({ bomWeightingScores: initialBomWeightingScores }) => {
+const BomWeightingScoreTable: React.FC & { Loading?: React.FC } = ({ bomWeightingScores: initialBomWeightingScores, onWeightingUpdated }) => {
const { t } = useTranslation("common");
const [bomWeightingScores, setBomWeightingScores] = useState(initialBomWeightingScores);
const [isEditMode, setIsEditMode] = useState(false);
@@ -120,10 +121,28 @@ const BomWeightingScoreTable: React.FC & { Loading?: React.FC } = ({ bomW
prev.map((r) => updatedById.get(r.id) ?? r),
);
+ // Wait a bit to ensure all weighting updates are committed to database
+ // before recalculating base scores
+ console.log("Waiting for weighting updates to commit...");
+ await new Promise(resolve => setTimeout(resolve, 200));
+
// After weighting changes, trigger BOM baseScore recalculation on the server
try {
+ console.log("Triggering BOM base score recalculation...");
const result = await recalcBomScoresClient();
updatedCount = result?.updatedCount ?? null;
+ console.log(`BOM base scores recalculated: ${updatedCount} BOMs updated`);
+
+ // Wait a bit more to ensure recalculation transaction is committed
+ // before refreshing the frontend
+ await new Promise(resolve => setTimeout(resolve, 300));
+
+ // Notify parent component to refresh BOM scores if needed
+ if (onWeightingUpdated) {
+ console.log("Refreshing BOM scores in frontend...");
+ await onWeightingUpdated();
+ console.log("BOM scores refreshed");
+ }
} catch (recalcError) {
console.error("Failed to recalculate BOM base scores:", recalcError);
// We don't block the main save flow if recalculation fails
diff --git a/src/components/BomWeightingTabs/BomWeightingTabs.tsx b/src/components/BomWeightingTabs/BomWeightingTabs.tsx
index fe7320e..6323dea 100644
--- a/src/components/BomWeightingTabs/BomWeightingTabs.tsx
+++ b/src/components/BomWeightingTabs/BomWeightingTabs.tsx
@@ -3,6 +3,7 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { BomWeightingScoreResult } from "@/app/api/settings/bomWeighting";
+import { fetchBomWeightingScoresClient } from "@/app/api/settings/bomWeighting/client";
import type { BomScoreResult } from "@/app/api/bom";
import { fetchBomScoresClient } from "@/app/api/bom/client";
import BomWeightingScoreTable from "@/components/BomWeightingScoreTable";
@@ -16,37 +17,61 @@ interface Props {
bomWeightingScores: BomWeightingScoreResult[];
}
-const BomWeightingTabs: React.FC = ({ bomWeightingScores }) => {
+const BomWeightingTabs: React.FC = ({ bomWeightingScores: initialBomWeightingScores }) => {
const { t } = useTranslation("common");
const [tab, setTab] = useState(0);
+ const [bomWeightingScores, setBomWeightingScores] = useState(initialBomWeightingScores);
const [bomScores, setBomScores] = useState(null);
const [loadingScores, setLoadingScores] = useState(false);
const [loadError, setLoadError] = useState(null);
- useEffect(() => {
- if (tab !== 1) return;
+ const loadBomScores = React.useCallback(async () => {
+ try {
+ setLoadingScores(true);
+ setLoadError(null);
+ console.log("Fetching BOM scores from /bom/scores...");
+ const data = await fetchBomScoresClient();
+ console.log("BOM scores received:", data);
+ setBomScores(data || []);
+ } catch (err: any) {
+ console.error("Failed to load BOM scores:", err);
+ const errorMsg =
+ err?.response?.data?.message || err?.message || t("Update Failed") || "Load failed";
+ setLoadError(errorMsg);
+ setBomScores([]);
+ } finally {
+ setLoadingScores(false);
+ }
+ }, [t]);
+
+ const loadBomWeightingScores = React.useCallback(async () => {
+ try {
+ console.log("Fetching BOM weighting scores...");
+ const data = await fetchBomWeightingScoresClient();
+ console.log("BOM weighting scores received:", data);
+ setBomWeightingScores(data || []);
+ } catch (err: any) {
+ console.error("Failed to load BOM weighting scores:", err);
+ }
+ }, []);
+
+ const handleWeightingUpdated = React.useCallback(async () => {
+ // Refresh both weighting scores and BOM scores
+ await Promise.all([
+ loadBomWeightingScores(),
+ loadBomScores()
+ ]);
+ }, [loadBomWeightingScores, loadBomScores]);
- const load = async () => {
- try {
- setLoadingScores(true);
- setLoadError(null);
- console.log("Fetching BOM scores from /bom/scores...");
- const data = await fetchBomScoresClient();
- console.log("BOM scores received:", data);
- setBomScores(data || []);
- } catch (err: any) {
- console.error("Failed to load BOM scores:", err);
- const errorMsg =
- err?.response?.data?.message || err?.message || t("Update Failed") || "Load failed";
- setLoadError(errorMsg);
- setBomScores([]);
- } finally {
- setLoadingScores(false);
- }
- };
+ // Sync initial prop values
+ useEffect(() => {
+ setBomWeightingScores(initialBomWeightingScores);
+ }, [initialBomWeightingScores]);
- void load();
- }, [tab, t]);
+ useEffect(() => {
+ if (tab !== 1) return;
+ void loadBomScores();
+ }, [tab, loadBomScores]);
return (
<>
@@ -83,7 +108,10 @@ const BomWeightingTabs: React.FC = ({ bomWeightingScores }) => {
{tab === 0 && (
-
+
)}
{tab === 1 && (
loadingScores ? (
diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx
index 066d65c..a462fef 100644
--- a/src/components/Breadcrumb/Breadcrumb.tsx
+++ b/src/components/Breadcrumb/Breadcrumb.tsx
@@ -38,6 +38,7 @@ const pathToLabelMap: { [path: string]: string } = {
"/putAway": "Put Away",
"/stockIssue": "Stock Issue",
"/report": "Report",
+ "/bagPrint": "打袋機",
};
const Breadcrumb = () => {
diff --git a/src/components/CreateItem/CreateItemWrapper.tsx b/src/components/CreateItem/CreateItemWrapper.tsx
index 343e6bf..2a4b468 100644
--- a/src/components/CreateItem/CreateItemWrapper.tsx
+++ b/src/components/CreateItem/CreateItemWrapper.tsx
@@ -24,7 +24,7 @@ const CreateItemWrapper: React.FC & SubComponents = async ({ id }) => {
if (id) {
result = await fetchItem(id);
const item = result.item;
- qcChecks = result.qcChecks;
+ qcChecks = result.qcChecks ?? [];
const activeRows = qcChecks.filter((it) => it.isActive).map((i) => i.id);
// Normalize LocationCode field (handle case sensitivity from MySQL)
diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx
index ae1c632..508f09e 100644
--- a/src/components/NavigationContent/NavigationContent.tsx
+++ b/src/components/NavigationContent/NavigationContent.tsx
@@ -172,9 +172,9 @@ const NavigationContent: React.FC = () => {
},
{
icon: ,
- label: "打袋機列印",
- path: "/testing",
- requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
+ label: "打袋機",
+ path: "/bagPrint",
+ requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN],
isHidden: false,
},
{
diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json
index 5a58930..0ac3c96 100644
--- a/src/i18n/zh/common.json
+++ b/src/i18n/zh/common.json
@@ -92,7 +92,7 @@
"User": "用戶",
"user": "用戶",
"User Group": "用戶群組",
- "Items": "物料",
+ "Items": "物品",
"BOM Weighting Score List": "物料清單權重得分",
"Material Weighting": "物料清單加權",
"Material Score": "物料清單得分",
@@ -190,7 +190,7 @@
"Inventory": "庫存",
"scheduling": "排程",
"settings": "設定",
- "items": "物料",
+ "items": "物品",
"edit":"編輯",
"bag": "包裝袋",
"Bag Usage": "包裝袋使用記錄",
diff --git a/src/i18n/zh/project.json b/src/i18n/zh/project.json
index 7522b1c..f5d7a11 100644
--- a/src/i18n/zh/project.json
+++ b/src/i18n/zh/project.json
@@ -3,7 +3,7 @@
"FG & Material Demand Forecast Detail": "FG 及材料需求預測詳情",
"Release": "發佈",
"Actions": "操作",
- "Product": "產品",
+ "Product": "物品",
"Details": "詳情",
"View BoM": "查看 BoM",
"description": "描述",