|
- "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<LotLabelPrintModalProps> = ({
- open,
- onClose,
- initialPayload = null,
- defaultPrinterName,
- hideScanSection,
- reminderText,
- statusTitleText,
- warehouseCodePrefixFilter,
- hideTriggeredLot,
- }) => {
- const L = useUiText();
- 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 [printQty, setPrintQty] = useState(1);
- const [printingLotLineId, setPrintingLotLineId] = useState<number | null>(
- 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 (
- <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
- <DialogTitle>{L("批號標籤列印", "Print lot labels")}</DialogTitle>
- <DialogContent>
- <Stack spacing={2} sx={{ mt: 1 }}>
- {statusTitleText ? (
- <Typography
- variant="h6"
- sx={{ fontWeight: 800, color: "error.main" }}
- >
- {statusTitleText}
- </Typography>
- ) : null}
- {reminderText ? (
- <Alert severity="warning">{reminderText}</Alert>
- ) : null}
- {effectiveHideScanSection ? null : (
- <>
- <Alert severity="info">
- {L("請掃描條碼(JSON 格式),例如", "Scan a barcode (JSON), for example")}{" "}
- <code>{'{"itemId":16431,"stockInLineId":10381'}</code>。
- </Alert>
-
- <Stack
- direction={{ xs: "column", md: "row" }}
- spacing={2}
- alignItems={{ xs: "stretch", md: "center" }}
- >
- <TextField
- inputRef={scanInputRef}
- label={L("掃碼內容", "Scan content")}
- value={scanInput}
- onChange={(e) => 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}
- />
- <Button
- variant="contained"
- onClick={() => void handleAnalyze()}
- disabled={analysisLoading || !scanInput.trim()}
- >
- {analysisLoading ? <CircularProgress size={18} /> : L("查詢", "Look up")}
- </Button>
- <Button
- variant="outlined"
- onClick={() => {
- resetAll();
- scanInputRef.current?.focus();
- }}
- disabled={analysisLoading}
- >
- {L("清除", "Clear")}
- </Button>
- </Stack>
- </>
- )}
-
- <Stack
- direction={{ xs: "column", md: "row" }}
- spacing={2}
- alignItems={{ xs: "stretch", md: "center" }}
- >
- <FormControl
- size="small"
- sx={{ minWidth: 260 }}
- disabled={printersLoading}
- >
- <InputLabel>{L("印表機", "Printer")}</InputLabel>
- <Select
- label={L("印表機", "Printer")}
- value={selectedPrinterId}
- onChange={(e) =>
- setSelectedPrinterId((e.target.value as number) ?? "")
- }
- >
- <MenuItem value="">
- <em>{printersLoading ? L("載入中...", "Loading...") : L("請選擇", "Select")}</em>
- </MenuItem>
- {printers.map((p) => (
- <MenuItem key={p.id} value={p.id}>
- {formatPrinterLabel(p)}
- </MenuItem>
- ))}
- </Select>
- </FormControl>
-
- <TextField
- label={L("列印張數", "Copies")}
- size="small"
- type="number"
- inputProps={{ min: 1, step: 1 }}
- value={printQty}
- onChange={(e) => setPrintQty(Number(e.target.value))}
- sx={{ width: 140 }}
- disabled={analysisLoading}
- />
-
- <Button
- variant="outlined"
- onClick={() => void handleRefreshLots()}
- disabled={analysisLoading}
- >
- {analysisLoading ? (
- <CircularProgress size={18} />
- ) : (
- L("刷新批號清單", "Refresh lot list")
- )}
- </Button>
-
- {selectedPrinter && (
- <Typography
- variant="body2"
- color="text.secondary"
- sx={{ ml: { md: "auto" } }}
- >
- {L("已選:", "Selected: ")}{formatPrinterLabel(selectedPrinter)}
- </Typography>
- )}
- </Stack>
-
- {analysis && (
- <Box>
- <Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
- {L("品號:", "Item no.: ")}{analysis.itemCode} {analysis.itemName}
- </Typography>
-
- {filteredLots.length === 0 ? (
- <Alert severity="warning">
- {L("找不到該樓層有可用批號(availableQty > 0)。", "No available lots on this floor (available qty > 0).")}
- </Alert>
- ) : (
- <Stack spacing={1}>
- {filteredLots.map((lot) => {
- const isPrinting =
- printingLotLineId === lot.inventoryLotLineId;
- const loc = String(lot.warehouseCode ?? "").trim();
- 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 ? L("(已掃)", " (scanned)") : ""}
- </Typography>
- <Typography variant="body2" color="text.secondary">
- {L("位置:", "Location: ")}{loc || "—"}
- </Typography>
- <Typography variant="body2" color="text.secondary">
- {L("可用量:", "Available: ")}{Number(lot.availableQty).toLocaleString()}{" "}
- {L("單位:", "Unit: ")}{lot.uom || ""}
- </Typography>
- </Box>
- <Box sx={{ ml: "auto" }}>
- <Button
- variant="contained"
- disabled={!canPrint || isPrinting}
- onClick={() =>
- void handlePrintOne(
- lot.inventoryLotLineId,
- lot.lotNo,
- )
- }
- >
- {isPrinting ? (
- <CircularProgress size={18} />
- ) : (
- L("列印標籤", "Print label")
- )}
- </Button>
- </Box>
- </Box>
- );
- })}
- </Stack>
- )}
- </Box>
- )}
-
- {!analysis && !analysisLoading && (
- <Typography variant="body2" color="text.secondary">
- {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).",
- )}
- </Typography>
- )}
- </Stack>
- </DialogContent>
- <DialogActions>
- <Button onClick={onClose}>{L("關閉", "Close")}</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 LotLabelPrintModal;
|