| @@ -90,6 +90,10 @@ export default function TestingPage() { | |||||
| // --- 6. GRN Preview (M18) --- | // --- 6. GRN Preview (M18) --- | ||||
| const [grnPreviewReceiptDate, setGrnPreviewReceiptDate] = useState("2026-03-16"); | 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<string>(""); | |||||
| // Generic handler for inline table edits | // Generic handler for inline table edits | ||||
| const handleItemChange = (setter: any, id: number, field: string, value: string) => { | 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 | // Layout Helper | ||||
| const Section = ({ title, children }: { title: string, children?: React.ReactNode }) => ( | const Section = ({ title, children }: { title: string, children?: React.ReactNode }) => ( | ||||
| <Paper sx={{ p: 3, minHeight: '450px', display: 'flex', flexDirection: 'column' }}> | <Paper sx={{ p: 3, minHeight: '450px', display: 'flex', flexDirection: 'column' }}> | ||||
| @@ -268,6 +299,7 @@ export default function TestingPage() { | |||||
| <Tab label="4. Laser" /> | <Tab label="4. Laser" /> | ||||
| <Tab label="5. HANS600S-M" /> | <Tab label="5. HANS600S-M" /> | ||||
| <Tab label="6. GRN Preview" /> | <Tab label="6. GRN Preview" /> | ||||
| <Tab label="7. M18 PO Sync" /> | |||||
| </Tabs> | </Tabs> | ||||
| <TabPanel value={tabValue} index={0}> | <TabPanel value={tabValue} index={0}> | ||||
| @@ -523,6 +555,43 @@ export default function TestingPage() { | |||||
| </Section> | </Section> | ||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tabValue} index={6}> | |||||
| <Section title="7. M18 PO Sync by Code"> | |||||
| <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}> | |||||
| <TextField | |||||
| size="small" | |||||
| label="PO Code" | |||||
| value={m18PoCode} | |||||
| onChange={(e) => setM18PoCode(e.target.value)} | |||||
| placeholder="e.g. PFP002PO26030341" | |||||
| sx={{ minWidth: 320 }} | |||||
| /> | |||||
| <Button | |||||
| variant="contained" | |||||
| color="primary" | |||||
| onClick={handleSyncM18PoByCode} | |||||
| disabled={isSyncingM18Po} | |||||
| > | |||||
| {isSyncingM18Po ? "Syncing..." : "Sync PO from M18"} | |||||
| </Button> | |||||
| </Stack> | |||||
| <Typography variant="body2" color="textSecondary"> | |||||
| Backend endpoint: <code>/m18/test/po-by-code?code=YOUR_CODE</code> | |||||
| </Typography> | |||||
| {m18PoSyncResult ? ( | |||||
| <TextField | |||||
| fullWidth | |||||
| multiline | |||||
| minRows={4} | |||||
| margin="normal" | |||||
| label="Sync Result" | |||||
| value={m18PoSyncResult} | |||||
| InputProps={{ readOnly: true }} | |||||
| /> | |||||
| ) : null} | |||||
| </Section> | |||||
| </TabPanel> | |||||
| {/* Dialog for OnPack */} | {/* Dialog for OnPack */} | ||||
| <Dialog open={isPrinterModalOpen} onClose={() => setIsPrinterModalOpen(false)} fullWidth maxWidth="sm"> | <Dialog open={isPrinterModalOpen} onClose={() => setIsPrinterModalOpen(false)} fullWidth maxWidth="sm"> | ||||
| <DialogTitle sx={{ bgcolor: 'success.main', color: 'white' }}>OnPack Printer Job Details</DialogTitle> | <DialogTitle sx={{ bgcolor: 'success.main', color: 'white' }}>OnPack Printer Job Details</DialogTitle> | ||||
| @@ -27,6 +27,8 @@ import Print from "@mui/icons-material/Print"; | |||||
| import Download from "@mui/icons-material/Download"; | import Download from "@mui/icons-material/Download"; | ||||
| import { checkPrinterStatus, downloadOnPackQrZip, fetchJobOrders, JobOrderListItem } from "@/app/api/bagPrint/actions"; | import { checkPrinterStatus, downloadOnPackQrZip, fetchJobOrders, JobOrderListItem } from "@/app/api/bagPrint/actions"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||||
| // Light blue theme (matching Python Bag1) | // Light blue theme (matching Python Bag1) | ||||
| const BG_TOP = "#E8F4FC"; | const BG_TOP = "#E8F4FC"; | ||||
| @@ -94,6 +96,11 @@ const BagPrintSearch: React.FC = () => { | |||||
| const [connected, setConnected] = useState(false); | const [connected, setConnected] = useState(false); | ||||
| const [printer, setPrinter] = useState<string>("dataflex"); | const [printer, setPrinter] = useState<string>("dataflex"); | ||||
| const [selectedId, setSelectedId] = useState<number | null>(null); | const [selectedId, setSelectedId] = useState<number | null>(null); | ||||
| const [printDialogOpen, setPrintDialogOpen] = useState(false); | |||||
| const [printTarget, setPrintTarget] = useState<JobOrderListItem | null>(null); | |||||
| const [printCount, setPrintCount] = useState(0); | |||||
| const [printContinuous, setPrintContinuous] = useState(false); | |||||
| const [printing, setPrinting] = useState(false); | |||||
| const [settingsOpen, setSettingsOpen] = useState(false); | const [settingsOpen, setSettingsOpen] = useState(false); | ||||
| const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: "success" | "info" | "error" }>({ open: false, message: "" }); | const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: "success" | "info" | "error" }>({ open: false, message: "" }); | ||||
| const [settings, setSettings] = useState(DEFAULT_SETTINGS); | const [settings, setSettings] = useState(DEFAULT_SETTINGS); | ||||
| @@ -187,8 +194,76 @@ const BagPrintSearch: React.FC = () => { | |||||
| const itemCode = jo.itemCode || "—"; | const itemCode = jo.itemCode || "—"; | ||||
| const itemName = jo.itemName || "—"; | const itemName = jo.itemName || "—"; | ||||
| setSnackbar({ open: true, message: `已點選:批次 ${batch} 品號 ${itemCode} ${itemName}`, severity: "info" }); | 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 () => { | const handleDownloadOnPackQr = async () => { | ||||
| @@ -374,6 +449,85 @@ const BagPrintSearch: React.FC = () => { | |||||
| )} | )} | ||||
| </Paper> | </Paper> | ||||
| {/* Print count dialog (DataFlex) */} | |||||
| <Dialog open={printDialogOpen} onClose={() => (printing ? null : setPrintDialogOpen(false))} maxWidth="xs" fullWidth> | |||||
| <DialogTitle>打袋機 DataFlex 列印數量</DialogTitle> | |||||
| <DialogContent> | |||||
| <Stack spacing={2} sx={{ mt: 1 }}> | |||||
| <Typography variant="body1" sx={{ fontWeight: 700 }}> | |||||
| 列印多少個袋? | |||||
| </Typography> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {printContinuous ? "連續 (C)" : `數量: ${printCount}`} | |||||
| </Typography> | |||||
| <Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap"> | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| onClick={() => { | |||||
| setPrintContinuous(false); | |||||
| setPrintCount((c) => c + 50); | |||||
| }} | |||||
| disabled={printing} | |||||
| > | |||||
| +50 | |||||
| </Button> | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| onClick={() => { | |||||
| setPrintContinuous(false); | |||||
| setPrintCount((c) => c + 10); | |||||
| }} | |||||
| disabled={printing} | |||||
| > | |||||
| +10 | |||||
| </Button> | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| onClick={() => { | |||||
| setPrintContinuous(false); | |||||
| setPrintCount((c) => c + 5); | |||||
| }} | |||||
| disabled={printing} | |||||
| > | |||||
| +5 | |||||
| </Button> | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| onClick={() => { | |||||
| setPrintContinuous(false); | |||||
| setPrintCount((c) => c + 1); | |||||
| }} | |||||
| disabled={printing} | |||||
| > | |||||
| +1 | |||||
| </Button> | |||||
| <Button | |||||
| size="small" | |||||
| variant={printContinuous ? "contained" : "outlined"} | |||||
| onClick={() => { | |||||
| setPrintContinuous(true); | |||||
| }} | |||||
| disabled={printing} | |||||
| > | |||||
| 連續 (C) | |||||
| </Button> | |||||
| </Stack> | |||||
| </Stack> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={() => setPrintDialogOpen(false)} disabled={printing}> | |||||
| 取消 | |||||
| </Button> | |||||
| <Button variant="contained" onClick={() => void confirmPrintDataFlex()} disabled={printing}> | |||||
| {printing ? <CircularProgress size={16} /> : "確認送出"} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| {/* Settings dialog */} | {/* Settings dialog */} | ||||
| <Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth> | <Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth> | ||||
| <DialogTitle>設定</DialogTitle> | <DialogTitle>設定</DialogTitle> | ||||
| @@ -7,7 +7,7 @@ import { useRouter, useSearchParams } from "next/navigation"; | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | import SearchBox, { Criterion } from "../SearchBox"; | ||||
| import SearchResults, { Column } from "../SearchResults"; | import SearchResults, { Column } from "../SearchResults"; | ||||
| import { EditNote } from "@mui/icons-material"; | 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 QrModal from "../PoDetail/QrModal"; | ||||
| import { WarehouseResult } from "@/app/api/warehouse"; | import { WarehouseResult } from "@/app/api/warehouse"; | ||||
| import NotificationIcon from "@mui/icons-material/NotificationImportant"; | import NotificationIcon from "@mui/icons-material/NotificationImportant"; | ||||
| @@ -18,6 +18,8 @@ import dayjs from "dayjs"; | |||||
| import { arrayToDateString, dayjsToDateString } from "@/app/utils/formatUtil"; | import { arrayToDateString, dayjsToDateString } from "@/app/utils/formatUtil"; | ||||
| import arraySupport from "dayjs/plugin/arraySupport"; | import arraySupport from "dayjs/plugin/arraySupport"; | ||||
| import { Checkbox, Box } from "@mui/material"; | import { Checkbox, Box } from "@mui/material"; | ||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||||
| dayjs.extend(arraySupport); | dayjs.extend(arraySupport); | ||||
| type Props = { | type Props = { | ||||
| @@ -263,6 +265,9 @@ const PoSearch: React.FC<Props> = ({ | |||||
| }, [po]); | }, [po]); | ||||
| const [isOpenScanner, setOpenScanner] = useState(false); | const [isOpenScanner, setOpenScanner] = useState(false); | ||||
| const [autoSyncStatus, setAutoSyncStatus] = useState<string | null>(null); | |||||
| const [isM18LookupLoading, setIsM18LookupLoading] = useState(false); | |||||
| const autoSyncInProgressRef = React.useRef(false); | |||||
| const onOpenScanner = useCallback(() => { | const onOpenScanner = useCallback(() => { | ||||
| setOpenScanner(true); | setOpenScanner(true); | ||||
| }, []); | }, []); | ||||
| @@ -282,12 +287,96 @@ const PoSearch: React.FC<Props> = ({ | |||||
| ...pagingController, | ...pagingController, | ||||
| ...filterArgs, | ...filterArgs, | ||||
| }; | }; | ||||
| setAutoSyncStatus(null); | |||||
| const res = await fetchPoListClient(params); | 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); | setFilteredPo(res.records); | ||||
| setTotalCount(res.total); | 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<string, string> = {}; | |||||
| 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<Props> = ({ | |||||
| <> | <> | ||||
| <SearchBox | <SearchBox | ||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| disabled={isM18LookupLoading} | |||||
| onSearch={(query) => { | onSearch={(query) => { | ||||
| if (isM18LookupLoading) return; | |||||
| console.log(query); | 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 | setSelectedPoIds([]); // reset selected po ids | ||||
| setSelectAll(false); // reset select all | setSelectAll(false); // reset select all | ||||
| }} | }} | ||||
| onReset={onReset} | onReset={onReset} | ||||
| /> | /> | ||||
| {autoSyncStatus ? ( | |||||
| <Typography | |||||
| variant="body2" | |||||
| color={isM18LookupLoading ? "warning.main" : "text.secondary"} | |||||
| sx={{ mb: 1 }} | |||||
| > | |||||
| {autoSyncStatus} | |||||
| </Typography> | |||||
| ) : null} | |||||
| <SearchResults<PoResult> | <SearchResults<PoResult> | ||||
| items={filteredPo} | items={filteredPo} | ||||
| columns={columns} | columns={columns} | ||||
| @@ -374,6 +480,13 @@ const PoSearch: React.FC<Props> = ({ | |||||
| {t("View Selected")} ({selectedPoIds.length}) | {t("View Selected")} ({selectedPoIds.length}) | ||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| <Backdrop | |||||
| open={isM18LookupLoading} | |||||
| sx={{ color: "#fff", zIndex: (theme) => theme.zIndex.modal + 1, flexDirection: "column", gap: 1 }} | |||||
| > | |||||
| <CircularProgress color="inherit" /> | |||||
| <Typography variant="body1">正在從M18找尋PO...</Typography> | |||||
| </Backdrop> | |||||
| </> | </> | ||||
| </> | </> | ||||
| ); | ); | ||||
| @@ -122,6 +122,8 @@ interface Props<T extends string> { | |||||
| onReset?: () => void; | onReset?: () => void; | ||||
| /** Optional actions rendered in the same row as Reset/Search (e.g. Download, Upload buttons) */ | /** Optional actions rendered in the same row as Reset/Search (e.g. Download, Upload buttons) */ | ||||
| extraActions?: React.ReactNode; | extraActions?: React.ReactNode; | ||||
| /** Disable inputs/actions while external task is running */ | |||||
| disabled?: boolean; | |||||
| } | } | ||||
| function SearchBox<T extends string>({ | function SearchBox<T extends string>({ | ||||
| @@ -129,6 +131,7 @@ function SearchBox<T extends string>({ | |||||
| onSearch, | onSearch, | ||||
| onReset, | onReset, | ||||
| extraActions, | extraActions, | ||||
| disabled = false, | |||||
| }: Props<T>) { | }: Props<T>) { | ||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const defaultAll: AutocompleteOptions = { | const defaultAll: AutocompleteOptions = { | ||||
| @@ -295,6 +298,7 @@ function SearchBox<T extends string>({ | |||||
| placeholder={c.placeholder} | placeholder={c.placeholder} | ||||
| onChange={makeInputChangeHandler(c.paramName)} | onChange={makeInputChangeHandler(c.paramName)} | ||||
| value={inputs[c.paramName]} | value={inputs[c.paramName]} | ||||
| disabled={disabled} | |||||
| /> | /> | ||||
| )} | )} | ||||
| {/* eslint-disable-next-line @typescript-eslint/no-unused-vars */} | {/* eslint-disable-next-line @typescript-eslint/no-unused-vars */} | ||||
| @@ -314,6 +318,7 @@ function SearchBox<T extends string>({ | |||||
| label={t(c.label)} | label={t(c.label)} | ||||
| onChange={makeSelectChangeHandler(c.paramName)} | onChange={makeSelectChangeHandler(c.paramName)} | ||||
| value={inputs[c.paramName] ?? "All"} | value={inputs[c.paramName] ?? "All"} | ||||
| disabled={disabled} | |||||
| > | > | ||||
| <MenuItem value={"All"}>{t("All")}</MenuItem> | <MenuItem value={"All"}>{t("All")}</MenuItem> | ||||
| {c.options.map((option) => ( | {c.options.map((option) => ( | ||||
| @@ -331,6 +336,7 @@ function SearchBox<T extends string>({ | |||||
| label={t(c.label)} | label={t(c.label)} | ||||
| onChange={makeSelectChangeHandler(c.paramName)} | onChange={makeSelectChangeHandler(c.paramName)} | ||||
| value={inputs[c.paramName] ?? "All"} | value={inputs[c.paramName] ?? "All"} | ||||
| disabled={disabled} | |||||
| > | > | ||||
| <MenuItem value={"All"}>{t("All")}</MenuItem> | <MenuItem value={"All"}>{t("All")}</MenuItem> | ||||
| {c.options.map((option) => ( | {c.options.map((option) => ( | ||||
| @@ -356,6 +362,7 @@ function SearchBox<T extends string>({ | |||||
| noOptionsText={c.noOptionsText ?? t("No options")} | noOptionsText={c.noOptionsText ?? t("No options")} | ||||
| disableClearable | disableClearable | ||||
| fullWidth | fullWidth | ||||
| disabled={disabled} | |||||
| value={ | value={ | ||||
| c.multiple | c.multiple | ||||
| ? intersectionWith( | ? intersectionWith( | ||||
| @@ -455,6 +462,7 @@ function SearchBox<T extends string>({ | |||||
| format={`${OUTPUT_DATE_FORMAT}`} | format={`${OUTPUT_DATE_FORMAT}`} | ||||
| label={t(c.label)} | label={t(c.label)} | ||||
| onChange={makeDateChangeHandler(c.paramName)} | onChange={makeDateChangeHandler(c.paramName)} | ||||
| disabled={disabled} | |||||
| value={ | value={ | ||||
| dayjs(inputs[c.paramName]).isValid() | dayjs(inputs[c.paramName]).isValid() | ||||
| ? dayjs(inputs[c.paramName]) | ? dayjs(inputs[c.paramName]) | ||||
| @@ -475,6 +483,7 @@ function SearchBox<T extends string>({ | |||||
| format={`${OUTPUT_DATE_FORMAT}`} | format={`${OUTPUT_DATE_FORMAT}`} | ||||
| label={c.label2 ? t(c.label2) : null} | label={c.label2 ? t(c.label2) : null} | ||||
| onChange={makeDateToChangeHandler(c.paramName)} | onChange={makeDateToChangeHandler(c.paramName)} | ||||
| disabled={disabled} | |||||
| value={ | value={ | ||||
| dayjs(inputs[`${c.paramName}To`]).isValid() | dayjs(inputs[`${c.paramName}To`]).isValid() | ||||
| ? dayjs(inputs[`${c.paramName}To`]) | ? dayjs(inputs[`${c.paramName}To`]) | ||||
| @@ -498,6 +507,7 @@ function SearchBox<T extends string>({ | |||||
| format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | ||||
| label={t(c.label)} | label={t(c.label)} | ||||
| onChange={makeDatetimeChangeHandler(c.paramName)} | onChange={makeDatetimeChangeHandler(c.paramName)} | ||||
| disabled={disabled} | |||||
| value={ | value={ | ||||
| dayjs(inputs[c.paramName]).isValid() | dayjs(inputs[c.paramName]).isValid() | ||||
| ? dayjs(inputs[c.paramName]) | ? dayjs(inputs[c.paramName]) | ||||
| @@ -519,6 +529,7 @@ function SearchBox<T extends string>({ | |||||
| format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | ||||
| label={c.label2 ? t(c.label2) : null} | label={c.label2 ? t(c.label2) : null} | ||||
| onChange={makeDatetimeToChangeHandler(c.paramName)} | onChange={makeDatetimeToChangeHandler(c.paramName)} | ||||
| disabled={disabled} | |||||
| value={ | value={ | ||||
| dayjs(inputs[`${c.paramName}To`]).isValid() | dayjs(inputs[`${c.paramName}To`]).isValid() | ||||
| ? dayjs(inputs[`${c.paramName}To`]) | ? dayjs(inputs[`${c.paramName}To`]) | ||||
| @@ -541,6 +552,7 @@ function SearchBox<T extends string>({ | |||||
| format={`${OUTPUT_DATE_FORMAT}`} | format={`${OUTPUT_DATE_FORMAT}`} | ||||
| label={t(c.label)} | label={t(c.label)} | ||||
| onChange={makeDateChangeHandler(c.paramName)} | onChange={makeDateChangeHandler(c.paramName)} | ||||
| disabled={disabled} | |||||
| /> | /> | ||||
| </FormControl> | </FormControl> | ||||
| </Box> | </Box> | ||||
| @@ -556,6 +568,7 @@ function SearchBox<T extends string>({ | |||||
| format="HH:mm" | format="HH:mm" | ||||
| label={t(c.label)} | label={t(c.label)} | ||||
| onChange={makeTimeChangeHandler(c.paramName)} | onChange={makeTimeChangeHandler(c.paramName)} | ||||
| disabled={disabled} | |||||
| value={ | value={ | ||||
| inputs[c.paramName] && dayjs(inputs[c.paramName], "HH:mm").isValid() | inputs[c.paramName] && dayjs(inputs[c.paramName], "HH:mm").isValid() | ||||
| ? dayjs(inputs[c.paramName], "HH:mm") | ? dayjs(inputs[c.paramName], "HH:mm") | ||||
| @@ -574,6 +587,7 @@ function SearchBox<T extends string>({ | |||||
| variant="outlined" | variant="outlined" | ||||
| startIcon={<RestartAlt />} | startIcon={<RestartAlt />} | ||||
| onClick={handleReset} | onClick={handleReset} | ||||
| disabled={disabled} | |||||
| sx={{ borderColor: "#e2e8f0", color: "#334155" }} | sx={{ borderColor: "#e2e8f0", color: "#334155" }} | ||||
| > | > | ||||
| {t("Reset")} | {t("Reset")} | ||||
| @@ -583,6 +597,7 @@ function SearchBox<T extends string>({ | |||||
| color="primary" | color="primary" | ||||
| startIcon={<Search />} | startIcon={<Search />} | ||||
| onClick={handleSearch} | onClick={handleSearch} | ||||
| disabled={disabled} | |||||
| > | > | ||||
| {t("Search")} | {t("Search")} | ||||
| </Button> | </Button> | ||||