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.
 
 

853 lines
28 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 { QRCodeSVG } from "qrcode.react";
  38. import { useTranslation } from "react-i18next";
  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. * Hide lots whose warehouse code equals an entry or starts with `{entry}-`.
  90. * JO passes `4F` so the pick-station list matches the suggestion block list.
  91. * The row that opened the dialog stays visible.
  92. */
  93. excludeWarehouseCodePrefixes?: string[];
  94. /**
  95. * When true, omit the API 「scanned」 lot from the merged list (legacy FG-style).
  96. * Workbench should leave false so the current row’s lot appears for label print / scan-pick.
  97. */
  98. hideTriggeredLot?: boolean;
  99. /** 提貨台表格列上的可用量/單位(API 的 sameItemLots 不含掃描行,需補上才能顯示「目前這筆」) */
  100. triggerLotAvailableQty?: number | null;
  101. triggerLotUom?: string | null;
  102. /** POL UomConversion id — workbench lot list only returns matching UOM */
  103. expectedUomId?: number | null;
  104. /** 此出庫行已掃碼/已完成時為 true,停用所有「掃碼提貨」(仍可列印標籤) */
  105. disableScanPick?: boolean;
  106. /**
  107. * When set, each lot row shows 「掃碼提貨」. Parent should call `workbenchScanPick`
  108. * with `inventoryLotLineId` and throw on failure.
  109. */
  110. onWorkbenchScanPick?: (args: {
  111. inventoryLotLineId: number;
  112. lotNo: string;
  113. qty?: number;
  114. }) => Promise<void>;
  115. /** Global submit qty shared with outer "Qty will submit". */
  116. submitQty?: number | null;
  117. onSubmitQtyChange?: (qty: number) => void;
  118. }
  119. function safeParseScanPayload(raw: string): ScanPayload | null {
  120. try {
  121. const obj = JSON.parse(raw);
  122. const itemId = Number(obj?.itemId);
  123. const stockInLineId = Number(obj?.stockInLineId);
  124. if (!Number.isFinite(itemId) || !Number.isFinite(stockInLineId))
  125. return null;
  126. return { itemId, stockInLineId };
  127. } catch {
  128. return null;
  129. }
  130. }
  131. function formatPrinterLabel(p: Printer): string {
  132. const name = (p.name || "").trim();
  133. if (name) return name;
  134. const desc = (p.description || "").trim();
  135. if (desc) return desc;
  136. const code = (p as { code?: string }).code?.trim?.() ?? "";
  137. if (code) return code;
  138. return `#${p.id}`;
  139. }
  140. function isLabelPrinter(p: Printer): boolean {
  141. const s = `${p.name ?? ""} ${p.description ?? ""} ${
  142. (p as { code?: string }).code ?? ""
  143. } ${p.type ?? ""} ${p.brand ?? ""}`.toLowerCase();
  144. return s.includes("label") && !s.includes("a4");
  145. }
  146. /** FP-MTMS Version Checklist | Functions Ref. No. 30 | v1.0.2 | 2026-11-02 */
  147. const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = ({
  148. open,
  149. onClose,
  150. initialPayload = null,
  151. initialItemId = null,
  152. defaultPrinterName,
  153. hideScanSection,
  154. reminderText,
  155. statusTitleText,
  156. statusTitleSeverity = "error",
  157. warehouseCodePrefixFilter,
  158. excludeWarehouseCodePrefixes,
  159. hideTriggeredLot = false,
  160. triggerLotAvailableQty = null,
  161. triggerLotUom = null,
  162. expectedUomId = null,
  163. disableScanPick = false,
  164. onWorkbenchScanPick,
  165. submitQty = null,
  166. onSubmitQtyChange,
  167. }) => {
  168. const { t } = useTranslation();
  169. const scanInputRef = useRef<HTMLInputElement | null>(null);
  170. const [scanInput, setScanInput] = useState("");
  171. const [scanError, setScanError] = useState<string | null>(null);
  172. const [printers, setPrinters] = useState<Printer[]>([]);
  173. const [printersLoading, setPrintersLoading] = useState(false);
  174. const [selectedPrinterId, setSelectedPrinterId] = useState<number | "">("");
  175. const [analysisLoading, setAnalysisLoading] = useState(false);
  176. const [analysis, setAnalysis] = useState<QrCodeAnalysisResponse | null>(null);
  177. const [lastPayload, setLastPayload] = useState<ScanPayload | null>(null);
  178. const [lastItemId, setLastItemId] = useState<number | null>(null);
  179. const [printQty, setPrintQty] = useState(1);
  180. const [printingLotLineId, setPrintingLotLineId] = useState<number | null>(
  181. null,
  182. );
  183. const [qrVisibleLotLineId, setQrVisibleLotLineId] = useState<number | null>(
  184. null,
  185. );
  186. const [snackbar, setSnackbar] = useState<{
  187. open: boolean;
  188. message: string;
  189. severity?: "success" | "info" | "error";
  190. }>({
  191. open: false,
  192. message: "",
  193. severity: "info",
  194. });
  195. const resetAll = useCallback(() => {
  196. setScanInput("");
  197. setScanError(null);
  198. setAnalysis(null);
  199. setPrintQty(1);
  200. setPrintingLotLineId(null);
  201. setQrVisibleLotLineId(null);
  202. }, []);
  203. useEffect(() => {
  204. if (!open) return;
  205. resetAll();
  206. const focusTimer = setTimeout(() => scanInputRef.current?.focus(), 50);
  207. return () => clearTimeout(focusTimer);
  208. }, [open, resetAll]);
  209. const loadPrinters = useCallback(async () => {
  210. setPrintersLoading(true);
  211. try {
  212. const data = (await fetchWorkbenchPrinters()) as Printer[];
  213. const list = Array.isArray(data) ? data : [];
  214. setPrinters(list.filter(isLabelPrinter));
  215. } catch (e) {
  216. setPrinters([]);
  217. setSnackbar({
  218. open: true,
  219. message: e instanceof Error ? e.message : t("Failed to load printer list"),
  220. severity: "error",
  221. });
  222. } finally {
  223. setPrintersLoading(false);
  224. }
  225. }, [t]);
  226. useEffect(() => {
  227. if (!open) return;
  228. void loadPrinters();
  229. }, [open, loadPrinters]);
  230. const effectiveHideScanSection = hideScanSection ?? initialPayload != null;
  231. const pickDefaultPrinterId = useCallback(
  232. (list: Printer[]): number | null => {
  233. if (!defaultPrinterName) return null;
  234. const target = defaultPrinterName.trim().toLowerCase();
  235. if (!target) return null;
  236. const byExact = list.find(
  237. (p) => formatPrinterLabel(p).trim().toLowerCase() === target,
  238. );
  239. if (byExact) return byExact.id;
  240. const byIncludes = list.find((p) =>
  241. formatPrinterLabel(p).trim().toLowerCase().includes(target),
  242. );
  243. return byIncludes?.id ?? null;
  244. },
  245. [defaultPrinterName],
  246. );
  247. useEffect(() => {
  248. if (!open) return;
  249. if (selectedPrinterId !== "") return;
  250. if (printers.length === 0) return;
  251. const id = pickDefaultPrinterId(printers);
  252. if (id != null) setSelectedPrinterId(id);
  253. }, [open, printers, selectedPrinterId, pickDefaultPrinterId]);
  254. const resolveExpectedUomId = useCallback((): number | null => {
  255. const n = Number(expectedUomId);
  256. return Number.isFinite(n) && n > 0 ? n : null;
  257. }, [expectedUomId]);
  258. const analyzePayload = useCallback(
  259. async (payload: ScanPayload) => {
  260. setLastPayload(payload);
  261. setScanError(null);
  262. setAnalysisLoading(true);
  263. try {
  264. const uomId = resolveExpectedUomId();
  265. const data = (await analyzeWorkbenchQrCode({
  266. ...payload,
  267. ...(uomId != null ? { uomId } : {}),
  268. })) as QrCodeAnalysisResponse;
  269. setAnalysis(data);
  270. setSnackbar({
  271. open: true,
  272. message: t("Loaded available lots for this item"),
  273. severity: "success",
  274. });
  275. } catch (e) {
  276. setAnalysis(null);
  277. setScanError(e instanceof Error ? e.message : t("Analysis failed"));
  278. } finally {
  279. setAnalysisLoading(false);
  280. }
  281. },
  282. [resolveExpectedUomId, t],
  283. );
  284. const analyzeByItem = useCallback(
  285. async (itemId: number) => {
  286. if (!Number.isFinite(itemId) || itemId <= 0) {
  287. setScanError(t("Invalid itemId, cannot load lot list."));
  288. return;
  289. }
  290. setLastItemId(itemId);
  291. setScanError(null);
  292. setAnalysisLoading(true);
  293. try {
  294. const uomId = resolveExpectedUomId();
  295. const data = (await fetchWorkbenchAvailableLotsByItem(
  296. itemId,
  297. uomId,
  298. )) as {
  299. itemId: number;
  300. itemCode: string;
  301. itemName: string;
  302. sameItemLots: QrCodeAnalysisResponse["sameItemLots"];
  303. };
  304. setAnalysis({
  305. itemId: data.itemId,
  306. itemCode: data.itemCode,
  307. itemName: data.itemName,
  308. scanned: null,
  309. sameItemLots: data.sameItemLots ?? [],
  310. });
  311. setSnackbar({
  312. open: true,
  313. message: t("Loaded available lots for this item"),
  314. severity: "success",
  315. });
  316. } catch (e) {
  317. setAnalysis(null);
  318. setScanError(e instanceof Error ? e.message : t("Analysis failed"));
  319. } finally {
  320. setAnalysisLoading(false);
  321. }
  322. },
  323. [resolveExpectedUomId, t],
  324. );
  325. const handleAnalyze = useCallback(async () => {
  326. const raw = scanInput.trim();
  327. const payload = safeParseScanPayload(raw);
  328. if (!payload) {
  329. setScanError(
  330. t("Invalid scan format. Please scan again."),
  331. );
  332. setAnalysis(null);
  333. return;
  334. }
  335. await analyzePayload(payload);
  336. }, [scanInput, analyzePayload, t]);
  337. const handleRefreshLots = useCallback(async () => {
  338. const payload = lastPayload ?? safeParseScanPayload(scanInput.trim());
  339. if (payload) {
  340. await analyzePayload(payload);
  341. return;
  342. }
  343. const candidateItemId =
  344. (Number.isFinite(lastItemId ?? NaN) && (lastItemId ?? 0) > 0
  345. ? (lastItemId as number)
  346. : Number(initialItemId));
  347. if (Number.isFinite(candidateItemId) && candidateItemId > 0) {
  348. await analyzeByItem(candidateItemId);
  349. return;
  350. }
  351. if (!payload) {
  352. setSnackbar({
  353. open: true,
  354. message: t("Scan or look up once before refreshing the lot list."),
  355. severity: "info",
  356. });
  357. return;
  358. }
  359. }, [analyzeByItem, analyzePayload, initialItemId, lastItemId, lastPayload, scanInput, t]);
  360. useEffect(() => {
  361. if (!open) return;
  362. if (initialPayload) {
  363. setScanInput(JSON.stringify(initialPayload));
  364. void analyzePayload(initialPayload);
  365. return;
  366. }
  367. if (Number.isFinite(Number(initialItemId)) && Number(initialItemId) > 0) {
  368. void analyzeByItem(Number(initialItemId));
  369. }
  370. }, [open, initialPayload, initialItemId, analyzePayload, analyzeByItem]);
  371. const availableLots = useMemo(() => {
  372. if (!analysis) return [];
  373. const list = (analysis.sameItemLots ?? []).filter(
  374. (x) => Number(x.availableQty) > 0 && !!String(x.lotNo || "").trim(),
  375. );
  376. const scannedLotLineId = analysis.scanned?.inventoryLotLineId;
  377. const scannedRow = scannedLotLineId
  378. ? list.find((x) => x.inventoryLotLineId === scannedLotLineId)
  379. : undefined;
  380. const tableQty = Number(triggerLotAvailableQty);
  381. const fromTable =
  382. Number.isFinite(tableQty) && tableQty >= 0 ? tableQty : 0;
  383. const fromApi = Number(scannedRow?.availableQty ?? 0);
  384. const scanned = analysis.scanned;
  385. const expectUom = Number(expectedUomId);
  386. const hasExpectUom = Number.isFinite(expectUom) && expectUom > 0;
  387. const scannedUomId = Number(scanned?.uomId ?? scannedRow?.uomId ?? 0);
  388. const scannedUomOk =
  389. !hasExpectUom ||
  390. !Number.isFinite(scannedUomId) ||
  391. scannedUomId <= 0 ||
  392. scannedUomId === expectUom;
  393. const scannedLot =
  394. scannedLotLineId && scannedUomOk
  395. ? {
  396. lotNo: scanned?.lotNo ?? "",
  397. inventoryLotLineId: scannedLotLineId,
  398. stockInLineId: Number(scanned?.stockInLineId ?? 0) || null,
  399. availableQty: Math.max(fromApi, fromTable) as number,
  400. uom: (scanned?.uom ?? scannedRow?.uom ?? triggerLotUom ?? "") as string,
  401. uomId: scannedUomId > 0 ? scannedUomId : null,
  402. warehouseCode:
  403. scanned?.warehouseCode ?? scannedRow?.warehouseCode,
  404. warehouseName:
  405. scanned?.warehouseName ?? scannedRow?.warehouseName,
  406. _scanned: true as const,
  407. }
  408. : null;
  409. const merged = [
  410. ...(!hideTriggeredLot && scannedLot ? [scannedLot] : []),
  411. ...list
  412. .filter((x) => x.inventoryLotLineId !== scannedLotLineId)
  413. .filter((x) => {
  414. if (!hasExpectUom) return true;
  415. const id = Number(x.uomId);
  416. return !Number.isFinite(id) || id <= 0 || id === expectUom;
  417. })
  418. .map((x) => ({ ...x, _scanned: false as const })),
  419. ];
  420. return merged;
  421. }, [
  422. analysis,
  423. hideTriggeredLot,
  424. triggerLotAvailableQty,
  425. triggerLotUom,
  426. expectedUomId,
  427. ]);
  428. const filteredLots = useMemo(() => {
  429. const prefix = String(warehouseCodePrefixFilter ?? "").trim().toUpperCase();
  430. const blocked = (excludeWarehouseCodePrefixes ?? [])
  431. .map((entry) => entry.trim().toUpperCase())
  432. .filter((entry) => entry.length > 0);
  433. return availableLots.filter((lot) => {
  434. // 使用者從本列開啟視窗:即使倉位在排除清單,仍顯示目前這筆批號
  435. if (lot._scanned) return true;
  436. const code = String(lot.warehouseCode ?? "").trim().toUpperCase();
  437. const blockedHit = blocked.some(
  438. (entry) => code === entry || code.startsWith(`${entry}-`),
  439. );
  440. if (blockedHit) return false;
  441. if (!prefix) return true;
  442. return code.startsWith(prefix);
  443. });
  444. }, [availableLots, warehouseCodePrefixFilter, excludeWarehouseCodePrefixes]);
  445. const selectedPrinter = useMemo(() => {
  446. if (selectedPrinterId === "") return null;
  447. return printers.find((p) => p.id === selectedPrinterId) ?? null;
  448. }, [printers, selectedPrinterId]);
  449. const canPrint =
  450. !!analysis && selectedPrinterId !== "" && printQty >= 1 && !analysisLoading;
  451. const handlePrintOne = useCallback(
  452. async (inventoryLotLineId: number, lotNo: string) => {
  453. if (selectedPrinterId === "") {
  454. setSnackbar({
  455. open: true,
  456. message: t("Please select a printer first"),
  457. severity: "error",
  458. });
  459. return;
  460. }
  461. if (printQty < 1 || !Number.isFinite(printQty)) {
  462. setSnackbar({
  463. open: true,
  464. message: t("Print quantity must be an integer of 1 or more"),
  465. severity: "error",
  466. });
  467. return;
  468. }
  469. setPrintingLotLineId(inventoryLotLineId);
  470. try {
  471. await printWorkbenchLotLabel({
  472. inventoryLotLineId,
  473. printerId: selectedPrinterId,
  474. printQty: Math.floor(printQty),
  475. });
  476. setSnackbar({
  477. open: true,
  478. message: t("Print sent: Lot {{lotNo}}", { lotNo }),
  479. severity: "success",
  480. });
  481. } catch (e) {
  482. setSnackbar({
  483. open: true,
  484. message: e instanceof Error ? e.message : t("Print failed"),
  485. severity: "error",
  486. });
  487. } finally {
  488. setPrintingLotLineId(null);
  489. }
  490. },
  491. [selectedPrinterId, printQty, t],
  492. );
  493. return (
  494. <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
  495. <DialogTitle>{t("Lot label print (pick station)")}</DialogTitle>
  496. <DialogContent>
  497. <Stack spacing={2} sx={{ mt: 1 }}>
  498. {statusTitleText ? (
  499. <Typography
  500. variant="h6"
  501. sx={{
  502. fontWeight: 800,
  503. color:
  504. statusTitleSeverity === "success"
  505. ? "success.main"
  506. : statusTitleSeverity === "warning"
  507. ? "warning.main"
  508. : "error.main",
  509. }}
  510. >
  511. {statusTitleText}
  512. </Typography>
  513. ) : null}
  514. {reminderText ? (
  515. <Alert severity="warning">{reminderText}</Alert>
  516. ) : null}
  517. {effectiveHideScanSection ? null : (
  518. <>
  519. {/*
  520. <Alert severity="info">
  521. 請掃描條碼(JSON 格式),例如{" "}
  522. <code>{'{"itemId":16431,"stockInLineId":10381'}</code>。
  523. </Alert>
  524. */}
  525. <Stack
  526. direction={{ xs: "column", md: "row" }}
  527. spacing={2}
  528. alignItems={{ xs: "stretch", md: "center" }}
  529. >
  530. <TextField
  531. inputRef={scanInputRef}
  532. label={t("Scan content")}
  533. value={scanInput}
  534. onChange={(e) => setScanInput(e.target.value)}
  535. fullWidth
  536. size="small"
  537. error={!!scanError}
  538. helperText={scanError || t("Scan then press Enter or click Look up")}
  539. onKeyDown={(e) => {
  540. if (e.key === "Enter") {
  541. e.preventDefault();
  542. void handleAnalyze();
  543. }
  544. }}
  545. disabled={analysisLoading}
  546. />
  547. <Button
  548. variant="contained"
  549. onClick={() => void handleAnalyze()}
  550. disabled={analysisLoading || !scanInput.trim()}
  551. >
  552. {analysisLoading ? <CircularProgress size={18} /> : t("Look up")}
  553. </Button>
  554. <Button
  555. variant="outlined"
  556. onClick={() => {
  557. resetAll();
  558. scanInputRef.current?.focus();
  559. }}
  560. disabled={analysisLoading}
  561. >
  562. {t("Clear")}
  563. </Button>
  564. </Stack>
  565. </>
  566. )}
  567. <Stack
  568. direction={{ xs: "column", md: "row" }}
  569. spacing={2}
  570. alignItems={{ xs: "stretch", md: "center" }}
  571. >
  572. <FormControl
  573. size="small"
  574. sx={{ minWidth: 260 }}
  575. disabled={printersLoading}
  576. >
  577. <InputLabel>{t("Printer")}</InputLabel>
  578. <Select
  579. label={t("Printer")}
  580. value={selectedPrinterId}
  581. onChange={(e) =>
  582. setSelectedPrinterId((e.target.value as number) ?? "")
  583. }
  584. >
  585. <MenuItem value="">
  586. <em>{printersLoading ? t("Loading") : t("Please select")}</em>
  587. </MenuItem>
  588. {printers.map((p) => (
  589. <MenuItem key={p.id} value={p.id}>
  590. {formatPrinterLabel(p)}
  591. </MenuItem>
  592. ))}
  593. </Select>
  594. </FormControl>
  595. <TextField
  596. label={t("Print copies")}
  597. size="small"
  598. type="number"
  599. inputProps={{ min: 1, step: 1 }}
  600. value={printQty}
  601. onChange={(e) => setPrintQty(Number(e.target.value))}
  602. sx={{ width: 140 }}
  603. disabled={analysisLoading}
  604. />
  605. {onWorkbenchScanPick ? (
  606. <TextField
  607. label={t("Submit Qty")}
  608. size="small"
  609. type="number"
  610. inputProps={{ min: 0, step: 1 }}
  611. value={
  612. Number.isFinite(Number(submitQty)) ? Number(submitQty) : 0
  613. }
  614. onChange={(e) => {
  615. const n = Number(e.target.value);
  616. if (!Number.isFinite(n) || n < 0) return;
  617. onSubmitQtyChange?.(n);
  618. }}
  619. sx={{ width: 140 }}
  620. disabled={analysisLoading}
  621. />
  622. ) : null}
  623. <Button
  624. variant="outlined"
  625. onClick={() => void handleRefreshLots()}
  626. disabled={analysisLoading}
  627. >
  628. {analysisLoading ? (
  629. <CircularProgress size={18} />
  630. ) : (
  631. t("Refresh lot list")
  632. )}
  633. </Button>
  634. {selectedPrinter && (
  635. <Typography
  636. variant="body2"
  637. color="text.secondary"
  638. sx={{ ml: { md: "auto" } }}
  639. >
  640. {t("Selected printer", { printer: formatPrinterLabel(selectedPrinter) })}
  641. </Typography>
  642. )}
  643. </Stack>
  644. {analysis && (
  645. <Box>
  646. <Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
  647. {t("Item code name", { code: analysis.itemCode, name: analysis.itemName })}
  648. </Typography>
  649. {filteredLots.length === 0 ? (
  650. <Alert severity="warning">
  651. {t("No available lots on this floor")}
  652. </Alert>
  653. ) : (
  654. <Stack spacing={1}>
  655. {filteredLots.map((lot) => {
  656. const isPrinting =
  657. printingLotLineId === lot.inventoryLotLineId;
  658. const loc = String(lot.warehouseCode ?? "").trim();
  659. const canShowLotQr =
  660. !!onWorkbenchScanPick &&
  661. !!analysis &&
  662. !analysisLoading &&
  663. !disableScanPick;
  664. const lotQrPayload =
  665. Number.isFinite(Number(analysis?.itemId)) &&
  666. Number.isFinite(Number(lot.stockInLineId))
  667. ? {
  668. itemId: Number(analysis?.itemId),
  669. stockInLineId: Number(lot.stockInLineId),
  670. }
  671. : null;
  672. return (
  673. <Box
  674. key={lot.inventoryLotLineId}
  675. sx={{
  676. p: 1.25,
  677. borderRadius: 1,
  678. border: "1px solid",
  679. borderColor: "divider",
  680. display: "flex",
  681. alignItems: "center",
  682. gap: 2,
  683. backgroundColor: lot._scanned
  684. ? "rgba(25, 118, 210, 0.08)"
  685. : "transparent",
  686. }}
  687. >
  688. <Box sx={{ minWidth: 220 }}>
  689. <Typography
  690. variant="body1"
  691. sx={{ fontWeight: lot._scanned ? 800 : 600 }}
  692. >
  693. Lot:{lot.lotNo}
  694. {lot._scanned ? t(" (current lot)") : ""}
  695. </Typography>
  696. <Typography variant="body2" color="text.secondary">
  697. {t("Location with value", { location: loc || "—" })}
  698. </Typography>
  699. <Typography variant="body2" color="text.secondary">
  700. {t("Available qty with uom", {
  701. qty: Number(lot.availableQty).toLocaleString(),
  702. uom: lot.uom || "",
  703. })}
  704. </Typography>
  705. </Box>
  706. <Stack
  707. direction="row"
  708. spacing={1}
  709. sx={{ ml: "auto" }}
  710. flexWrap="wrap"
  711. useFlexGap
  712. >
  713. <Button
  714. variant="contained"
  715. disabled={!canPrint || isPrinting}
  716. onClick={() =>
  717. void handlePrintOne(
  718. lot.inventoryLotLineId,
  719. lot.lotNo,
  720. )
  721. }
  722. >
  723. {isPrinting ? (
  724. <CircularProgress size={18} />
  725. ) : (
  726. t("Print label")
  727. )}
  728. </Button>
  729. {onWorkbenchScanPick ? (
  730. <Button
  731. variant="outlined"
  732. color="secondary"
  733. title={
  734. !lotQrPayload
  735. ? t("This row has no QR payload")
  736. : disableScanPick
  737. ? t("This pick line already scanned or completed, QR cannot be shown")
  738. : undefined
  739. }
  740. disabled={
  741. !canShowLotQr || !lotQrPayload || isPrinting
  742. }
  743. onClick={() =>
  744. setQrVisibleLotLineId((prev) =>
  745. prev === lot.inventoryLotLineId
  746. ? null
  747. : lot.inventoryLotLineId,
  748. )
  749. }
  750. >
  751. {t("Show QR")}
  752. </Button>
  753. ) : null}
  754. </Stack>
  755. {qrVisibleLotLineId === lot.inventoryLotLineId &&
  756. lotQrPayload ? (
  757. <Box
  758. sx={{
  759. mt: 1.5,
  760. ml: "auto",
  761. p: 1.5,
  762. borderRadius: 1,
  763. border: "1px dashed",
  764. borderColor: "divider",
  765. textAlign: "center",
  766. minWidth: 220,
  767. }}
  768. >
  769. <QRCodeSVG
  770. value={JSON.stringify(lotQrPayload)}
  771. size={160}
  772. includeMargin
  773. />
  774. </Box>
  775. ) : null}
  776. </Box>
  777. );
  778. })}
  779. </Stack>
  780. )}
  781. </Box>
  782. )}
  783. {!analysis && !analysisLoading && (
  784. <Typography variant="body2" color="text.secondary">
  785. {onWorkbenchScanPick
  786. ? t("No lots available to print labels")
  787. : ""}
  788. </Typography>
  789. )}
  790. </Stack>
  791. </DialogContent>
  792. <DialogActions>
  793. <Button onClick={onClose}>{t("Close")}</Button>
  794. </DialogActions>
  795. <Snackbar
  796. open={snackbar.open}
  797. autoHideDuration={3500}
  798. onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
  799. message={snackbar.message}
  800. anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
  801. />
  802. </Dialog>
  803. );
  804. };
  805. export default WorkbenchLotLabelPrintModal;