PC-20260115JRSN\Administrator 5 дней назад
Родитель
Сommit
3df19f9a0b
6 измененных файлов: 598 добавлений и 0 удалений
  1. +23
    -0
      src/app/(main)/laserPrint/page.tsx
  2. +135
    -0
      src/app/api/laserPrint/actions.ts
  3. +1
    -0
      src/components/Breadcrumb/Breadcrumb.tsx
  4. +431
    -0
      src/components/LaserPrint/LaserPrintSearch.tsx
  5. +7
    -0
      src/components/NavigationContent/NavigationContent.tsx
  6. +1
    -0
      src/routes.ts

+ 23
- 0
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 (
<>
<Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}>
<Typography variant="h4" marginInlineEnd={2}>
檸檬機(激光機)
</Typography>
</Stack>
<LaserPrintSearch />
</>
);
};

export default LaserPrintPage;

+ 135
- 0
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<JobOrderListItem[]> {
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<JobOrderListItem[]>;
}

export async function fetchLaserBag2Settings(): Promise<LaserBag2Settings> {
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<LaserBag2Settings>;
}

export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise<LaserBag2SendResponse> {
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<PrinterStatusResponse> {
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<void> {
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}`);
}
}

+ 1
- 0
src/components/Breadcrumb/Breadcrumb.tsx Просмотреть файл

@@ -46,6 +46,7 @@ const pathToLabelMap: { [path: string]: string } = {
"/stockIssue": "Stock Issue", "/stockIssue": "Stock Issue",
"/report": "Report", "/report": "Report",
"/bagPrint": "打袋機", "/bagPrint": "打袋機",
"/laserPrint": "檸檬機(激光機)",
"/settings/itemPrice": "Price Inquiry", "/settings/itemPrice": "Price Inquiry",
}; };




+ 431
- 0
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<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("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 (
<Box sx={{ minHeight: "70vh", display: "flex", flexDirection: "column" }}>
{successSignal && (
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccessSignal(null)}>
{successSignal}
</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/埠(預設 192.168.18.77:45678)。
</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="PP1175"
helperText="預設 PP1175;可輸入多個品號,例如 PP1175,AB999。留空則列表顯示當日全部包裝工單。"
/>
</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;

+ 7
- 0
src/components/NavigationContent/NavigationContent.tsx Просмотреть файл

@@ -180,6 +180,13 @@ const NavigationContent: React.FC = () => {
requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN],
isHidden: false, isHidden: false,
}, },
{
icon: <Print />,
label: "檸檬機(激光機)",
path: "/laserPrint",
requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN],
isHidden: false,
},
{ {
icon: <Assessment />, icon: <Assessment />,
label: "報告管理", label: "報告管理",


+ 1
- 0
src/routes.ts Просмотреть файл

@@ -5,6 +5,7 @@ export const PRIVATE_ROUTES = [
"/jo/testing", "/jo/testing",
"/ps", "/ps",
"/bagPrint", "/bagPrint",
"/laserPrint",
"/report", "/report",
"/invoice", "/invoice",
"/projects", "/projects",


Загрузка…
Отмена
Сохранить