|
- "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,
- downloadOnPackTextQrZip,
- fetchJobOrders,
- JobOrderListItem,
- } from "@/app/api/bagPrint/actions";
- import dayjs from "dayjs";
- 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 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<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 [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);
- const [downloadingOnPackText, setDownloadingOnPackText] = 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<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 () => {
- 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);
- }
- };
-
- const handleDownloadOnPackTextQr = 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;
- }
-
- 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);
- }
- };
-
- 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>
- <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 || downloadingOnPackText || jobOrders.length === 0}
- >
- {downloadingOnPack ? "下載中..." : "下載 OnPack 汁水機 QR code"}
- </Button>
- <Button
- variant="contained"
- color="secondary"
- startIcon={<Download />}
- onClick={handleDownloadOnPackTextQr}
- disabled={loading || downloadingOnPack || downloadingOnPackText || jobOrders.length === 0}
- >
- {downloadingOnPackText ? "下載中..." : "下載 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;
- 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>
- </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>
-
- <Snackbar
- open={snackbar.open}
- autoHideDuration={3000}
- onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
- message={snackbar.message}
- anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
- />
- </Box>
- );
- };
-
- export default BagPrintSearch;
|