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 */}
+
+
+
+ } 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/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,
},
{