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}
+
+ )}
+
+
+
+
+ } onClick={goPrevDay} disabled={sendingJobId !== null}>
+ 前一天
+
+ setPlanDate(e.target.value)}
+ size="small"
+ sx={{ width: 160 }}
+ InputLabelProps={{ shrink: true }}
+ disabled={sendingJobId !== null}
+ />
+ } onClick={goNextDay} disabled={sendingJobId !== null}>
+ 後一天
+
+
+
+ }
+ onClick={() => setSettingsOpen(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 && }
+
+ );
+ })}
+
+
+ )}
+
+
+
+
+ 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",