From c9f05abfb095dbb2b3b6ad5f21d717b8f1bb48d2 Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Fri, 20 Mar 2026 15:52:30 +0800 Subject: [PATCH] added po number syn m18 --- src/app/(main)/testing/page.tsx | 69 +++++++++ src/components/BagPrint/BagPrintSearch.tsx | 158 ++++++++++++++++++++- src/components/PoSearch/PoSearch.tsx | 147 ++++++++++++++++--- src/components/SearchBox/SearchBox.tsx | 15 ++ 4 files changed, 370 insertions(+), 19 deletions(-) diff --git a/src/app/(main)/testing/page.tsx b/src/app/(main)/testing/page.tsx index 21c4048..420986e 100644 --- a/src/app/(main)/testing/page.tsx +++ b/src/app/(main)/testing/page.tsx @@ -90,6 +90,10 @@ export default function TestingPage() { // --- 6. GRN Preview (M18) --- const [grnPreviewReceiptDate, setGrnPreviewReceiptDate] = useState("2026-03-16"); + // --- 7. M18 PO Sync by Code --- + const [m18PoCode, setM18PoCode] = useState(""); + const [isSyncingM18Po, setIsSyncingM18Po] = useState(false); + const [m18PoSyncResult, setM18PoSyncResult] = useState(""); // Generic handler for inline table edits const handleItemChange = (setter: any, id: number, field: string, value: string) => { @@ -247,6 +251,33 @@ export default function TestingPage() { } }; + // M18 PO Sync By Code (Section 7) + const handleSyncM18PoByCode = async () => { + if (!m18PoCode.trim()) { + alert("Please enter PO code."); + return; + } + setIsSyncingM18Po(true); + setM18PoSyncResult(""); + try { + const response = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent(m18PoCode.trim())}`, + { method: "GET" }, + ); + if (response.status === 401 || response.status === 403) return; + const text = await response.text(); + setM18PoSyncResult(text); + if (!response.ok) { + alert(`Sync failed: ${response.status}`); + } + } catch (e) { + console.error("M18 PO Sync By Code Error:", e); + alert("M18 PO sync failed. Check console/network."); + } finally { + setIsSyncingM18Po(false); + } + }; + // Layout Helper const Section = ({ title, children }: { title: string, children?: React.ReactNode }) => ( @@ -268,6 +299,7 @@ export default function TestingPage() { + @@ -523,6 +555,43 @@ export default function TestingPage() { + +
+ + setM18PoCode(e.target.value)} + placeholder="e.g. PFP002PO26030341" + sx={{ minWidth: 320 }} + /> + + + + Backend endpoint: /m18/test/po-by-code?code=YOUR_CODE + + {m18PoSyncResult ? ( + + ) : null} +
+
+ {/* Dialog for OnPack */} setIsPrinterModalOpen(false)} fullWidth maxWidth="sm"> OnPack Printer Job Details diff --git a/src/components/BagPrint/BagPrintSearch.tsx b/src/components/BagPrint/BagPrintSearch.tsx index 3792540..c9a6418 100644 --- a/src/components/BagPrint/BagPrintSearch.tsx +++ b/src/components/BagPrint/BagPrintSearch.tsx @@ -27,6 +27,8 @@ import Print from "@mui/icons-material/Print"; import Download from "@mui/icons-material/Download"; import { checkPrinterStatus, downloadOnPackQrZip, fetchJobOrders, JobOrderListItem } from "@/app/api/bagPrint/actions"; import dayjs from "dayjs"; +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; // Light blue theme (matching Python Bag1) const BG_TOP = "#E8F4FC"; @@ -94,6 +96,11 @@ const BagPrintSearch: React.FC = () => { const [connected, setConnected] = useState(false); const [printer, setPrinter] = useState("dataflex"); const [selectedId, setSelectedId] = useState(null); + const [printDialogOpen, setPrintDialogOpen] = useState(false); + const [printTarget, setPrintTarget] = useState(null); + const [printCount, setPrintCount] = useState(0); + const [printContinuous, setPrintContinuous] = useState(false); + const [printing, setPrinting] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: "success" | "info" | "error" }>({ open: false, message: "" }); const [settings, setSettings] = useState(DEFAULT_SETTINGS); @@ -187,8 +194,76 @@ const BagPrintSearch: React.FC = () => { const itemCode = jo.itemCode || "—"; const itemName = jo.itemName || "—"; setSnackbar({ open: true, message: `已點選:批次 ${batch} 品號 ${itemCode} ${itemName}`, severity: "info" }); - // TODO: Actual printing would require backend API to proxy to printer (TCP/serial) - // For now, show info. Backend could add /py/print-dataflex, /py/print-label, /py/print-laser + + // Align with Bag2.py "click row -> ask bag count -> print" for DataFlex. + if (printer === "dataflex") { + setPrintTarget(jo); + setPrintCount(0); + setPrintContinuous(false); + setPrintDialogOpen(true); + } + }; + + const confirmPrintDataFlex = async () => { + if (!printTarget) return; + if (printer !== "dataflex") { + setSnackbar({ open: true, message: "此頁目前只支援打袋機 DataFlex 列印", severity: "error" }); + return; + } + + if (!printContinuous && printCount < 1) { + setSnackbar({ open: true, message: "請先按 +50、+10、+5 或 +1 選擇數量。", severity: "error" }); + return; + } + + const qty = printContinuous ? -1 : printCount; + const printerIp = settings.dabag_ip; + const printerPort = Number(settings.dabag_port || 3008); + + if (!printerIp) { + setSnackbar({ open: true, message: "請先在設定中填寫打袋機 DataFlex 的 IP。", severity: "error" }); + return; + } + + setPrinting(true); + try { + const resp = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-dataflex`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + itemCode: printTarget.itemCode || "—", + itemName: printTarget.itemName || "—", + lotNo: printTarget.lotNo || "—", + // DataFlex zpl (Bag2.py) only needs itemId + stockInLineId for QR payload (optional). + itemId: printTarget.itemId, + stockInLineId: printTarget.stockInLineId, + printerIp, + printerPort, + printQty: qty, + }), + }); + + if (resp.status === 401 || resp.status === 403) return; + + if (!resp.ok) { + const msg = await resp.text().catch(() => ""); + setSnackbar({ + open: true, + message: `DataFlex 列印失敗(狀態碼 ${resp.status})。${msg ? msg.slice(0, 120) : ""}`, + severity: "error", + }); + return; + } + + const batch = getBatch(printTarget); + const printedText = qty === -1 ? "連續 (C)" : `${qty}`; + setSnackbar({ open: true, message: `已送出列印:批次 ${batch} x ${printedText}`, severity: "success" }); + setPrintDialogOpen(false); + } catch (e) { + setSnackbar({ open: true, message: e instanceof Error ? e.message : "DataFlex 列印失敗", severity: "error" }); + } finally { + setPrinting(false); + } }; const handleDownloadOnPackQr = async () => { @@ -374,6 +449,85 @@ const BagPrintSearch: React.FC = () => { )}
+ {/* Print count dialog (DataFlex) */} + (printing ? null : setPrintDialogOpen(false))} maxWidth="xs" fullWidth> + 打袋機 DataFlex 列印數量 + + + + 列印多少個袋? + + + {printContinuous ? "連續 (C)" : `數量: ${printCount}`} + + + + + + + + + + + + + + + + {/* Settings dialog */} setSettingsOpen(false)} maxWidth="sm" fullWidth> 設定 diff --git a/src/components/PoSearch/PoSearch.tsx b/src/components/PoSearch/PoSearch.tsx index 2cb2d7f..ca95ebe 100644 --- a/src/components/PoSearch/PoSearch.tsx +++ b/src/components/PoSearch/PoSearch.tsx @@ -7,7 +7,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import SearchBox, { Criterion } from "../SearchBox"; import SearchResults, { Column } from "../SearchResults"; import { EditNote } from "@mui/icons-material"; -import { Button, Grid, Tab, Tabs, TabsProps, Typography } from "@mui/material"; +import { Backdrop, Button, CircularProgress, Grid, Tab, Tabs, TabsProps, Typography } from "@mui/material"; import QrModal from "../PoDetail/QrModal"; import { WarehouseResult } from "@/app/api/warehouse"; import NotificationIcon from "@mui/icons-material/NotificationImportant"; @@ -18,6 +18,8 @@ import dayjs from "dayjs"; import { arrayToDateString, dayjsToDateString } from "@/app/utils/formatUtil"; import arraySupport from "dayjs/plugin/arraySupport"; import { Checkbox, Box } from "@mui/material"; +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; dayjs.extend(arraySupport); type Props = { @@ -263,6 +265,9 @@ const PoSearch: React.FC = ({ }, [po]); const [isOpenScanner, setOpenScanner] = useState(false); + const [autoSyncStatus, setAutoSyncStatus] = useState(null); + const [isM18LookupLoading, setIsM18LookupLoading] = useState(false); + const autoSyncInProgressRef = React.useRef(false); const onOpenScanner = useCallback(() => { setOpenScanner(true); }, []); @@ -282,12 +287,96 @@ const PoSearch: React.FC = ({ ...pagingController, ...filterArgs, }; + setAutoSyncStatus(null); + const res = await fetchPoListClient(params); - // const res = await testing(params); - if (res) { - console.log(res); + if (!res) return; + + if (res.records && res.records.length > 0) { + setFilteredPo(res.records); + setTotalCount(res.total); + return; + } + + const searchedCodeRaw = (filterArgs as any)?.code; + const searchedCode = + typeof searchedCodeRaw === "string" ? searchedCodeRaw.trim() : ""; + + const shouldAutoSyncFromM18 = + searchedCode.length > 14 && + (searchedCode.startsWith("PP") || searchedCode.startsWith("PF")); + + if (!shouldAutoSyncFromM18 || autoSyncInProgressRef.current) { setFilteredPo(res.records); setTotalCount(res.total); + return; + } + + try { + autoSyncInProgressRef.current = true; + setIsM18LookupLoading(true); + setAutoSyncStatus("正在從M18找尋PO..."); + const syncResp = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent( + searchedCode, + )}`, + { method: "GET" }, + ); + + if (!syncResp.ok) { + throw new Error(`M18 sync failed: ${syncResp.status}`); + } + + let syncJson: any = null; + try { + syncJson = await syncResp.json(); + } catch { + // Some endpoints may respond with plain text + const txt = await syncResp.text(); + syncJson = { raw: txt }; + } + + const syncOk = Boolean(syncJson?.totalSuccess && syncJson.totalSuccess > 0); + if (syncOk) { + setAutoSyncStatus("成功找到PO"); + + // Re-fetch /po/list directly from client to avoid cached server action results. + const cleanedQuery: Record = {}; + Object.entries(params).forEach(([k, v]) => { + if (v === undefined || v === null) return; + if (typeof v === "string" && v.trim() === "") return; + cleanedQuery[k] = String(v); + }); + + const listResp = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams( + cleanedQuery, + ).toString()}`, + { method: "GET" }, + ); + if (listResp.ok) { + const listJson = await listResp.json(); + setFilteredPo(listJson.records ?? []); + setTotalCount(listJson.total ?? 0); + setAutoSyncStatus("成功找到PO"); + return; + } + setAutoSyncStatus("找不到PO"); + } else { + setAutoSyncStatus("找不到PO"); + } + + // Ensure UI updates even if sync didn't change results + setFilteredPo(res.records); + setTotalCount(res.total ?? 0); + } catch (e) { + console.error("Auto sync error:", e); + setAutoSyncStatus("找不到PO"); + setFilteredPo(res.records); + setTotalCount(res.total ?? 0); + } finally { + setIsM18LookupLoading(false); + autoSyncInProgressRef.current = false; } }, [], @@ -328,26 +417,43 @@ const PoSearch: React.FC = ({ <> { + if (isM18LookupLoading) return; console.log(query); - setFilterArgs({ - code: query.code, - supplier: query.supplier, - status: query.status === "All" ? "" : query.status, - escalated: - query.escalated === "All" - ? undefined - : query.escalated === t("Escalated"), - estimatedArrivalDate: query.estimatedArrivalDate === "Invalid Date" ? "" : query.estimatedArrivalDate, - estimatedArrivalDateTo: query.estimatedArrivalDateTo === "Invalid Date" ? "" : query.estimatedArrivalDateTo, - orderDate: query.orderDate === "Invalid Date" ? "" : query.orderDate, - orderDateTo: query.orderDateTo === "Invalid Date" ? "" : query.orderDateTo, - }); + const code = typeof query.code === "string" ? query.code.trim() : ""; + if (code) { + // When PO code is provided, ignore other search criteria (especially date ranges). + setFilterArgs({ code }); + } else { + setFilterArgs({ + code: query.code, + supplier: query.supplier, + status: query.status === "All" ? "" : query.status, + escalated: + query.escalated === "All" + ? undefined + : query.escalated === t("Escalated"), + estimatedArrivalDate: query.estimatedArrivalDate === "Invalid Date" ? "" : query.estimatedArrivalDate, + estimatedArrivalDateTo: query.estimatedArrivalDateTo === "Invalid Date" ? "" : query.estimatedArrivalDateTo, + orderDate: query.orderDate === "Invalid Date" ? "" : query.orderDate, + orderDateTo: query.orderDateTo === "Invalid Date" ? "" : query.orderDateTo, + }); + } setSelectedPoIds([]); // reset selected po ids setSelectAll(false); // reset select all }} onReset={onReset} /> + {autoSyncStatus ? ( + + {autoSyncStatus} + + ) : null} items={filteredPo} columns={columns} @@ -374,6 +480,13 @@ const PoSearch: React.FC = ({ {t("View Selected")} ({selectedPoIds.length}) + theme.zIndex.modal + 1, flexDirection: "column", gap: 1 }} + > + + 正在從M18找尋PO... + ); diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index fd59feb..ccd10c5 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -122,6 +122,8 @@ interface Props { onReset?: () => void; /** Optional actions rendered in the same row as Reset/Search (e.g. Download, Upload buttons) */ extraActions?: React.ReactNode; + /** Disable inputs/actions while external task is running */ + disabled?: boolean; } function SearchBox({ @@ -129,6 +131,7 @@ function SearchBox({ onSearch, onReset, extraActions, + disabled = false, }: Props) { const { t } = useTranslation("common"); const defaultAll: AutocompleteOptions = { @@ -295,6 +298,7 @@ function SearchBox({ placeholder={c.placeholder} onChange={makeInputChangeHandler(c.paramName)} value={inputs[c.paramName]} + disabled={disabled} /> )} {/* eslint-disable-next-line @typescript-eslint/no-unused-vars */} @@ -314,6 +318,7 @@ function SearchBox({ label={t(c.label)} onChange={makeSelectChangeHandler(c.paramName)} value={inputs[c.paramName] ?? "All"} + disabled={disabled} > {t("All")} {c.options.map((option) => ( @@ -331,6 +336,7 @@ function SearchBox({ label={t(c.label)} onChange={makeSelectChangeHandler(c.paramName)} value={inputs[c.paramName] ?? "All"} + disabled={disabled} > {t("All")} {c.options.map((option) => ( @@ -356,6 +362,7 @@ function SearchBox({ noOptionsText={c.noOptionsText ?? t("No options")} disableClearable fullWidth + disabled={disabled} value={ c.multiple ? intersectionWith( @@ -455,6 +462,7 @@ function SearchBox({ format={`${OUTPUT_DATE_FORMAT}`} label={t(c.label)} onChange={makeDateChangeHandler(c.paramName)} + disabled={disabled} value={ dayjs(inputs[c.paramName]).isValid() ? dayjs(inputs[c.paramName]) @@ -475,6 +483,7 @@ function SearchBox({ format={`${OUTPUT_DATE_FORMAT}`} label={c.label2 ? t(c.label2) : null} onChange={makeDateToChangeHandler(c.paramName)} + disabled={disabled} value={ dayjs(inputs[`${c.paramName}To`]).isValid() ? dayjs(inputs[`${c.paramName}To`]) @@ -498,6 +507,7 @@ function SearchBox({ format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} label={t(c.label)} onChange={makeDatetimeChangeHandler(c.paramName)} + disabled={disabled} value={ dayjs(inputs[c.paramName]).isValid() ? dayjs(inputs[c.paramName]) @@ -519,6 +529,7 @@ function SearchBox({ format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} label={c.label2 ? t(c.label2) : null} onChange={makeDatetimeToChangeHandler(c.paramName)} + disabled={disabled} value={ dayjs(inputs[`${c.paramName}To`]).isValid() ? dayjs(inputs[`${c.paramName}To`]) @@ -541,6 +552,7 @@ function SearchBox({ format={`${OUTPUT_DATE_FORMAT}`} label={t(c.label)} onChange={makeDateChangeHandler(c.paramName)} + disabled={disabled} /> @@ -556,6 +568,7 @@ function SearchBox({ format="HH:mm" label={t(c.label)} onChange={makeTimeChangeHandler(c.paramName)} + disabled={disabled} value={ inputs[c.paramName] && dayjs(inputs[c.paramName], "HH:mm").isValid() ? dayjs(inputs[c.paramName], "HH:mm") @@ -574,6 +587,7 @@ function SearchBox({ variant="outlined" startIcon={} onClick={handleReset} + disabled={disabled} sx={{ borderColor: "#e2e8f0", color: "#334155" }} > {t("Reset")} @@ -583,6 +597,7 @@ function SearchBox({ color="primary" startIcon={} onClick={handleSearch} + disabled={disabled} > {t("Search")}