FPSMS-frontend
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 

896 行
29 KiB

  1. "use client";
  2. /**
  3. * Workbench copy of `LotLabelPrintModal`: same label-print flow, plus optional
  4. * 「掃碼提貨」 per listed lot row (parent calls `workbenchScanPick` with `inventoryLotLineId`).
  5. */
  6. import React, {
  7. useCallback,
  8. useEffect,
  9. useMemo,
  10. useRef,
  11. useState,
  12. } from "react";
  13. import {
  14. Alert,
  15. Box,
  16. Button,
  17. CircularProgress,
  18. Dialog,
  19. DialogActions,
  20. DialogContent,
  21. DialogTitle,
  22. FormControl,
  23. InputLabel,
  24. MenuItem,
  25. Select,
  26. Snackbar,
  27. Stack,
  28. TextField,
  29. Typography,
  30. } from "@mui/material";
  31. import {
  32. analyzeWorkbenchQrCode,
  33. fetchWorkbenchAvailableLotsByItem,
  34. fetchWorkbenchPrinters,
  35. printWorkbenchLotLabel,
  36. } from "@/app/api/doworkbench/actions";
  37. import { fetchDoFloorSettingsClient } from "@/app/api/settings/deliveryOrderFloor/client";
  38. import { QRCodeSVG } from "qrcode.react";
  39. type ScanPayload = {
  40. itemId: number;
  41. stockInLineId: number;
  42. };
  43. type Printer = {
  44. id: number;
  45. name?: string;
  46. description?: string;
  47. ip?: string;
  48. port?: number;
  49. type?: string;
  50. brand?: string;
  51. };
  52. type QrCodeAnalysisResponse = {
  53. itemId: number;
  54. itemCode: string;
  55. itemName: string;
  56. scanned?: {
  57. stockInLineId: number;
  58. lotNo: string;
  59. inventoryLotLineId: number;
  60. warehouseCode?: string | null;
  61. warehouseName?: string | null;
  62. uom?: string | null;
  63. uomId?: number | null;
  64. } | null;
  65. sameItemLots: Array<{
  66. lotNo: string;
  67. inventoryLotLineId: number;
  68. stockInLineId?: number | null;
  69. availableQty: number;
  70. uom: string;
  71. uomId?: number | null;
  72. warehouseCode?: string | null;
  73. warehouseName?: string | null;
  74. }>;
  75. };
  76. export interface WorkbenchLotLabelPrintModalProps {
  77. open: boolean;
  78. onClose: () => void;
  79. initialPayload?: ScanPayload | null;
  80. initialItemId?: number | null;
  81. defaultPrinterName?: string;
  82. hideScanSection?: boolean;
  83. reminderText?: string;
  84. statusTitleText?: string;
  85. /** 與 statusTitleText 搭配;預設 error(舊版固定紅字) */
  86. statusTitleSeverity?: "success" | "warning" | "error";
  87. warehouseCodePrefixFilter?: string;
  88. /**
  89. * When true, omit the API 「scanned」 lot from the merged list (legacy FG-style).
  90. * Workbench should leave false so the current row’s lot appears for label print / scan-pick.
  91. */
  92. hideTriggeredLot?: boolean;
  93. /** 提貨台表格列上的可用量/單位(API 的 sameItemLots 不含掃描行,需補上才能顯示「目前這筆」) */
  94. triggerLotAvailableQty?: number | null;
  95. triggerLotUom?: string | null;
  96. /** POL UomConversion id — workbench lot list only returns matching UOM */
  97. expectedUomId?: number | null;
  98. /** 此出庫行已掃碼/已完成時為 true,停用所有「掃碼提貨」(仍可列印標籤) */
  99. disableScanPick?: boolean;
  100. /**
  101. * When set, each lot row shows 「掃碼提貨」. Parent should call `workbenchScanPick`
  102. * with `inventoryLotLineId` and throw on failure.
  103. */
  104. onWorkbenchScanPick?: (args: {
  105. inventoryLotLineId: number;
  106. lotNo: string;
  107. qty?: number;
  108. }) => Promise<void>;
  109. /** Global submit qty shared with outer "Qty will submit". */
  110. submitQty?: number | null;
  111. onSubmitQtyChange?: (qty: number) => void;
  112. /** 揀貨規則:do 用送貨單排除倉,jo 用工單排除倉,控制列印清單/QR。 */
  113. pickRuleScope?: "do" | "jo";
  114. }
  115. function safeParseScanPayload(raw: string): ScanPayload | null {
  116. try {
  117. const obj = JSON.parse(raw);
  118. const itemId = Number(obj?.itemId);
  119. const stockInLineId = Number(obj?.stockInLineId);
  120. if (!Number.isFinite(itemId) || !Number.isFinite(stockInLineId))
  121. return null;
  122. return { itemId, stockInLineId };
  123. } catch {
  124. return null;
  125. }
  126. }
  127. function formatPrinterLabel(p: Printer): string {
  128. const name = (p.name || "").trim();
  129. if (name) return name;
  130. const desc = (p.description || "").trim();
  131. if (desc) return desc;
  132. const code = (p as { code?: string }).code?.trim?.() ?? "";
  133. if (code) return code;
  134. return `#${p.id}`;
  135. }
  136. function isLabelPrinter(p: Printer): boolean {
  137. const s = `${p.name ?? ""} ${p.description ?? ""} ${
  138. (p as { code?: string }).code ?? ""
  139. } ${p.type ?? ""} ${p.brand ?? ""}`.toLowerCase();
  140. return s.includes("label") && !s.includes("a4");
  141. }
  142. /** FP-MTMS Version Checklist | Functions Ref. No. 30 | v1.0.1 | 2026-07-22 */
  143. const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = ({
  144. open,
  145. onClose,
  146. initialPayload = null,
  147. initialItemId = null,
  148. defaultPrinterName,
  149. hideScanSection,
  150. reminderText,
  151. statusTitleText,
  152. statusTitleSeverity = "error",
  153. warehouseCodePrefixFilter,
  154. hideTriggeredLot = false,
  155. triggerLotAvailableQty = null,
  156. triggerLotUom = null,
  157. expectedUomId = null,
  158. disableScanPick = false,
  159. onWorkbenchScanPick,
  160. submitQty = null,
  161. onSubmitQtyChange,
  162. pickRuleScope,
  163. }) => {
  164. const scanInputRef = useRef<HTMLInputElement | null>(null);
  165. const [scanInput, setScanInput] = useState("");
  166. const [scanError, setScanError] = useState<string | null>(null);
  167. const [printers, setPrinters] = useState<Printer[]>([]);
  168. const [printersLoading, setPrintersLoading] = useState(false);
  169. const [selectedPrinterId, setSelectedPrinterId] = useState<number | "">("");
  170. const [analysisLoading, setAnalysisLoading] = useState(false);
  171. const [analysis, setAnalysis] = useState<QrCodeAnalysisResponse | null>(null);
  172. const [lastPayload, setLastPayload] = useState<ScanPayload | null>(null);
  173. const [lastItemId, setLastItemId] = useState<number | null>(null);
  174. const [printQty, setPrintQty] = useState(1);
  175. const [printingLotLineId, setPrintingLotLineId] = useState<number | null>(
  176. null,
  177. );
  178. const [qrVisibleLotLineId, setQrVisibleLotLineId] = useState<number | null>(
  179. null,
  180. );
  181. const [excludePrintRule, setExcludePrintRule] = useState<{
  182. hideList: Set<string>;
  183. excluded: Set<string>;
  184. } | null>(null);
  185. const [snackbar, setSnackbar] = useState<{
  186. open: boolean;
  187. message: string;
  188. severity?: "success" | "info" | "error";
  189. }>({
  190. open: false,
  191. message: "",
  192. severity: "info",
  193. });
  194. const resetAll = useCallback(() => {
  195. setScanInput("");
  196. setScanError(null);
  197. setAnalysis(null);
  198. setPrintQty(1);
  199. setPrintingLotLineId(null);
  200. setQrVisibleLotLineId(null);
  201. }, []);
  202. useEffect(() => {
  203. if (!open) return;
  204. resetAll();
  205. const t = setTimeout(() => scanInputRef.current?.focus(), 50);
  206. return () => clearTimeout(t);
  207. }, [open, resetAll]);
  208. useEffect(() => {
  209. if (!open || !pickRuleScope) {
  210. setExcludePrintRule(null);
  211. return;
  212. }
  213. let cancelled = false;
  214. void fetchDoFloorSettingsClient()
  215. .then((settings) => {
  216. if (cancelled) return;
  217. const csv =
  218. pickRuleScope === "do" ? settings.doExcludeWarehouses : settings.joExcludeWarehouses;
  219. const mode =
  220. pickRuleScope === "do" ? settings.doExcludePrintMode : settings.joExcludePrintMode;
  221. const hideQrRaw =
  222. pickRuleScope === "do" ? settings.doExcludeHideQrWarehouses : settings.joExcludeHideQrWarehouses;
  223. const excluded = new Set(
  224. csv
  225. .split(",")
  226. .map((code) => code.trim().toUpperCase())
  227. .filter(Boolean),
  228. );
  229. const hideQr =
  230. hideQrRaw == null || hideQrRaw === ""
  231. ? mode === "hideQr"
  232. ? excluded
  233. : new Set<string>()
  234. : new Set(
  235. hideQrRaw
  236. .split(",")
  237. .map((code) => code.trim().toUpperCase())
  238. .filter((code) => code && excluded.has(code)),
  239. );
  240. const hideList = new Set([...excluded].filter((code) => !hideQr.has(code)));
  241. setExcludePrintRule({ hideList, excluded });
  242. })
  243. .catch(() => {
  244. if (!cancelled) setExcludePrintRule(null);
  245. });
  246. return () => {
  247. cancelled = true;
  248. };
  249. }, [open, pickRuleScope]);
  250. const loadPrinters = useCallback(async () => {
  251. setPrintersLoading(true);
  252. try {
  253. const data = (await fetchWorkbenchPrinters()) as Printer[];
  254. const list = Array.isArray(data) ? data : [];
  255. setPrinters(list.filter(isLabelPrinter));
  256. } catch (e) {
  257. setPrinters([]);
  258. setSnackbar({
  259. open: true,
  260. message: e instanceof Error ? e.message : "載入印表機清單失敗",
  261. severity: "error",
  262. });
  263. } finally {
  264. setPrintersLoading(false);
  265. }
  266. }, []);
  267. useEffect(() => {
  268. if (!open) return;
  269. void loadPrinters();
  270. }, [open, loadPrinters]);
  271. const effectiveHideScanSection = hideScanSection ?? initialPayload != null;
  272. const pickDefaultPrinterId = useCallback(
  273. (list: Printer[]): number | null => {
  274. if (!defaultPrinterName) return null;
  275. const target = defaultPrinterName.trim().toLowerCase();
  276. if (!target) return null;
  277. const byExact = list.find(
  278. (p) => formatPrinterLabel(p).trim().toLowerCase() === target,
  279. );
  280. if (byExact) return byExact.id;
  281. const byIncludes = list.find((p) =>
  282. formatPrinterLabel(p).trim().toLowerCase().includes(target),
  283. );
  284. return byIncludes?.id ?? null;
  285. },
  286. [defaultPrinterName],
  287. );
  288. useEffect(() => {
  289. if (!open) return;
  290. if (selectedPrinterId !== "") return;
  291. if (printers.length === 0) return;
  292. const id = pickDefaultPrinterId(printers);
  293. if (id != null) setSelectedPrinterId(id);
  294. }, [open, printers, selectedPrinterId, pickDefaultPrinterId]);
  295. const resolveExpectedUomId = useCallback((): number | null => {
  296. const n = Number(expectedUomId);
  297. return Number.isFinite(n) && n > 0 ? n : null;
  298. }, [expectedUomId]);
  299. const analyzePayload = useCallback(
  300. async (payload: ScanPayload) => {
  301. setLastPayload(payload);
  302. setScanError(null);
  303. setAnalysisLoading(true);
  304. try {
  305. const uomId = resolveExpectedUomId();
  306. const data = (await analyzeWorkbenchQrCode({
  307. ...payload,
  308. ...(uomId != null ? { uomId } : {}),
  309. })) as QrCodeAnalysisResponse;
  310. setAnalysis(data);
  311. setSnackbar({
  312. open: true,
  313. message: "已載入同品可用批號清單",
  314. severity: "success",
  315. });
  316. } catch (e) {
  317. setAnalysis(null);
  318. setScanError(e instanceof Error ? e.message : "分析失敗");
  319. } finally {
  320. setAnalysisLoading(false);
  321. }
  322. },
  323. [resolveExpectedUomId],
  324. );
  325. const analyzeByItem = useCallback(
  326. async (itemId: number) => {
  327. if (!Number.isFinite(itemId) || itemId <= 0) {
  328. setScanError("無效 itemId,無法載入批號清單。");
  329. return;
  330. }
  331. setLastItemId(itemId);
  332. setScanError(null);
  333. setAnalysisLoading(true);
  334. try {
  335. const uomId = resolveExpectedUomId();
  336. const data = (await fetchWorkbenchAvailableLotsByItem(
  337. itemId,
  338. uomId,
  339. )) as {
  340. itemId: number;
  341. itemCode: string;
  342. itemName: string;
  343. sameItemLots: QrCodeAnalysisResponse["sameItemLots"];
  344. };
  345. setAnalysis({
  346. itemId: data.itemId,
  347. itemCode: data.itemCode,
  348. itemName: data.itemName,
  349. scanned: null,
  350. sameItemLots: data.sameItemLots ?? [],
  351. });
  352. setSnackbar({
  353. open: true,
  354. message: "已載入同品可用批號清單",
  355. severity: "success",
  356. });
  357. } catch (e) {
  358. setAnalysis(null);
  359. setScanError(e instanceof Error ? e.message : "分析失敗");
  360. } finally {
  361. setAnalysisLoading(false);
  362. }
  363. },
  364. [resolveExpectedUomId],
  365. );
  366. const handleAnalyze = useCallback(async () => {
  367. const raw = scanInput.trim();
  368. const payload = safeParseScanPayload(raw);
  369. if (!payload) {
  370. setScanError(
  371. '掃碼內容格式錯誤,請重新掃碼',
  372. );
  373. setAnalysis(null);
  374. return;
  375. }
  376. await analyzePayload(payload);
  377. }, [scanInput, analyzePayload]);
  378. const handleRefreshLots = useCallback(async () => {
  379. const payload = lastPayload ?? safeParseScanPayload(scanInput.trim());
  380. if (payload) {
  381. await analyzePayload(payload);
  382. return;
  383. }
  384. const candidateItemId =
  385. (Number.isFinite(lastItemId ?? NaN) && (lastItemId ?? 0) > 0
  386. ? (lastItemId as number)
  387. : Number(initialItemId));
  388. if (Number.isFinite(candidateItemId) && candidateItemId > 0) {
  389. await analyzeByItem(candidateItemId);
  390. return;
  391. }
  392. if (!payload) {
  393. setSnackbar({
  394. open: true,
  395. message: "請先掃碼或查詢一次,才可刷新批號清單。",
  396. severity: "info",
  397. });
  398. return;
  399. }
  400. }, [analyzeByItem, analyzePayload, initialItemId, lastItemId, lastPayload, scanInput]);
  401. useEffect(() => {
  402. if (!open) return;
  403. if (initialPayload) {
  404. setScanInput(JSON.stringify(initialPayload));
  405. void analyzePayload(initialPayload);
  406. return;
  407. }
  408. if (Number.isFinite(Number(initialItemId)) && Number(initialItemId) > 0) {
  409. void analyzeByItem(Number(initialItemId));
  410. }
  411. }, [open, initialPayload, initialItemId, analyzePayload, analyzeByItem]);
  412. const availableLots = useMemo(() => {
  413. if (!analysis) return [];
  414. const list = (analysis.sameItemLots ?? []).filter(
  415. (x) => Number(x.availableQty) > 0 && !!String(x.lotNo || "").trim(),
  416. );
  417. const scannedLotLineId = analysis.scanned?.inventoryLotLineId;
  418. const scannedRow = scannedLotLineId
  419. ? list.find((x) => x.inventoryLotLineId === scannedLotLineId)
  420. : undefined;
  421. const tableQty = Number(triggerLotAvailableQty);
  422. const fromTable =
  423. Number.isFinite(tableQty) && tableQty >= 0 ? tableQty : 0;
  424. const fromApi = Number(scannedRow?.availableQty ?? 0);
  425. const scanned = analysis.scanned;
  426. const expectUom = Number(expectedUomId);
  427. const hasExpectUom = Number.isFinite(expectUom) && expectUom > 0;
  428. const scannedUomId = Number(scanned?.uomId ?? scannedRow?.uomId ?? 0);
  429. const scannedUomOk =
  430. !hasExpectUom ||
  431. !Number.isFinite(scannedUomId) ||
  432. scannedUomId <= 0 ||
  433. scannedUomId === expectUom;
  434. const scannedLot =
  435. scannedLotLineId && scannedUomOk
  436. ? {
  437. lotNo: scanned?.lotNo ?? "",
  438. inventoryLotLineId: scannedLotLineId,
  439. stockInLineId: Number(scanned?.stockInLineId ?? 0) || null,
  440. availableQty: Math.max(fromApi, fromTable) as number,
  441. uom: (scanned?.uom ?? scannedRow?.uom ?? triggerLotUom ?? "") as string,
  442. uomId: scannedUomId > 0 ? scannedUomId : null,
  443. warehouseCode:
  444. scanned?.warehouseCode ?? scannedRow?.warehouseCode,
  445. warehouseName:
  446. scanned?.warehouseName ?? scannedRow?.warehouseName,
  447. _scanned: true as const,
  448. }
  449. : null;
  450. const merged = [
  451. ...(!hideTriggeredLot && scannedLot ? [scannedLot] : []),
  452. ...list
  453. .filter((x) => x.inventoryLotLineId !== scannedLotLineId)
  454. .filter((x) => {
  455. if (!hasExpectUom) return true;
  456. const id = Number(x.uomId);
  457. return !Number.isFinite(id) || id <= 0 || id === expectUom;
  458. })
  459. .map((x) => ({ ...x, _scanned: false as const })),
  460. ];
  461. return merged;
  462. }, [
  463. analysis,
  464. hideTriggeredLot,
  465. triggerLotAvailableQty,
  466. triggerLotUom,
  467. expectedUomId,
  468. ]);
  469. const filteredLots = useMemo(() => {
  470. const prefix = String(warehouseCodePrefixFilter ?? "").trim();
  471. const hideList = excludePrintRule?.hideList;
  472. return availableLots.filter((lot) => {
  473. if (prefix && !lot._scanned) {
  474. const code = String(lot.warehouseCode ?? "");
  475. if (!code.startsWith(prefix)) return false;
  476. }
  477. if (hideList && !lot._scanned) {
  478. const code = String(lot.warehouseCode ?? "").trim().toUpperCase();
  479. if (code && hideList.has(code)) return false;
  480. }
  481. return true;
  482. });
  483. }, [availableLots, warehouseCodePrefixFilter, excludePrintRule]);
  484. const selectedPrinter = useMemo(() => {
  485. if (selectedPrinterId === "") return null;
  486. return printers.find((p) => p.id === selectedPrinterId) ?? null;
  487. }, [printers, selectedPrinterId]);
  488. const canPrint =
  489. !!analysis && selectedPrinterId !== "" && printQty >= 1 && !analysisLoading;
  490. const handlePrintOne = useCallback(
  491. async (inventoryLotLineId: number, lotNo: string) => {
  492. if (selectedPrinterId === "") {
  493. setSnackbar({
  494. open: true,
  495. message: "請先選擇印表機",
  496. severity: "error",
  497. });
  498. return;
  499. }
  500. if (printQty < 1 || !Number.isFinite(printQty)) {
  501. setSnackbar({
  502. open: true,
  503. message: "列印張數需為大於等於 1 的整數",
  504. severity: "error",
  505. });
  506. return;
  507. }
  508. setPrintingLotLineId(inventoryLotLineId);
  509. try {
  510. await printWorkbenchLotLabel({
  511. inventoryLotLineId,
  512. printerId: selectedPrinterId,
  513. printQty: Math.floor(printQty),
  514. });
  515. setSnackbar({
  516. open: true,
  517. message: `已送出列印:Lot ${lotNo}`,
  518. severity: "success",
  519. });
  520. } catch (e) {
  521. setSnackbar({
  522. open: true,
  523. message: e instanceof Error ? e.message : "列印失敗",
  524. severity: "error",
  525. });
  526. } finally {
  527. setPrintingLotLineId(null);
  528. }
  529. },
  530. [selectedPrinterId, printQty],
  531. );
  532. return (
  533. <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
  534. <DialogTitle>批號標籤列印(提貨台)</DialogTitle>
  535. <DialogContent>
  536. <Stack spacing={2} sx={{ mt: 1 }}>
  537. {statusTitleText ? (
  538. <Typography
  539. variant="h6"
  540. sx={{
  541. fontWeight: 800,
  542. color:
  543. statusTitleSeverity === "success"
  544. ? "success.main"
  545. : statusTitleSeverity === "warning"
  546. ? "warning.main"
  547. : "error.main",
  548. }}
  549. >
  550. {statusTitleText}
  551. </Typography>
  552. ) : null}
  553. {reminderText ? (
  554. <Alert severity="warning">{reminderText}</Alert>
  555. ) : null}
  556. {effectiveHideScanSection ? null : (
  557. <>
  558. {/*
  559. <Alert severity="info">
  560. 請掃描條碼(JSON 格式),例如{" "}
  561. <code>{'{"itemId":16431,"stockInLineId":10381'}</code>。
  562. </Alert>
  563. */}
  564. <Stack
  565. direction={{ xs: "column", md: "row" }}
  566. spacing={2}
  567. alignItems={{ xs: "stretch", md: "center" }}
  568. >
  569. <TextField
  570. inputRef={scanInputRef}
  571. label="掃碼內容"
  572. value={scanInput}
  573. onChange={(e) => setScanInput(e.target.value)}
  574. fullWidth
  575. size="small"
  576. error={!!scanError}
  577. helperText={scanError || "掃描後按 Enter 或點「查詢」"}
  578. onKeyDown={(e) => {
  579. if (e.key === "Enter") {
  580. e.preventDefault();
  581. void handleAnalyze();
  582. }
  583. }}
  584. disabled={analysisLoading}
  585. />
  586. <Button
  587. variant="contained"
  588. onClick={() => void handleAnalyze()}
  589. disabled={analysisLoading || !scanInput.trim()}
  590. >
  591. {analysisLoading ? <CircularProgress size={18} /> : "查詢"}
  592. </Button>
  593. <Button
  594. variant="outlined"
  595. onClick={() => {
  596. resetAll();
  597. scanInputRef.current?.focus();
  598. }}
  599. disabled={analysisLoading}
  600. >
  601. 清除
  602. </Button>
  603. </Stack>
  604. </>
  605. )}
  606. <Stack
  607. direction={{ xs: "column", md: "row" }}
  608. spacing={2}
  609. alignItems={{ xs: "stretch", md: "center" }}
  610. >
  611. <FormControl
  612. size="small"
  613. sx={{ minWidth: 260 }}
  614. disabled={printersLoading}
  615. >
  616. <InputLabel>印表機</InputLabel>
  617. <Select
  618. label="印表機"
  619. value={selectedPrinterId}
  620. onChange={(e) =>
  621. setSelectedPrinterId((e.target.value as number) ?? "")
  622. }
  623. >
  624. <MenuItem value="">
  625. <em>{printersLoading ? "載入中..." : "請選擇"}</em>
  626. </MenuItem>
  627. {printers.map((p) => (
  628. <MenuItem key={p.id} value={p.id}>
  629. {formatPrinterLabel(p)}
  630. </MenuItem>
  631. ))}
  632. </Select>
  633. </FormControl>
  634. <TextField
  635. label="列印張數"
  636. size="small"
  637. type="number"
  638. inputProps={{ min: 1, step: 1 }}
  639. value={printQty}
  640. onChange={(e) => setPrintQty(Number(e.target.value))}
  641. sx={{ width: 140 }}
  642. disabled={analysisLoading}
  643. />
  644. {onWorkbenchScanPick ? (
  645. <TextField
  646. label="提交數量"
  647. size="small"
  648. type="number"
  649. inputProps={{ min: 0, step: 1 }}
  650. value={
  651. Number.isFinite(Number(submitQty)) ? Number(submitQty) : 0
  652. }
  653. onChange={(e) => {
  654. const n = Number(e.target.value);
  655. if (!Number.isFinite(n) || n < 0) return;
  656. onSubmitQtyChange?.(n);
  657. }}
  658. sx={{ width: 140 }}
  659. disabled={analysisLoading}
  660. />
  661. ) : null}
  662. <Button
  663. variant="outlined"
  664. onClick={() => void handleRefreshLots()}
  665. disabled={analysisLoading}
  666. >
  667. {analysisLoading ? (
  668. <CircularProgress size={18} />
  669. ) : (
  670. "刷新批號清單"
  671. )}
  672. </Button>
  673. {selectedPrinter && (
  674. <Typography
  675. variant="body2"
  676. color="text.secondary"
  677. sx={{ ml: { md: "auto" } }}
  678. >
  679. 已選:{formatPrinterLabel(selectedPrinter)}
  680. </Typography>
  681. )}
  682. </Stack>
  683. {analysis && (
  684. <Box>
  685. <Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
  686. 品號:{analysis.itemCode} {analysis.itemName}
  687. </Typography>
  688. {filteredLots.length === 0 ? (
  689. <Alert severity="warning">
  690. 找不到該樓層有可用批號(availableQty &gt; 0)。
  691. </Alert>
  692. ) : (
  693. <Stack spacing={1}>
  694. {filteredLots.map((lot) => {
  695. const isPrinting =
  696. printingLotLineId === lot.inventoryLotLineId;
  697. const loc = String(lot.warehouseCode ?? "").trim();
  698. const suppressQr = Boolean(
  699. excludePrintRule?.excluded.has(loc.toUpperCase()),
  700. );
  701. const canShowLotQr =
  702. !!onWorkbenchScanPick &&
  703. !!analysis &&
  704. !analysisLoading &&
  705. !disableScanPick &&
  706. !suppressQr;
  707. const lotQrPayload =
  708. Number.isFinite(Number(analysis?.itemId)) &&
  709. Number.isFinite(Number(lot.stockInLineId))
  710. ? {
  711. itemId: Number(analysis?.itemId),
  712. stockInLineId: Number(lot.stockInLineId),
  713. }
  714. : null;
  715. return (
  716. <Box
  717. key={lot.inventoryLotLineId}
  718. sx={{
  719. p: 1.25,
  720. borderRadius: 1,
  721. border: "1px solid",
  722. borderColor: "divider",
  723. display: "flex",
  724. alignItems: "center",
  725. gap: 2,
  726. backgroundColor: lot._scanned
  727. ? "rgba(25, 118, 210, 0.08)"
  728. : "transparent",
  729. }}
  730. >
  731. <Box sx={{ minWidth: 220 }}>
  732. <Typography
  733. variant="body1"
  734. sx={{ fontWeight: lot._scanned ? 800 : 600 }}
  735. >
  736. Lot:{lot.lotNo}
  737. {lot._scanned ? "(當前批次)" : ""}
  738. </Typography>
  739. <Typography variant="body2" color="text.secondary">
  740. 位置:{loc || "—"}
  741. </Typography>
  742. <Typography variant="body2" color="text.secondary">
  743. 可用量:{Number(lot.availableQty).toLocaleString()}{" "}
  744. 單位:{lot.uom || ""}
  745. </Typography>
  746. </Box>
  747. <Stack
  748. direction="row"
  749. spacing={1}
  750. sx={{ ml: "auto" }}
  751. flexWrap="wrap"
  752. useFlexGap
  753. >
  754. <Button
  755. variant="contained"
  756. disabled={!canPrint || isPrinting}
  757. onClick={() =>
  758. void handlePrintOne(
  759. lot.inventoryLotLineId,
  760. lot.lotNo,
  761. )
  762. }
  763. >
  764. {isPrinting ? (
  765. <CircularProgress size={18} />
  766. ) : (
  767. "列印標籤"
  768. )}
  769. </Button>
  770. {onWorkbenchScanPick && !suppressQr ? (
  771. <Button
  772. variant="outlined"
  773. color="secondary"
  774. title={
  775. !lotQrPayload
  776. ? "此列無法取得 QR payload(需 stockInLineId)"
  777. : disableScanPick
  778. ? "此出庫行已掃碼或已完成,無法顯示 QR"
  779. : undefined
  780. }
  781. disabled={
  782. !canShowLotQr || !lotQrPayload || isPrinting
  783. }
  784. onClick={() =>
  785. setQrVisibleLotLineId((prev) =>
  786. prev === lot.inventoryLotLineId
  787. ? null
  788. : lot.inventoryLotLineId,
  789. )
  790. }
  791. >
  792. 顯示 QR
  793. </Button>
  794. ) : null}
  795. </Stack>
  796. {qrVisibleLotLineId === lot.inventoryLotLineId &&
  797. lotQrPayload &&
  798. !suppressQr ? (
  799. <Box
  800. sx={{
  801. mt: 1.5,
  802. ml: "auto",
  803. p: 1.5,
  804. borderRadius: 1,
  805. border: "1px dashed",
  806. borderColor: "divider",
  807. textAlign: "center",
  808. minWidth: 220,
  809. }}
  810. >
  811. <QRCodeSVG
  812. value={JSON.stringify(lotQrPayload)}
  813. size={160}
  814. includeMargin
  815. />
  816. </Box>
  817. ) : null}
  818. </Box>
  819. );
  820. })}
  821. </Stack>
  822. )}
  823. </Box>
  824. )}
  825. {!analysis && !analysisLoading && (
  826. <Typography variant="body2" color="text.secondary">
  827. {onWorkbenchScanPick
  828. ? "沒有任何批號可列印標籤"
  829. : ""}
  830. </Typography>
  831. )}
  832. </Stack>
  833. </DialogContent>
  834. <DialogActions>
  835. <Button onClick={onClose}>關閉</Button>
  836. </DialogActions>
  837. <Snackbar
  838. open={snackbar.open}
  839. autoHideDuration={3500}
  840. onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
  841. message={snackbar.message}
  842. anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
  843. />
  844. </Dialog>
  845. );
  846. };
  847. export default WorkbenchLotLabelPrintModal;