"use client"; import { useUiText } from "@/i18n/useUiText"; import { FLOOR_A_KEY, LOC_PREFIX } from "@/config/demoFloors"; 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 { NEXT_PUBLIC_API_URL } from "@/config/api"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; 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; }; sameItemLots: Array<{ lotNo: string; inventoryLotLineId: number; availableQty: number; uom: string; warehouseCode?: string | null; warehouseName?: string | null; }>; }; export interface LotLabelPrintModalProps { open: boolean; onClose: () => void; /** 當由其他流程自動打開時,可帶入掃碼 payload 以便自動查詢 */ initialPayload?: ScanPayload | null; /** 預設要自動選取的印表機名稱(完全匹配優先,其次為包含) */ defaultPrinterName?: string; /** 隱藏「掃碼內容/查詢/清除」區塊(通常搭配 initialPayload 使用) */ hideScanSection?: boolean; /** 額外提醒(顯示在最上方) */ reminderText?: string; /** 額外標題(顯示在最上方,reminderText 之下) */ statusTitleText?: string; /** 只顯示特定倉位前綴(例如 FLOOR_A_KEY + "-") */ warehouseCodePrefixFilter?: string; /** 不顯示觸發視窗的批號(analysis.scanned) */ hideTriggeredLot?: boolean; } 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(); // Keep only "Label 機" printers; exclude A4 printers etc. return s.includes("label") && !s.includes("a4"); } const LotLabelPrintModal: React.FC = ({ open, onClose, initialPayload = null, defaultPrinterName, hideScanSection, reminderText, statusTitleText, warehouseCodePrefixFilter, hideTriggeredLot, }) => { const L = useUiText(); 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 [printQty, setPrintQty] = useState(1); const [printingLotLineId, setPrintingLotLineId] = useState( null, ); const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: "success" | "info" | "error"; }>({ open: false, message: "", severity: "info", }); const baseApi = useMemo( () => (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""), [], ); const resetAll = useCallback(() => { setScanInput(""); setScanError(null); setAnalysis(null); setPrintQty(1); setPrintingLotLineId(null); }, []); useEffect(() => { if (!open) return; resetAll(); const t = setTimeout(() => scanInputRef.current?.focus(), 50); return () => clearTimeout(t); }, [open, resetAll]); const loadPrinters = useCallback(async () => { if (!baseApi) { setSnackbar({ open: true, message: L("NEXT_PUBLIC_API_URL 未設定,無法連線後端。", "NEXT_PUBLIC_API_URL is not set, so the backend cannot be reached."), severity: "error", }); return; } setPrintersLoading(true); try { const res = await clientAuthFetch(`${baseApi}/printers`, { method: "GET", }); if (!res.ok) { const msg = await res.text().catch(() => ""); throw new Error(msg || `HTTP ${res.status}`); } const data = (await res.json()) as Printer[]; const list = Array.isArray(data) ? data : []; setPrinters(list.filter(isLabelPrinter)); } catch (e) { setPrinters([]); setSnackbar({ open: true, message: e instanceof Error ? e.message : L("載入印表機清單失敗", "Could not load the printer list"), severity: "error", }); } finally { setPrintersLoading(false); } }, [baseApi]); 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) => { if (!baseApi) { setScanError(L("NEXT_PUBLIC_API_URL 未設定,無法連線後端。", "NEXT_PUBLIC_API_URL is not set, so the backend cannot be reached.")); return; } setLastPayload(payload); setScanError(null); setAnalysisLoading(true); try { const res = await clientAuthFetch( `${baseApi}/inventoryLotLine/analyze-qr-code`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }, ); if (!res.ok) { const msg = await res.text().catch(() => ""); throw new Error(msg || L(`分析失敗(HTTP ${res.status})`, `Lookup failed (HTTP ${res.status})`)); } const data = (await res.json()) as QrCodeAnalysisResponse; setAnalysis(data); setSnackbar({ open: true, message: L("已載入同品可用批號清單", "Loaded available lots for this item"), severity: "success", }); } catch (e) { setAnalysis(null); setScanError(e instanceof Error ? e.message : L("分析失敗", "Lookup failed")); } finally { setAnalysisLoading(false); } }, [baseApi], ); const handleAnalyze = useCallback(async () => { const raw = scanInput.trim(); const payload = safeParseScanPayload(raw); if (!payload) { setScanError( L('掃碼內容格式錯誤,需為 JSON,例如 {"itemId":16431,"stockInLineId":10381}', 'Scan content must be JSON, for example {"itemId":16431,"stockInLineId":10381}'), ); setAnalysis(null); return; } await analyzePayload(payload); }, [scanInput, analyzePayload]); const handleRefreshLots = useCallback(async () => { const payload = lastPayload ?? safeParseScanPayload(scanInput.trim()); if (!payload) { setSnackbar({ open: true, message: L("請先掃碼或查詢一次,才可刷新批號清單。", "Scan or look up once before refreshing the lot list."), severity: "info", }); return; } await analyzePayload(payload); }, [analyzePayload, lastPayload, scanInput]); useEffect(() => { if (!open) return; if (!initialPayload) return; setScanInput(JSON.stringify(initialPayload)); void analyzePayload(initialPayload); }, [open, initialPayload, analyzePayload]); 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 scannedLot = scannedLotLineId ? { lotNo: analysis.scanned.lotNo, inventoryLotLineId: scannedLotLineId, availableQty: (scannedRow?.availableQty ?? 0) as number, uom: (scannedRow?.uom ?? "") as string, warehouseCode: analysis.scanned.warehouseCode ?? scannedRow?.warehouseCode, warehouseName: analysis.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]); const filteredLots = useMemo(() => { const prefix = String(warehouseCodePrefixFilter ?? "").trim(); if (!prefix) return availableLots; return availableLots.filter((lot) => { const code = String(lot.warehouseCode ?? ""); return code.startsWith(prefix) || code.startsWith(LOC_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 (!baseApi) { setSnackbar({ open: true, message: L("NEXT_PUBLIC_API_URL 未設定,無法連線後端。", "NEXT_PUBLIC_API_URL is not set, so the backend cannot be reached."), severity: "error", }); return; } if (selectedPrinterId === "") { setSnackbar({ open: true, message: L("請先選擇印表機", "Select a printer first"), severity: "error", }); return; } if (printQty < 1 || !Number.isFinite(printQty)) { setSnackbar({ open: true, message: L("列印張數需為大於等於 1 的整數", "Copies to print must be a whole number of at least 1"), severity: "error", }); return; } setPrintingLotLineId(inventoryLotLineId); try { const sp = new URLSearchParams(); sp.set("inventoryLotLineId", String(inventoryLotLineId)); sp.set("printerId", String(selectedPrinterId)); sp.set("printQty", String(Math.floor(printQty))); const url = `${baseApi}/inventoryLotLine/print-label?${sp.toString()}`; const res = await clientAuthFetch(url, { method: "GET" }); if (!res.ok) { const msg = await res.text().catch(() => ""); throw new Error(msg || L(`列印失敗(HTTP ${res.status})`, `Print failed (HTTP ${res.status})`)); } setSnackbar({ open: true, message: L(`已送出列印:Lot ${lotNo}`, `Print sent: lot ${lotNo}`), severity: "success", }); } catch (e) { setSnackbar({ open: true, message: e instanceof Error ? e.message : L("列印失敗", "Print failed"), severity: "error", }); } finally { setPrintingLotLineId(null); } }, [baseApi, selectedPrinterId, printQty], ); return ( {L("批號標籤列印", "Print lot labels")} {statusTitleText ? ( {statusTitleText} ) : null} {reminderText ? ( {reminderText} ) : null} {effectiveHideScanSection ? null : ( <> {L("請掃描條碼(JSON 格式),例如", "Scan a barcode (JSON), for example")}{" "} {'{"itemId":16431,"stockInLineId":10381'}。 setScanInput(e.target.value)} fullWidth size="small" error={!!scanError} helperText={scanError || L("掃描後按 Enter 或點「查詢」", "After scanning, press Enter or choose Look up")} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void handleAnalyze(); } }} disabled={analysisLoading} /> )} {L("印表機", "Printer")} setPrintQty(Number(e.target.value))} sx={{ width: 140 }} disabled={analysisLoading} /> {selectedPrinter && ( {L("已選:", "Selected: ")}{formatPrinterLabel(selectedPrinter)} )} {analysis && ( {L("品號:", "Item no.: ")}{analysis.itemCode} {analysis.itemName} {filteredLots.length === 0 ? ( {L("找不到該樓層有可用批號(availableQty > 0)。", "No available lots on this floor (available qty > 0).")} ) : ( {filteredLots.map((lot) => { const isPrinting = printingLotLineId === lot.inventoryLotLineId; const loc = String(lot.warehouseCode ?? "").trim(); return ( Lot:{lot.lotNo} {lot._scanned ? L("(已掃)", " (scanned)") : ""} {L("位置:", "Location: ")}{loc || "—"} {L("可用量:", "Available: ")}{Number(lot.availableQty).toLocaleString()}{" "} {L("單位:", "Unit: ")}{lot.uom || ""} ); })} )} )} {!analysis && !analysisLoading && ( {L( "掃碼後會用 /inventoryLotLine/analyze-qr-code 查詢同品批號,列印則呼叫 /inventoryLotLine/print-label(需先選印表機)。", "After a scan, /inventoryLotLine/analyze-qr-code looks up lots of the same item. Printing calls /inventoryLotLine/print-label (choose a printer first).", )} )} setSnackbar((s) => ({ ...s, open: false }))} message={snackbar.message} anchorOrigin={{ vertical: "bottom", horizontal: "center" }} /> ); }; export default LotLabelPrintModal;