"use client"; /** * Workbench copy of `LotLabelPrintModal`: same label-print flow, plus optional * 「掃碼提貨」 per listed lot row (parent calls `workbenchScanPick` with `inventoryLotLineId`). */ import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, InputLabel, MenuItem, Select, Snackbar, Stack, TextField, Typography, } from "@mui/material"; import { analyzeWorkbenchQrCode, fetchWorkbenchAvailableLotsByItem, fetchWorkbenchPrinters, printWorkbenchLotLabel, } from "@/app/api/doworkbench/actions"; import { QRCodeSVG } from "qrcode.react"; import { useTranslation } from "react-i18next"; type ScanPayload = { itemId: number; stockInLineId: number; }; type Printer = { id: number; name?: string; description?: string; ip?: string; port?: number; type?: string; brand?: string; }; type QrCodeAnalysisResponse = { itemId: number; itemCode: string; itemName: string; scanned?: { stockInLineId: number; lotNo: string; inventoryLotLineId: number; warehouseCode?: string | null; warehouseName?: string | null; uom?: string | null; uomId?: number | null; } | null; sameItemLots: Array<{ lotNo: string; inventoryLotLineId: number; stockInLineId?: number | null; availableQty: number; uom: string; uomId?: number | null; warehouseCode?: string | null; warehouseName?: string | null; }>; }; export interface WorkbenchLotLabelPrintModalProps { open: boolean; onClose: () => void; initialPayload?: ScanPayload | null; initialItemId?: number | null; defaultPrinterName?: string; hideScanSection?: boolean; reminderText?: string; statusTitleText?: string; /** 與 statusTitleText 搭配;預設 error(舊版固定紅字) */ statusTitleSeverity?: "success" | "warning" | "error"; warehouseCodePrefixFilter?: string; /** * When true, omit the API 「scanned」 lot from the merged list (legacy FG-style). * Workbench should leave false so the current row’s lot appears for label print / scan-pick. */ hideTriggeredLot?: boolean; /** 提貨台表格列上的可用量/單位(API 的 sameItemLots 不含掃描行,需補上才能顯示「目前這筆」) */ triggerLotAvailableQty?: number | null; triggerLotUom?: string | null; /** POL UomConversion id — workbench lot list only returns matching UOM */ expectedUomId?: number | null; /** 此出庫行已掃碼/已完成時為 true,停用所有「掃碼提貨」(仍可列印標籤) */ disableScanPick?: boolean; /** * When set, each lot row shows 「掃碼提貨」. Parent should call `workbenchScanPick` * with `inventoryLotLineId` and throw on failure. */ onWorkbenchScanPick?: (args: { inventoryLotLineId: number; lotNo: string; qty?: number; }) => Promise; /** Global submit qty shared with outer "Qty will submit". */ submitQty?: number | null; onSubmitQtyChange?: (qty: number) => void; } function safeParseScanPayload(raw: string): ScanPayload | null { try { const obj = JSON.parse(raw); const itemId = Number(obj?.itemId); const stockInLineId = Number(obj?.stockInLineId); if (!Number.isFinite(itemId) || !Number.isFinite(stockInLineId)) return null; return { itemId, stockInLineId }; } catch { return null; } } function formatPrinterLabel(p: Printer): string { const name = (p.name || "").trim(); if (name) return name; const desc = (p.description || "").trim(); if (desc) return desc; const code = (p as { code?: string }).code?.trim?.() ?? ""; if (code) return code; return `#${p.id}`; } function isLabelPrinter(p: Printer): boolean { const s = `${p.name ?? ""} ${p.description ?? ""} ${ (p as { code?: string }).code ?? "" } ${p.type ?? ""} ${p.brand ?? ""}`.toLowerCase(); return s.includes("label") && !s.includes("a4"); } /** FP-MTMS Version Checklist | Functions Ref. No. 30 | v1.0.1 | 2026-07-22 */ const WorkbenchLotLabelPrintModal: React.FC = ({ open, onClose, initialPayload = null, initialItemId = null, defaultPrinterName, hideScanSection, reminderText, statusTitleText, statusTitleSeverity = "error", warehouseCodePrefixFilter, hideTriggeredLot = false, triggerLotAvailableQty = null, triggerLotUom = null, expectedUomId = null, disableScanPick = false, onWorkbenchScanPick, submitQty = null, onSubmitQtyChange, }) => { const { t } = useTranslation(); const scanInputRef = useRef(null); const [scanInput, setScanInput] = useState(""); const [scanError, setScanError] = useState(null); const [printers, setPrinters] = useState([]); const [printersLoading, setPrintersLoading] = useState(false); const [selectedPrinterId, setSelectedPrinterId] = useState(""); const [analysisLoading, setAnalysisLoading] = useState(false); const [analysis, setAnalysis] = useState(null); const [lastPayload, setLastPayload] = useState(null); const [lastItemId, setLastItemId] = useState(null); const [printQty, setPrintQty] = useState(1); const [printingLotLineId, setPrintingLotLineId] = useState( null, ); const [qrVisibleLotLineId, setQrVisibleLotLineId] = useState( null, ); const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: "success" | "info" | "error"; }>({ open: false, message: "", severity: "info", }); const resetAll = useCallback(() => { setScanInput(""); setScanError(null); setAnalysis(null); setPrintQty(1); setPrintingLotLineId(null); setQrVisibleLotLineId(null); }, []); useEffect(() => { if (!open) return; resetAll(); const focusTimer = setTimeout(() => scanInputRef.current?.focus(), 50); return () => clearTimeout(focusTimer); }, [open, resetAll]); const loadPrinters = useCallback(async () => { setPrintersLoading(true); try { const data = (await fetchWorkbenchPrinters()) as Printer[]; const list = Array.isArray(data) ? data : []; setPrinters(list.filter(isLabelPrinter)); } catch (e) { setPrinters([]); setSnackbar({ open: true, message: e instanceof Error ? e.message : t("Failed to load printer list"), severity: "error", }); } finally { setPrintersLoading(false); } }, [t]); useEffect(() => { if (!open) return; void loadPrinters(); }, [open, loadPrinters]); const effectiveHideScanSection = hideScanSection ?? initialPayload != null; const pickDefaultPrinterId = useCallback( (list: Printer[]): number | null => { if (!defaultPrinterName) return null; const target = defaultPrinterName.trim().toLowerCase(); if (!target) return null; const byExact = list.find( (p) => formatPrinterLabel(p).trim().toLowerCase() === target, ); if (byExact) return byExact.id; const byIncludes = list.find((p) => formatPrinterLabel(p).trim().toLowerCase().includes(target), ); return byIncludes?.id ?? null; }, [defaultPrinterName], ); useEffect(() => { if (!open) return; if (selectedPrinterId !== "") return; if (printers.length === 0) return; const id = pickDefaultPrinterId(printers); if (id != null) setSelectedPrinterId(id); }, [open, printers, selectedPrinterId, pickDefaultPrinterId]); const resolveExpectedUomId = useCallback((): number | null => { const n = Number(expectedUomId); return Number.isFinite(n) && n > 0 ? n : null; }, [expectedUomId]); const analyzePayload = useCallback( async (payload: ScanPayload) => { setLastPayload(payload); setScanError(null); setAnalysisLoading(true); try { const uomId = resolveExpectedUomId(); const data = (await analyzeWorkbenchQrCode({ ...payload, ...(uomId != null ? { uomId } : {}), })) as QrCodeAnalysisResponse; setAnalysis(data); setSnackbar({ open: true, message: t("Loaded available lots for this item"), severity: "success", }); } catch (e) { setAnalysis(null); setScanError(e instanceof Error ? e.message : t("Analysis failed")); } finally { setAnalysisLoading(false); } }, [resolveExpectedUomId, t], ); const analyzeByItem = useCallback( async (itemId: number) => { if (!Number.isFinite(itemId) || itemId <= 0) { setScanError(t("Invalid itemId, cannot load lot list.")); return; } setLastItemId(itemId); setScanError(null); setAnalysisLoading(true); try { const uomId = resolveExpectedUomId(); const data = (await fetchWorkbenchAvailableLotsByItem( itemId, uomId, )) as { itemId: number; itemCode: string; itemName: string; sameItemLots: QrCodeAnalysisResponse["sameItemLots"]; }; setAnalysis({ itemId: data.itemId, itemCode: data.itemCode, itemName: data.itemName, scanned: null, sameItemLots: data.sameItemLots ?? [], }); setSnackbar({ open: true, message: t("Loaded available lots for this item"), severity: "success", }); } catch (e) { setAnalysis(null); setScanError(e instanceof Error ? e.message : t("Analysis failed")); } finally { setAnalysisLoading(false); } }, [resolveExpectedUomId, t], ); const handleAnalyze = useCallback(async () => { const raw = scanInput.trim(); const payload = safeParseScanPayload(raw); if (!payload) { setScanError( t("Invalid scan format. Please scan again."), ); setAnalysis(null); return; } await analyzePayload(payload); }, [scanInput, analyzePayload, t]); const handleRefreshLots = useCallback(async () => { const payload = lastPayload ?? safeParseScanPayload(scanInput.trim()); if (payload) { await analyzePayload(payload); return; } const candidateItemId = (Number.isFinite(lastItemId ?? NaN) && (lastItemId ?? 0) > 0 ? (lastItemId as number) : Number(initialItemId)); if (Number.isFinite(candidateItemId) && candidateItemId > 0) { await analyzeByItem(candidateItemId); return; } if (!payload) { setSnackbar({ open: true, message: t("Scan or look up once before refreshing the lot list."), severity: "info", }); return; } }, [analyzeByItem, analyzePayload, initialItemId, lastItemId, lastPayload, scanInput, t]); useEffect(() => { if (!open) return; if (initialPayload) { setScanInput(JSON.stringify(initialPayload)); void analyzePayload(initialPayload); return; } if (Number.isFinite(Number(initialItemId)) && Number(initialItemId) > 0) { void analyzeByItem(Number(initialItemId)); } }, [open, initialPayload, initialItemId, analyzePayload, analyzeByItem]); const availableLots = useMemo(() => { if (!analysis) return []; const list = (analysis.sameItemLots ?? []).filter( (x) => Number(x.availableQty) > 0 && !!String(x.lotNo || "").trim(), ); const scannedLotLineId = analysis.scanned?.inventoryLotLineId; const scannedRow = scannedLotLineId ? list.find((x) => x.inventoryLotLineId === scannedLotLineId) : undefined; const tableQty = Number(triggerLotAvailableQty); const fromTable = Number.isFinite(tableQty) && tableQty >= 0 ? tableQty : 0; const fromApi = Number(scannedRow?.availableQty ?? 0); const scanned = analysis.scanned; const expectUom = Number(expectedUomId); const hasExpectUom = Number.isFinite(expectUom) && expectUom > 0; const scannedUomId = Number(scanned?.uomId ?? scannedRow?.uomId ?? 0); const scannedUomOk = !hasExpectUom || !Number.isFinite(scannedUomId) || scannedUomId <= 0 || scannedUomId === expectUom; const scannedLot = scannedLotLineId && scannedUomOk ? { lotNo: scanned?.lotNo ?? "", inventoryLotLineId: scannedLotLineId, stockInLineId: Number(scanned?.stockInLineId ?? 0) || null, availableQty: Math.max(fromApi, fromTable) as number, uom: (scanned?.uom ?? scannedRow?.uom ?? triggerLotUom ?? "") as string, uomId: scannedUomId > 0 ? scannedUomId : null, warehouseCode: scanned?.warehouseCode ?? scannedRow?.warehouseCode, warehouseName: scanned?.warehouseName ?? scannedRow?.warehouseName, _scanned: true as const, } : null; const merged = [ ...(!hideTriggeredLot && scannedLot ? [scannedLot] : []), ...list .filter((x) => x.inventoryLotLineId !== scannedLotLineId) .filter((x) => { if (!hasExpectUom) return true; const id = Number(x.uomId); return !Number.isFinite(id) || id <= 0 || id === expectUom; }) .map((x) => ({ ...x, _scanned: false as const })), ]; return merged; }, [ analysis, hideTriggeredLot, triggerLotAvailableQty, triggerLotUom, expectedUomId, ]); const filteredLots = useMemo(() => { const prefix = String(warehouseCodePrefixFilter ?? "").trim(); if (!prefix) return availableLots; return availableLots.filter((lot) => { // 使用者從本列開啟視窗:即使 API 未帶 warehouseCode,仍應顯示目前這筆批號 if (lot._scanned) return true; return String(lot.warehouseCode ?? "").startsWith(prefix); }); }, [availableLots, warehouseCodePrefixFilter]); const selectedPrinter = useMemo(() => { if (selectedPrinterId === "") return null; return printers.find((p) => p.id === selectedPrinterId) ?? null; }, [printers, selectedPrinterId]); const canPrint = !!analysis && selectedPrinterId !== "" && printQty >= 1 && !analysisLoading; const handlePrintOne = useCallback( async (inventoryLotLineId: number, lotNo: string) => { if (selectedPrinterId === "") { setSnackbar({ open: true, message: t("Please select a printer first"), severity: "error", }); return; } if (printQty < 1 || !Number.isFinite(printQty)) { setSnackbar({ open: true, message: t("Print quantity must be an integer of 1 or more"), severity: "error", }); return; } setPrintingLotLineId(inventoryLotLineId); try { await printWorkbenchLotLabel({ inventoryLotLineId, printerId: selectedPrinterId, printQty: Math.floor(printQty), }); setSnackbar({ open: true, message: t("Print sent: Lot {{lotNo}}", { lotNo }), severity: "success", }); } catch (e) { setSnackbar({ open: true, message: e instanceof Error ? e.message : t("Print failed"), severity: "error", }); } finally { setPrintingLotLineId(null); } }, [selectedPrinterId, printQty, t], ); return ( {t("Lot label print (pick station)")} {statusTitleText ? ( {statusTitleText} ) : null} {reminderText ? ( {reminderText} ) : null} {effectiveHideScanSection ? null : ( <> {/* 請掃描條碼(JSON 格式),例如{" "} {'{"itemId":16431,"stockInLineId":10381'} */} setScanInput(e.target.value)} fullWidth size="small" error={!!scanError} helperText={scanError || t("Scan then press Enter or click Look up")} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void handleAnalyze(); } }} disabled={analysisLoading} /> )} {t("Printer")} setPrintQty(Number(e.target.value))} sx={{ width: 140 }} disabled={analysisLoading} /> {onWorkbenchScanPick ? ( { const n = Number(e.target.value); if (!Number.isFinite(n) || n < 0) return; onSubmitQtyChange?.(n); }} sx={{ width: 140 }} disabled={analysisLoading} /> ) : null} {selectedPrinter && ( {t("Selected printer", { printer: formatPrinterLabel(selectedPrinter) })} )} {analysis && ( {t("Item code name", { code: analysis.itemCode, name: analysis.itemName })} {filteredLots.length === 0 ? ( {t("No available lots on this floor")} ) : ( {filteredLots.map((lot) => { const isPrinting = printingLotLineId === lot.inventoryLotLineId; const loc = String(lot.warehouseCode ?? "").trim(); const canShowLotQr = !!onWorkbenchScanPick && !!analysis && !analysisLoading && !disableScanPick; const lotQrPayload = Number.isFinite(Number(analysis?.itemId)) && Number.isFinite(Number(lot.stockInLineId)) ? { itemId: Number(analysis?.itemId), stockInLineId: Number(lot.stockInLineId), } : null; return ( Lot:{lot.lotNo} {lot._scanned ? t(" (current lot)") : ""} {t("Location with value", { location: loc || "—" })} {t("Available qty with uom", { qty: Number(lot.availableQty).toLocaleString(), uom: lot.uom || "", })} {onWorkbenchScanPick ? ( ) : null} {qrVisibleLotLineId === lot.inventoryLotLineId && lotQrPayload ? ( ) : null} ); })} )} )} {!analysis && !analysisLoading && ( {onWorkbenchScanPick ? t("No lots available to print labels") : ""} )} setSnackbar((s) => ({ ...s, open: false }))} message={snackbar.message} anchorOrigin={{ vertical: "bottom", horizontal: "center" }} /> ); }; export default WorkbenchLotLabelPrintModal;