|
- "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<string> {
- 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<JobOrderListItem[]>([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState<string | null>(null);
- const [connected, setConnected] = useState(false);
- const [printer, setPrinter] = useState<string>("dataflex");
- const [selectedId, setSelectedId] = useState<number | null>(null);
- const [printDialogOpen, setPrintDialogOpen] = useState(false);
- const [printTarget, setPrintTarget] = useState<JobOrderListItem | null>(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<OnPackExpiryItemCodeDto[]>([]);
- const [expiryCodeInput, setExpiryCodeInput] = useState("");
- const [expiryCodesLoading, setExpiryCodesLoading] = useState(false);
- const [nameDrafts, setNameDrafts] = useState<Record<string, string>>({});
- const [expirySortKey, setExpirySortKey] = useState<ExpirySortKey>("itemCode");
- const [expirySortDir, setExpirySortDir] = useState<"asc" | "desc">("asc");
- const [lemonCodeSet, setLemonCodeSet] = useState<Set<string>>(() => new Set());
- const expiryAddRef = useRef(false);
- const expiryDeleteRef = useRef(false);
- const expirySaveRef = useRef<Set<string>>(new Set());
- const expiryToggleRef = useRef<Set<string>>(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<string>) => {
- 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 (
- <Box sx={{ minHeight: "70vh", display: "flex", flexDirection: "column" }}>
- {/* Top: date nav + printer + settings */}
- <Paper sx={{ p: 2, mb: 2, backgroundColor: BG_TOP }}>
- <Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={2}>
- <Stack direction="row" alignItems="center" spacing={2}>
- <Button variant="outlined" startIcon={<ChevronLeft />} onClick={goPrevDay}>
- 前一天
- </Button>
- <TextField
- type="date"
- value={planDate}
- onChange={(e) => setPlanDate(e.target.value)}
- size="small"
- sx={{ width: 160 }}
- InputLabelProps={{ shrink: true }}
- />
- <Button variant="outlined" endIcon={<ChevronRight />} onClick={goNextDay}>
- 後一天
- </Button>
- </Stack>
- <Stack direction="row" alignItems="center" spacing={2}>
- <Button variant="outlined" startIcon={<Settings />} onClick={() => setSettingsOpen(true)}>
- 設定
- </Button>
- {canSeeOnPackAdmin && (
- <Button variant="outlined" onClick={() => setTemplatesOpen(true)}>
- OnPack 到期日 ZIP
- </Button>
- )}
- <Box
- sx={{
- px: 1.5,
- py: 0.75,
- borderRadius: 1,
- backgroundColor: printerConnected ? BG_STATUS_OK : BG_STATUS_ERROR,
- color: printerConnected ? FG_STATUS_OK : FG_STATUS_ERROR,
- fontWeight: 600,
- whiteSpace: "nowrap",
- }}
- title={printerMessage}
- >
- 列印機:
- </Box>
- <FormControl size="small" sx={{ minWidth: 180 }}>
- <InputLabel>列印機</InputLabel>
- <Select value={printer} label="列印機" onChange={handlePrinterChange}>
- {PRINTER_OPTIONS.map((opt) => (
- <MenuItem key={opt.value} value={opt.value}>
- {opt.label}
- </MenuItem>
- ))}
- </Select>
- </FormControl>
- </Stack>
- </Stack>
- <Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}>
- {printerMessage}
- </Typography>
- <Stack direction="row" sx={{ mt: 2 }} spacing={2} flexWrap="wrap" useFlexGap>
- <Button
- variant="contained"
- startIcon={<Download />}
- onClick={handleDownloadOnPackQr}
- disabled={
- loading ||
- downloadingOnPack ||
- downloadingOnPackExp ||
- downloadingOnPackText ||
- downloadingOnPackTextExp ||
- jobOrders.length === 0
- }
- >
- {downloadingOnPack ? "下載中..." : "下載 OnPack 汁水機 QR code"}
- </Button>
- <Button
- variant="contained"
- startIcon={<Download />}
- onClick={handleDownloadOnPackQrWithExpiry}
- disabled={
- loading ||
- downloadingOnPack ||
- downloadingOnPackExp ||
- downloadingOnPackText ||
- downloadingOnPackTextExp ||
- jobOrders.length === 0
- }
- >
- {downloadingOnPackExp ? "下載中..." : "下載 OnPack 汁水機(含到期日)"}
- </Button>
- <Button
- variant="contained"
- color="secondary"
- startIcon={<Download />}
- onClick={handleDownloadOnPackTextQr}
- disabled={
- loading ||
- downloadingOnPack ||
- downloadingOnPackExp ||
- downloadingOnPackText ||
- downloadingOnPackTextExp ||
- jobOrders.length === 0
- }
- >
- {downloadingOnPackText ? "下載中..." : "下載 OnPack2023檸檬機"}
- </Button>
- {canSeeOnPackAdmin && (
- <Button
- variant="contained"
- color="secondary"
- startIcon={<Download />}
- onClick={handleDownloadOnPackTextQrWithExpiry}
- disabled={
- loading ||
- downloadingOnPack ||
- downloadingOnPackExp ||
- downloadingOnPackText ||
- downloadingOnPackTextExp ||
- jobOrders.length === 0
- }
- >
- {downloadingOnPackTextExp ? "下載中..." : "下載 OnPack2023檸檬機(含到期日)"}
- </Button>
- )}
- </Stack>
- </Paper>
-
- {/* Job orders list */}
- <Paper sx={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column", backgroundColor: BG_LIST }}>
- {loading ? (
- <Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", py: 8 }}>
- <CircularProgress />
- </Box>
- ) : jobOrders.length === 0 ? (
- <Box sx={{ py: 8, textAlign: "center" }}>
- <Typography color="text.secondary">當日無工單</Typography>
- </Box>
- ) : (
- <Box sx={{ overflow: "auto", flex: 1, p: 2 }}>
- <Stack spacing={1}>
- {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 (
- <Paper
- key={jo.id}
- elevation={1}
- sx={{
- p: 2,
- display: "flex",
- alignItems: "flex-start",
- gap: 2,
- cursor: "pointer",
- backgroundColor: isSelected ? BG_ROW_SELECTED : BG_ROW,
- "&:hover": { backgroundColor: isSelected ? BG_ROW_SELECTED : "#b8d4eb" },
- transition: "background-color 0.2s",
- }}
- onClick={() => handleRowClick(jo)}
- >
- <Box sx={{ minWidth: 120, flexShrink: 0 }}>
- <Typography variant="h6" sx={{ fontSize: "1.1rem" }}>
- {batch}
- </Typography>
- {qtyStr !== "—" && (
- <Typography variant="body2" color="text.secondary">
- 數量:{qtyStr}
- </Typography>
- )}
- </Box>
- <Box sx={{ minWidth: 140, flexShrink: 0 }}>
- <Typography variant="h6" sx={{ fontSize: "1.1rem" }}>
- {jo.code || "—"}
- </Typography>
- </Box>
- <Box sx={{ minWidth: 140, flexShrink: 0 }}>
- <Typography variant="h6" sx={{ fontSize: "1.35rem" }}>
- {jo.itemCode || "—"}
- </Typography>
- <Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mt: 0.5 }}>
- {juiceExpiryOk ? <Chip size="small" label="汁水機" color="primary" /> : null}
- {lemonOk ? <Chip size="small" label="檸檬機" color="secondary" /> : null}
- </Stack>
- </Box>
- <Box sx={{ flex: 1, minWidth: 0 }}>
- <Typography variant="h6" sx={{ fontSize: "1.35rem", wordBreak: "break-word" }}>
- {jo.itemName || "—"}
- </Typography>
- </Box>
- <Button
- size="small"
- variant="contained"
- startIcon={<Print />}
- onClick={(e) => {
- e.stopPropagation();
- handleRowClick(jo);
- }}
- >
- 列印
- </Button>
- </Paper>
- );
- })}
- </Stack>
- </Box>
- )}
- </Paper>
-
- {/* Print count dialog (DataFlex) */}
- <Dialog open={printDialogOpen} onClose={() => (printing ? null : setPrintDialogOpen(false))} maxWidth="xs" fullWidth>
- <DialogTitle>打袋機 DataFlex 列印數量</DialogTitle>
- <DialogContent>
- <Stack spacing={2} sx={{ mt: 1 }}>
- <Typography variant="body1" sx={{ fontWeight: 700 }}>
- 列印多少個袋?
- </Typography>
- <Typography variant="body2" color="text.secondary">
- {printContinuous ? "連續 (C)" : `數量: ${printCount}`}
- </Typography>
- <Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap">
- <Button
- size="small"
- variant="contained"
- onClick={() => {
- setPrintContinuous(false);
- setPrintCount((c) => c + 50);
- }}
- disabled={printing}
- >
- +50
- </Button>
- <Button
- size="small"
- variant="contained"
- onClick={() => {
- setPrintContinuous(false);
- setPrintCount((c) => c + 10);
- }}
- disabled={printing}
- >
- +10
- </Button>
- <Button
- size="small"
- variant="contained"
- onClick={() => {
- setPrintContinuous(false);
- setPrintCount((c) => c + 5);
- }}
- disabled={printing}
- >
- +5
- </Button>
- <Button
- size="small"
- variant="contained"
- onClick={() => {
- setPrintContinuous(false);
- setPrintCount((c) => c + 1);
- }}
- disabled={printing}
- >
- +1
- </Button>
- <Button
- size="small"
- variant={printContinuous ? "contained" : "outlined"}
- onClick={() => {
- setPrintContinuous(true);
- }}
- disabled={printing}
- >
- 連續 (C)
- </Button>
- </Stack>
- </Stack>
- </DialogContent>
- <DialogActions>
- <Button onClick={() => setPrintDialogOpen(false)} disabled={printing}>
- 取消
- </Button>
- <Button variant="contained" onClick={() => void confirmPrintDataFlex()} disabled={printing}>
- {printing ? <CircularProgress size={16} /> : "確認送出"}
- </Button>
- </DialogActions>
- </Dialog>
-
- {/* Settings dialog */}
- <Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth>
- <DialogTitle>設定</DialogTitle>
- <DialogContent>
- <Stack spacing={2} sx={{ mt: 1 }}>
- <Typography variant="subtitle2" color="primary">
- 打袋機 DataFlex
- </Typography>
- <TextField
- label="IP"
- size="small"
- value={settings.dabag_ip}
- onChange={(e) => setSettings((s) => ({ ...s, dabag_ip: e.target.value }))}
- fullWidth
- />
- <TextField
- label="Port"
- size="small"
- value={settings.dabag_port}
- onChange={(e) => setSettings((s) => ({ ...s, dabag_port: e.target.value }))}
- fullWidth
- />
- <Typography variant="subtitle2" color="primary">
- 激光機
- </Typography>
- <TextField
- label="IP"
- size="small"
- value={settings.laser_ip}
- onChange={(e) => setSettings((s) => ({ ...s, laser_ip: e.target.value }))}
- fullWidth
- />
- <TextField
- label="Port"
- size="small"
- value={settings.laser_port}
- onChange={(e) => setSettings((s) => ({ ...s, laser_port: e.target.value }))}
- fullWidth
- />
- </Stack>
- </DialogContent>
- <DialogActions>
- <Button onClick={() => setSettingsOpen(false)}>取消</Button>
- <Button
- variant="contained"
- onClick={() => {
- saveSettings(settings);
- setSnackbar({ open: true, message: "設定已儲存", severity: "success" });
- setSettingsOpen(false);
- checkCurrentPrinter();
- }}
- >
- 儲存
- </Button>
- </DialogActions>
- </Dialog>
-
- <Dialog
- open={templatesOpen && canSeeOnPackAdmin}
- onClose={() => setTemplatesOpen(false)}
- maxWidth="xl"
- fullWidth
- scroll="paper"
- PaperProps={{ sx: { height: "90vh", maxHeight: "90vh" } }}
- >
- <DialogTitle>OnPack 到期日 ZIP 品號</DialogTitle>
- <DialogContent
- sx={{
- display: "flex",
- flexDirection: "column",
- overflow: "hidden",
- pt: 1,
- }}
- >
- <Stack spacing={1.5} sx={{ flexShrink: 0, mb: 1 }}>
- <Typography variant="subtitle2" color="primary">
- 汁水機({expiryCodes.length})
- </Typography>
- <Typography variant="body2" color="text.secondary">
- 「下載 OnPack 汁水機(含到期日)」只用此清單。名稱印在 Product 行,過長可改短後按儲存。
- 點欄位標題可排序;冷藏/-18/列印第一次點擊會把「未設」排到最前。
- </Typography>
- <Stack direction="row" spacing={1} alignItems="center">
- <TextField
- label="新增品號"
- size="small"
- placeholder="例如 PP2211"
- value={expiryCodeInput}
- onChange={(e) => setExpiryCodeInput(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- void handleAddExpiryCode();
- }
- }}
- sx={{ minWidth: 180 }}
- />
- <Button variant="contained" onClick={() => void handleAddExpiryCode()}>
- 加入
- </Button>
- </Stack>
- </Stack>
- {expiryCodesLoading ? (
- <Box sx={{ display: "flex", justifyContent: "center", py: 1 }}>
- <CircularProgress size={20} />
- </Box>
- ) : expiryCodes.length === 0 ? (
- <Typography color="text.secondary">清單空白</Typography>
- ) : (
- <TableContainer sx={{ flex: 1, minHeight: 0, overflow: "auto" }}>
- <Table size="small" stickyHeader>
- <TableHead>
- <TableRow>
- <TableCell sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
- <TableSortLabel
- active={expirySortKey === "itemCode"}
- direction={expirySortKey === "itemCode" ? expirySortDir : "asc"}
- onClick={() => onExpirySort("itemCode")}
- >
- 品號
- </TableSortLabel>
- </TableCell>
- <TableCell sx={{ fontWeight: 700 }}>
- <TableSortLabel
- active={expirySortKey === "name"}
- direction={expirySortKey === "name" ? expirySortDir : "asc"}
- onClick={() => onExpirySort("name")}
- >
- 中文名稱+單位
- </TableSortLabel>
- </TableCell>
- <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
- <TableSortLabel
- active={expirySortKey === "defaultDays"}
- direction={expirySortKey === "defaultDays" ? expirySortDir : "asc"}
- onClick={() => onExpirySort("defaultDays")}
- >
- 冷藏
- </TableSortLabel>
- <Typography variant="caption" display="block" color="text.secondary">
- 天
- </Typography>
- </TableCell>
- <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
- <TableSortLabel
- active={expirySortKey === "minus18Days"}
- direction={expirySortKey === "minus18Days" ? expirySortDir : "asc"}
- onClick={() => onExpirySort("minus18Days")}
- >
- -18
- </TableSortLabel>
- <Typography variant="caption" display="block" color="text.secondary">
- 天
- </Typography>
- </TableCell>
- <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
- <TableSortLabel
- active={expirySortKey === "useMinus18"}
- direction={expirySortKey === "useMinus18" ? expirySortDir : "asc"}
- onClick={() => onExpirySort("useMinus18")}
- >
- 用 -18
- </TableSortLabel>
- </TableCell>
- <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
- <TableSortLabel
- active={expirySortKey === "effectiveDays"}
- direction={expirySortKey === "effectiveDays" ? expirySortDir : "asc"}
- onClick={() => onExpirySort("effectiveDays")}
- >
- 列印
- </TableSortLabel>
- <Typography variant="caption" display="block" color="text.secondary">
- 天
- </Typography>
- </TableCell>
- <TableCell align="right" sx={{ fontWeight: 700 }}>
- 操作
- </TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {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 (
- <TableRow key={row.itemCode} hover>
- <TableCell sx={{ fontFamily: "monospace", fontWeight: 700, whiteSpace: "nowrap" }}>
- {row.itemCode}
- </TableCell>
- <TableCell sx={{ minWidth: 280 }}>
- <Stack direction="row" spacing={1} alignItems="center">
- <TextField
- size="small"
- value={draft}
- onChange={(e) =>
- setNameDrafts((prev) => ({ ...prev, [row.itemCode]: e.target.value }))
- }
- placeholder={row.defaultPrintName || "例如 豬肉水(1包X2千克)"}
- inputProps={{ maxLength: 255 }}
- fullWidth
- />
- <Button
- variant="contained"
- size="small"
- disabled={!nameDirty}
- onClick={() => void handleSaveExpiryPrintName(row.itemCode)}
- >
- 儲存
- </Button>
- </Stack>
- </TableCell>
- <TableCell align="center">
- <Typography
- variant="h6"
- sx={{ fontWeight: 700, lineHeight: 1.2 }}
- color={row.defaultDays == null ? "warning.main" : "text.primary"}
- >
- {daysLabel(row.defaultDays)}
- </Typography>
- </TableCell>
- <TableCell align="center">
- <Typography
- variant="h6"
- sx={{ fontWeight: 700, lineHeight: 1.2 }}
- color={row.minus18Days == null ? "warning.main" : "text.primary"}
- >
- {daysLabel(row.minus18Days)}
- </Typography>
- </TableCell>
- <TableCell align="center">
- <Tooltip title={canUseMinus18 ? "改用 -18 天數列印到期日" : missingHint}>
- <span>
- <Switch
- size="small"
- checked={row.useMinus18 === true}
- disabled={!canUseMinus18}
- onChange={(e) => void handleToggleUseMinus18(row.itemCode, e.target.checked)}
- inputProps={{ "aria-label": `${row.itemCode} 用 -18` }}
- />
- </span>
- </Tooltip>
- </TableCell>
- <TableCell align="center">
- <Typography
- variant="h6"
- sx={{ fontWeight: 800, lineHeight: 1.2 }}
- color={row.effectiveDays == null ? "warning.main" : "primary.main"}
- >
- {daysLabel(row.effectiveDays)}
- </Typography>
- {!hasShelf && (
- <Typography variant="caption" color="warning.main" display="block">
- 未設定
- </Typography>
- )}
- </TableCell>
- <TableCell align="right">
- <Button
- size="small"
- color="error"
- onClick={() => void handleDeleteExpiryCode(row.itemCode)}
- >
- 移除
- </Button>
- </TableCell>
- </TableRow>
- );
- })}
- </TableBody>
- </Table>
- </TableContainer>
- )}
- <Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, mt: 1 }}>
- 檸檬機到期日 ZIP 品號稍後加入。
- </Typography>
- </DialogContent>
- <DialogActions>
- <Button onClick={() => setTemplatesOpen(false)}>關閉</Button>
- </DialogActions>
- </Dialog>
-
- <Snackbar
- open={snackbar.open}
- autoHideDuration={snackbar.duration ?? 3000}
- onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
- anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
- >
- <Alert
- onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
- severity={snackbar.severity ?? "info"}
- variant="filled"
- sx={{ width: "100%", maxWidth: 720 }}
- >
- {snackbar.message}
- </Alert>
- </Snackbar>
- </Box>
- );
- };
-
- export default BagPrintSearch;
|