|
- "use client";
-
- import React, { useCallback, useEffect, useRef, useState } from "react";
- import {
- Alert,
- Box,
- Button,
- CircularProgress,
- Dialog,
- DialogActions,
- DialogContent,
- DialogTitle,
- Paper,
- Snackbar,
- Stack,
- TextField,
- Typography,
- } 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 {
- checkPrinterStatus,
- fetchLaserJobOrders,
- fetchLaserBag2Settings,
- type LaserLastReceiveSuccess,
- JobOrderListItem,
- patchSetting,
- expiryDateForLaserSend,
- sendLaserBag2Job,
- } from "@/app/api/laserPrint/actions";
- import dayjs from "dayjs";
- import { formatHongKongDateTime } from "@/utils/formatHongKongDateTime";
-
- 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 REFRESH_MS = 60 * 1000;
- const PRINTER_CHECK_MS = 60 * 1000;
- const PRINTER_RETRY_MS = 30 * 1000;
- const LASER_SEND_COUNT = 3;
- const BETWEEN_SEND_MS = 3000;
- const SUCCESS_SIGNAL_MS = 3500;
-
- 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 delay(ms: number): Promise<void> {
- return new Promise((resolve) => setTimeout(resolve, ms));
- }
-
- const LaserPrintSearch: 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 [selectedId, setSelectedId] = useState<number | null>(null);
- const [sendingJobId, setSendingJobId] = useState<number | null>(null);
- const [settingsOpen, setSettingsOpen] = useState(false);
- const [errorSnackbar, setErrorSnackbar] = useState<{ open: boolean; message: string }>({
- open: false,
- message: "",
- });
- const [successSignal, setSuccessSignal] = useState<string | null>(null);
- const [laserHost, setLaserHost] = useState("");
- const [laserPort, setLaserPort] = useState("45678");
- const [laserItemCodes, setLaserItemCodes] = useState("");
- const [lastLaserReceive, setLastLaserReceive] = useState<LaserLastReceiveSuccess | null>(null);
- const [settingsLoaded, setSettingsLoaded] = useState(false);
- const [printerConnected, setPrinterConnected] = useState(false);
- const [printerMessage, setPrinterMessage] = useState("激光機未連接");
- const sendInFlightRef = useRef(false);
-
- const loadSystemSettings = useCallback(async () => {
- try {
- const s = await fetchLaserBag2Settings();
- const host = (s.host ?? "").trim();
- setLaserHost(host === "192.168.18.77" || host === "192.168.17.10" ? "" : host);
- setLaserPort(String(s.port));
- const itemCodes = (s.itemCodes ?? "")
- .split(",")
- .map((code) => code.trim())
- .filter((code) => code && code !== "PP1175" && code !== "PP2211")
- .join(",");
- setLaserItemCodes(itemCodes);
- setLastLaserReceive(s.lastReceiveSuccess ?? null);
- setSettingsLoaded(true);
- } catch (e) {
- setLastLaserReceive(null);
- setErrorSnackbar({
- open: true,
- message: e instanceof Error ? e.message : "無法載入系統設定",
- });
- setSettingsLoaded(true);
- }
- }, []);
-
- useEffect(() => {
- void loadSystemSettings();
- }, [loadSystemSettings]);
-
- useEffect(() => {
- if (!successSignal) return;
- const t = setTimeout(() => setSuccessSignal(null), SUCCESS_SIGNAL_MS);
- return () => clearTimeout(t);
- }, [successSignal]);
-
- const loadJobOrders = useCallback(
- async (fromUserChange = false) => {
- setLoading(true);
- setError(null);
- try {
- const data = await fetchLaserJobOrders(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(() => {
- void loadJobOrders(true);
- }, [planDate]);
-
- useEffect(() => {
- if (!connected) return;
- const id = setInterval(() => void loadJobOrders(false), REFRESH_MS);
- return () => clearInterval(id);
- }, [connected, loadJobOrders]);
-
- /** Server uses DB LASER_PRINT.host/port (omit body) so UI matches /plastic/print-laser-bag2. */
- const checkLaser = useCallback(async () => {
- try {
- const result = await checkPrinterStatus({ printerType: "laser" });
- setPrinterConnected(result.connected);
- setPrinterMessage(result.message);
- } catch (e) {
- setPrinterConnected(false);
- setPrinterMessage(e instanceof Error ? e.message : "激光機狀態檢查失敗");
- }
- }, []);
-
- useEffect(() => {
- if (!settingsLoaded) return;
- void checkLaser();
- }, [settingsLoaded, checkLaser]);
-
- useEffect(() => {
- if (!settingsLoaded) return;
- const intervalMs = printerConnected ? PRINTER_CHECK_MS : PRINTER_RETRY_MS;
- const id = setInterval(() => {
- void checkLaser();
- }, intervalMs);
- return () => clearInterval(id);
- }, [printerConnected, checkLaser, settingsLoaded]);
-
- 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 sendOne = (jo: JobOrderListItem) =>
- sendLaserBag2Job({
- itemId: jo.itemId,
- stockInLineId: jo.stockInLineId,
- itemCode: jo.itemCode,
- itemName: jo.itemName,
- jobOrderId: jo.id,
- jobOrderNo: jo.code,
- lotNo: jo.lotNo,
- expiryDate: expiryDateForLaserSend(jo.expiryDate),
- source: "MANUAL",
- });
-
- const handleRowClick = async (jo: JobOrderListItem) => {
- if (sendInFlightRef.current || sendingJobId !== null) return;
-
- if (!laserHost.trim()) {
- setErrorSnackbar({ open: true, message: "請在系統設定中填寫激光機 IP。" });
- return;
- }
-
- sendInFlightRef.current = true;
- setSelectedId(jo.id);
- setSendingJobId(jo.id);
- try {
- let lastAck: string | undefined;
- let anyReceiveAck = false;
- let sentOk = 0;
- let laterFail: string | null = null;
- for (let i = 0; i < LASER_SEND_COUNT; i++) {
- const r = await sendOne(jo);
- if (!r.success) {
- const failMsg = r.message?.trim() || `第 ${i + 1} 次送出失敗`;
- if (sentOk === 0) {
- setErrorSnackbar({ open: true, message: failMsg });
- return;
- }
- laterFail = failMsg;
- break;
- }
- sentOk += 1;
- if (r.printerAck) lastAck = r.printerAck;
- if (r.receiveAcknowledged) anyReceiveAck = true;
- if (i < LASER_SEND_COUNT - 1) {
- await delay(BETWEEN_SEND_MS);
- }
- }
- const ackHint =
- anyReceiveAck && lastAck
- ? `(印表機已回覆:${lastAck})`
- : lastAck
- ? `(最後回覆:${lastAck})`
- : "";
- setSuccessSignal(
- laterFail
- ? `已送出 ${sentOk} 次至激光機${ackHint}(後續重送失敗:${laterFail})`
- : `已送出 ${sentOk} 次至激光機${ackHint}`,
- );
- await loadSystemSettings();
- } catch (e) {
- setErrorSnackbar({
- open: true,
- message: e instanceof Error ? e.message : "送出失敗",
- });
- } finally {
- setSendingJobId(null);
- sendInFlightRef.current = false;
- }
- };
-
- const saveSettings = async () => {
- try {
- await patchSetting("LASER_PRINT.host", laserHost.trim());
- await patchSetting("LASER_PRINT.port", laserPort.trim() || "45678");
- await patchSetting("LASER_PRINT.itemCodes", laserItemCodes.trim());
- setSuccessSignal("設定已儲存");
- setSettingsOpen(false);
- void checkLaser();
- await loadSystemSettings();
- void loadJobOrders(false);
- } catch (e) {
- setErrorSnackbar({
- open: true,
- message: e instanceof Error ? e.message : "儲存失敗",
- });
- }
- };
-
- return (
- <Box sx={{ minHeight: "70vh", display: "flex", flexDirection: "column" }}>
- {successSignal && (
- <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccessSignal(null)}>
- {successSignal}
- </Alert>
- )}
- {settingsLoaded && lastLaserReceive && (
- <Alert severity="info" sx={{ mb: 2 }}>
- <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
- 上次印表機已確認工單:{lastLaserReceive.jobOrderNo ?? "—"}
- {formatHongKongDateTime(lastLaserReceive.sentAt)}
- {lastLaserReceive.source ? ` (${lastLaserReceive.source === "AUTO" ? "自動送出" : "手動點選"})` : ""}
- </Typography>
- <Typography variant="body2" sx={{ mt: 0.5 }}>
- 此時間只會在激光機回覆 receive 時更新。之後送出失敗不會改這裡。
- </Typography>
- </Alert>
- )}
-
- <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} disabled={sendingJobId !== null}>
- 前一天
- </Button>
- <TextField
- type="date"
- value={planDate}
- onChange={(e) => setPlanDate(e.target.value)}
- size="small"
- sx={{ width: 160 }}
- InputLabelProps={{ shrink: true }}
- disabled={sendingJobId !== null}
- />
- <Button variant="outlined" endIcon={<ChevronRight />} onClick={goNextDay} disabled={sendingJobId !== null}>
- 後一天
- </Button>
- </Stack>
- <Stack direction="row" alignItems="center" spacing={2}>
- <Button
- variant="outlined"
- startIcon={<Settings />}
- onClick={() => setSettingsOpen(true)}
- disabled={sendingJobId !== null}
- >
- 設定(系統)
- </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>
- </Stack>
- </Stack>
- </Paper>
-
- <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>
- ) : error ? (
- <Box sx={{ py: 8, textAlign: "center" }}>
- <Typography color="error">{error}</Typography>
- </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 isSending = sendingJobId === jo.id;
- return (
- <Paper
- key={jo.id}
- elevation={1}
- sx={{
- p: 2,
- display: "flex",
- alignItems: "flex-start",
- gap: 2,
- cursor: sendingJobId !== null ? "wait" : "pointer",
- backgroundColor: isSelected ? BG_ROW_SELECTED : BG_ROW,
- "&:hover": {
- backgroundColor:
- sendingJobId !== null
- ? isSelected
- ? BG_ROW_SELECTED
- : BG_ROW
- : isSelected
- ? BG_ROW_SELECTED
- : "#b8d4eb",
- },
- transition: "background-color 0.2s",
- opacity: sendingJobId !== null && !isSending ? 0.65 : 1,
- }}
- onClick={() => void 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>
- {isSending && <CircularProgress size={28} sx={{ alignSelf: "center", flexShrink: 0 }} />}
- </Paper>
- );
- })}
- </Stack>
- </Box>
- )}
- </Paper>
-
- <Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth>
- <DialogTitle>激光機(系統設定)</DialogTitle>
- <DialogContent>
- <Stack spacing={2} sx={{ mt: 1 }}>
- <Typography variant="body2" color="text.secondary">
- 儲存後寫入資料庫,後端送出走此 IP/埠。未填 IP 則不會送出。
- </Typography>
- <TextField
- label="IP"
- size="small"
- value={laserHost}
- onChange={(e) => setLaserHost(e.target.value)}
- fullWidth
- />
- <TextField
- label="Port"
- size="small"
- value={laserPort}
- onChange={(e) => setLaserPort(e.target.value)}
- fullWidth
- />
- <TextField
- label="列表品號(逗號分隔)"
- size="small"
- value={laserItemCodes}
- onChange={(e) => setLaserItemCodes(e.target.value)}
- fullWidth
- placeholder="例如 DEMO-FG-01"
- helperText="可輸入多個品號,例如 DEMO-FG-01,DEMO-FG-02。留空則列表顯示當日全部包裝工單。"
- />
- </Stack>
- </DialogContent>
- <DialogActions>
- <Button onClick={() => setSettingsOpen(false)}>取消</Button>
- <Button variant="contained" onClick={() => void saveSettings()}>
- 儲存
- </Button>
- </DialogActions>
- </Dialog>
-
- <Snackbar
- open={errorSnackbar.open}
- autoHideDuration={6000}
- onClose={() => setErrorSnackbar((s) => ({ ...s, open: false }))}
- message={errorSnackbar.message}
- anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
- />
- </Box>
- );
- };
-
- export default LaserPrintSearch;
|