Sfoglia il codice sorgente

lot label print modal for pick order

MergeProblem1
Tommy\2Fi-Staff 12 ore fa
parent
commit
1272409bbb
3 ha cambiato i file con 4572 aggiunte e 2807 eliminazioni
  1. +199
    -43
      src/app/(main)/testing/page.tsx
  2. +3778
    -2764
      src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx
  3. +595
    -0
      src/components/InventorySearch/LotLabelPrintModal.tsx

+ 199
- 43
src/app/(main)/testing/page.tsx Vedi File

@@ -22,6 +22,7 @@ import { FileDownload } from "@mui/icons-material";
import dayjs from "dayjs";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import LotLabelPrintModal from "@/components/InventorySearch/LotLabelPrintModal";
import {
buildOnPackJobOrdersPayload,
downloadOnPackTextQrZip,
@@ -60,30 +61,43 @@ function TabPanel(props: TabPanelProps) {

export default function TestingPage() {
const [tabValue, setTabValue] = useState(0);
const [lotLabelModalOpen, setLotLabelModalOpen] = useState(false);

const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};

// --- 1. GRN Preview (M18) ---
const [grnPreviewReceiptDate, setGrnPreviewReceiptDate] = useState("2026-03-16");
const [grnPreviewReceiptDate, setGrnPreviewReceiptDate] =
useState("2026-03-16");
// --- 2. OnPack NGPCL (same job-order → ZIP logic as /bagPrint) ---
const [onpackPlanDate, setOnpackPlanDate] = useState(() => dayjs().format("YYYY-MM-DD"));
const [onpackJobOrders, setOnpackJobOrders] = useState<JobOrderListItem[]>([]);
const [onpackPlanDate, setOnpackPlanDate] = useState(() =>
dayjs().format("YYYY-MM-DD"),
);
const [onpackJobOrders, setOnpackJobOrders] = useState<JobOrderListItem[]>(
[],
);
const [onpackLoading, setOnpackLoading] = useState(false);
const [onpackLoadError, setOnpackLoadError] = useState<string | null>(null);
const [onpackLemonDownloading, setOnpackLemonDownloading] = useState(false);
const [onpackPushLoading, setOnpackPushLoading] = useState(false);
const [onpackPushResult, setOnpackPushResult] = useState<string | null>(null);
// --- 3. Laser Bag2 auto-send (same as /laserPrint + DB LASER_PRINT.*) ---
const [laserAutoPlanDate, setLaserAutoPlanDate] = useState(() => dayjs().format("YYYY-MM-DD"));
const [laserAutoPlanDate, setLaserAutoPlanDate] = useState(() =>
dayjs().format("YYYY-MM-DD"),
);
const [laserAutoLimit, setLaserAutoLimit] = useState("1");
const [laserAutoLoading, setLaserAutoLoading] = useState(false);
const [laserAutoReport, setLaserAutoReport] = useState<LaserBag2AutoSendReport | null>(null);
const [laserAutoReport, setLaserAutoReport] =
useState<LaserBag2AutoSendReport | null>(null);
const [laserAutoError, setLaserAutoError] = useState<string | null>(null);
const [laserLastReceive, setLaserLastReceive] = useState<LaserLastReceiveSuccess | null>(null);
const [laserLastReceive, setLaserLastReceive] =
useState<LaserLastReceiveSuccess | null>(null);

const onpackPayload = useMemo(() => buildOnPackJobOrdersPayload(onpackJobOrders), [onpackJobOrders]);
const onpackPayload = useMemo(
() => buildOnPackJobOrdersPayload(onpackJobOrders),
[onpackJobOrders],
);

useEffect(() => {
if (tabValue !== 1) return;
@@ -96,7 +110,9 @@ export default function TestingPage() {
if (!cancelled) setOnpackJobOrders(data);
} catch (e) {
if (!cancelled) {
setOnpackLoadError(e instanceof Error ? e.message : "Failed to load job orders");
setOnpackLoadError(
e instanceof Error ? e.message : "Failed to load job orders",
);
setOnpackJobOrders([]);
}
} finally {
@@ -127,7 +143,9 @@ export default function TestingPage() {
const handleDownloadGrnPreviewXlsx = async () => {
try {
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/report/grn-preview-m18?receiptDate=${encodeURIComponent(grnPreviewReceiptDate)}`,
`${NEXT_PUBLIC_API_URL}/report/grn-preview-m18?receiptDate=${encodeURIComponent(
grnPreviewReceiptDate,
)}`,
{ method: "GET" },
);
if (response.status === 401 || response.status === 403) return;
@@ -140,7 +158,10 @@ export default function TestingPage() {
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "GRN Preview");

const xlsxArrayBuffer = XLSX.write(wb, { bookType: "xlsx", type: "array" });
const xlsxArrayBuffer = XLSX.write(wb, {
bookType: "xlsx",
type: "array",
});
const blob = new Blob([xlsxArrayBuffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
@@ -148,7 +169,10 @@ export default function TestingPage() {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", `grn-preview-m18-${grnPreviewReceiptDate}.xlsx`);
link.setAttribute(
"download",
`grn-preview-m18-${grnPreviewReceiptDate}.xlsx`,
);
document.body.appendChild(link);
link.click();
link.remove();
@@ -172,7 +196,9 @@ export default function TestingPage() {

const handleOnpackDownloadLemonZip = async () => {
if (onpackPayload.length === 0) {
alert("No job orders with item code for this plan date (same rule as Bag Print).");
alert(
"No job orders with item code for this plan date (same rule as Bag Print).",
);
return;
}
setOnpackLemonDownloading(true);
@@ -220,7 +246,9 @@ export default function TestingPage() {
setOnpackPushResult(null);
try {
const r = await pushOnPackTextQrZipToNgpcl({ jobOrders: onpackPayload });
setOnpackPushResult(`${r.pushed ? "Pushed" : "Not pushed"}: ${r.message}`);
setOnpackPushResult(
`${r.pushed ? "Pushed" : "Not pushed"}: ${r.message}`,
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setOnpackPushResult(`Error: ${msg}`);
@@ -230,12 +258,34 @@ export default function TestingPage() {
}
};

const Section = ({ title, children }: { title: string; children?: React.ReactNode }) => (
<Paper sx={{ p: 3, minHeight: "450px", display: "flex", flexDirection: "column" }}>
<Typography variant="h5" gutterBottom color="primary" sx={{ borderBottom: "2px solid #f0f0f0", pb: 1, mb: 2 }}>
const Section = ({
title,
children,
}: {
title: string;
children?: React.ReactNode;
}) => (
<Paper
sx={{
p: 3,
minHeight: "450px",
display: "flex",
flexDirection: "column",
}}
>
<Typography
variant="h5"
gutterBottom
color="primary"
sx={{ borderBottom: "2px solid #f0f0f0", pb: 1, mb: 2 }}
>
{title}
</Typography>
{children || <Typography color="textSecondary" sx={{ m: "auto" }}>Waiting for implementation...</Typography>}
{children || (
<Typography color="textSecondary" sx={{ m: "auto" }}>
Waiting for implementation...
</Typography>
)}
</Paper>
);

@@ -245,15 +295,26 @@ export default function TestingPage() {
Testing
</Typography>

<Tabs value={tabValue} onChange={handleTabChange} aria-label="testing sections tabs" centered variant="fullWidth">
<Tabs
value={tabValue}
onChange={handleTabChange}
aria-label="testing sections tabs"
centered
variant="fullWidth"
>
<Tab label="1. GRN Preview" />
<Tab label="2. OnPack NGPCL" />
<Tab label="3. Laser Bag2 自動送" />
<Tab label="4. 批號標籤列印" />
</Tabs>

<TabPanel value={tabValue} index={0}>
<Section title="1. GRN Preview (M18)">
<Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}>
<Stack
direction="row"
spacing={2}
sx={{ mb: 2, alignItems: "center" }}
>
<TextField
size="small"
label="Receipt Date"
@@ -273,7 +334,8 @@ export default function TestingPage() {
</Button>
</Stack>
<Typography variant="body2" color="textSecondary">
Backend endpoint: <code>/report/grn-preview-m18?receiptDate=YYYY-MM-DD</code>
Backend endpoint:{" "}
<code>/report/grn-preview-m18?receiptDate=YYYY-MM-DD</code>
</Typography>
</Section>
</TabPanel>
@@ -281,16 +343,24 @@ export default function TestingPage() {
<TabPanel value={tabValue} index={1}>
<Section title="2. OnPack NGPCL (same logic as /bagPrint)">
<Alert severity="info" sx={{ mb: 2 }}>
Uses <strong>GET /py/job-orders?planStart=</strong> for the day, then the same <code>jobOrders</code> payload as{" "}
<strong>Bag Print → 下載 OnPack2023檸檬機</strong>. The ZIP contains loose <code>.job</code> / <code>.image</code> / BMPs — extract
Uses <strong>GET /py/job-orders?planStart=</strong> for the day,
then the same <code>jobOrders</code> payload as{" "}
<strong>Bag Print → 下載 OnPack2023檸檬機</strong>. The ZIP contains
loose <code>.job</code> / <code>.image</code> / BMPs — extract
before sending to NGE; the ZIP itself is only a transport bundle.
</Alert>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
Distinct item codes in the list produce one label set each (backend groups by code). Configure <code>ngpcl.push-url</code> on the server
to POST the same lemon ZIP bytes to your NGPCL HTTP gateway; otherwise use download only.
Distinct item codes in the list produce one label set each (backend
groups by code). Configure <code>ngpcl.push-url</code> on the server
to POST the same lemon ZIP bytes to your NGPCL HTTP gateway;
otherwise use download only.
</Typography>

<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ mb: 2, alignItems: "center", flexWrap: "wrap" }}>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{ mb: 2, alignItems: "center", flexWrap: "wrap" }}
>
<TextField
size="small"
label="Plan date (planStart)"
@@ -302,7 +372,10 @@ export default function TestingPage() {
<Typography variant="body2" color="textSecondary">
{onpackLoading ? (
<>
<CircularProgress size={16} sx={{ mr: 1, verticalAlign: "middle" }} />
<CircularProgress
size={16}
sx={{ mr: 1, verticalAlign: "middle" }}
/>
Loading job orders…
</>
) : (
@@ -357,19 +430,44 @@ export default function TestingPage() {
sx={{ mb: 2, fontFamily: "monospace" }}
/>

<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ mb: 2, flexWrap: "wrap" }}>
<Button variant="contained" color="success" onClick={handleOnpackDownloadLemonZip} disabled={onpackLemonDownloading || onpackLoading}>
{onpackLemonDownloading ? "Downloading…" : "Download lemon OnPack ZIP"}
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{ mb: 2, flexWrap: "wrap" }}
>
<Button
variant="contained"
color="success"
onClick={handleOnpackDownloadLemonZip}
disabled={onpackLemonDownloading || onpackLoading}
>
{onpackLemonDownloading
? "Downloading…"
: "Download lemon OnPack ZIP"}
</Button>
<Button variant="outlined" onClick={handleOnpackPushNgpcl} disabled={onpackPushLoading || onpackLoading}>
{onpackPushLoading ? "Pushing…" : "Push to NGPCL (server → ngpcl.push-url)"}
<Button
variant="outlined"
onClick={handleOnpackPushNgpcl}
disabled={onpackPushLoading || onpackLoading}
>
{onpackPushLoading
? "Pushing…"
: "Push to NGPCL (server → ngpcl.push-url)"}
</Button>
</Stack>
{onpackPushResult ? (
<TextField fullWidth multiline minRows={2} label="Last NGPCL push result" value={onpackPushResult} InputProps={{ readOnly: true }} />
<TextField
fullWidth
multiline
minRows={2}
label="Last NGPCL push result"
value={onpackPushResult}
InputProps={{ readOnly: true }}
/>
) : null}
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
<code>POST /plastic/download-onpack-qr-text</code> · <code>POST /plastic/ngpcl/push-onpack-qr-text</code> (same body)
<code>POST /plastic/download-onpack-qr-text</code> ·{" "}
<code>POST /plastic/ngpcl/push-onpack-qr-text</code> (same body)
</Typography>
</Section>
</TabPanel>
@@ -382,27 +480,46 @@ export default function TestingPage() {
上次印表機已確認(receive)的工單(資料庫)
</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>
工單號:{laserLastReceive.jobOrderNo ?? "—"} Lot:{laserLastReceive.lotNo ?? "—"}
工單號:{laserLastReceive.jobOrderNo ?? "—"} Lot:
{laserLastReceive.lotNo ?? "—"}
</Typography>
<Typography variant="body2" sx={{ mt: 0.5, fontFamily: "monospace" }}>
<Typography
variant="body2"
sx={{ mt: 0.5, fontFamily: "monospace" }}
>
JSON:{" "}
{laserLastReceive.itemId != null && laserLastReceive.stockInLineId != null
{laserLastReceive.itemId != null &&
laserLastReceive.stockInLineId != null
? JSON.stringify({
itemId: laserLastReceive.itemId,
stockInLineId: laserLastReceive.stockInLineId,
})
: "—"}
</Typography>
<Typography variant="caption" color="textSecondary" display="block" sx={{ mt: 0.5 }}>
<Typography
variant="caption"
color="textSecondary"
display="block"
sx={{ mt: 0.5 }}
>
{laserLastReceive.sentAt ?? ""} {laserLastReceive.source ?? ""}
</Typography>
</Alert>
) : null}
<Alert severity="warning" sx={{ mb: 2 }}>
依資料庫 <strong>LASER_PRINT.host</strong>、<strong>LASER_PRINT.port</strong>、<strong>LASER_PRINT.itemCodes</strong> 查當日包裝工單並送 TCP(每筆工單預設 3 次、間隔 3 秒,與前端點列相同)。
排程預設關閉;啟用請設 <code>laser.bag2.auto-send.enabled=true</code>(後端 application.yml)。
依資料庫 <strong>LASER_PRINT.host</strong>、
<strong>LASER_PRINT.port</strong>、
<strong>LASER_PRINT.itemCodes</strong> 查當日包裝工單並送
TCP(每筆工單預設 3 次、間隔 3 秒,與前端點列相同)。
排程預設關閉;啟用請設{" "}
<code>laser.bag2.auto-send.enabled=true</code>(後端
application.yml)。
</Alert>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ mb: 2, alignItems: "center", flexWrap: "wrap" }}>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{ mb: 2, alignItems: "center", flexWrap: "wrap" }}
>
<TextField
size="small"
label="Plan date (planStart)"
@@ -419,8 +536,15 @@ export default function TestingPage() {
sx={{ width: 200 }}
helperText="手動測試建議 1;排程預設每分鐘最多 1 筆"
/>
<Button variant="contained" color="primary" onClick={() => void handleLaserBag2AutoSend()} disabled={laserAutoLoading}>
{laserAutoLoading ? "送出中…" : "執行 POST /plastic/laser-bag2-auto-send"}
<Button
variant="contained"
color="primary"
onClick={() => void handleLaserBag2AutoSend()}
disabled={laserAutoLoading}
>
{laserAutoLoading
? "送出中…"
: "執行 POST /plastic/laser-bag2-auto-send"}
</Button>
</Stack>
{laserAutoError ? (
@@ -440,10 +564,42 @@ export default function TestingPage() {
/>
) : null}
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
<code>POST /api/plastic/laser-bag2-auto-send?planStart=YYYY-MM-DD&amp;limitPerRun=N</code>
<code>
POST
/api/plastic/laser-bag2-auto-send?planStart=YYYY-MM-DD&amp;limitPerRun=N
</code>
</Typography>
</Section>
</TabPanel>

<TabPanel value={tabValue} index={3}>
<Section title="4. 批號標籤列印(掃碼 → 查同品批號 → 選印表機 → 列印)">
<Alert severity="info" sx={{ mb: 2 }}>
此工具會呼叫後端 <code>/inventoryLotLine/analyze-qr-code</code>{" "}
找同品可用批號,再用 <code>/inventoryLotLine/print-label</code>(需
printerId)送出列印。
</Alert>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Button
variant="contained"
onClick={() => setLotLabelModalOpen(true)}
>
開啟列印視窗
</Button>
<Typography
variant="body2"
color="text.secondary"
sx={{ alignSelf: "center" }}
>
掃碼格式:<code>{'{"itemId":16431,"stockInLineId":10381'}</code>
</Typography>
</Stack>
<LotLabelPrintModal
open={lotLabelModalOpen}
onClose={() => setLotLabelModalOpen(false)}
/>
</Section>
</TabPanel>
</Box>
);
}

+ 3778
- 2764
src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx
File diff soppresso perché troppo grande
Vedi File


+ 595
- 0
src/components/InventorySearch/LotLabelPrintModal.tsx Vedi File

@@ -0,0 +1,595 @@
"use client";

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;
};
sameItemLots: Array<{
lotNo: string;
inventoryLotLineId: number;
availableQty: number;
uom: string;
}>;
};

export interface LotLabelPrintModalProps {
open: boolean;
onClose: () => void;
/** 當由其他流程自動打開時,可帶入掃碼 payload 以便自動查詢 */
initialPayload?: ScanPayload | null;
/** 預設要自動選取的印表機名稱(完全匹配優先,其次為包含) */
defaultPrinterName?: string;
/** 隱藏「掃碼內容/查詢/清除」區塊(通常搭配 initialPayload 使用) */
hideScanSection?: boolean;
/** 額外提醒(顯示在最上方) */
reminderText?: string;
}

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,
}) => {
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 [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: "NEXT_PUBLIC_API_URL 未設定,無法連線後端。",
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 : "載入印表機清單失敗",
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("NEXT_PUBLIC_API_URL 未設定,無法連線後端。");
return;
}

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 || `分析失敗(HTTP ${res.status})`);
}
const data = (await res.json()) as QrCodeAnalysisResponse;
setAnalysis(data);
setSnackbar({
open: true,
message: "已載入同品可用批號清單",
severity: "success",
});
} catch (e) {
setAnalysis(null);
setScanError(e instanceof Error ? e.message : "分析失敗");
} finally {
setAnalysisLoading(false);
}
},
[baseApi],
);

const handleAnalyze = useCallback(async () => {
const raw = scanInput.trim();
const payload = safeParseScanPayload(raw);
if (!payload) {
setScanError(
'掃碼內容格式錯誤,需為 JSON,例如 {"itemId":16431,"stockInLineId":10381}',
);
setAnalysis(null);
return;
}
await analyzePayload(payload);
}, [scanInput, analyzePayload]);

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 scannedLot = analysis.scanned?.inventoryLotLineId
? {
lotNo: analysis.scanned.lotNo,
inventoryLotLineId: analysis.scanned.inventoryLotLineId,
availableQty: (list.find(
(x) => x.inventoryLotLineId === analysis.scanned.inventoryLotLineId,
)?.availableQty ?? 0) as number,
uom: (list.find(
(x) => x.inventoryLotLineId === analysis.scanned.inventoryLotLineId,
)?.uom ?? "") as string,
_scanned: true as const,
}
: null;

const merged = [
...(scannedLot ? [scannedLot] : []),
...list
.filter((x) => x.inventoryLotLineId !== scannedLot?.inventoryLotLineId)
.map((x) => ({ ...x, _scanned: false as const })),
];

return merged;
}, [analysis]);

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: "NEXT_PUBLIC_API_URL 未設定,無法連線後端。",
severity: "error",
});
return;
}
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 {
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 || `列印失敗(HTTP ${res.status})`);
}
setSnackbar({
open: true,
message: `已送出列印:Lot ${lotNo}`,
severity: "success",
});
} catch (e) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "列印失敗",
severity: "error",
});
} finally {
setPrintingLotLineId(null);
}
},
[baseApi, selectedPrinterId, printQty],
);

return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>批號標籤列印</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
{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}
/>

<Button
variant="outlined"
onClick={() => void loadPrinters()}
disabled={printersLoading}
>
{printersLoading ? (
<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>

{availableLots.length === 0 ? (
<Alert severity="warning">
找不到可用批號(availableQty &gt; 0)。
</Alert>
) : (
<Stack spacing={1}>
{availableLots.map((lot) => {
const isPrinting =
printingLotLineId === lot.inventoryLotLineId;
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">
可用量:{Number(lot.availableQty).toLocaleString()}{" "}
{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} />
) : (
"列印標籤"
)}
</Button>
</Box>
</Box>
);
})}
</Stack>
)}
</Box>
)}

{!analysis && !analysisLoading && (
<Typography variant="body2" color="text.secondary">
掃碼後會用 `/inventoryLotLine/analyze-qr-code`
查詢同品批號,列印則呼叫
`/inventoryLotLine/print-label`(需先選印表機)。
</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 LotLabelPrintModal;

Caricamento…
Annulla
Salva