diff --git a/src/app/(main)/laserPrint/page.tsx b/src/app/(main)/laserPrint/page.tsx new file mode 100644 index 0000000..081900e --- /dev/null +++ b/src/app/(main)/laserPrint/page.tsx @@ -0,0 +1,23 @@ +import LaserPrintSearch from "@/components/LaserPrint/LaserPrintSearch"; +import { Stack, Typography } from "@mui/material"; +import { Metadata } from "next"; +import React from "react"; + +export const metadata: Metadata = { + title: "檸檬機(激光機)", +}; + +const LaserPrintPage: React.FC = () => { + return ( + <> + + + 檸檬機(激光機) + + + + + ); +}; + +export default LaserPrintPage; diff --git a/src/app/api/laserPrint/actions.ts b/src/app/api/laserPrint/actions.ts new file mode 100644 index 0000000..8b54844 --- /dev/null +++ b/src/app/api/laserPrint/actions.ts @@ -0,0 +1,135 @@ +"use client"; + +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; + +export interface JobOrderListItem { + id: number; + code: string | null; + planStart: string | null; + itemCode: string | null; + itemName: string | null; + reqQty: number | null; + stockInLineId: number | null; + itemId: number | null; + lotNo: string | null; +} + +export interface LaserBag2Settings { + host: string; + port: number; + /** Comma-separated item codes; empty string = show all packaging job orders */ + itemCodes: string; +} + +export interface LaserBag2SendRequest { + itemId: number | null; + stockInLineId: number | null; + itemCode: string | null; + itemName: string | null; + printerIp?: string; + printerPort?: number; +} + +export interface LaserBag2SendResponse { + success: boolean; + message: string; + payloadSent?: string | null; +} + +/** + * Uses server LASER_PRINT.itemCodes filter. Calls public GET /py/laser-job-orders (same as Python Bag2 /py/job-orders), + * so it works without relying on authenticated /plastic routes. + */ +export async function fetchLaserJobOrders(planStart: string): Promise { + const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); + if (!base) { + throw new Error("NEXT_PUBLIC_API_URL is not set; cannot reach API."); + } + const url = `${base}/py/laser-job-orders?planStart=${encodeURIComponent(planStart)}`; + let res: Response; + try { + res = await clientAuthFetch(url, { method: "GET" }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error( + `無法連線 API(${url}):${msg}。請確認後端已啟動且 NEXT_PUBLIC_API_URL 指向正確(例如 http://localhost:8090/api)。`, + ); + } + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error( + `載入工單失敗(${res.status})${body ? `:${body.slice(0, 200)}` : ""}`, + ); + } + return res.json() as Promise; +} + +export async function fetchLaserBag2Settings(): Promise { + const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); + if (!base) { + throw new Error("NEXT_PUBLIC_API_URL is not set."); + } + const url = `${base}/plastic/laser-bag2-settings`; + let res: Response; + try { + res = await clientAuthFetch(url, { method: "GET" }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(`無法連線至 ${url}:${msg}`); + } + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`載入設定失敗(${res.status})${body ? body.slice(0, 200) : ""}`); + } + return res.json() as Promise; +} + +export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise { + const url = `${NEXT_PUBLIC_API_URL}/plastic/print-laser-bag2`; + const res = await clientAuthFetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = (await res.json()) as LaserBag2SendResponse; + if (!res.ok) { + return data; + } + return data; +} + +export interface PrinterStatusRequest { + printerType: "laser"; + printerIp?: string; + printerPort?: number; +} + +export interface PrinterStatusResponse { + connected: boolean; + message: string; +} + +export async function checkPrinterStatus(request: PrinterStatusRequest): Promise { + const url = `${NEXT_PUBLIC_API_URL}/plastic/check-printer`; + const res = await clientAuthFetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); + const data = (await res.json()) as PrinterStatusResponse; + return data; +} + +export async function patchSetting(name: string, value: string): Promise { + const url = `${NEXT_PUBLIC_API_URL}/settings/${encodeURIComponent(name)}`; + const res = await clientAuthFetch(url, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + if (!res.ok) { + const t = await res.text().catch(() => ""); + throw new Error(t || `Failed to save setting: ${res.status}`); + } +} diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 5456d5a..6818599 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -46,6 +46,7 @@ const pathToLabelMap: { [path: string]: string } = { "/stockIssue": "Stock Issue", "/report": "Report", "/bagPrint": "打袋機", + "/laserPrint": "檸檬機(激光機)", "/settings/itemPrice": "Price Inquiry", }; diff --git a/src/components/LaserPrint/LaserPrintSearch.tsx b/src/components/LaserPrint/LaserPrintSearch.tsx new file mode 100644 index 0000000..a0d62b7 --- /dev/null +++ b/src/components/LaserPrint/LaserPrintSearch.tsx @@ -0,0 +1,431 @@ +"use client"; + +import React, { useCallback, useEffect, 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, + JobOrderListItem, + patchSetting, + sendLaserBag2Job, +} from "@/app/api/laserPrint/actions"; +import dayjs from "dayjs"; + +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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const LaserPrintSearch: React.FC = () => { + const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); + const [jobOrders, setJobOrders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [connected, setConnected] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [sendingJobId, setSendingJobId] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); + const [errorSnackbar, setErrorSnackbar] = useState<{ open: boolean; message: string }>({ + open: false, + message: "", + }); + const [successSignal, setSuccessSignal] = useState(null); + const [laserHost, setLaserHost] = useState("192.168.18.77"); + const [laserPort, setLaserPort] = useState("45678"); + const [laserItemCodes, setLaserItemCodes] = useState("PP1175"); + const [settingsLoaded, setSettingsLoaded] = useState(false); + const [printerConnected, setPrinterConnected] = useState(false); + const [printerMessage, setPrinterMessage] = useState("檸檬機(激光機)未連接"); + + const loadSystemSettings = useCallback(async () => { + try { + const s = await fetchLaserBag2Settings(); + setLaserHost(s.host); + setLaserPort(String(s.port)); + setLaserItemCodes(s.itemCodes ?? "PP1175"); + setSettingsLoaded(true); + } catch (e) { + 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]); + + const checkLaser = useCallback(async () => { + const portNum = Number(laserPort || 45678); + try { + const result = await checkPrinterStatus({ + printerType: "laser", + printerIp: laserHost.trim(), + printerPort: Number.isFinite(portNum) ? portNum : 45678, + }); + setPrinterConnected(result.connected); + setPrinterMessage(result.message); + } catch (e) { + setPrinterConnected(false); + setPrinterMessage(e instanceof Error ? e.message : "檸檬機(激光機)狀態檢查失敗"); + } + }, [laserHost, laserPort]); + + 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, + }); + + const handleRowClick = async (jo: JobOrderListItem) => { + if (sendingJobId !== null) return; + + if (!laserHost.trim()) { + setErrorSnackbar({ open: true, message: "請在系統設定中填寫檸檬機(激光機) IP。" }); + return; + } + + setSelectedId(jo.id); + setSendingJobId(jo.id); + try { + for (let i = 0; i < LASER_SEND_COUNT; i++) { + const r = await sendOne(jo); + if (!r.success) { + setErrorSnackbar({ + open: true, + message: r.message || "檸檬機(激光機)未收到指令", + }); + return; + } + if (i < LASER_SEND_COUNT - 1) { + await delay(BETWEEN_SEND_MS); + } + } + setSuccessSignal(`已送出 ${LASER_SEND_COUNT} 次至檸檬機(激光機)`); + } catch (e) { + setErrorSnackbar({ + open: true, + message: e instanceof Error ? e.message : "送出失敗", + }); + } finally { + setSendingJobId(null); + } + }; + + 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 ( + + {successSignal && ( + setSuccessSignal(null)}> + {successSignal} + + )} + + + + + + setPlanDate(e.target.value)} + size="small" + sx={{ width: 160 }} + InputLabelProps={{ shrink: true }} + disabled={sendingJobId !== null} + /> + + + + + + 檸檬機(激光機): + + + + + + + {loading ? ( + + + + ) : error ? ( + + {error} + + ) : jobOrders.length === 0 ? ( + + 當日無工單 + + ) : ( + + + {jobOrders.map((jo) => { + const batch = getBatch(jo); + const qtyStr = formatQty(jo.reqQty); + const isSelected = selectedId === jo.id; + const isSending = sendingJobId === jo.id; + return ( + void handleRowClick(jo)} + > + + + {batch} + + {qtyStr !== "—" && ( + + 數量:{qtyStr} + + )} + + + + {jo.code || "—"} + + + + + {jo.itemCode || "—"} + + + + + {jo.itemName || "—"} + + + {isSending && } + + ); + })} + + + )} + + + setSettingsOpen(false)} maxWidth="sm" fullWidth> + 檸檬機(激光機)(系統設定) + + + + 儲存後寫入資料庫,後端送出走此 IP/埠(預設 192.168.18.77:45678)。 + + setLaserHost(e.target.value)} + fullWidth + /> + setLaserPort(e.target.value)} + fullWidth + /> + setLaserItemCodes(e.target.value)} + fullWidth + placeholder="PP1175" + helperText="預設 PP1175;可輸入多個品號,例如 PP1175,AB999。留空則列表顯示當日全部包裝工單。" + /> + + + + + + + + + setErrorSnackbar((s) => ({ ...s, open: false }))} + message={errorSnackbar.message} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + /> + + ); +}; + +export default LaserPrintSearch; diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 37b79b9..065fa74 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -180,6 +180,13 @@ const NavigationContent: React.FC = () => { requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], isHidden: false, }, + { + icon: , + label: "檸檬機(激光機)", + path: "/laserPrint", + requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], + isHidden: false, + }, { icon: , label: "報告管理", diff --git a/src/routes.ts b/src/routes.ts index b85b0b8..6d66c9e 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -5,6 +5,7 @@ export const PRIVATE_ROUTES = [ "/jo/testing", "/ps", "/bagPrint", + "/laserPrint", "/report", "/invoice", "/projects",