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.
 
 

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