FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

656 lines
22 KiB

  1. "use client";
  2. import React, { useCallback, useEffect, useState } from "react";
  3. import {
  4. Box,
  5. Button,
  6. FormControl,
  7. InputLabel,
  8. MenuItem,
  9. Select,
  10. Stack,
  11. Typography,
  12. Paper,
  13. CircularProgress,
  14. SelectChangeEvent,
  15. Dialog,
  16. DialogTitle,
  17. DialogContent,
  18. DialogActions,
  19. TextField,
  20. Snackbar,
  21. } from "@mui/material";
  22. import ChevronLeft from "@mui/icons-material/ChevronLeft";
  23. import ChevronRight from "@mui/icons-material/ChevronRight";
  24. import Settings from "@mui/icons-material/Settings";
  25. import Print from "@mui/icons-material/Print";
  26. import Download from "@mui/icons-material/Download";
  27. import {
  28. checkPrinterStatus,
  29. downloadOnPackQrZip,
  30. downloadOnPackTextQrZip,
  31. fetchJobOrders,
  32. JobOrderListItem,
  33. } from "@/app/api/bagPrint/actions";
  34. import dayjs from "dayjs";
  35. import { NEXT_PUBLIC_API_URL } from "@/config/api";
  36. import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
  37. // Light blue theme (matching Python Bag1)
  38. const BG_TOP = "#E8F4FC";
  39. const BG_LIST = "#D4E8F7";
  40. const BG_ROW = "#C5E1F5";
  41. const BG_ROW_SELECTED = "#6BB5FF";
  42. const BG_STATUS_ERROR = "#FFCCCB";
  43. const BG_STATUS_OK = "#90EE90";
  44. const FG_STATUS_ERROR = "#B22222";
  45. const FG_STATUS_OK = "#006400";
  46. const PRINTER_OPTIONS = [
  47. { value: "dataflex", label: "打袋機 DataFlex" },
  48. { value: "laser", label: "激光機" },
  49. ];
  50. const REFRESH_MS = 60 * 1000;
  51. const PRINTER_CHECK_MS = 60 * 1000;
  52. const PRINTER_RETRY_MS = 30 * 1000;
  53. const SETTINGS_KEY = "bagPrint_settings";
  54. const DEFAULT_SETTINGS = {
  55. dabag_ip: "",
  56. dabag_port: "3008",
  57. laser_ip: "192.168.17.10",
  58. laser_port: "45678",
  59. };
  60. function loadSettings(): typeof DEFAULT_SETTINGS {
  61. if (typeof window === "undefined") return DEFAULT_SETTINGS;
  62. try {
  63. const s = localStorage.getItem(SETTINGS_KEY);
  64. if (s) return { ...DEFAULT_SETTINGS, ...JSON.parse(s) };
  65. } catch {}
  66. return DEFAULT_SETTINGS;
  67. }
  68. function saveSettings(s: typeof DEFAULT_SETTINGS) {
  69. if (typeof window === "undefined") return;
  70. try {
  71. localStorage.setItem(SETTINGS_KEY, JSON.stringify(s));
  72. } catch {}
  73. }
  74. function formatQty(val: number | null | undefined): string {
  75. if (val == null) return "—";
  76. try {
  77. const n = Number(val);
  78. if (Number.isInteger(n)) return n.toLocaleString();
  79. return n.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 }).replace(/\.?0+$/, "");
  80. } catch {
  81. return String(val);
  82. }
  83. }
  84. function getBatch(jo: JobOrderListItem): string {
  85. return (jo.lotNo || "—").trim() || "—";
  86. }
  87. const BagPrintSearch: React.FC = () => {
  88. const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD"));
  89. const [jobOrders, setJobOrders] = useState<JobOrderListItem[]>([]);
  90. const [loading, setLoading] = useState(true);
  91. const [error, setError] = useState<string | null>(null);
  92. const [connected, setConnected] = useState(false);
  93. const [printer, setPrinter] = useState<string>("dataflex");
  94. const [selectedId, setSelectedId] = useState<number | null>(null);
  95. const [printDialogOpen, setPrintDialogOpen] = useState(false);
  96. const [printTarget, setPrintTarget] = useState<JobOrderListItem | null>(null);
  97. const [printCount, setPrintCount] = useState(0);
  98. const [printContinuous, setPrintContinuous] = useState(false);
  99. const [printing, setPrinting] = useState(false);
  100. const [settingsOpen, setSettingsOpen] = useState(false);
  101. const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: "success" | "info" | "error" }>({ open: false, message: "" });
  102. const [settings, setSettings] = useState(DEFAULT_SETTINGS);
  103. const [printerConnected, setPrinterConnected] = useState(false);
  104. const [printerMessage, setPrinterMessage] = useState("列印機未連接");
  105. const [downloadingOnPack, setDownloadingOnPack] = useState(false);
  106. const [downloadingOnPackText, setDownloadingOnPackText] = useState(false);
  107. useEffect(() => {
  108. setSettings(loadSettings());
  109. }, []);
  110. const loadJobOrders = useCallback(async (fromUserChange = false) => {
  111. setLoading(true);
  112. setError(null);
  113. try {
  114. const data = await fetchJobOrders(planDate);
  115. setJobOrders(data);
  116. setConnected(true);
  117. if (fromUserChange) setSelectedId(null);
  118. } catch (e) {
  119. setError(e instanceof Error ? e.message : "連接不到服務器");
  120. setConnected(false);
  121. setJobOrders([]);
  122. } finally {
  123. setLoading(false);
  124. }
  125. }, [planDate]);
  126. useEffect(() => {
  127. loadJobOrders(true);
  128. }, [planDate]);
  129. useEffect(() => {
  130. if (!connected) return;
  131. const id = setInterval(() => loadJobOrders(false), REFRESH_MS);
  132. return () => clearInterval(id);
  133. }, [connected, loadJobOrders]);
  134. const checkCurrentPrinter = useCallback(async () => {
  135. try {
  136. const request =
  137. printer === "dataflex"
  138. ? {
  139. printerType: "dataflex" as const,
  140. printerIp: settings.dabag_ip,
  141. printerPort: Number(settings.dabag_port || 3008),
  142. }
  143. : {
  144. printerType: "laser" as const,
  145. printerIp: settings.laser_ip,
  146. printerPort: Number(settings.laser_port || 45678),
  147. };
  148. const result = await checkPrinterStatus(request);
  149. setPrinterConnected(result.connected);
  150. setPrinterMessage(result.message);
  151. } catch (e) {
  152. setPrinterConnected(false);
  153. setPrinterMessage(e instanceof Error ? e.message : "列印機狀態檢查失敗");
  154. }
  155. }, [printer, settings]);
  156. useEffect(() => {
  157. checkCurrentPrinter();
  158. }, [checkCurrentPrinter]);
  159. useEffect(() => {
  160. const intervalMs = printerConnected ? PRINTER_CHECK_MS : PRINTER_RETRY_MS;
  161. const id = setInterval(() => {
  162. checkCurrentPrinter();
  163. }, intervalMs);
  164. return () => clearInterval(id);
  165. }, [printerConnected, checkCurrentPrinter]);
  166. const goPrevDay = () => {
  167. setPlanDate((d) => dayjs(d).subtract(1, "day").format("YYYY-MM-DD"));
  168. };
  169. const goNextDay = () => {
  170. setPlanDate((d) => dayjs(d).add(1, "day").format("YYYY-MM-DD"));
  171. };
  172. const handlePrinterChange = (e: SelectChangeEvent<string>) => {
  173. setPrinter(e.target.value);
  174. };
  175. const handleRowClick = (jo: JobOrderListItem) => {
  176. setSelectedId(jo.id);
  177. const batch = getBatch(jo);
  178. const itemCode = jo.itemCode || "—";
  179. const itemName = jo.itemName || "—";
  180. setSnackbar({ open: true, message: `已點選:批次 ${batch} 品號 ${itemCode} ${itemName}`, severity: "info" });
  181. // Align with Bag2.py "click row -> ask bag count -> print" for DataFlex.
  182. if (printer === "dataflex") {
  183. setPrintTarget(jo);
  184. setPrintCount(0);
  185. setPrintContinuous(false);
  186. setPrintDialogOpen(true);
  187. }
  188. };
  189. const confirmPrintDataFlex = async () => {
  190. if (!printTarget) return;
  191. if (printer !== "dataflex") {
  192. setSnackbar({ open: true, message: "此頁目前只支援打袋機 DataFlex 列印", severity: "error" });
  193. return;
  194. }
  195. if (!printContinuous && printCount < 1) {
  196. setSnackbar({ open: true, message: "請先按 +50、+10、+5 或 +1 選擇數量。", severity: "error" });
  197. return;
  198. }
  199. const qty = printContinuous ? -1 : printCount;
  200. const printerIp = settings.dabag_ip;
  201. const printerPort = Number(settings.dabag_port || 3008);
  202. if (!printerIp) {
  203. setSnackbar({ open: true, message: "請先在設定中填寫打袋機 DataFlex 的 IP。", severity: "error" });
  204. return;
  205. }
  206. setPrinting(true);
  207. try {
  208. const resp = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-dataflex`, {
  209. method: "POST",
  210. headers: { "Content-Type": "application/json" },
  211. body: JSON.stringify({
  212. itemCode: printTarget.itemCode || "—",
  213. itemName: printTarget.itemName || "—",
  214. lotNo: printTarget.lotNo || "—",
  215. // DataFlex zpl (Bag2.py) only needs itemId + stockInLineId for QR payload (optional).
  216. itemId: printTarget.itemId,
  217. stockInLineId: printTarget.stockInLineId,
  218. printerIp,
  219. printerPort,
  220. printQty: qty,
  221. }),
  222. });
  223. if (resp.status === 401 || resp.status === 403) return;
  224. if (!resp.ok) {
  225. const msg = await resp.text().catch(() => "");
  226. setSnackbar({
  227. open: true,
  228. message: `DataFlex 列印失敗(狀態碼 ${resp.status})。${msg ? msg.slice(0, 120) : ""}`,
  229. severity: "error",
  230. });
  231. return;
  232. }
  233. const batch = getBatch(printTarget);
  234. const printedText = qty === -1 ? "連續 (C)" : `${qty}`;
  235. setSnackbar({ open: true, message: `已送出列印:批次 ${batch} x ${printedText}`, severity: "success" });
  236. setPrintDialogOpen(false);
  237. } catch (e) {
  238. setSnackbar({ open: true, message: e instanceof Error ? e.message : "DataFlex 列印失敗", severity: "error" });
  239. } finally {
  240. setPrinting(false);
  241. }
  242. };
  243. const handleDownloadOnPackQr = async () => {
  244. const onPackJobOrders = jobOrders
  245. .map((jobOrder) => ({
  246. jobOrderId: jobOrder.id,
  247. itemCode: jobOrder.itemCode?.trim() || "",
  248. }))
  249. .filter((jobOrder) => jobOrder.itemCode.length > 0);
  250. if (onPackJobOrders.length === 0) {
  251. setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" });
  252. return;
  253. }
  254. setDownloadingOnPack(true);
  255. try {
  256. const blob = await downloadOnPackQrZip({
  257. jobOrders: onPackJobOrders,
  258. });
  259. const url = window.URL.createObjectURL(blob);
  260. const link = document.createElement("a");
  261. link.href = url;
  262. link.setAttribute("download", `onpack_qr_${planDate}.zip`);
  263. document.body.appendChild(link);
  264. link.click();
  265. link.remove();
  266. window.URL.revokeObjectURL(url);
  267. setSnackbar({ open: true, message: "OnPack QR code ZIP 已下載", severity: "success" });
  268. } catch (e) {
  269. setSnackbar({
  270. open: true,
  271. message: e instanceof Error ? e.message : "下載 OnPack QR code 失敗",
  272. severity: "error",
  273. });
  274. } finally {
  275. setDownloadingOnPack(false);
  276. }
  277. };
  278. const handleDownloadOnPackTextQr = async () => {
  279. const onPackJobOrders = jobOrders
  280. .map((jobOrder) => ({
  281. jobOrderId: jobOrder.id,
  282. itemCode: jobOrder.itemCode?.trim() || "",
  283. }))
  284. .filter((jobOrder) => jobOrder.itemCode.length > 0);
  285. if (onPackJobOrders.length === 0) {
  286. setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" });
  287. return;
  288. }
  289. setDownloadingOnPackText(true);
  290. try {
  291. const blob = await downloadOnPackTextQrZip({
  292. jobOrders: onPackJobOrders,
  293. });
  294. const url = window.URL.createObjectURL(blob);
  295. const link = document.createElement("a");
  296. link.href = url;
  297. link.setAttribute("download", `onpack2023_lemon_qr_${planDate}.zip`);
  298. document.body.appendChild(link);
  299. link.click();
  300. link.remove();
  301. window.URL.revokeObjectURL(url);
  302. setSnackbar({ open: true, message: "OnPack2023檸檬機 ZIP 已下載", severity: "success" });
  303. } catch (e) {
  304. setSnackbar({
  305. open: true,
  306. message: e instanceof Error ? e.message : "下載 OnPack2023檸檬機 失敗",
  307. severity: "error",
  308. });
  309. } finally {
  310. setDownloadingOnPackText(false);
  311. }
  312. };
  313. return (
  314. <Box sx={{ minHeight: "70vh", display: "flex", flexDirection: "column" }}>
  315. {/* Top: date nav + printer + settings */}
  316. <Paper sx={{ p: 2, mb: 2, backgroundColor: BG_TOP }}>
  317. <Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={2}>
  318. <Stack direction="row" alignItems="center" spacing={2}>
  319. <Button variant="outlined" startIcon={<ChevronLeft />} onClick={goPrevDay}>
  320. 前一天
  321. </Button>
  322. <TextField
  323. type="date"
  324. value={planDate}
  325. onChange={(e) => setPlanDate(e.target.value)}
  326. size="small"
  327. sx={{ width: 160 }}
  328. InputLabelProps={{ shrink: true }}
  329. />
  330. <Button variant="outlined" endIcon={<ChevronRight />} onClick={goNextDay}>
  331. 後一天
  332. </Button>
  333. </Stack>
  334. <Stack direction="row" alignItems="center" spacing={2}>
  335. <Button variant="outlined" startIcon={<Settings />} onClick={() => setSettingsOpen(true)}>
  336. 設定
  337. </Button>
  338. <Box
  339. sx={{
  340. px: 1.5,
  341. py: 0.75,
  342. borderRadius: 1,
  343. backgroundColor: printerConnected ? BG_STATUS_OK : BG_STATUS_ERROR,
  344. color: printerConnected ? FG_STATUS_OK : FG_STATUS_ERROR,
  345. fontWeight: 600,
  346. whiteSpace: "nowrap",
  347. }}
  348. title={printerMessage}
  349. >
  350. 列印機:
  351. </Box>
  352. <FormControl size="small" sx={{ minWidth: 180 }}>
  353. <InputLabel>列印機</InputLabel>
  354. <Select value={printer} label="列印機" onChange={handlePrinterChange}>
  355. {PRINTER_OPTIONS.map((opt) => (
  356. <MenuItem key={opt.value} value={opt.value}>
  357. {opt.label}
  358. </MenuItem>
  359. ))}
  360. </Select>
  361. </FormControl>
  362. </Stack>
  363. </Stack>
  364. <Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}>
  365. {printerMessage}
  366. </Typography>
  367. <Stack direction="row" sx={{ mt: 2 }} spacing={2} flexWrap="wrap" useFlexGap>
  368. <Button
  369. variant="contained"
  370. startIcon={<Download />}
  371. onClick={handleDownloadOnPackQr}
  372. disabled={loading || downloadingOnPack || downloadingOnPackText || jobOrders.length === 0}
  373. >
  374. {downloadingOnPack ? "下載中..." : "下載 OnPack 汁水機 QR code"}
  375. </Button>
  376. <Button
  377. variant="contained"
  378. color="secondary"
  379. startIcon={<Download />}
  380. onClick={handleDownloadOnPackTextQr}
  381. disabled={loading || downloadingOnPack || downloadingOnPackText || jobOrders.length === 0}
  382. >
  383. {downloadingOnPackText ? "下載中..." : "下載 OnPack2023檸檬機"}
  384. </Button>
  385. </Stack>
  386. </Paper>
  387. {/* Job orders list */}
  388. <Paper sx={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column", backgroundColor: BG_LIST }}>
  389. {loading ? (
  390. <Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", py: 8 }}>
  391. <CircularProgress />
  392. </Box>
  393. ) : jobOrders.length === 0 ? (
  394. <Box sx={{ py: 8, textAlign: "center" }}>
  395. <Typography color="text.secondary">當日無工單</Typography>
  396. </Box>
  397. ) : (
  398. <Box sx={{ overflow: "auto", flex: 1, p: 2 }}>
  399. <Stack spacing={1}>
  400. {jobOrders.map((jo) => {
  401. const batch = getBatch(jo);
  402. const qtyStr = formatQty(jo.reqQty);
  403. const isSelected = selectedId === jo.id;
  404. return (
  405. <Paper
  406. key={jo.id}
  407. elevation={1}
  408. sx={{
  409. p: 2,
  410. display: "flex",
  411. alignItems: "flex-start",
  412. gap: 2,
  413. cursor: "pointer",
  414. backgroundColor: isSelected ? BG_ROW_SELECTED : BG_ROW,
  415. "&:hover": { backgroundColor: isSelected ? BG_ROW_SELECTED : "#b8d4eb" },
  416. transition: "background-color 0.2s",
  417. }}
  418. onClick={() => handleRowClick(jo)}
  419. >
  420. <Box sx={{ minWidth: 120, flexShrink: 0 }}>
  421. <Typography variant="h6" sx={{ fontSize: "1.1rem" }}>
  422. {batch}
  423. </Typography>
  424. {qtyStr !== "—" && (
  425. <Typography variant="body2" color="text.secondary">
  426. 數量:{qtyStr}
  427. </Typography>
  428. )}
  429. </Box>
  430. <Box sx={{ minWidth: 140, flexShrink: 0 }}>
  431. <Typography variant="h6" sx={{ fontSize: "1.1rem" }}>
  432. {jo.code || "—"}
  433. </Typography>
  434. </Box>
  435. <Box sx={{ minWidth: 140, flexShrink: 0 }}>
  436. <Typography variant="h6" sx={{ fontSize: "1.35rem" }}>
  437. {jo.itemCode || "—"}
  438. </Typography>
  439. </Box>
  440. <Box sx={{ flex: 1, minWidth: 0 }}>
  441. <Typography variant="h6" sx={{ fontSize: "1.35rem", wordBreak: "break-word" }}>
  442. {jo.itemName || "—"}
  443. </Typography>
  444. </Box>
  445. <Button
  446. size="small"
  447. variant="contained"
  448. startIcon={<Print />}
  449. onClick={(e) => {
  450. e.stopPropagation();
  451. handleRowClick(jo);
  452. }}
  453. >
  454. 列印
  455. </Button>
  456. </Paper>
  457. );
  458. })}
  459. </Stack>
  460. </Box>
  461. )}
  462. </Paper>
  463. {/* Print count dialog (DataFlex) */}
  464. <Dialog open={printDialogOpen} onClose={() => (printing ? null : setPrintDialogOpen(false))} maxWidth="xs" fullWidth>
  465. <DialogTitle>打袋機 DataFlex 列印數量</DialogTitle>
  466. <DialogContent>
  467. <Stack spacing={2} sx={{ mt: 1 }}>
  468. <Typography variant="body1" sx={{ fontWeight: 700 }}>
  469. 列印多少個袋?
  470. </Typography>
  471. <Typography variant="body2" color="text.secondary">
  472. {printContinuous ? "連續 (C)" : `數量: ${printCount}`}
  473. </Typography>
  474. <Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap">
  475. <Button
  476. size="small"
  477. variant="contained"
  478. onClick={() => {
  479. setPrintContinuous(false);
  480. setPrintCount((c) => c + 50);
  481. }}
  482. disabled={printing}
  483. >
  484. +50
  485. </Button>
  486. <Button
  487. size="small"
  488. variant="contained"
  489. onClick={() => {
  490. setPrintContinuous(false);
  491. setPrintCount((c) => c + 10);
  492. }}
  493. disabled={printing}
  494. >
  495. +10
  496. </Button>
  497. <Button
  498. size="small"
  499. variant="contained"
  500. onClick={() => {
  501. setPrintContinuous(false);
  502. setPrintCount((c) => c + 5);
  503. }}
  504. disabled={printing}
  505. >
  506. +5
  507. </Button>
  508. <Button
  509. size="small"
  510. variant="contained"
  511. onClick={() => {
  512. setPrintContinuous(false);
  513. setPrintCount((c) => c + 1);
  514. }}
  515. disabled={printing}
  516. >
  517. +1
  518. </Button>
  519. <Button
  520. size="small"
  521. variant={printContinuous ? "contained" : "outlined"}
  522. onClick={() => {
  523. setPrintContinuous(true);
  524. }}
  525. disabled={printing}
  526. >
  527. 連續 (C)
  528. </Button>
  529. </Stack>
  530. </Stack>
  531. </DialogContent>
  532. <DialogActions>
  533. <Button onClick={() => setPrintDialogOpen(false)} disabled={printing}>
  534. 取消
  535. </Button>
  536. <Button variant="contained" onClick={() => void confirmPrintDataFlex()} disabled={printing}>
  537. {printing ? <CircularProgress size={16} /> : "確認送出"}
  538. </Button>
  539. </DialogActions>
  540. </Dialog>
  541. {/* Settings dialog */}
  542. <Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth>
  543. <DialogTitle>設定</DialogTitle>
  544. <DialogContent>
  545. <Stack spacing={2} sx={{ mt: 1 }}>
  546. <Typography variant="subtitle2" color="primary">
  547. 打袋機 DataFlex
  548. </Typography>
  549. <TextField
  550. label="IP"
  551. size="small"
  552. value={settings.dabag_ip}
  553. onChange={(e) => setSettings((s) => ({ ...s, dabag_ip: e.target.value }))}
  554. fullWidth
  555. />
  556. <TextField
  557. label="Port"
  558. size="small"
  559. value={settings.dabag_port}
  560. onChange={(e) => setSettings((s) => ({ ...s, dabag_port: e.target.value }))}
  561. fullWidth
  562. />
  563. <Typography variant="subtitle2" color="primary">
  564. 激光機
  565. </Typography>
  566. <TextField
  567. label="IP"
  568. size="small"
  569. value={settings.laser_ip}
  570. onChange={(e) => setSettings((s) => ({ ...s, laser_ip: e.target.value }))}
  571. fullWidth
  572. />
  573. <TextField
  574. label="Port"
  575. size="small"
  576. value={settings.laser_port}
  577. onChange={(e) => setSettings((s) => ({ ...s, laser_port: e.target.value }))}
  578. fullWidth
  579. />
  580. </Stack>
  581. </DialogContent>
  582. <DialogActions>
  583. <Button onClick={() => setSettingsOpen(false)}>取消</Button>
  584. <Button
  585. variant="contained"
  586. onClick={() => {
  587. saveSettings(settings);
  588. setSnackbar({ open: true, message: "設定已儲存", severity: "success" });
  589. setSettingsOpen(false);
  590. checkCurrentPrinter();
  591. }}
  592. >
  593. 儲存
  594. </Button>
  595. </DialogActions>
  596. </Dialog>
  597. <Snackbar
  598. open={snackbar.open}
  599. autoHideDuration={3000}
  600. onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
  601. message={snackbar.message}
  602. anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
  603. />
  604. </Box>
  605. );
  606. };
  607. export default BagPrintSearch;