"use client"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Box, Button, Chip, FormControl, InputLabel, MenuItem, Select, Stack, Typography, Paper, CircularProgress, SelectChangeEvent, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Snackbar, Switch, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TableSortLabel, Tooltip, } 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 { buildOnPackJobOrdersPayload, checkPrinterStatus, downloadOnPackQrZip, downloadOnPackQrZipWithExpiry, downloadOnPackTextQrZip, downloadOnPackTextQrZipWithExpiry, fetchJobOrders, fetchOnPackExpiryCodes, addOnPackExpiryCode, updateOnPackExpiryCode, deleteOnPackExpiryCode, fetchOnPackSupportedCatalog, JobOrderListItem, OnPackExpiryItemCodeDto, } from "@/app/api/bagPrint/actions"; import dayjs from "dayjs"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; // 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 ONPACK_ADMIN_USERNAME = "2fi"; /** Login username from backend JWT `sub` (UserDetails.username). */ function loginUsernameFromSession(session: SessionWithTokens | null | undefined): string { const token = session?.accessToken?.trim(); if (token) { try { const parts = token.split("."); if (parts.length >= 2) { const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); const payload = JSON.parse(atob(padded)) as { sub?: unknown }; if (typeof payload.sub === "string" && payload.sub.trim()) { return payload.sub.trim(); } } } catch { // fall through to display name } } return (session?.user?.name ?? "").trim(); } 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() || "—"; } function expiryCodeSet(rows: OnPackExpiryItemCodeDto[]): Set { return new Set(rows.map((r) => r.itemCode.trim().toUpperCase()).filter(Boolean)); } function daysLabel(value: number | null | undefined): string { return value == null ? "未設" : String(value); } type ExpirySortKey = | "itemCode" | "name" | "defaultDays" | "minus18Days" | "useMinus18" | "effectiveDays"; function displayName(row: OnPackExpiryItemCodeDto): string { return (row.printName || row.defaultPrintName || "").trim(); } function cmpText(a: string, b: string): number { return a.localeCompare(b, "zh-Hant", { numeric: true, sensitivity: "base" }); } /** Null (未設) sorts first on asc so unset rows are easy to find. */ function cmpDays(a: number | null | undefined, b: number | null | undefined): number { const av = a == null ? Number.NEGATIVE_INFINITY : a; const bv = b == null ? Number.NEGATIVE_INFINITY : b; return av - bv; } function skippedExpirySnackbar(okMessage: string, skipped: string[]): { open: true; message: string; severity: "success" | "warning"; duration: number; } { if (skipped.length === 0) { return { open: true, message: okMessage, severity: "success", duration: 3000 }; } return { open: true, message: `${okMessage}。以下品號沒有到期日,已略過不入 ZIP:${skipped.join("、")}。請到設定 → 物品預設保質期新增。`, severity: "warning", duration: 10000, }; } function sortExpiryRows( rows: OnPackExpiryItemCodeDto[], key: ExpirySortKey, dir: "asc" | "desc", ): OnPackExpiryItemCodeDto[] { const sign = dir === "asc" ? 1 : -1; return [...rows].sort((a, b) => { let cmp = 0; switch (key) { case "itemCode": cmp = cmpText(a.itemCode, b.itemCode); break; case "name": cmp = cmpText(displayName(a), displayName(b)); break; case "defaultDays": cmp = cmpDays(a.defaultDays, b.defaultDays); break; case "minus18Days": cmp = cmpDays(a.minus18Days, b.minus18Days); break; case "useMinus18": cmp = Number(a.useMinus18 === true) - Number(b.useMinus18 === true); break; case "effectiveDays": cmp = cmpDays(a.effectiveDays, b.effectiveDays); break; } if (cmp === 0) cmp = cmpText(a.itemCode, b.itemCode); return cmp * sign; }); } const BagPrintSearch: React.FC = () => { const { data: session } = useSession() as { data: SessionWithTokens | null }; const canSeeOnPackAdmin = loginUsernameFromSession(session).toLowerCase() === ONPACK_ADMIN_USERNAME; 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 [printDialogOpen, setPrintDialogOpen] = useState(false); const [printTarget, setPrintTarget] = useState(null); const [printCount, setPrintCount] = useState(0); const [printContinuous, setPrintContinuous] = useState(false); const [printing, setPrinting] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [templatesOpen, setTemplatesOpen] = useState(false); const [expiryCodes, setExpiryCodes] = useState([]); const [expiryCodeInput, setExpiryCodeInput] = useState(""); const [expiryCodesLoading, setExpiryCodesLoading] = useState(false); const [nameDrafts, setNameDrafts] = useState>({}); const [expirySortKey, setExpirySortKey] = useState("itemCode"); const [expirySortDir, setExpirySortDir] = useState<"asc" | "desc">("asc"); const [lemonCodeSet, setLemonCodeSet] = useState>(() => new Set()); const expiryAddRef = useRef(false); const expiryDeleteRef = useRef(false); const expirySaveRef = useRef>(new Set()); const expiryToggleRef = useRef>(new Set()); const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: "success" | "info" | "warning" | "error"; duration?: number; }>({ open: false, message: "" }); const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [printerConnected, setPrinterConnected] = useState(false); const [printerMessage, setPrinterMessage] = useState("列印機未連接"); const [downloadingOnPack, setDownloadingOnPack] = useState(false); const [downloadingOnPackExp, setDownloadingOnPackExp] = useState(false); const [downloadingOnPackText, setDownloadingOnPackText] = useState(false); const [downloadingOnPackTextExp, setDownloadingOnPackTextExp] = useState(false); const downloadingOnPackRef = useRef(false); const downloadingOnPackExpRef = useRef(false); const downloadingOnPackTextRef = useRef(false); const downloadingOnPackTextExpRef = useRef(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" }); // Align with Bag2.py "click row -> ask bag count -> print" for DataFlex. if (printer === "dataflex") { setPrintTarget(jo); setPrintCount(0); setPrintContinuous(false); setPrintDialogOpen(true); } }; const confirmPrintDataFlex = async () => { if (!printTarget) return; if (printer !== "dataflex") { setSnackbar({ open: true, message: "此頁目前只支援打袋機 DataFlex 列印", severity: "error" }); return; } if (!printContinuous && printCount < 1) { setSnackbar({ open: true, message: "請先按 +50、+10、+5 或 +1 選擇數量。", severity: "error" }); return; } const qty = printContinuous ? -1 : printCount; const printerIp = settings.dabag_ip; const printerPort = Number(settings.dabag_port || 3008); if (!printerIp) { setSnackbar({ open: true, message: "請先在設定中填寫打袋機 DataFlex 的 IP。", severity: "error" }); return; } setPrinting(true); try { const resp = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-dataflex`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ itemCode: printTarget.itemCode || "—", itemName: printTarget.itemName || "—", lotNo: printTarget.lotNo || "—", // DataFlex zpl (Bag2.py) only needs itemId + stockInLineId for QR payload (optional). itemId: printTarget.itemId, stockInLineId: printTarget.stockInLineId, printerIp, printerPort, printQty: qty, }), }); if (resp.status === 401 || resp.status === 403) return; if (!resp.ok) { const msg = await resp.text().catch(() => ""); setSnackbar({ open: true, message: `DataFlex 列印失敗(狀態碼 ${resp.status})。${msg ? msg.slice(0, 120) : ""}`, severity: "error", }); return; } const batch = getBatch(printTarget); const printedText = qty === -1 ? "連續 (C)" : `${qty}`; setSnackbar({ open: true, message: `已送出列印:批次 ${batch} x ${printedText}`, severity: "success" }); setPrintDialogOpen(false); } catch (e) { setSnackbar({ open: true, message: e instanceof Error ? e.message : "DataFlex 列印失敗", severity: "error" }); } finally { setPrinting(false); } }; const handleDownloadOnPackQr = async () => { if (downloadingOnPackRef.current) return; const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); if (onPackJobOrders.length === 0) { setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" }); return; } downloadingOnPackRef.current = true; 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); downloadingOnPackRef.current = false; } }; const handleDownloadOnPackQrWithExpiry = async () => { if (downloadingOnPackExpRef.current) return; const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); if (onPackJobOrders.length === 0) { setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" }); return; } downloadingOnPackExpRef.current = true; setDownloadingOnPackExp(true); try { const { blob, skippedWithoutExpiry } = await downloadOnPackQrZipWithExpiry({ jobOrders: onPackJobOrders, planDate, }); const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.setAttribute("download", `onpack_qr_exp_${planDate}.zip`); document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); setSnackbar(skippedExpirySnackbar("OnPack 汁水機(含到期日)ZIP 已下載", skippedWithoutExpiry)); } catch (e) { setSnackbar({ open: true, message: e instanceof Error ? e.message : "下載 OnPack 汁水機(含到期日)失敗", severity: "error", }); } finally { setDownloadingOnPackExp(false); downloadingOnPackExpRef.current = false; } }; const handleDownloadOnPackTextQr = async () => { if (downloadingOnPackTextRef.current) return; const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); if (onPackJobOrders.length === 0) { setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" }); return; } downloadingOnPackTextRef.current = true; setDownloadingOnPackText(true); try { const blob = await downloadOnPackTextQrZip({ jobOrders: onPackJobOrders, }); const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.setAttribute("download", `onpack2023_lemon_qr_${planDate}.zip`); document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); setSnackbar({ open: true, message: "OnPack2023檸檬機 ZIP 已下載", severity: "success" }); } catch (e) { setSnackbar({ open: true, message: e instanceof Error ? e.message : "下載 OnPack2023檸檬機 失敗", severity: "error", }); } finally { setDownloadingOnPackText(false); downloadingOnPackTextRef.current = false; } }; const handleDownloadOnPackTextQrWithExpiry = async () => { if (downloadingOnPackTextExpRef.current) return; const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); if (onPackJobOrders.length === 0) { setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" }); return; } downloadingOnPackTextExpRef.current = true; setDownloadingOnPackTextExp(true); try { const { blob, skippedWithoutExpiry } = await downloadOnPackTextQrZipWithExpiry({ jobOrders: onPackJobOrders, planDate, }); const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.setAttribute("download", `onpack2023_lemon_qr_exp_${planDate}.zip`); document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); setSnackbar(skippedExpirySnackbar("OnPack2023檸檬機(含到期日)ZIP 已下載", skippedWithoutExpiry)); } catch (e) { setSnackbar({ open: true, message: e instanceof Error ? e.message : "下載 OnPack2023檸檬機(含到期日)失敗", severity: "error", }); } finally { setDownloadingOnPackTextExp(false); downloadingOnPackTextExpRef.current = false; } }; const loadExpiryCodes = useCallback(async (notify = false) => { setExpiryCodesLoading(true); try { const rows = await fetchOnPackExpiryCodes("juice"); setExpiryCodes(rows); setNameDrafts( Object.fromEntries( rows.map((r) => [r.itemCode, r.printName || r.defaultPrintName || ""]), ), ); } catch (e) { if (notify) { setSnackbar({ open: true, message: e instanceof Error ? e.message : "讀取到期日 ZIP 品號失敗", severity: "error", }); } } finally { setExpiryCodesLoading(false); } }, []); useEffect(() => { void loadExpiryCodes(); }, [loadExpiryCodes]); useEffect(() => { void (async () => { try { const catalog = await fetchOnPackSupportedCatalog(); setLemonCodeSet( new Set( (catalog.lemon ?? []) .filter((row) => row.printable) .map((row) => row.itemCode.trim().toUpperCase()) .filter(Boolean), ), ); } catch { /* 檸檬機標籤可沒有;不擋畫面 */ } })(); }, []); useEffect(() => { if (!templatesOpen) return; void loadExpiryCodes(true); }, [templatesOpen, loadExpiryCodes]); const handleAddExpiryCode = async () => { if (expiryAddRef.current) return; const itemCode = expiryCodeInput.trim(); if (!itemCode) { setSnackbar({ open: true, message: "請先填寫品號", severity: "error" }); return; } expiryAddRef.current = true; try { await addOnPackExpiryCode(itemCode, "juice"); setExpiryCodeInput(""); setSnackbar({ open: true, message: `已加入到期日 ZIP:${itemCode.toUpperCase()}`, severity: "success" }); await loadExpiryCodes(); } catch (e) { setSnackbar({ open: true, message: e instanceof Error ? e.message : "新增到期日 ZIP 品號失敗", severity: "error", }); } finally { expiryAddRef.current = false; } }; const handleDeleteExpiryCode = async (itemCode: string) => { if (expiryDeleteRef.current) return; expiryDeleteRef.current = true; try { await deleteOnPackExpiryCode(itemCode, "juice"); setSnackbar({ open: true, message: `已從到期日 ZIP 移除 ${itemCode}`, severity: "success" }); await loadExpiryCodes(); } catch (e) { setSnackbar({ open: true, message: e instanceof Error ? e.message : "刪除到期日 ZIP 品號失敗", severity: "error", }); } finally { expiryDeleteRef.current = false; } }; const applyExpiryRow = (updated: OnPackExpiryItemCodeDto) => { setExpiryCodes((prev) => prev.map((r) => (r.itemCode === updated.itemCode ? updated : r))); setNameDrafts((prev) => ({ ...prev, [updated.itemCode]: updated.printName || updated.defaultPrintName || "", })); }; const handleSaveExpiryPrintName = async (itemCode: string) => { if (expirySaveRef.current.has(itemCode)) return; expirySaveRef.current.add(itemCode); try { const updated = await updateOnPackExpiryCode({ itemCode, machine: "juice", printName: (nameDrafts[itemCode] ?? "").trim(), }); applyExpiryRow(updated); setSnackbar({ open: true, message: `已儲存 ${itemCode} 列印名稱`, severity: "success" }); } catch (e) { setSnackbar({ open: true, message: e instanceof Error ? e.message : "儲存列印名稱失敗", severity: "error", }); } finally { expirySaveRef.current.delete(itemCode); } }; const handleToggleUseMinus18 = async (itemCode: string, useMinus18: boolean) => { if (expiryToggleRef.current.has(itemCode)) return; expiryToggleRef.current.add(itemCode); try { const updated = await updateOnPackExpiryCode({ itemCode, machine: "juice", useMinus18, }); applyExpiryRow(updated); setSnackbar({ open: true, message: useMinus18 ? `已改用 ${itemCode} 的 -18 天數` : `已改用 ${itemCode} 的冷藏天數`, severity: "success", }); } catch (e) { setSnackbar({ open: true, message: e instanceof Error ? e.message : "更新保質期旗標失敗", severity: "error", }); } finally { expiryToggleRef.current.delete(itemCode); } }; const juiceExpiryCodeSet = expiryCodeSet(expiryCodes); const sortedExpiryCodes = useMemo( () => sortExpiryRows(expiryCodes, expirySortKey, expirySortDir), [expiryCodes, expirySortKey, expirySortDir], ); const onExpirySort = (key: ExpirySortKey) => { if (expirySortKey === key) { setExpirySortDir((d) => (d === "asc" ? "desc" : "asc")); } else { setExpirySortKey(key); setExpirySortDir("asc"); } }; return ( {/* Top: date nav + printer + settings */} setPlanDate(e.target.value)} size="small" sx={{ width: 160 }} InputLabelProps={{ shrink: true }} /> {canSeeOnPackAdmin && ( )} 列印機: 列印機 {printerMessage} {canSeeOnPackAdmin && ( )} {/* Job orders list */} {loading ? ( ) : jobOrders.length === 0 ? ( 當日無工單 ) : ( {jobOrders.map((jo) => { const batch = getBatch(jo); const qtyStr = formatQty(jo.reqQty); const isSelected = selectedId === jo.id; const codeKey = (jo.itemCode || "").trim().toUpperCase(); const juiceExpiryOk = juiceExpiryCodeSet.has(codeKey); const lemonOk = lemonCodeSet.has(codeKey); return ( handleRowClick(jo)} > {batch} {qtyStr !== "—" && ( 數量:{qtyStr} )} {jo.code || "—"} {jo.itemCode || "—"} {juiceExpiryOk ? : null} {lemonOk ? : null} {jo.itemName || "—"} ); })} )} {/* Print count dialog (DataFlex) */} (printing ? null : setPrintDialogOpen(false))} maxWidth="xs" fullWidth> 打袋機 DataFlex 列印數量 列印多少個袋? {printContinuous ? "連續 (C)" : `數量: ${printCount}`} {/* 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 /> setTemplatesOpen(false)} maxWidth="xl" fullWidth scroll="paper" PaperProps={{ sx: { height: "90vh", maxHeight: "90vh" } }} > OnPack 到期日 ZIP 品號 汁水機({expiryCodes.length}) 「下載 OnPack 汁水機(含到期日)」只用此清單。名稱印在 Product 行,過長可改短後按儲存。 點欄位標題可排序;冷藏/-18/列印第一次點擊會把「未設」排到最前。 setExpiryCodeInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void handleAddExpiryCode(); } }} sx={{ minWidth: 180 }} /> {expiryCodesLoading ? ( ) : expiryCodes.length === 0 ? ( 清單空白 ) : ( onExpirySort("itemCode")} > 品號 onExpirySort("name")} > 中文名稱+單位 onExpirySort("defaultDays")} > 冷藏 onExpirySort("minus18Days")} > -18 onExpirySort("useMinus18")} > 用 -18 onExpirySort("effectiveDays")} > 列印 操作 {sortedExpiryCodes.map((row) => { const draft = nameDrafts[row.itemCode] ?? ""; const savedName = row.printName || row.defaultPrintName || ""; const nameDirty = draft.trim() !== savedName.trim(); const hasShelf = row.defaultDays != null || row.minus18Days != null; const canUseMinus18 = row.minus18Days != null && row.minus18Days > 0; const missingHint = hasShelf ? canUseMinus18 ? "" : "此品號沒有 -18 天數" : "未設定保質期,請到設定 → 物品預設保質期新增"; return ( {row.itemCode} setNameDrafts((prev) => ({ ...prev, [row.itemCode]: e.target.value })) } placeholder={row.defaultPrintName || "例如 豬肉水(1包X2千克)"} inputProps={{ maxLength: 255 }} fullWidth /> {daysLabel(row.defaultDays)} {daysLabel(row.minus18Days)} void handleToggleUseMinus18(row.itemCode, e.target.checked)} inputProps={{ "aria-label": `${row.itemCode} 用 -18` }} /> {daysLabel(row.effectiveDays)} {!hasShelf && ( 未設定 )} ); })}
)} 檸檬機到期日 ZIP 品號稍後加入。
setSnackbar((s) => ({ ...s, open: false }))} anchorOrigin={{ vertical: "bottom", horizontal: "center" }} > setSnackbar((s) => ({ ...s, open: false }))} severity={snackbar.severity ?? "info"} variant="filled" sx={{ width: "100%", maxWidth: 720 }} > {snackbar.message}
); }; export default BagPrintSearch;