FPSMS-frontend
Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 

480 linhas
16 KiB

  1. "use client";
  2. import React, { useCallback, useEffect, useRef, useState } from "react";
  3. import {
  4. Alert,
  5. Box,
  6. Button,
  7. CircularProgress,
  8. Dialog,
  9. DialogActions,
  10. DialogContent,
  11. DialogTitle,
  12. Paper,
  13. Snackbar,
  14. Stack,
  15. TextField,
  16. Typography,
  17. } from "@mui/material";
  18. import ChevronLeft from "@mui/icons-material/ChevronLeft";
  19. import ChevronRight from "@mui/icons-material/ChevronRight";
  20. import Settings from "@mui/icons-material/Settings";
  21. import {
  22. checkPrinterStatus,
  23. fetchLaserJobOrders,
  24. fetchLaserBag2Settings,
  25. type LaserLastReceiveSuccess,
  26. JobOrderListItem,
  27. patchSetting,
  28. expiryDateForLaserSend,
  29. sendLaserBag2Job,
  30. } from "@/app/api/laserPrint/actions";
  31. import dayjs from "dayjs";
  32. import { formatHongKongDateTime } from "@/utils/formatHongKongDateTime";
  33. const BG_TOP = "#E8F4FC";
  34. const BG_LIST = "#D4E8F7";
  35. const BG_ROW = "#C5E1F5";
  36. const BG_ROW_SELECTED = "#6BB5FF";
  37. const BG_STATUS_ERROR = "#FFCCCB";
  38. const BG_STATUS_OK = "#90EE90";
  39. const FG_STATUS_ERROR = "#B22222";
  40. const FG_STATUS_OK = "#006400";
  41. const REFRESH_MS = 60 * 1000;
  42. const PRINTER_CHECK_MS = 60 * 1000;
  43. const PRINTER_RETRY_MS = 30 * 1000;
  44. const LASER_SEND_COUNT = 3;
  45. const BETWEEN_SEND_MS = 3000;
  46. const SUCCESS_SIGNAL_MS = 3500;
  47. function formatQty(val: number | null | undefined): string {
  48. if (val == null) return "—";
  49. try {
  50. const n = Number(val);
  51. if (Number.isInteger(n)) return n.toLocaleString();
  52. return n
  53. .toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })
  54. .replace(/\.?0+$/, "");
  55. } catch {
  56. return String(val);
  57. }
  58. }
  59. function getBatch(jo: JobOrderListItem): string {
  60. return (jo.lotNo || "—").trim() || "—";
  61. }
  62. function delay(ms: number): Promise<void> {
  63. return new Promise((resolve) => setTimeout(resolve, ms));
  64. }
  65. const LaserPrintSearch: React.FC = () => {
  66. const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD"));
  67. const [jobOrders, setJobOrders] = useState<JobOrderListItem[]>([]);
  68. const [loading, setLoading] = useState(true);
  69. const [error, setError] = useState<string | null>(null);
  70. const [connected, setConnected] = useState(false);
  71. const [selectedId, setSelectedId] = useState<number | null>(null);
  72. const [sendingJobId, setSendingJobId] = useState<number | null>(null);
  73. const [settingsOpen, setSettingsOpen] = useState(false);
  74. const [errorSnackbar, setErrorSnackbar] = useState<{ open: boolean; message: string }>({
  75. open: false,
  76. message: "",
  77. });
  78. const [successSignal, setSuccessSignal] = useState<string | null>(null);
  79. const [laserHost, setLaserHost] = useState("");
  80. const [laserPort, setLaserPort] = useState("45678");
  81. const [laserItemCodes, setLaserItemCodes] = useState("");
  82. const [lastLaserReceive, setLastLaserReceive] = useState<LaserLastReceiveSuccess | null>(null);
  83. const [settingsLoaded, setSettingsLoaded] = useState(false);
  84. const [printerConnected, setPrinterConnected] = useState(false);
  85. const [printerMessage, setPrinterMessage] = useState("激光機未連接");
  86. const sendInFlightRef = useRef(false);
  87. const loadSystemSettings = useCallback(async () => {
  88. try {
  89. const s = await fetchLaserBag2Settings();
  90. const host = (s.host ?? "").trim();
  91. setLaserHost(host === "192.168.18.77" || host === "192.168.17.10" ? "" : host);
  92. setLaserPort(String(s.port));
  93. const itemCodes = (s.itemCodes ?? "")
  94. .split(",")
  95. .map((code) => code.trim())
  96. .filter((code) => code && code !== "PP1175" && code !== "PP2211")
  97. .join(",");
  98. setLaserItemCodes(itemCodes);
  99. setLastLaserReceive(s.lastReceiveSuccess ?? null);
  100. setSettingsLoaded(true);
  101. } catch (e) {
  102. setLastLaserReceive(null);
  103. setErrorSnackbar({
  104. open: true,
  105. message: e instanceof Error ? e.message : "無法載入系統設定",
  106. });
  107. setSettingsLoaded(true);
  108. }
  109. }, []);
  110. useEffect(() => {
  111. void loadSystemSettings();
  112. }, [loadSystemSettings]);
  113. useEffect(() => {
  114. if (!successSignal) return;
  115. const t = setTimeout(() => setSuccessSignal(null), SUCCESS_SIGNAL_MS);
  116. return () => clearTimeout(t);
  117. }, [successSignal]);
  118. const loadJobOrders = useCallback(
  119. async (fromUserChange = false) => {
  120. setLoading(true);
  121. setError(null);
  122. try {
  123. const data = await fetchLaserJobOrders(planDate);
  124. setJobOrders(data);
  125. setConnected(true);
  126. if (fromUserChange) setSelectedId(null);
  127. } catch (e) {
  128. setError(e instanceof Error ? e.message : "連接不到服務器");
  129. setConnected(false);
  130. setJobOrders([]);
  131. } finally {
  132. setLoading(false);
  133. }
  134. },
  135. [planDate],
  136. );
  137. useEffect(() => {
  138. void loadJobOrders(true);
  139. }, [planDate]);
  140. useEffect(() => {
  141. if (!connected) return;
  142. const id = setInterval(() => void loadJobOrders(false), REFRESH_MS);
  143. return () => clearInterval(id);
  144. }, [connected, loadJobOrders]);
  145. /** Server uses DB LASER_PRINT.host/port (omit body) so UI matches /plastic/print-laser-bag2. */
  146. const checkLaser = useCallback(async () => {
  147. try {
  148. const result = await checkPrinterStatus({ printerType: "laser" });
  149. setPrinterConnected(result.connected);
  150. setPrinterMessage(result.message);
  151. } catch (e) {
  152. setPrinterConnected(false);
  153. setPrinterMessage(e instanceof Error ? e.message : "激光機狀態檢查失敗");
  154. }
  155. }, []);
  156. useEffect(() => {
  157. if (!settingsLoaded) return;
  158. void checkLaser();
  159. }, [settingsLoaded, checkLaser]);
  160. useEffect(() => {
  161. if (!settingsLoaded) return;
  162. const intervalMs = printerConnected ? PRINTER_CHECK_MS : PRINTER_RETRY_MS;
  163. const id = setInterval(() => {
  164. void checkLaser();
  165. }, intervalMs);
  166. return () => clearInterval(id);
  167. }, [printerConnected, checkLaser, settingsLoaded]);
  168. const goPrevDay = () => {
  169. setPlanDate((d) => dayjs(d).subtract(1, "day").format("YYYY-MM-DD"));
  170. };
  171. const goNextDay = () => {
  172. setPlanDate((d) => dayjs(d).add(1, "day").format("YYYY-MM-DD"));
  173. };
  174. const sendOne = (jo: JobOrderListItem) =>
  175. sendLaserBag2Job({
  176. itemId: jo.itemId,
  177. stockInLineId: jo.stockInLineId,
  178. itemCode: jo.itemCode,
  179. itemName: jo.itemName,
  180. jobOrderId: jo.id,
  181. jobOrderNo: jo.code,
  182. lotNo: jo.lotNo,
  183. expiryDate: expiryDateForLaserSend(jo.expiryDate),
  184. source: "MANUAL",
  185. });
  186. const handleRowClick = async (jo: JobOrderListItem) => {
  187. if (sendInFlightRef.current || sendingJobId !== null) return;
  188. if (!laserHost.trim()) {
  189. setErrorSnackbar({ open: true, message: "請在系統設定中填寫激光機 IP。" });
  190. return;
  191. }
  192. sendInFlightRef.current = true;
  193. setSelectedId(jo.id);
  194. setSendingJobId(jo.id);
  195. try {
  196. let lastAck: string | undefined;
  197. let anyReceiveAck = false;
  198. let sentOk = 0;
  199. let laterFail: string | null = null;
  200. for (let i = 0; i < LASER_SEND_COUNT; i++) {
  201. const r = await sendOne(jo);
  202. if (!r.success) {
  203. const failMsg = r.message?.trim() || `第 ${i + 1} 次送出失敗`;
  204. if (sentOk === 0) {
  205. setErrorSnackbar({ open: true, message: failMsg });
  206. return;
  207. }
  208. laterFail = failMsg;
  209. break;
  210. }
  211. sentOk += 1;
  212. if (r.printerAck) lastAck = r.printerAck;
  213. if (r.receiveAcknowledged) anyReceiveAck = true;
  214. if (i < LASER_SEND_COUNT - 1) {
  215. await delay(BETWEEN_SEND_MS);
  216. }
  217. }
  218. const ackHint =
  219. anyReceiveAck && lastAck
  220. ? `(印表機已回覆:${lastAck})`
  221. : lastAck
  222. ? `(最後回覆:${lastAck})`
  223. : "";
  224. setSuccessSignal(
  225. laterFail
  226. ? `已送出 ${sentOk} 次至激光機${ackHint}(後續重送失敗:${laterFail})`
  227. : `已送出 ${sentOk} 次至激光機${ackHint}`,
  228. );
  229. await loadSystemSettings();
  230. } catch (e) {
  231. setErrorSnackbar({
  232. open: true,
  233. message: e instanceof Error ? e.message : "送出失敗",
  234. });
  235. } finally {
  236. setSendingJobId(null);
  237. sendInFlightRef.current = false;
  238. }
  239. };
  240. const saveSettings = async () => {
  241. try {
  242. await patchSetting("LASER_PRINT.host", laserHost.trim());
  243. await patchSetting("LASER_PRINT.port", laserPort.trim() || "45678");
  244. await patchSetting("LASER_PRINT.itemCodes", laserItemCodes.trim());
  245. setSuccessSignal("設定已儲存");
  246. setSettingsOpen(false);
  247. void checkLaser();
  248. await loadSystemSettings();
  249. void loadJobOrders(false);
  250. } catch (e) {
  251. setErrorSnackbar({
  252. open: true,
  253. message: e instanceof Error ? e.message : "儲存失敗",
  254. });
  255. }
  256. };
  257. return (
  258. <Box sx={{ minHeight: "70vh", display: "flex", flexDirection: "column" }}>
  259. {successSignal && (
  260. <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccessSignal(null)}>
  261. {successSignal}
  262. </Alert>
  263. )}
  264. {settingsLoaded && lastLaserReceive && (
  265. <Alert severity="info" sx={{ mb: 2 }}>
  266. <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
  267. 上次印表機已確認工單:{lastLaserReceive.jobOrderNo ?? "—"} 
  268. {formatHongKongDateTime(lastLaserReceive.sentAt)}
  269. {lastLaserReceive.source ? ` (${lastLaserReceive.source === "AUTO" ? "自動送出" : "手動點選"})` : ""}
  270. </Typography>
  271. <Typography variant="body2" sx={{ mt: 0.5 }}>
  272. 此時間只會在激光機回覆 receive 時更新。之後送出失敗不會改這裡。
  273. </Typography>
  274. </Alert>
  275. )}
  276. <Paper sx={{ p: 2, mb: 2, backgroundColor: BG_TOP }}>
  277. <Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={2}>
  278. <Stack direction="row" alignItems="center" spacing={2}>
  279. <Button variant="outlined" startIcon={<ChevronLeft />} onClick={goPrevDay} disabled={sendingJobId !== null}>
  280. 前一天
  281. </Button>
  282. <TextField
  283. type="date"
  284. value={planDate}
  285. onChange={(e) => setPlanDate(e.target.value)}
  286. size="small"
  287. sx={{ width: 160 }}
  288. InputLabelProps={{ shrink: true }}
  289. disabled={sendingJobId !== null}
  290. />
  291. <Button variant="outlined" endIcon={<ChevronRight />} onClick={goNextDay} disabled={sendingJobId !== null}>
  292. 後一天
  293. </Button>
  294. </Stack>
  295. <Stack direction="row" alignItems="center" spacing={2}>
  296. <Button
  297. variant="outlined"
  298. startIcon={<Settings />}
  299. onClick={() => setSettingsOpen(true)}
  300. disabled={sendingJobId !== null}
  301. >
  302. 設定(系統)
  303. </Button>
  304. <Box
  305. sx={{
  306. px: 1.5,
  307. py: 0.75,
  308. borderRadius: 1,
  309. backgroundColor: printerConnected ? BG_STATUS_OK : BG_STATUS_ERROR,
  310. color: printerConnected ? FG_STATUS_OK : FG_STATUS_ERROR,
  311. fontWeight: 600,
  312. whiteSpace: "nowrap",
  313. }}
  314. title={printerMessage}
  315. >
  316. 激光機:
  317. </Box>
  318. </Stack>
  319. </Stack>
  320. </Paper>
  321. <Paper sx={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column", backgroundColor: BG_LIST }}>
  322. {loading ? (
  323. <Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", py: 8 }}>
  324. <CircularProgress />
  325. </Box>
  326. ) : error ? (
  327. <Box sx={{ py: 8, textAlign: "center" }}>
  328. <Typography color="error">{error}</Typography>
  329. </Box>
  330. ) : jobOrders.length === 0 ? (
  331. <Box sx={{ py: 8, textAlign: "center" }}>
  332. <Typography color="text.secondary">當日無工單</Typography>
  333. </Box>
  334. ) : (
  335. <Box sx={{ overflow: "auto", flex: 1, p: 2 }}>
  336. <Stack spacing={1}>
  337. {jobOrders.map((jo) => {
  338. const batch = getBatch(jo);
  339. const qtyStr = formatQty(jo.reqQty);
  340. const isSelected = selectedId === jo.id;
  341. const isSending = sendingJobId === jo.id;
  342. return (
  343. <Paper
  344. key={jo.id}
  345. elevation={1}
  346. sx={{
  347. p: 2,
  348. display: "flex",
  349. alignItems: "flex-start",
  350. gap: 2,
  351. cursor: sendingJobId !== null ? "wait" : "pointer",
  352. backgroundColor: isSelected ? BG_ROW_SELECTED : BG_ROW,
  353. "&:hover": {
  354. backgroundColor:
  355. sendingJobId !== null
  356. ? isSelected
  357. ? BG_ROW_SELECTED
  358. : BG_ROW
  359. : isSelected
  360. ? BG_ROW_SELECTED
  361. : "#b8d4eb",
  362. },
  363. transition: "background-color 0.2s",
  364. opacity: sendingJobId !== null && !isSending ? 0.65 : 1,
  365. }}
  366. onClick={() => void handleRowClick(jo)}
  367. >
  368. <Box sx={{ minWidth: 120, flexShrink: 0 }}>
  369. <Typography variant="h6" sx={{ fontSize: "1.1rem" }}>
  370. {batch}
  371. </Typography>
  372. {qtyStr !== "—" && (
  373. <Typography variant="body2" color="text.secondary">
  374. 數量:{qtyStr}
  375. </Typography>
  376. )}
  377. </Box>
  378. <Box sx={{ minWidth: 140, flexShrink: 0 }}>
  379. <Typography variant="h6" sx={{ fontSize: "1.1rem" }}>
  380. {jo.code || "—"}
  381. </Typography>
  382. </Box>
  383. <Box sx={{ minWidth: 140, flexShrink: 0 }}>
  384. <Typography variant="h6" sx={{ fontSize: "1.35rem" }}>
  385. {jo.itemCode || "—"}
  386. </Typography>
  387. </Box>
  388. <Box sx={{ flex: 1, minWidth: 0 }}>
  389. <Typography variant="h6" sx={{ fontSize: "1.35rem", wordBreak: "break-word" }}>
  390. {jo.itemName || "—"}
  391. </Typography>
  392. </Box>
  393. {isSending && <CircularProgress size={28} sx={{ alignSelf: "center", flexShrink: 0 }} />}
  394. </Paper>
  395. );
  396. })}
  397. </Stack>
  398. </Box>
  399. )}
  400. </Paper>
  401. <Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth>
  402. <DialogTitle>激光機(系統設定)</DialogTitle>
  403. <DialogContent>
  404. <Stack spacing={2} sx={{ mt: 1 }}>
  405. <Typography variant="body2" color="text.secondary">
  406. 儲存後寫入資料庫,後端送出走此 IP/埠。未填 IP 則不會送出。
  407. </Typography>
  408. <TextField
  409. label="IP"
  410. size="small"
  411. value={laserHost}
  412. onChange={(e) => setLaserHost(e.target.value)}
  413. fullWidth
  414. />
  415. <TextField
  416. label="Port"
  417. size="small"
  418. value={laserPort}
  419. onChange={(e) => setLaserPort(e.target.value)}
  420. fullWidth
  421. />
  422. <TextField
  423. label="列表品號(逗號分隔)"
  424. size="small"
  425. value={laserItemCodes}
  426. onChange={(e) => setLaserItemCodes(e.target.value)}
  427. fullWidth
  428. placeholder="例如 DEMO-FG-01"
  429. helperText="可輸入多個品號,例如 DEMO-FG-01,DEMO-FG-02。留空則列表顯示當日全部包裝工單。"
  430. />
  431. </Stack>
  432. </DialogContent>
  433. <DialogActions>
  434. <Button onClick={() => setSettingsOpen(false)}>取消</Button>
  435. <Button variant="contained" onClick={() => void saveSettings()}>
  436. 儲存
  437. </Button>
  438. </DialogActions>
  439. </Dialog>
  440. <Snackbar
  441. open={errorSnackbar.open}
  442. autoHideDuration={6000}
  443. onClose={() => setErrorSnackbar((s) => ({ ...s, open: false }))}
  444. message={errorSnackbar.message}
  445. anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
  446. />
  447. </Box>
  448. );
  449. };
  450. export default LaserPrintSearch;