From d5fb8294ef5d83a2d5bb8a66e27d1145e41f6a6a Mon Sep 17 00:00:00 2001 From: "vluk@2fi-solutions.com.hk" Date: Tue, 10 Mar 2026 01:16:25 +0800 Subject: [PATCH] adding bag printing page, copy from Bag1.py --- src/app/(main)/bagPrint/page.tsx | 23 + src/app/api/bagPrint/actions.ts | 82 ++++ src/components/BagPrint/BagPrintSearch.tsx | 445 ++++++++++++++++++ src/components/Breadcrumb/Breadcrumb.tsx | 1 + .../NavigationContent/NavigationContent.tsx | 6 +- 5 files changed, 554 insertions(+), 3 deletions(-) create mode 100644 src/app/(main)/bagPrint/page.tsx create mode 100644 src/app/api/bagPrint/actions.ts create mode 100644 src/components/BagPrint/BagPrintSearch.tsx 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/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 */} + + + + + setPlanDate(e.target.value)} + size="small" + sx={{ width: 160 }} + InputLabelProps={{ shrink: true }} + /> + + + + + + 列印機: + + + 列印機 + + + + + + {printerMessage} + + + + + + + {/* 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 || "—"} + + + + + ); + })} + + + )} + + + {/* Settings dialog */} + setSettingsOpen(false)} maxWidth="sm" fullWidth> + 設定 + + + + 打袋機 DataFlex + + setSettings((s) => ({ ...s, dabag_ip: e.target.value }))} + fullWidth + /> + setSettings((s) => ({ ...s, dabag_port: e.target.value }))} + fullWidth + /> + + 激光機 + + setSettings((s) => ({ ...s, laser_ip: e.target.value }))} + fullWidth + /> + setSettings((s) => ({ ...s, laser_port: e.target.value }))} + fullWidth + /> + + + + + + + + + setSnackbar((s) => ({ ...s, open: false }))} + message={snackbar.message} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + /> + + ); +}; + +export default BagPrintSearch; 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/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, }, {