|
- "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<void>;
- /** 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<WorkbenchLotLabelPrintModalProps> = ({
- 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<HTMLInputElement | null>(null);
- const [scanInput, setScanInput] = useState("");
- const [scanError, setScanError] = useState<string | null>(null);
-
- const [printers, setPrinters] = useState<Printer[]>([]);
- const [printersLoading, setPrintersLoading] = useState(false);
- const [selectedPrinterId, setSelectedPrinterId] = useState<number | "">("");
-
- const [analysisLoading, setAnalysisLoading] = useState(false);
- const [analysis, setAnalysis] = useState<QrCodeAnalysisResponse | null>(null);
- const [lastPayload, setLastPayload] = useState<ScanPayload | null>(null);
- const [lastItemId, setLastItemId] = useState<number | null>(null);
-
- const [printQty, setPrintQty] = useState(1);
- const [printingLotLineId, setPrintingLotLineId] = useState<number | null>(
- null,
- );
- const [qrVisibleLotLineId, setQrVisibleLotLineId] = useState<number | null>(
- 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 (
- <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
- <DialogTitle>批號標籤列印(提貨台)</DialogTitle>
- <DialogContent>
- <Stack spacing={2} sx={{ mt: 1 }}>
- {statusTitleText ? (
- <Typography
- variant="h6"
- sx={{
- fontWeight: 800,
- color:
- statusTitleSeverity === "success"
- ? "success.main"
- : statusTitleSeverity === "warning"
- ? "warning.main"
- : "error.main",
- }}
- >
- {statusTitleText}
- </Typography>
- ) : null}
- {reminderText ? (
- <Alert severity="warning">{reminderText}</Alert>
- ) : null}
- {effectiveHideScanSection ? null : (
- <>
- {/*
- <Alert severity="info">
- 請掃描條碼(JSON 格式),例如{" "}
- <code>{'{"itemId":16431,"stockInLineId":10381'}</code>。
- </Alert>
- */}
- <Stack
- direction={{ xs: "column", md: "row" }}
- spacing={2}
- alignItems={{ xs: "stretch", md: "center" }}
- >
- <TextField
- inputRef={scanInputRef}
- label="掃碼內容"
- value={scanInput}
- onChange={(e) => setScanInput(e.target.value)}
- fullWidth
- size="small"
- error={!!scanError}
- helperText={scanError || "掃描後按 Enter 或點「查詢」"}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- void handleAnalyze();
- }
- }}
- disabled={analysisLoading}
- />
- <Button
- variant="contained"
- onClick={() => void handleAnalyze()}
- disabled={analysisLoading || !scanInput.trim()}
- >
- {analysisLoading ? <CircularProgress size={18} /> : "查詢"}
- </Button>
- <Button
- variant="outlined"
- onClick={() => {
- resetAll();
- scanInputRef.current?.focus();
- }}
- disabled={analysisLoading}
- >
- 清除
- </Button>
- </Stack>
- </>
- )}
-
- <Stack
- direction={{ xs: "column", md: "row" }}
- spacing={2}
- alignItems={{ xs: "stretch", md: "center" }}
- >
- <FormControl
- size="small"
- sx={{ minWidth: 260 }}
- disabled={printersLoading}
- >
- <InputLabel>印表機</InputLabel>
- <Select
- label="印表機"
- value={selectedPrinterId}
- onChange={(e) =>
- setSelectedPrinterId((e.target.value as number) ?? "")
- }
- >
- <MenuItem value="">
- <em>{printersLoading ? "載入中..." : "請選擇"}</em>
- </MenuItem>
- {printers.map((p) => (
- <MenuItem key={p.id} value={p.id}>
- {formatPrinterLabel(p)}
- </MenuItem>
- ))}
- </Select>
- </FormControl>
-
- <TextField
- label="列印張數"
- size="small"
- type="number"
- inputProps={{ min: 1, step: 1 }}
- value={printQty}
- onChange={(e) => setPrintQty(Number(e.target.value))}
- sx={{ width: 140 }}
- disabled={analysisLoading}
- />
-
- {onWorkbenchScanPick ? (
- <TextField
- label="提交數量"
- size="small"
- type="number"
- inputProps={{ min: 0, step: 1 }}
- value={
- Number.isFinite(Number(submitQty)) ? Number(submitQty) : 0
- }
- onChange={(e) => {
- const n = Number(e.target.value);
- if (!Number.isFinite(n) || n < 0) return;
- onSubmitQtyChange?.(n);
- }}
- sx={{ width: 140 }}
- disabled={analysisLoading}
- />
- ) : null}
-
- <Button
- variant="outlined"
- onClick={() => void handleRefreshLots()}
- disabled={analysisLoading}
- >
- {analysisLoading ? (
- <CircularProgress size={18} />
- ) : (
- "刷新批號清單"
- )}
- </Button>
-
- {selectedPrinter && (
- <Typography
- variant="body2"
- color="text.secondary"
- sx={{ ml: { md: "auto" } }}
- >
- 已選:{formatPrinterLabel(selectedPrinter)}
- </Typography>
- )}
- </Stack>
-
- {analysis && (
- <Box>
- <Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
- 品號:{analysis.itemCode} {analysis.itemName}
- </Typography>
-
- {filteredLots.length === 0 ? (
- <Alert severity="warning">
- 找不到該樓層有可用批號(availableQty > 0)。
- </Alert>
- ) : (
- <Stack spacing={1}>
- {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 (
- <Box
- key={lot.inventoryLotLineId}
- sx={{
- p: 1.25,
- borderRadius: 1,
- border: "1px solid",
- borderColor: "divider",
- display: "flex",
- alignItems: "center",
- gap: 2,
- backgroundColor: lot._scanned
- ? "rgba(25, 118, 210, 0.08)"
- : "transparent",
- }}
- >
- <Box sx={{ minWidth: 220 }}>
- <Typography
- variant="body1"
- sx={{ fontWeight: lot._scanned ? 800 : 600 }}
- >
- Lot:{lot.lotNo}
- {lot._scanned ? "(當前批次)" : ""}
- </Typography>
- <Typography variant="body2" color="text.secondary">
- 位置:{loc || "—"}
- </Typography>
- <Typography variant="body2" color="text.secondary">
- 可用量:{Number(lot.availableQty).toLocaleString()}{" "}
- 單位:{lot.uom || ""}
- </Typography>
- </Box>
- <Stack
- direction="row"
- spacing={1}
- sx={{ ml: "auto" }}
- flexWrap="wrap"
- useFlexGap
- >
- <Button
- variant="contained"
- disabled={!canPrint || isPrinting}
- onClick={() =>
- void handlePrintOne(
- lot.inventoryLotLineId,
- lot.lotNo,
- )
- }
- >
- {isPrinting ? (
- <CircularProgress size={18} />
- ) : (
- "列印標籤"
- )}
- </Button>
- {onWorkbenchScanPick ? (
- <Button
- variant="outlined"
- color="secondary"
- title={
- !lotQrPayload
- ? "此列無法取得 QR payload(需 stockInLineId)"
- : disableScanPick
- ? "此出庫行已掃碼或已完成,無法顯示 QR"
- : undefined
- }
- disabled={
- !canShowLotQr || !lotQrPayload || isPrinting
- }
- onClick={() =>
- setQrVisibleLotLineId((prev) =>
- prev === lot.inventoryLotLineId
- ? null
- : lot.inventoryLotLineId,
- )
- }
- >
- 顯示 QR
- </Button>
- ) : null}
- </Stack>
- {qrVisibleLotLineId === lot.inventoryLotLineId &&
- lotQrPayload ? (
- <Box
- sx={{
- mt: 1.5,
- ml: "auto",
- p: 1.5,
- borderRadius: 1,
- border: "1px dashed",
- borderColor: "divider",
- textAlign: "center",
- minWidth: 220,
- }}
- >
- <QRCodeSVG
- value={JSON.stringify(lotQrPayload)}
- size={160}
- includeMargin
- />
- </Box>
- ) : null}
- </Box>
- );
- })}
- </Stack>
- )}
- </Box>
- )}
-
- {!analysis && !analysisLoading && (
- <Typography variant="body2" color="text.secondary">
-
- {onWorkbenchScanPick
- ? "沒有任何批號可列印標籤"
- : ""}
- </Typography>
- )}
- </Stack>
- </DialogContent>
- <DialogActions>
- <Button onClick={onClose}>關閉</Button>
- </DialogActions>
-
- <Snackbar
- open={snackbar.open}
- autoHideDuration={3500}
- onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
- message={snackbar.message}
- anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
- />
- </Dialog>
- );
- };
-
- export default WorkbenchLotLabelPrintModal;
|