"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"; 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; } | null; sameItemLots: Array<{ lotNo: string; inventoryLotLineId: number; stockInLineId?: number | null; availableQty: number; uom: string; 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; /** 此出庫行已掃碼/已完成時為 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"); } const WorkbenchLotLabelPrintModal: React.FC = ({ open, onClose, initialPayload = null, initialItemId = null, defaultPrinterName, hideScanSection, reminderText, statusTitleText, statusTitleSeverity = "error", warehouseCodePrefixFilter, hideTriggeredLot = false, triggerLotAvailableQty = null, triggerLotUom = null, disableScanPick = false, onWorkbenchScanPick, submitQty = null, onSubmitQtyChange, }) => { 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 t = setTimeout(() => scanInputRef.current?.focus(), 50); return () => clearTimeout(t); }, [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 : "載入印表機清單失敗", severity: "error", }); } finally { setPrintersLoading(false); } }, []); 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 analyzePayload = useCallback( async (payload: ScanPayload) => { setLastPayload(payload); setScanError(null); setAnalysisLoading(true); try { const data = (await analyzeWorkbenchQrCode(payload)) as QrCodeAnalysisResponse; setAnalysis(data); setSnackbar({ open: true, message: "已載入同品可用批號清單", severity: "success", }); } catch (e) { setAnalysis(null); setScanError(e instanceof Error ? e.message : "分析失敗"); } finally { setAnalysisLoading(false); } }, [], ); const analyzeByItem = useCallback( async (itemId: number) => { if (!Number.isFinite(itemId) || itemId <= 0) { setScanError("無效 itemId,無法載入批號清單。"); return; } setLastItemId(itemId); setScanError(null); setAnalysisLoading(true); try { const data = (await fetchWorkbenchAvailableLotsByItem(itemId)) 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: "已載入同品可用批號清單", severity: "success", }); } catch (e) { setAnalysis(null); setScanError(e instanceof Error ? e.message : "分析失敗"); } finally { setAnalysisLoading(false); } }, [], ); const handleAnalyze = useCallback(async () => { const raw = scanInput.trim(); const payload = safeParseScanPayload(raw); if (!payload) { setScanError( '掃碼內容格式錯誤,請重新掃碼', ); setAnalysis(null); return; } await analyzePayload(payload); }, [scanInput, analyzePayload]); 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: "請先掃碼或查詢一次,才可刷新批號清單。", severity: "info", }); return; } }, [analyzeByItem, analyzePayload, initialItemId, lastItemId, lastPayload, scanInput]); 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 scannedLot = scannedLotLineId ? { lotNo: scanned?.lotNo ?? "", inventoryLotLineId: scannedLotLineId, stockInLineId: Number(scanned?.stockInLineId ?? 0) || null, availableQty: Math.max(fromApi, fromTable) as number, uom: (scannedRow?.uom ?? triggerLotUom ?? "") as string, 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) .map((x) => ({ ...x, _scanned: false as const })), ]; return merged; }, [analysis, hideTriggeredLot, triggerLotAvailableQty, triggerLotUom]); 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: "請先選擇印表機", severity: "error", }); return; } if (printQty < 1 || !Number.isFinite(printQty)) { setSnackbar({ open: true, message: "列印張數需為大於等於 1 的整數", severity: "error", }); return; } setPrintingLotLineId(inventoryLotLineId); try { await printWorkbenchLotLabel({ inventoryLotLineId, printerId: selectedPrinterId, printQty: Math.floor(printQty), }); setSnackbar({ open: true, message: `已送出列印:Lot ${lotNo}`, severity: "success", }); } catch (e) { setSnackbar({ open: true, message: e instanceof Error ? e.message : "列印失敗", severity: "error", }); } finally { setPrintingLotLineId(null); } }, [selectedPrinterId, printQty], ); return ( 批號標籤列印(提貨台) {statusTitleText ? ( {statusTitleText} ) : null} {reminderText ? ( {reminderText} ) : null} {effectiveHideScanSection ? null : ( <> {/* 請掃描條碼(JSON 格式),例如{" "} {'{"itemId":16431,"stockInLineId":10381'} */} setScanInput(e.target.value)} fullWidth size="small" error={!!scanError} helperText={scanError || "掃描後按 Enter 或點「查詢」"} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void handleAnalyze(); } }} disabled={analysisLoading} /> )} 印表機 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 && ( 已選:{formatPrinterLabel(selectedPrinter)} )} {analysis && ( 品號:{analysis.itemCode} {analysis.itemName} {filteredLots.length === 0 ? ( 找不到該樓層有可用批號(availableQty > 0)。 ) : ( {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 ? "(當前批次)" : ""} 位置:{loc || "—"} 可用量:{Number(lot.availableQty).toLocaleString()}{" "} 單位:{lot.uom || ""} {onWorkbenchScanPick ? ( ) : null} {qrVisibleLotLineId === lot.inventoryLotLineId && lotQrPayload ? ( ) : null} ); })} )} )} {!analysis && !analysisLoading && ( {onWorkbenchScanPick ? "沒有任何批號可列印標籤" : ""} )} setSnackbar((s) => ({ ...s, open: false }))} message={snackbar.message} anchorOrigin={{ vertical: "bottom", horizontal: "center" }} /> ); }; export default WorkbenchLotLabelPrintModal;