FPSMS-frontend
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 

653 Zeilen
22 KiB

  1. "use client";
  2. import { useUiText } from "@/i18n/useUiText";
  3. import { FLOOR_A_KEY, LOC_PREFIX } from "@/config/demoFloors";
  4. import React, {
  5. useCallback,
  6. useEffect,
  7. useMemo,
  8. useRef,
  9. useState,
  10. } from "react";
  11. import {
  12. Alert,
  13. Box,
  14. Button,
  15. CircularProgress,
  16. Dialog,
  17. DialogActions,
  18. DialogContent,
  19. DialogTitle,
  20. FormControl,
  21. InputLabel,
  22. MenuItem,
  23. Select,
  24. Snackbar,
  25. Stack,
  26. TextField,
  27. Typography,
  28. } from "@mui/material";
  29. import { NEXT_PUBLIC_API_URL } from "@/config/api";
  30. import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
  31. type ScanPayload = {
  32. itemId: number;
  33. stockInLineId: number;
  34. };
  35. type Printer = {
  36. id: number;
  37. name?: string;
  38. description?: string;
  39. ip?: string;
  40. port?: number;
  41. type?: string;
  42. brand?: string;
  43. };
  44. type QrCodeAnalysisResponse = {
  45. itemId: number;
  46. itemCode: string;
  47. itemName: string;
  48. scanned: {
  49. stockInLineId: number;
  50. lotNo: string;
  51. inventoryLotLineId: number;
  52. warehouseCode?: string | null;
  53. warehouseName?: string | null;
  54. };
  55. sameItemLots: Array<{
  56. lotNo: string;
  57. inventoryLotLineId: number;
  58. availableQty: number;
  59. uom: string;
  60. warehouseCode?: string | null;
  61. warehouseName?: string | null;
  62. }>;
  63. };
  64. export interface LotLabelPrintModalProps {
  65. open: boolean;
  66. onClose: () => void;
  67. /** 當由其他流程自動打開時,可帶入掃碼 payload 以便自動查詢 */
  68. initialPayload?: ScanPayload | null;
  69. /** 預設要自動選取的印表機名稱(完全匹配優先,其次為包含) */
  70. defaultPrinterName?: string;
  71. /** 隱藏「掃碼內容/查詢/清除」區塊(通常搭配 initialPayload 使用) */
  72. hideScanSection?: boolean;
  73. /** 額外提醒(顯示在最上方) */
  74. reminderText?: string;
  75. /** 額外標題(顯示在最上方,reminderText 之下) */
  76. statusTitleText?: string;
  77. /** 只顯示特定倉位前綴(例如 FLOOR_A_KEY + "-") */
  78. warehouseCodePrefixFilter?: string;
  79. /** 不顯示觸發視窗的批號(analysis.scanned) */
  80. hideTriggeredLot?: boolean;
  81. }
  82. function safeParseScanPayload(raw: string): ScanPayload | null {
  83. try {
  84. const obj = JSON.parse(raw);
  85. const itemId = Number(obj?.itemId);
  86. const stockInLineId = Number(obj?.stockInLineId);
  87. if (!Number.isFinite(itemId) || !Number.isFinite(stockInLineId))
  88. return null;
  89. return { itemId, stockInLineId };
  90. } catch {
  91. return null;
  92. }
  93. }
  94. function formatPrinterLabel(p: Printer): string {
  95. const name = (p.name || "").trim();
  96. if (name) return name;
  97. const desc = (p.description || "").trim();
  98. if (desc) return desc;
  99. const code = (p as { code?: string }).code?.trim?.() ?? "";
  100. if (code) return code;
  101. return `#${p.id}`;
  102. }
  103. function isLabelPrinter(p: Printer): boolean {
  104. const s = `${p.name ?? ""} ${p.description ?? ""} ${
  105. (p as { code?: string }).code ?? ""
  106. } ${p.type ?? ""} ${p.brand ?? ""}`.toLowerCase();
  107. // Keep only "Label 機" printers; exclude A4 printers etc.
  108. return s.includes("label") && !s.includes("a4");
  109. }
  110. const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
  111. open,
  112. onClose,
  113. initialPayload = null,
  114. defaultPrinterName,
  115. hideScanSection,
  116. reminderText,
  117. statusTitleText,
  118. warehouseCodePrefixFilter,
  119. hideTriggeredLot,
  120. }) => {
  121. const L = useUiText();
  122. const scanInputRef = useRef<HTMLInputElement | null>(null);
  123. const [scanInput, setScanInput] = useState("");
  124. const [scanError, setScanError] = useState<string | null>(null);
  125. const [printers, setPrinters] = useState<Printer[]>([]);
  126. const [printersLoading, setPrintersLoading] = useState(false);
  127. const [selectedPrinterId, setSelectedPrinterId] = useState<number | "">("");
  128. const [analysisLoading, setAnalysisLoading] = useState(false);
  129. const [analysis, setAnalysis] = useState<QrCodeAnalysisResponse | null>(null);
  130. const [lastPayload, setLastPayload] = useState<ScanPayload | null>(null);
  131. const [printQty, setPrintQty] = useState(1);
  132. const [printingLotLineId, setPrintingLotLineId] = useState<number | null>(
  133. null,
  134. );
  135. const [snackbar, setSnackbar] = useState<{
  136. open: boolean;
  137. message: string;
  138. severity?: "success" | "info" | "error";
  139. }>({
  140. open: false,
  141. message: "",
  142. severity: "info",
  143. });
  144. const baseApi = useMemo(
  145. () => (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""),
  146. [],
  147. );
  148. const resetAll = useCallback(() => {
  149. setScanInput("");
  150. setScanError(null);
  151. setAnalysis(null);
  152. setPrintQty(1);
  153. setPrintingLotLineId(null);
  154. }, []);
  155. useEffect(() => {
  156. if (!open) return;
  157. resetAll();
  158. const t = setTimeout(() => scanInputRef.current?.focus(), 50);
  159. return () => clearTimeout(t);
  160. }, [open, resetAll]);
  161. const loadPrinters = useCallback(async () => {
  162. if (!baseApi) {
  163. setSnackbar({
  164. open: true,
  165. message: L("NEXT_PUBLIC_API_URL 未設定,無法連線後端。", "NEXT_PUBLIC_API_URL is not set, so the backend cannot be reached."),
  166. severity: "error",
  167. });
  168. return;
  169. }
  170. setPrintersLoading(true);
  171. try {
  172. const res = await clientAuthFetch(`${baseApi}/printers`, {
  173. method: "GET",
  174. });
  175. if (!res.ok) {
  176. const msg = await res.text().catch(() => "");
  177. throw new Error(msg || `HTTP ${res.status}`);
  178. }
  179. const data = (await res.json()) as Printer[];
  180. const list = Array.isArray(data) ? data : [];
  181. setPrinters(list.filter(isLabelPrinter));
  182. } catch (e) {
  183. setPrinters([]);
  184. setSnackbar({
  185. open: true,
  186. message: e instanceof Error ? e.message : L("載入印表機清單失敗", "Could not load the printer list"),
  187. severity: "error",
  188. });
  189. } finally {
  190. setPrintersLoading(false);
  191. }
  192. }, [baseApi]);
  193. useEffect(() => {
  194. if (!open) return;
  195. void loadPrinters();
  196. }, [open, loadPrinters]);
  197. const effectiveHideScanSection = hideScanSection ?? initialPayload != null;
  198. const pickDefaultPrinterId = useCallback(
  199. (list: Printer[]): number | null => {
  200. if (!defaultPrinterName) return null;
  201. const target = defaultPrinterName.trim().toLowerCase();
  202. if (!target) return null;
  203. const byExact = list.find(
  204. (p) => formatPrinterLabel(p).trim().toLowerCase() === target,
  205. );
  206. if (byExact) return byExact.id;
  207. const byIncludes = list.find((p) =>
  208. formatPrinterLabel(p).trim().toLowerCase().includes(target),
  209. );
  210. return byIncludes?.id ?? null;
  211. },
  212. [defaultPrinterName],
  213. );
  214. useEffect(() => {
  215. if (!open) return;
  216. if (selectedPrinterId !== "") return;
  217. if (printers.length === 0) return;
  218. const id = pickDefaultPrinterId(printers);
  219. if (id != null) setSelectedPrinterId(id);
  220. }, [open, printers, selectedPrinterId, pickDefaultPrinterId]);
  221. const analyzePayload = useCallback(
  222. async (payload: ScanPayload) => {
  223. if (!baseApi) {
  224. setScanError(L("NEXT_PUBLIC_API_URL 未設定,無法連線後端。", "NEXT_PUBLIC_API_URL is not set, so the backend cannot be reached."));
  225. return;
  226. }
  227. setLastPayload(payload);
  228. setScanError(null);
  229. setAnalysisLoading(true);
  230. try {
  231. const res = await clientAuthFetch(
  232. `${baseApi}/inventoryLotLine/analyze-qr-code`,
  233. {
  234. method: "POST",
  235. headers: { "Content-Type": "application/json" },
  236. body: JSON.stringify(payload),
  237. },
  238. );
  239. if (!res.ok) {
  240. const msg = await res.text().catch(() => "");
  241. throw new Error(msg || L(`分析失敗(HTTP ${res.status})`, `Lookup failed (HTTP ${res.status})`));
  242. }
  243. const data = (await res.json()) as QrCodeAnalysisResponse;
  244. setAnalysis(data);
  245. setSnackbar({
  246. open: true,
  247. message: L("已載入同品可用批號清單", "Loaded available lots for this item"),
  248. severity: "success",
  249. });
  250. } catch (e) {
  251. setAnalysis(null);
  252. setScanError(e instanceof Error ? e.message : L("分析失敗", "Lookup failed"));
  253. } finally {
  254. setAnalysisLoading(false);
  255. }
  256. },
  257. [baseApi],
  258. );
  259. const handleAnalyze = useCallback(async () => {
  260. const raw = scanInput.trim();
  261. const payload = safeParseScanPayload(raw);
  262. if (!payload) {
  263. setScanError(
  264. L('掃碼內容格式錯誤,需為 JSON,例如 {"itemId":16431,"stockInLineId":10381}', 'Scan content must be JSON, for example {"itemId":16431,"stockInLineId":10381}'),
  265. );
  266. setAnalysis(null);
  267. return;
  268. }
  269. await analyzePayload(payload);
  270. }, [scanInput, analyzePayload]);
  271. const handleRefreshLots = useCallback(async () => {
  272. const payload = lastPayload ?? safeParseScanPayload(scanInput.trim());
  273. if (!payload) {
  274. setSnackbar({
  275. open: true,
  276. message: L("請先掃碼或查詢一次,才可刷新批號清單。", "Scan or look up once before refreshing the lot list."),
  277. severity: "info",
  278. });
  279. return;
  280. }
  281. await analyzePayload(payload);
  282. }, [analyzePayload, lastPayload, scanInput]);
  283. useEffect(() => {
  284. if (!open) return;
  285. if (!initialPayload) return;
  286. setScanInput(JSON.stringify(initialPayload));
  287. void analyzePayload(initialPayload);
  288. }, [open, initialPayload, analyzePayload]);
  289. const availableLots = useMemo(() => {
  290. if (!analysis) return [];
  291. const list = (analysis.sameItemLots ?? []).filter(
  292. (x) => Number(x.availableQty) > 0 && !!String(x.lotNo || "").trim(),
  293. );
  294. const scannedLotLineId = analysis.scanned?.inventoryLotLineId;
  295. const scannedRow = scannedLotLineId
  296. ? list.find((x) => x.inventoryLotLineId === scannedLotLineId)
  297. : undefined;
  298. const scannedLot = scannedLotLineId
  299. ? {
  300. lotNo: analysis.scanned.lotNo,
  301. inventoryLotLineId: scannedLotLineId,
  302. availableQty: (scannedRow?.availableQty ?? 0) as number,
  303. uom: (scannedRow?.uom ?? "") as string,
  304. warehouseCode:
  305. analysis.scanned.warehouseCode ?? scannedRow?.warehouseCode,
  306. warehouseName:
  307. analysis.scanned.warehouseName ?? scannedRow?.warehouseName,
  308. _scanned: true as const,
  309. }
  310. : null;
  311. const merged = [
  312. ...(!hideTriggeredLot && scannedLot ? [scannedLot] : []),
  313. ...list
  314. .filter((x) => x.inventoryLotLineId !== scannedLotLineId)
  315. .map((x) => ({ ...x, _scanned: false as const })),
  316. ];
  317. return merged;
  318. }, [analysis, hideTriggeredLot]);
  319. const filteredLots = useMemo(() => {
  320. const prefix = String(warehouseCodePrefixFilter ?? "").trim();
  321. if (!prefix) return availableLots;
  322. return availableLots.filter((lot) => {
  323. const code = String(lot.warehouseCode ?? "");
  324. return code.startsWith(prefix) || code.startsWith(LOC_PREFIX);
  325. });
  326. }, [availableLots, warehouseCodePrefixFilter]);
  327. const selectedPrinter = useMemo(() => {
  328. if (selectedPrinterId === "") return null;
  329. return printers.find((p) => p.id === selectedPrinterId) ?? null;
  330. }, [printers, selectedPrinterId]);
  331. const canPrint =
  332. !!analysis && selectedPrinterId !== "" && printQty >= 1 && !analysisLoading;
  333. const handlePrintOne = useCallback(
  334. async (inventoryLotLineId: number, lotNo: string) => {
  335. if (!baseApi) {
  336. setSnackbar({
  337. open: true,
  338. message: L("NEXT_PUBLIC_API_URL 未設定,無法連線後端。", "NEXT_PUBLIC_API_URL is not set, so the backend cannot be reached."),
  339. severity: "error",
  340. });
  341. return;
  342. }
  343. if (selectedPrinterId === "") {
  344. setSnackbar({
  345. open: true,
  346. message: L("請先選擇印表機", "Select a printer first"),
  347. severity: "error",
  348. });
  349. return;
  350. }
  351. if (printQty < 1 || !Number.isFinite(printQty)) {
  352. setSnackbar({
  353. open: true,
  354. message: L("列印張數需為大於等於 1 的整數", "Copies to print must be a whole number of at least 1"),
  355. severity: "error",
  356. });
  357. return;
  358. }
  359. setPrintingLotLineId(inventoryLotLineId);
  360. try {
  361. const sp = new URLSearchParams();
  362. sp.set("inventoryLotLineId", String(inventoryLotLineId));
  363. sp.set("printerId", String(selectedPrinterId));
  364. sp.set("printQty", String(Math.floor(printQty)));
  365. const url = `${baseApi}/inventoryLotLine/print-label?${sp.toString()}`;
  366. const res = await clientAuthFetch(url, { method: "GET" });
  367. if (!res.ok) {
  368. const msg = await res.text().catch(() => "");
  369. throw new Error(msg || L(`列印失敗(HTTP ${res.status})`, `Print failed (HTTP ${res.status})`));
  370. }
  371. setSnackbar({
  372. open: true,
  373. message: L(`已送出列印:Lot ${lotNo}`, `Print sent: lot ${lotNo}`),
  374. severity: "success",
  375. });
  376. } catch (e) {
  377. setSnackbar({
  378. open: true,
  379. message: e instanceof Error ? e.message : L("列印失敗", "Print failed"),
  380. severity: "error",
  381. });
  382. } finally {
  383. setPrintingLotLineId(null);
  384. }
  385. },
  386. [baseApi, selectedPrinterId, printQty],
  387. );
  388. return (
  389. <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
  390. <DialogTitle>{L("批號標籤列印", "Print lot labels")}</DialogTitle>
  391. <DialogContent>
  392. <Stack spacing={2} sx={{ mt: 1 }}>
  393. {statusTitleText ? (
  394. <Typography
  395. variant="h6"
  396. sx={{ fontWeight: 800, color: "error.main" }}
  397. >
  398. {statusTitleText}
  399. </Typography>
  400. ) : null}
  401. {reminderText ? (
  402. <Alert severity="warning">{reminderText}</Alert>
  403. ) : null}
  404. {effectiveHideScanSection ? null : (
  405. <>
  406. <Alert severity="info">
  407. {L("請掃描條碼(JSON 格式),例如", "Scan a barcode (JSON), for example")}{" "}
  408. <code>{'{"itemId":16431,"stockInLineId":10381'}</code>。
  409. </Alert>
  410. <Stack
  411. direction={{ xs: "column", md: "row" }}
  412. spacing={2}
  413. alignItems={{ xs: "stretch", md: "center" }}
  414. >
  415. <TextField
  416. inputRef={scanInputRef}
  417. label={L("掃碼內容", "Scan content")}
  418. value={scanInput}
  419. onChange={(e) => setScanInput(e.target.value)}
  420. fullWidth
  421. size="small"
  422. error={!!scanError}
  423. helperText={scanError || L("掃描後按 Enter 或點「查詢」", "After scanning, press Enter or choose Look up")}
  424. onKeyDown={(e) => {
  425. if (e.key === "Enter") {
  426. e.preventDefault();
  427. void handleAnalyze();
  428. }
  429. }}
  430. disabled={analysisLoading}
  431. />
  432. <Button
  433. variant="contained"
  434. onClick={() => void handleAnalyze()}
  435. disabled={analysisLoading || !scanInput.trim()}
  436. >
  437. {analysisLoading ? <CircularProgress size={18} /> : L("查詢", "Look up")}
  438. </Button>
  439. <Button
  440. variant="outlined"
  441. onClick={() => {
  442. resetAll();
  443. scanInputRef.current?.focus();
  444. }}
  445. disabled={analysisLoading}
  446. >
  447. {L("清除", "Clear")}
  448. </Button>
  449. </Stack>
  450. </>
  451. )}
  452. <Stack
  453. direction={{ xs: "column", md: "row" }}
  454. spacing={2}
  455. alignItems={{ xs: "stretch", md: "center" }}
  456. >
  457. <FormControl
  458. size="small"
  459. sx={{ minWidth: 260 }}
  460. disabled={printersLoading}
  461. >
  462. <InputLabel>{L("印表機", "Printer")}</InputLabel>
  463. <Select
  464. label={L("印表機", "Printer")}
  465. value={selectedPrinterId}
  466. onChange={(e) =>
  467. setSelectedPrinterId((e.target.value as number) ?? "")
  468. }
  469. >
  470. <MenuItem value="">
  471. <em>{printersLoading ? L("載入中...", "Loading...") : L("請選擇", "Select")}</em>
  472. </MenuItem>
  473. {printers.map((p) => (
  474. <MenuItem key={p.id} value={p.id}>
  475. {formatPrinterLabel(p)}
  476. </MenuItem>
  477. ))}
  478. </Select>
  479. </FormControl>
  480. <TextField
  481. label={L("列印張數", "Copies")}
  482. size="small"
  483. type="number"
  484. inputProps={{ min: 1, step: 1 }}
  485. value={printQty}
  486. onChange={(e) => setPrintQty(Number(e.target.value))}
  487. sx={{ width: 140 }}
  488. disabled={analysisLoading}
  489. />
  490. <Button
  491. variant="outlined"
  492. onClick={() => void handleRefreshLots()}
  493. disabled={analysisLoading}
  494. >
  495. {analysisLoading ? (
  496. <CircularProgress size={18} />
  497. ) : (
  498. L("刷新批號清單", "Refresh lot list")
  499. )}
  500. </Button>
  501. {selectedPrinter && (
  502. <Typography
  503. variant="body2"
  504. color="text.secondary"
  505. sx={{ ml: { md: "auto" } }}
  506. >
  507. {L("已選:", "Selected: ")}{formatPrinterLabel(selectedPrinter)}
  508. </Typography>
  509. )}
  510. </Stack>
  511. {analysis && (
  512. <Box>
  513. <Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
  514. {L("品號:", "Item no.: ")}{analysis.itemCode} {analysis.itemName}
  515. </Typography>
  516. {filteredLots.length === 0 ? (
  517. <Alert severity="warning">
  518. {L("找不到該樓層有可用批號(availableQty > 0)。", "No available lots on this floor (available qty > 0).")}
  519. </Alert>
  520. ) : (
  521. <Stack spacing={1}>
  522. {filteredLots.map((lot) => {
  523. const isPrinting =
  524. printingLotLineId === lot.inventoryLotLineId;
  525. const loc = String(lot.warehouseCode ?? "").trim();
  526. return (
  527. <Box
  528. key={lot.inventoryLotLineId}
  529. sx={{
  530. p: 1.25,
  531. borderRadius: 1,
  532. border: "1px solid",
  533. borderColor: "divider",
  534. display: "flex",
  535. alignItems: "center",
  536. gap: 2,
  537. backgroundColor: lot._scanned
  538. ? "rgba(25, 118, 210, 0.08)"
  539. : "transparent",
  540. }}
  541. >
  542. <Box sx={{ minWidth: 220 }}>
  543. <Typography
  544. variant="body1"
  545. sx={{ fontWeight: lot._scanned ? 800 : 600 }}
  546. >
  547. Lot:{lot.lotNo}
  548. {lot._scanned ? L("(已掃)", " (scanned)") : ""}
  549. </Typography>
  550. <Typography variant="body2" color="text.secondary">
  551. {L("位置:", "Location: ")}{loc || "—"}
  552. </Typography>
  553. <Typography variant="body2" color="text.secondary">
  554. {L("可用量:", "Available: ")}{Number(lot.availableQty).toLocaleString()}{" "}
  555. {L("單位:", "Unit: ")}{lot.uom || ""}
  556. </Typography>
  557. </Box>
  558. <Box sx={{ ml: "auto" }}>
  559. <Button
  560. variant="contained"
  561. disabled={!canPrint || isPrinting}
  562. onClick={() =>
  563. void handlePrintOne(
  564. lot.inventoryLotLineId,
  565. lot.lotNo,
  566. )
  567. }
  568. >
  569. {isPrinting ? (
  570. <CircularProgress size={18} />
  571. ) : (
  572. L("列印標籤", "Print label")
  573. )}
  574. </Button>
  575. </Box>
  576. </Box>
  577. );
  578. })}
  579. </Stack>
  580. )}
  581. </Box>
  582. )}
  583. {!analysis && !analysisLoading && (
  584. <Typography variant="body2" color="text.secondary">
  585. {L(
  586. "掃碼後會用 /inventoryLotLine/analyze-qr-code 查詢同品批號,列印則呼叫 /inventoryLotLine/print-label(需先選印表機)。",
  587. "After a scan, /inventoryLotLine/analyze-qr-code looks up lots of the same item. Printing calls /inventoryLotLine/print-label (choose a printer first).",
  588. )}
  589. </Typography>
  590. )}
  591. </Stack>
  592. </DialogContent>
  593. <DialogActions>
  594. <Button onClick={onClose}>{L("關閉", "Close")}</Button>
  595. </DialogActions>
  596. <Snackbar
  597. open={snackbar.open}
  598. autoHideDuration={3500}
  599. onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
  600. message={snackbar.message}
  601. anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
  602. />
  603. </Dialog>
  604. );
  605. };
  606. export default LotLabelPrintModal;