From 7415dbe4b65600f8fbe80c95220ea2b6704299c4 Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Tue, 24 Mar 2026 13:03:39 +0800 Subject: [PATCH 01/12] added some purchase chart --- .../purchase/exportPurchaseChartMaster.ts | 54 + src/app/(main)/chart/purchase/page.tsx | 1103 ++++++++++++++++- src/app/api/chart/client.ts | 308 ++++- 3 files changed, 1438 insertions(+), 27 deletions(-) create mode 100644 src/app/(main)/chart/purchase/exportPurchaseChartMaster.ts diff --git a/src/app/(main)/chart/purchase/exportPurchaseChartMaster.ts b/src/app/(main)/chart/purchase/exportPurchaseChartMaster.ts new file mode 100644 index 0000000..536e1f9 --- /dev/null +++ b/src/app/(main)/chart/purchase/exportPurchaseChartMaster.ts @@ -0,0 +1,54 @@ +/** + * Multi-sheet 總表 export for the 採購 chart page — mirrors on-screen charts and drill-down data. + */ +import { exportMultiSheetToXlsx, type MultiSheetSpec } from "../_components/exportChartToXlsx"; + +export type PurchaseChartMasterExportPayload = { + /** ISO timestamp for audit */ + exportedAtIso: string; + /** 篩選與情境 — key-value rows */ + metaRows: Record[]; + /** 預計送貨 donut (依預計到貨日、上方篩選) */ + estimatedDonutRows: Record[]; + /** 實際已送貨 donut (依訂單日期、上方篩選) */ + actualStatusDonutRows: Record[]; + /** 貨品摘要表 (當前 drill) */ + itemSummaryRows: Record[]; + /** 供應商分佈 (由採購單明細彙總) */ + supplierDistributionRows: Record[]; + /** 採購單列表 */ + purchaseOrderListRows: Record[]; + /** 全量採購單行明細 (每張 PO 所有行) */ + purchaseOrderLineRows: Record[]; +}; + +function sheetOrPlaceholder(name: string, rows: Record[], emptyMessage: string): MultiSheetSpec { + if (rows.length > 0) return { name, rows }; + return { + name, + rows: [{ 說明: emptyMessage }], + }; +} + +/** + * Build worksheet specs (used by {@link exportPurchaseChartMasterToFile}). + */ +export function buildPurchaseChartMasterSheets(payload: PurchaseChartMasterExportPayload): MultiSheetSpec[] { + return [ + { name: "篩選條件與情境", rows: payload.metaRows }, + sheetOrPlaceholder("預計送貨", payload.estimatedDonutRows, "無資料(請確認訂單日期與篩選)"), + sheetOrPlaceholder("實際已送貨", payload.actualStatusDonutRows, "無資料"), + sheetOrPlaceholder("貨品摘要", payload.itemSummaryRows, "無資料(可能為篩選交集為空或未載入)"), + sheetOrPlaceholder("供應商分佈", payload.supplierDistributionRows, "無資料"), + sheetOrPlaceholder("採購單列表", payload.purchaseOrderListRows, "無採購單明細可匯出"), + sheetOrPlaceholder("採購單行明細", payload.purchaseOrderLineRows, "無行資料(採購單列表為空)"), + ]; +} + +export function exportPurchaseChartMasterToFile( + payload: PurchaseChartMasterExportPayload, + filenameBase: string +): void { + const sheets = buildPurchaseChartMasterSheets(payload); + exportMultiSheetToXlsx(sheets, filenameBase); +} diff --git a/src/app/(main)/chart/purchase/page.tsx b/src/app/(main)/chart/purchase/page.tsx index 6ccab29..2fd31a3 100644 --- a/src/app/(main)/chart/purchase/page.tsx +++ b/src/app/(main)/chart/purchase/page.tsx @@ -1,30 +1,608 @@ "use client"; import React, { useState } from "react"; -import { Box, Typography, Skeleton, Alert, TextField } from "@mui/material"; +import { + Box, + Typography, + Skeleton, + Alert, + TextField, + CircularProgress, + Button, + Stack, + Grid, + Autocomplete, + Chip, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from "@mui/material"; import dynamic from "next/dynamic"; import ShoppingCart from "@mui/icons-material/ShoppingCart"; -import { fetchPurchaseOrderByStatus } from "@/app/api/chart/client"; +import TableChart from "@mui/icons-material/TableChart"; +import { + fetchPurchaseOrderByStatus, + fetchPurchaseOrderDetailsByStatus, + fetchPurchaseOrderItems, + fetchPurchaseOrderItemsByStatus, + fetchPurchaseOrderFilterOptions, + fetchPurchaseOrderEstimatedArrivalSummary, + fetchPurchaseOrderEstimatedArrivalBreakdown, + PurchaseOrderDetailByStatusRow, + PurchaseOrderItemRow, + PurchaseOrderChartFilters, + PurchaseOrderFilterOptions, + PurchaseOrderEstimatedArrivalRow, + PurchaseOrderDrillQuery, + PurchaseOrderEstimatedArrivalBreakdown, +} from "@/app/api/chart/client"; import ChartCard from "../_components/ChartCard"; +import { exportPurchaseChartMasterToFile } from "./exportPurchaseChartMaster"; import dayjs from "dayjs"; const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); const PAGE_TITLE = "採購"; +const DEFAULT_DRILL_STATUS = "completed"; +/** Must match backend `getPurchaseOrderByStatus` (orderDate). Using "complete" here desyncs drill-down from the donut counts. */ +const DRILL_DATE_FILTER = "order" as const; + +const EST_BUCKETS = ["delivered", "not_delivered", "cancelled", "other"] as const; + +/** 預計送貨 — 已送 / 未送 / 已取消 / 其他 */ +const ESTIMATE_DONUT_COLORS = ["#2e7d32", "#f57c00", "#78909c", "#7b1fa2"]; + +/** 實際已送貨(依狀態)— 依序上色 */ +const STATUS_DONUT_COLORS = ["#1565c0", "#00838f", "#6a1b9a", "#c62828", "#5d4037", "#00695c"]; + +/** ApexCharts + React: avoid updating state inside dataPointSelection synchronously (DOM getAttribute null). */ +function deferChartClick(fn: () => void) { + window.setTimeout(fn, 0); +} + +/** UI labels only; API still uses English status values. */ +function poStatusLabelZh(status: string): string { + const s = status.trim().toLowerCase(); + switch (s) { + case "pending": + return "待處理"; + case "completed": + return "已完成"; + case "receiving": + return "收貨中"; + default: + return status; + } +} + +function bucketLabelZh(bucket: string): string { + switch (bucket) { + case "delivered": + return "已送"; + case "not_delivered": + return "未送"; + case "cancelled": + return "已取消"; + case "other": + return "其他"; + default: + return bucket; + } +} + +function emptyFilterOptions(): PurchaseOrderFilterOptions { + return { suppliers: [], items: [], poNos: [] }; +} export default function PurchaseChartPage() { const [poTargetDate, setPoTargetDate] = useState(() => dayjs().format("YYYY-MM-DD")); const [error, setError] = useState(null); const [chartData, setChartData] = useState<{ status: string; count: number }[]>([]); + const [estimatedArrivalData, setEstimatedArrivalData] = useState([]); const [loading, setLoading] = useState(true); + const [estimatedLoading, setEstimatedLoading] = useState(true); + const [filterOptions, setFilterOptions] = useState(emptyFilterOptions); + const [filterOptionsLoading, setFilterOptionsLoading] = useState(false); + + const [filterSupplierIds, setFilterSupplierIds] = useState([]); + const [filterItemCodes, setFilterItemCodes] = useState([]); + const [filterPoNos, setFilterPoNos] = useState([]); + + const [selectedStatus, setSelectedStatus] = useState(null); + /** Prefer id (shop row); code-only used when supplierId missing */ + const [selectedSupplierId, setSelectedSupplierId] = useState(null); + const [selectedSupplierCode, setSelectedSupplierCode] = useState(null); + const [selectedItemCode, setSelectedItemCode] = useState(null); + /** 預計送貨 donut — filters lower charts via API */ + const [selectedEstimatedBucket, setSelectedEstimatedBucket] = useState(null); + const [poDetails, setPoDetails] = useState([]); + const [poDetailsLoading, setPoDetailsLoading] = useState(false); + const [selectedPo, setSelectedPo] = useState(null); + const [itemsSummary, setItemsSummary] = useState([]); + const [itemsSummaryLoading, setItemsSummaryLoading] = useState(false); + const [poLineItems, setPoLineItems] = useState([]); + const [poLineItemsLoading, setPoLineItemsLoading] = useState(false); + const [masterExportLoading, setMasterExportLoading] = useState(false); + const [eaBreakdown, setEaBreakdown] = useState(null); + const [eaBreakdownLoading, setEaBreakdownLoading] = useState(false); + + const effectiveStatus = selectedStatus ?? DEFAULT_DRILL_STATUS; + + /** Top charts (實際已送貨 + 預計送貨): date + multi-select only — no drill-down from lower charts. */ + const barFilters = React.useMemo((): PurchaseOrderChartFilters => { + return { + supplierIds: filterSupplierIds.length ? filterSupplierIds : undefined, + itemCodes: filterItemCodes.length ? filterItemCodes : undefined, + purchaseOrderNos: filterPoNos.length ? filterPoNos : undefined, + }; + }, [filterSupplierIds, filterItemCodes, filterPoNos]); + + /** Drill-down: bar filters ∩ supplier/貨品 chart selection. */ + const drillFilters = React.useMemo((): PurchaseOrderChartFilters | null => { + if ( + selectedSupplierId != null && + selectedSupplierId > 0 && + filterSupplierIds.length > 0 && + !filterSupplierIds.includes(selectedSupplierId) + ) { + return null; + } + if ( + selectedItemCode?.trim() && + filterItemCodes.length > 0 && + !filterItemCodes.includes(selectedItemCode.trim()) + ) { + return null; + } + if (selectedSupplierCode?.trim() && filterSupplierIds.length > 0) { + const opt = filterOptions.suppliers.find((s) => s.code === selectedSupplierCode.trim()); + if (!opt || opt.supplierId <= 0 || !filterSupplierIds.includes(opt.supplierId)) { + return null; + } + } + + const out: PurchaseOrderChartFilters = { + supplierIds: filterSupplierIds.length ? [...filterSupplierIds] : undefined, + itemCodes: filterItemCodes.length ? [...filterItemCodes] : undefined, + purchaseOrderNos: filterPoNos.length ? [...filterPoNos] : undefined, + }; + + if (selectedSupplierId != null && selectedSupplierId > 0) { + if (!out.supplierIds?.length) { + out.supplierIds = [selectedSupplierId]; + } else if (out.supplierIds.includes(selectedSupplierId)) { + out.supplierIds = [selectedSupplierId]; + } + out.supplierCode = undefined; + } else if (selectedSupplierCode?.trim()) { + const code = selectedSupplierCode.trim(); + const opt = filterOptions.suppliers.find((s) => s.code === code); + if (out.supplierIds?.length) { + if (opt && opt.supplierId > 0 && out.supplierIds.includes(opt.supplierId)) { + out.supplierIds = [opt.supplierId]; + } else { + return null; + } + } else { + out.supplierIds = undefined; + out.supplierCode = code; + } + } + + if (selectedItemCode?.trim()) { + const ic = selectedItemCode.trim(); + if (!out.itemCodes?.length) { + out.itemCodes = [ic]; + } else if (out.itemCodes.includes(ic)) { + out.itemCodes = [ic]; + } + } + + return out; + }, [ + filterSupplierIds, + filterItemCodes, + filterPoNos, + selectedSupplierId, + selectedSupplierCode, + selectedItemCode, + filterOptions.suppliers, + ]); + + const drillQueryOpts = React.useMemo((): PurchaseOrderDrillQuery | null => { + if (drillFilters === null) return null; + return { + ...drillFilters, + estimatedArrivalBucket: selectedEstimatedBucket ?? undefined, + }; + }, [drillFilters, selectedEstimatedBucket]); + + React.useEffect(() => { + setFilterOptionsLoading(true); + fetchPurchaseOrderFilterOptions(poTargetDate) + .then(setFilterOptions) + .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) + .finally(() => setFilterOptionsLoading(false)); + }, [poTargetDate]); React.useEffect(() => { setLoading(true); - fetchPurchaseOrderByStatus(poTargetDate) + fetchPurchaseOrderByStatus(poTargetDate, barFilters) .then((data) => setChartData(data as { status: string; count: number }[])) .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) .finally(() => setLoading(false)); - }, [poTargetDate]); + }, [poTargetDate, barFilters]); + + React.useEffect(() => { + setEstimatedLoading(true); + fetchPurchaseOrderEstimatedArrivalSummary(poTargetDate, barFilters) + .then(setEstimatedArrivalData) + .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) + .finally(() => setEstimatedLoading(false)); + }, [poTargetDate, barFilters]); + + React.useEffect(() => { + if (!selectedEstimatedBucket || !poTargetDate) { + setEaBreakdown(null); + return; + } + setEaBreakdownLoading(true); + fetchPurchaseOrderEstimatedArrivalBreakdown(poTargetDate, selectedEstimatedBucket, barFilters) + .then(setEaBreakdown) + .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) + .finally(() => setEaBreakdownLoading(false)); + }, [selectedEstimatedBucket, poTargetDate, barFilters]); + + React.useEffect(() => { + if (drillQueryOpts === null) { + setPoDetails([]); + return; + } + setPoDetailsLoading(true); + fetchPurchaseOrderDetailsByStatus(effectiveStatus, poTargetDate, { + dateFilter: DRILL_DATE_FILTER, + ...drillQueryOpts, + }) + .then((rows) => setPoDetails(rows)) + .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) + .finally(() => setPoDetailsLoading(false)); + }, [effectiveStatus, poTargetDate, drillQueryOpts]); + + React.useEffect(() => { + if (selectedPo) return; + if (drillQueryOpts === null) { + setItemsSummary([]); + return; + } + setItemsSummaryLoading(true); + fetchPurchaseOrderItemsByStatus(effectiveStatus, poTargetDate, { + dateFilter: DRILL_DATE_FILTER, + ...drillQueryOpts, + }) + .then((rows) => setItemsSummary(rows)) + .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) + .finally(() => setItemsSummaryLoading(false)); + }, [selectedPo, effectiveStatus, poTargetDate, drillQueryOpts]); + + React.useEffect(() => { + if (selectedPo) return; + setPoLineItems([]); + setPoLineItemsLoading(false); + }, [selectedPo]); + + const handleStatusClick = (status: string) => { + const normalized = status.trim().toLowerCase(); + setSelectedStatus((prev) => (prev === normalized ? null : normalized)); + /** 與「預計送貨」圓環互斥:只顯示一則上方圓環篩選說明 */ + setSelectedEstimatedBucket(null); + setSelectedPo(null); + setSelectedSupplierId(null); + setSelectedSupplierCode(null); + setSelectedItemCode(null); + setPoLineItems([]); + }; + + const handleEstimatedBucketClick = (index: number) => { + const bucket = EST_BUCKETS[index]; + if (!bucket) return; + setSelectedEstimatedBucket((prev) => (prev === bucket ? null : bucket)); + /** 與「實際已送貨」圓環互斥:只顯示一則上方圓環篩選說明 */ + setSelectedStatus(null); + setSelectedPo(null); + setPoLineItems([]); + }; + + const handleClearFilters = () => { + setFilterSupplierIds([]); + setFilterItemCodes([]); + setFilterPoNos([]); + setSelectedSupplierId(null); + setSelectedSupplierCode(null); + setSelectedItemCode(null); + setSelectedEstimatedBucket(null); + setSelectedStatus(null); + setSelectedPo(null); + setPoLineItems([]); + }; + + const handleItemSummaryClick = (index: number) => { + const row = itemsSummary[index]; + if (!row?.itemCode) return; + setSelectedItemCode((prev) => (prev === row.itemCode ? null : row.itemCode)); + setSelectedPo(null); + setPoLineItems([]); + }; + + const handleSupplierClick = (row: { + supplierId: number | null; + supplierCode: string; + }) => { + if (row.supplierId != null && row.supplierId > 0) { + setSelectedSupplierId((prev) => (prev === row.supplierId ? null : row.supplierId)); + setSelectedSupplierCode(null); + } else if (row.supplierCode.trim()) { + setSelectedSupplierCode((prev) => (prev === row.supplierCode ? null : row.supplierCode)); + setSelectedSupplierId(null); + } + setSelectedPo(null); + setPoLineItems([]); + }; + + const handlePoClick = async (row: PurchaseOrderDetailByStatusRow) => { + setSelectedPo(row); + setPoLineItems([]); + setPoLineItemsLoading(true); + try { + const rows = await fetchPurchaseOrderItems(row.purchaseOrderId); + setPoLineItems(rows); + } catch (err) { + setError(err instanceof Error ? err.message : "Request failed"); + } finally { + setPoLineItemsLoading(false); + } + }; + + const supplierChartData = React.useMemo(() => { + const map = new Map< + string, + { + supplier: string; + supplierId: number | null; + supplierCode: string; + count: number; + totalQty: number; + } + >(); + poDetails.forEach((row) => { + const sid = row.supplierId != null && row.supplierId > 0 ? row.supplierId : null; + const code = String(row.supplierCode ?? "").trim(); + const name = String(row.supplierName ?? "").trim(); + const label = + `${code} ${name}`.trim() || (sid != null ? `(Supplier #${sid})` : "(Unknown supplier)"); + const key = sid != null ? `sid:${sid}` : `code:${code}|name:${name}`; + const curr = map.get(key) ?? { + supplier: label, + supplierId: sid, + supplierCode: code, + count: 0, + totalQty: 0, + }; + curr.count += 1; + curr.totalQty += Number(row.totalQty ?? 0); + map.set(key, curr); + }); + return Array.from(map.values()).sort((a, b) => b.totalQty - a.totalQty); + }, [poDetails]); + + const estimatedChartSeries = React.useMemo(() => { + const m = new Map(estimatedArrivalData.map((r) => [r.bucket, r.count])); + return EST_BUCKETS.map((b) => m.get(b) ?? 0); + }, [estimatedArrivalData]); + + const handleExportPurchaseMaster = React.useCallback(async () => { + setMasterExportLoading(true); + try { + const exportedAtIso = new Date().toISOString(); + const filterSupplierText = + filterOptions.suppliers + .filter((s) => filterSupplierIds.includes(s.supplierId)) + .map((s) => `${s.code} ${s.name}`.trim()) + .join(";") || "(未選)"; + const filterItemText = + filterOptions.items + .filter((i) => filterItemCodes.includes(i.itemCode)) + .map((i) => `${i.itemCode} ${i.itemName}`.trim()) + .join(";") || "(未選)"; + const filterPoText = filterPoNos.length ? filterPoNos.join(";") : "(未選)"; + + const metaRows: Record[] = [ + { 項目: "匯出時間_UTC", 值: exportedAtIso }, + { 項目: "訂單日期", 值: poTargetDate }, + { 項目: "多選_供應商", 值: filterSupplierText }, + { 項目: "多選_貨品", 值: filterItemText }, + { 項目: "多選_採購單號", 值: filterPoText }, + { + 項目: "預計送貨圓環_點選", + 值: selectedEstimatedBucket ? bucketLabelZh(selectedEstimatedBucket) : "(未選)", + }, + { + 項目: "實際已送貨圓環_點選狀態", + 值: + selectedStatus != null + ? poStatusLabelZh(selectedStatus) + : `(未選;下方圖表預設狀態 ${poStatusLabelZh(DEFAULT_DRILL_STATUS)})`, + }, + { 項目: "下方圖表套用狀態_英文", 值: effectiveStatus }, + { 項目: "下方圖表套用狀態_中文", 值: poStatusLabelZh(effectiveStatus) }, + { + 項目: "圓環篩選_供應商", + 值: + selectedSupplierId != null && selectedSupplierId > 0 + ? `supplierId=${selectedSupplierId}` + : selectedSupplierCode?.trim() + ? `supplierCode=${selectedSupplierCode.trim()}` + : "(未選)", + }, + { 項目: "圓環篩選_貨品", 值: selectedItemCode?.trim() ? selectedItemCode.trim() : "(未選)" }, + { + 項目: "下方查詢是否有效", + 值: drillQueryOpts === null ? "否(篩選交集無效,下方表為空)" : "是", + }, + { + 項目: "圖表說明", + 值: + "預計送貨圖:預計到貨日=訂單日期。實際已送貨圖:訂單日期。貨品/供應商/採購單表:依下方查詢與預計送貨扇形。採購單行明細:匯出當前列表中每張採購單之全部行。", + }, + ]; + + const estimatedDonutRows = EST_BUCKETS.map((b, i) => ({ + 類別: bucketLabelZh(b), + bucket代碼: b, + 數量: estimatedChartSeries[i] ?? 0, + })); + + const actualStatusDonutRows = chartData.map((p) => ({ + 狀態中文: poStatusLabelZh(p.status), + status代碼: p.status, + 數量: p.count, + })); + + const itemSummaryRows = itemsSummary.map((i) => ({ + 貨品: i.itemCode, + 名稱: i.itemName, + 訂購數量: i.orderedQty, + 已收貨: i.receivedQty, + 待收貨: i.pendingQty, + UOM: i.uom, + })); + + const supplierDistributionRows = supplierChartData.map((s) => ({ + 供應商: s.supplier, + 供應商編號: s.supplierCode, + supplierId: s.supplierId ?? "", + 採購單數: s.count, + 總數量: s.totalQty, + })); + + const purchaseOrderListRows = poDetails.map((p) => ({ + 採購單號: p.purchaseOrderNo, + 狀態: poStatusLabelZh(p.status), + status代碼: p.status, + 訂單日期: p.orderDate, + 預計到貨日: p.estimatedArrivalDate, + 供應商編號: p.supplierCode, + 供應商名稱: p.supplierName, + supplierId: p.supplierId ?? "", + 項目數: p.itemCount, + 總數量: p.totalQty, + })); + + const purchaseOrderLineRows: Record[] = []; + if (poDetails.length > 0) { + const lineBatches = await Promise.all( + poDetails.map((po) => + fetchPurchaseOrderItems(po.purchaseOrderId).then((lines) => + lines.map((line) => ({ + 採購單號: po.purchaseOrderNo, + 採購單ID: po.purchaseOrderId, + 狀態: poStatusLabelZh(po.status), + 訂單日期: po.orderDate, + 預計到貨日: po.estimatedArrivalDate, + 供應商編號: po.supplierCode, + 供應商名稱: po.supplierName, + 貨品: line.itemCode, + 品名: line.itemName, + UOM: line.uom, + 訂購數量: line.orderedQty, + 已收貨: line.receivedQty, + 待收貨: line.pendingQty, + })) + ) + ) + ); + lineBatches.flat().forEach((row) => purchaseOrderLineRows.push(row)); + } + + exportPurchaseChartMasterToFile( + { + exportedAtIso, + metaRows, + estimatedDonutRows, + actualStatusDonutRows, + itemSummaryRows, + supplierDistributionRows, + purchaseOrderListRows, + purchaseOrderLineRows, + }, + `採購圖表總表_${poTargetDate}_${dayjs().format("HHmmss")}` + ); + } catch (err) { + setError(err instanceof Error ? err.message : "總表匯出失敗"); + } finally { + setMasterExportLoading(false); + } + }, [ + poTargetDate, + filterOptions.suppliers, + filterOptions.items, + filterSupplierIds, + filterItemCodes, + filterPoNos, + selectedEstimatedBucket, + selectedStatus, + effectiveStatus, + drillQueryOpts, + estimatedChartSeries, + chartData, + itemsSummary, + supplierChartData, + poDetails, + selectedSupplierId, + selectedSupplierCode, + selectedItemCode, + ]); + + const itemChartKey = `${effectiveStatus}|${poTargetDate}|${DRILL_DATE_FILTER}|${JSON.stringify(drillQueryOpts)}|ea:${selectedEstimatedBucket ?? ""}|s`; + const supplierChartKey = `${itemChartKey}|${selectedItemCode ?? ""}|sup`; + const poChartKey = `${supplierChartKey}|po`; + + /** 下方三張圖:僅選預計送貨扇形時,資料依 bucket 不再套用「實際已送貨」單一狀態。 */ + const lowerChartsTitlePrefix = React.useMemo(() => { + if (selectedEstimatedBucket) { + return `預計送貨「${bucketLabelZh(selectedEstimatedBucket)}」`; + } + return `實際已送貨 ${poStatusLabelZh(effectiveStatus)}`; + }, [selectedEstimatedBucket, effectiveStatus]); + + const lowerChartsDefaultStatusHint = React.useMemo(() => { + if (selectedEstimatedBucket) return ""; + if (selectedStatus) return ""; + return `(預設狀態:${poStatusLabelZh(DEFAULT_DRILL_STATUS)})`; + }, [selectedEstimatedBucket, selectedStatus]); + + const filterHint = [ + selectedItemCode ? `貨品: ${selectedItemCode}` : null, + selectedSupplierId != null && selectedSupplierId > 0 + ? `供應商 id: ${selectedSupplierId}` + : selectedSupplierCode + ? `供應商 code: ${selectedSupplierCode}` + : null, + ] + .filter(Boolean) + .join(" · "); + + const hasBarFilters = filterSupplierIds.length > 0 || filterItemCodes.length > 0 || filterPoNos.length > 0; + const hasChartDrill = + selectedStatus != null || + selectedItemCode || + selectedSupplierId != null || + selectedSupplierCode || + selectedEstimatedBucket || + selectedPo; return ( @@ -37,38 +615,517 @@ export default function PurchaseChartPage() { )} + + + 上方「預計送貨」與「實際已送貨」依訂單日期與篩選條件;點擊圓環可篩選下方圖表(與其他條件交集)。 + + + {(hasBarFilters || hasChartDrill) && ( + + )} + + + + setPoTargetDate(e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ minWidth: 160 }} + /> + `${o.code} ${o.name}`.trim() || String(o.supplierId)} + value={filterOptions.suppliers.filter((s) => filterSupplierIds.includes(s.supplierId))} + onChange={(_, v) => setFilterSupplierIds(v.map((x) => x.supplierId))} + renderTags={(tagValue, getTagProps) => + tagValue.map((option, index) => ( + + )) + } + renderInput={(params) => } + /> + `${o.itemCode} ${o.itemName}`.trim()} + value={filterOptions.items.filter((i) => filterItemCodes.includes(i.itemCode))} + onChange={(_, v) => setFilterItemCodes(v.map((x) => x.itemCode))} + renderTags={(tagValue, getTagProps) => + tagValue.map((option, index) => ( + + )) + } + renderInput={(params) => } + /> + o.poNo} + value={filterOptions.poNos.filter((p) => filterPoNos.includes(p.poNo))} + onChange={(_, v) => setFilterPoNos(v.map((x) => x.poNo))} + renderTags={(tagValue, getTagProps) => + tagValue.map((option, index) => ( + + )) + } + renderInput={(params) => } + /> + + + + + ({ + 類別: bucketLabelZh(b), + 數量: estimatedChartSeries[i] ?? 0, + }))} + > + {estimatedLoading ? ( + + ) : ( + { + const idx = config?.dataPointIndex ?? -1; + if (idx < 0 || idx >= EST_BUCKETS.length) return; + deferChartClick(() => handleEstimatedBucketClick(idx)); + }, + }, + animations: { enabled: false }, + }, + labels: EST_BUCKETS.map(bucketLabelZh), + colors: ESTIMATE_DONUT_COLORS, + legend: { position: "bottom" }, + plotOptions: { + pie: { + donut: { + labels: { + show: true, + total: { + show: true, + label: "預計送貨", + }, + }, + }, + }, + }, + }} + series={estimatedChartSeries} + type="donut" + width="100%" + height={320} + /> + )} + + + + ({ 狀態: poStatusLabelZh(p.status), 數量: p.count }))} + > + {loading ? ( + + ) : ( + { + const idx = config?.dataPointIndex ?? -1; + if (idx < 0 || idx >= chartData.length) return; + const row = chartData[idx]; + if (!row?.status) return; + const status = row.status; + deferChartClick(() => handleStatusClick(status)); + }, + }, + animations: { enabled: false }, + }, + labels: chartData.map((p) => poStatusLabelZh(p.status)), + colors: chartData.map((_, i) => STATUS_DONUT_COLORS[i % STATUS_DONUT_COLORS.length]), + legend: { position: "bottom" }, + }} + series={chartData.map((p) => p.count)} + type="donut" + width="100%" + height={320} + /> + )} + + + + + {selectedEstimatedBucket && ( + + + 「{bucketLabelZh(selectedEstimatedBucket)}」關聯對象 + + + 條件與左側「預計送貨」圓環一致:預計到貨日 = 訂單日期({poTargetDate}),並含上方供應商/貨品/採購單號多選。 + + {eaBreakdownLoading ? ( + + + + ) : eaBreakdown ? ( + + + + 供應商 + + + + + + 編號 + 名稱 + 採購單數 + + + + {eaBreakdown.suppliers.length === 0 ? ( + + + + 無 + + + + ) : ( + eaBreakdown.suppliers.map((s, i) => ( + + {s.supplierCode} + {s.supplierName} + {s.poCount} + + )) + )} + +
+
+
+ + + 貨品 + + + + + + 貨品編號 + 名稱 + 採購單數 + 總數量 + + + + {eaBreakdown.items.length === 0 ? ( + + + + 無 + + + + ) : ( + eaBreakdown.items.map((it, i) => ( + + {it.itemCode} + {it.itemName} + {it.poCount} + {it.totalQty} + + )) + )} + +
+
+
+ + + 採購單 + + + + + + 採購單號 + 供應商 + 狀態 + 訂單日期 + + + + {eaBreakdown.purchaseOrders.length === 0 ? ( + + + + 無 + + + + ) : ( + eaBreakdown.purchaseOrders.map((po) => ( + + {po.purchaseOrderNo} + {`${po.supplierCode} ${po.supplierName}`.trim()} + {poStatusLabelZh(po.status)} + {po.orderDate} + + )) + )} + +
+
+
+
+ ) : null} +
+ )} + + {(selectedEstimatedBucket || selectedStatus != null) && ( + + {selectedEstimatedBucket && ( + + 下方圖表已依「預計送貨」篩選:{bucketLabelZh(selectedEstimatedBucket)} + (預計到貨日 = 訂單日期,並含多選;此時不再套用右側「實際已送貨」狀態;再點同一扇形可取消)。 + + )} + {selectedStatus != null && ( + + 下方圖表已依「實際已送貨」所選狀態:{poStatusLabelZh(selectedStatus)}(再點同一狀態可取消)。 + + )} + + )} + ({ 狀態: p.status, 數量: p.count }))} - filters={ - setPoTargetDate(e.target.value)} - InputLabelProps={{ shrink: true }} - sx={{ minWidth: 160 }} + title={`${lowerChartsTitlePrefix} 的貨品摘要(code / 名稱)${lowerChartsDefaultStatusHint}${ + selectedItemCode ? ` — 已選貨品:${selectedItemCode}` : "" + }`} + exportFilename={`採購單_貨品摘要_${selectedEstimatedBucket ?? effectiveStatus}`} + exportData={itemsSummary.map((i) => ({ + 貨品: i.itemCode, + 名稱: i.itemName, + 訂購數量: i.orderedQty, + 已收貨: i.receivedQty, + UOM: i.uom, + }))} + > + {drillQueryOpts === null ? ( + 無符合交集的篩選(請調整上方條件或圖表點選) + ) : itemsSummaryLoading ? ( + + + + ) : itemsSummary.length === 0 ? ( + + 無資料(請確認訂單日期{selectedEstimatedBucket ? "與篩選" : "與狀態"}) + + ) : ( + { + const idx = config?.dataPointIndex ?? -1; + if (idx < 0 || idx >= itemsSummary.length) return; + deferChartClick(() => handleItemSummaryClick(idx)); + }, + }, + animations: { enabled: false }, + }, + labels: itemsSummary.map((i) => `${i.itemCode} ${i.itemName}`.trim()), + legend: { position: "bottom" }, + plotOptions: { + pie: { + donut: { + labels: { + show: true, + total: { + show: true, + label: "貨品", + }, + }, + }, + }, + }, + }} + series={itemsSummary.map((i) => i.orderedQty)} + type="donut" + width="100%" + height={380} /> - } + )} + + + ({ + 供應商: s.supplier, + 採購單數: s.count, + 總數量: s.totalQty, + }))} > - {loading ? ( - + {drillQueryOpts === null ? ( + 無符合交集的篩選 + ) : poDetailsLoading ? ( + + + + ) : supplierChartData.length === 0 ? ( + 無供應商資料(請先確認上方貨品篩選或日期) ) : ( p.status), + chart: { + type: "donut", + events: { + dataPointSelection: (_event: unknown, _chartContext: unknown, config: { dataPointIndex?: number }) => { + const idx = config?.dataPointIndex ?? -1; + if (idx < 0 || idx >= supplierChartData.length) return; + const row = supplierChartData[idx]; + if (!row.supplierId && !row.supplierCode?.trim()) return; + deferChartClick(() => handleSupplierClick(row)); + }, + }, + animations: { enabled: false }, + }, + labels: supplierChartData.map((s) => s.supplier), legend: { position: "bottom" }, }} - series={chartData.map((p) => p.count)} + series={supplierChartData.map((s) => s.totalQty)} type="donut" width="100%" - height={320} + height={360} + /> + )} + + + ({ + 採購單號: p.purchaseOrderNo, + 供應商: `${p.supplierCode} ${p.supplierName}`.trim(), + 總數量: p.totalQty, + 項目數: p.itemCount, + 訂單日期: p.orderDate, + }))} + > + {drillQueryOpts === null ? ( + 無符合交集的篩選 + ) : poDetailsLoading ? ( + + + + ) : poDetails.length === 0 ? ( + + 無採購單。請確認該「訂單日期」是否有此狀態的採購單。 + + ) : ( + { + const idx = config?.dataPointIndex ?? -1; + if (idx < 0 || idx >= poDetails.length) return; + const po = poDetails[idx]; + deferChartClick(() => void handlePoClick(po)); + }, + }, + animations: { enabled: false }, + }, + xaxis: { categories: poDetails.map((p) => p.purchaseOrderNo) }, + dataLabels: { enabled: false }, + }} + series={[ + { + name: "總數量", + data: poDetails.map((p) => p.totalQty), + }, + ]} + type="bar" + width="100%" + height={360} /> )} + + {selectedPo && ( + ({ + 貨品: i.itemCode, + 名稱: i.itemName, + 訂購數量: i.orderedQty, + 已收貨: i.receivedQty, + 待收貨: i.pendingQty, + UOM: i.uom, + }))} + > + {poLineItemsLoading ? ( + + + + ) : ( + `${i.itemCode} ${i.itemName}`.trim()) }, + plotOptions: { bar: { horizontal: true } }, + dataLabels: { enabled: false }, + }} + series={[ + { name: "訂購數量", data: poLineItems.map((i) => i.orderedQty) }, + { name: "待收貨", data: poLineItems.map((i) => i.pendingQty) }, + ]} + type="bar" + width="100%" + height={Math.max(320, poLineItems.length * 38)} + /> + )} + + )}
); } diff --git a/src/app/api/chart/client.ts b/src/app/api/chart/client.ts index 89bb878..ca0c245 100644 --- a/src/app/api/chart/client.ts +++ b/src/app/api/chart/client.ts @@ -29,6 +29,81 @@ export interface PurchaseOrderByStatusRow { count: number; } +/** Multi-select filters for purchase charts (repeated `supplierId` / `itemCode` / `purchaseOrderNo` query params). */ +export type PurchaseOrderChartFilters = { + supplierIds?: number[]; + itemCodes?: string[]; + purchaseOrderNos?: string[]; + /** Single supplier code (drill when row has no supplier id); not used with `supplierIds`. */ + supplierCode?: string; +}; + +function appendPurchaseOrderListParams(p: URLSearchParams, filters?: PurchaseOrderChartFilters) { + (filters?.supplierIds ?? []).forEach((id) => { + if (Number.isFinite(id) && id > 0) p.append("supplierId", String(id)); + }); + (filters?.itemCodes ?? []).forEach((c) => { + const t = String(c).trim(); + if (t) p.append("itemCode", t); + }); + (filters?.purchaseOrderNos ?? []).forEach((n) => { + const t = String(n).trim(); + if (t) p.append("purchaseOrderNo", t); + }); + const sc = filters?.supplierCode?.trim(); + if (sc) p.set("supplierCode", sc); +} + +export interface PoFilterSupplierOption { + supplierId: number; + code: string; + name: string; +} + +export interface PoFilterItemOption { + itemCode: string; + itemName: string; +} + +export interface PoFilterPoNoOption { + poNo: string; +} + +export interface PurchaseOrderFilterOptions { + suppliers: PoFilterSupplierOption[]; + items: PoFilterItemOption[]; + poNos: PoFilterPoNoOption[]; +} + +export interface PurchaseOrderEstimatedArrivalRow { + bucket: string; + count: number; +} + +export interface PurchaseOrderDetailByStatusRow { + purchaseOrderId: number; + purchaseOrderNo: string; + status: string; + orderDate: string; + estimatedArrivalDate: string; + /** Shop / supplier FK; use for grouping when code is blank */ + supplierId: number | null; + supplierCode: string; + supplierName: string; + itemCount: number; + totalQty: number; +} + +export interface PurchaseOrderItemRow { + purchaseOrderLineId: number; + itemCode: string; + itemName: string; + orderedQty: number; + uom: string; + receivedQty: number; + pendingQty: number; +} + export interface StockInOutByDateRow { date: string; inQty: number; @@ -317,11 +392,13 @@ export async function fetchDeliveryOrderByDate( } export async function fetchPurchaseOrderByStatus( - targetDate?: string + targetDate?: string, + filters?: PurchaseOrderChartFilters ): Promise { - const q = targetDate - ? buildParams({ targetDate }) - : ""; + const p = new URLSearchParams(); + if (targetDate) p.set("targetDate", targetDate); + appendPurchaseOrderListParams(p, filters); + const q = p.toString(); const res = await clientAuthFetch( q ? `${BASE}/purchase-order-by-status?${q}` : `${BASE}/purchase-order-by-status` ); @@ -333,6 +410,229 @@ export async function fetchPurchaseOrderByStatus( })); } +export async function fetchPurchaseOrderFilterOptions( + targetDate?: string +): Promise { + const p = new URLSearchParams(); + if (targetDate) p.set("targetDate", targetDate); + const q = p.toString(); + const res = await clientAuthFetch( + q ? `${BASE}/purchase-order-filter-options?${q}` : `${BASE}/purchase-order-filter-options` + ); + if (!res.ok) throw new Error("Failed to fetch purchase order filter options"); + const data = await res.json(); + const row = (data ?? {}) as Record; + const suppliers = (Array.isArray(row.suppliers) ? row.suppliers : []) as Record[]; + const items = (Array.isArray(row.items) ? row.items : []) as Record[]; + const poNos = (Array.isArray(row.poNos) ? row.poNos : []) as Record[]; + return { + suppliers: suppliers.map((r) => ({ + supplierId: Number(r.supplierId ?? r.supplierid ?? 0), + code: String(r.code ?? ""), + name: String(r.name ?? ""), + })), + items: items.map((r) => ({ + itemCode: String(r.itemCode ?? r.itemcode ?? ""), + itemName: String(r.itemName ?? r.itemname ?? ""), + })), + poNos: poNos.map((r) => ({ + poNo: String(r.poNo ?? r.pono ?? ""), + })), + }; +} + +export async function fetchPurchaseOrderEstimatedArrivalSummary( + targetDate?: string, + filters?: PurchaseOrderChartFilters +): Promise { + const p = new URLSearchParams(); + if (targetDate) p.set("targetDate", targetDate); + appendPurchaseOrderListParams(p, filters); + const q = p.toString(); + const res = await clientAuthFetch( + q + ? `${BASE}/purchase-order-estimated-arrival-summary?${q}` + : `${BASE}/purchase-order-estimated-arrival-summary` + ); + if (!res.ok) throw new Error("Failed to fetch estimated arrival summary"); + const data = await res.json(); + return ((Array.isArray(data) ? data : []) as Record[]).map((r: Record) => ({ + bucket: String(r.bucket ?? ""), + count: Number(r.count ?? 0), + })); +} + +export interface EstimatedArrivalBreakdownSupplierRow { + supplierId: number | null; + supplierCode: string; + supplierName: string; + poCount: number; +} + +export interface EstimatedArrivalBreakdownItemRow { + itemCode: string; + itemName: string; + poCount: number; + totalQty: number; +} + +export interface EstimatedArrivalBreakdownPoRow { + purchaseOrderId: number; + purchaseOrderNo: string; + status: string; + orderDate: string; + supplierId: number | null; + supplierCode: string; + supplierName: string; +} + +export interface PurchaseOrderEstimatedArrivalBreakdown { + suppliers: EstimatedArrivalBreakdownSupplierRow[]; + items: EstimatedArrivalBreakdownItemRow[]; + purchaseOrders: EstimatedArrivalBreakdownPoRow[]; +} + +/** Related suppliers / items / POs for one 預計送貨 bucket (same bar filters as the donut). */ +export async function fetchPurchaseOrderEstimatedArrivalBreakdown( + targetDate: string, + estimatedArrivalBucket: string, + filters?: PurchaseOrderChartFilters +): Promise { + const p = new URLSearchParams(); + p.set("targetDate", targetDate); + p.set("estimatedArrivalBucket", estimatedArrivalBucket.trim().toLowerCase()); + appendPurchaseOrderListParams(p, filters); + const res = await clientAuthFetch(`${BASE}/purchase-order-estimated-arrival-breakdown?${p.toString()}`); + if (!res.ok) throw new Error("Failed to fetch estimated arrival breakdown"); + const data = await res.json(); + const row = (data ?? {}) as Record; + const suppliers = (Array.isArray(row.suppliers) ? row.suppliers : []) as Record[]; + const items = (Array.isArray(row.items) ? row.items : []) as Record[]; + const purchaseOrders = (Array.isArray(row.purchaseOrders) ? row.purchaseOrders : []) as Record[]; + return { + suppliers: suppliers.map((r) => ({ + supplierId: (() => { + const v = r.supplierId ?? r.supplierid; + if (v == null || v === "") return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; + })(), + supplierCode: String(r.supplierCode ?? r.suppliercode ?? ""), + supplierName: String(r.supplierName ?? r.suppliername ?? ""), + poCount: Number(r.poCount ?? r.pocount ?? 0), + })), + items: items.map((r) => ({ + itemCode: String(r.itemCode ?? r.itemcode ?? ""), + itemName: String(r.itemName ?? r.itemname ?? ""), + poCount: Number(r.poCount ?? r.pocount ?? 0), + totalQty: Number(r.totalQty ?? r.totalqty ?? 0), + })), + purchaseOrders: purchaseOrders.map((r) => ({ + purchaseOrderId: Number(r.purchaseOrderId ?? r.purchaseorderid ?? 0), + purchaseOrderNo: String(r.purchaseOrderNo ?? r.purchaseorderno ?? ""), + status: String(r.status ?? ""), + orderDate: String(r.orderDate ?? r.orderdate ?? ""), + supplierId: (() => { + const v = r.supplierId ?? r.supplierid; + if (v == null || v === "") return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; + })(), + supplierCode: String(r.supplierCode ?? r.suppliercode ?? ""), + supplierName: String(r.supplierName ?? r.suppliername ?? ""), + })), + }; +} + +export type PurchaseOrderDrillQuery = PurchaseOrderChartFilters & { + /** order = PO order date; complete = PO complete date (for received/completed on a day) */ + dateFilter?: "order" | "complete"; + /** delivered | not_delivered | cancelled | other — same as 預計送貨 donut buckets */ + estimatedArrivalBucket?: string; +}; + +export async function fetchPurchaseOrderDetailsByStatus( + status: string, + targetDate?: string, + opts?: PurchaseOrderDrillQuery +): Promise { + const p = new URLSearchParams(); + p.set("status", status.trim().toLowerCase()); + if (targetDate) p.set("targetDate", targetDate); + if (opts?.dateFilter) p.set("dateFilter", opts.dateFilter); + if (opts?.estimatedArrivalBucket?.trim()) { + p.set("estimatedArrivalBucket", opts.estimatedArrivalBucket.trim().toLowerCase()); + } + appendPurchaseOrderListParams(p, opts); + const q = p.toString(); + const res = await clientAuthFetch(`${BASE}/purchase-order-details-by-status?${q}`); + if (!res.ok) throw new Error("Failed to fetch purchase order details by status"); + const data = await res.json(); + return ((Array.isArray(data) ? data : []) as Record[]).map((r: Record) => ({ + purchaseOrderId: Number(r.purchaseOrderId ?? 0), + purchaseOrderNo: String(r.purchaseOrderNo ?? ""), + status: String(r.status ?? ""), + orderDate: String(r.orderDate ?? ""), + estimatedArrivalDate: String(r.estimatedArrivalDate ?? ""), + supplierId: (() => { + const v = r.supplierId; + if (v == null || v === "") return null; + const n = Number(v); + return Number.isFinite(n) && n > 0 ? n : null; + })(), + supplierCode: String(r.supplierCode ?? ""), + supplierName: String(r.supplierName ?? ""), + itemCount: Number(r.itemCount ?? 0), + totalQty: Number(r.totalQty ?? 0), + })); +} + +export async function fetchPurchaseOrderItems( + purchaseOrderId: number +): Promise { + const q = buildParams({ purchaseOrderId }); + const res = await clientAuthFetch(`${BASE}/purchase-order-items?${q}`); + if (!res.ok) throw new Error("Failed to fetch purchase order items"); + const data = await res.json(); + return ((Array.isArray(data) ? data : []) as Record[]).map((r: Record) => ({ + purchaseOrderLineId: Number(r.purchaseOrderLineId ?? 0), + itemCode: String(r.itemCode ?? ""), + itemName: String(r.itemName ?? ""), + orderedQty: Number(r.orderedQty ?? 0), + uom: String(r.uom ?? ""), + receivedQty: Number(r.receivedQty ?? 0), + pendingQty: Number(r.pendingQty ?? 0), + })); +} + +export async function fetchPurchaseOrderItemsByStatus( + status: string, + targetDate?: string, + opts?: PurchaseOrderDrillQuery +): Promise { + const p = new URLSearchParams(); + p.set("status", status.trim().toLowerCase()); + if (targetDate) p.set("targetDate", targetDate); + if (opts?.dateFilter) p.set("dateFilter", opts.dateFilter); + if (opts?.estimatedArrivalBucket?.trim()) { + p.set("estimatedArrivalBucket", opts.estimatedArrivalBucket.trim().toLowerCase()); + } + appendPurchaseOrderListParams(p, opts); + const q = p.toString(); + const res = await clientAuthFetch(`${BASE}/purchase-order-items-by-status?${q}`); + if (!res.ok) throw new Error("Failed to fetch purchase order items by status"); + const data = await res.json(); + return ((Array.isArray(data) ? data : []) as Record[]).map((r: Record) => ({ + purchaseOrderLineId: 0, + itemCode: String(r.itemCode ?? ""), + itemName: String(r.itemName ?? ""), + orderedQty: Number(r.orderedQty ?? 0), + uom: String(r.uom ?? ""), + receivedQty: Number(r.receivedQty ?? 0), + pendingQty: Number(r.pendingQty ?? 0), + })); +} + export async function fetchStockInOutByDate( startDate?: string, endDate?: string From 159cfbbc449b5b6807b107ac982080288b966401 Mon Sep 17 00:00:00 2001 From: "B.E.N.S.O.N" Date: Tue, 24 Mar 2026 15:08:09 +0800 Subject: [PATCH 02/12] FGStockOutTraceabilityReport Excel Version --- src/app/(main)/report/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index 6fd7854..992df05 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -504,7 +504,7 @@ export default function ReportPage() { setLoading={setLoading} reportTitle={currentReport.title} /> - ) : currentReport.id === 'rep-013' ? ( + ) : currentReport.id === 'rep-013' || currentReport.id === 'rep-009' ? ( <> )} diff --git a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx index 861d152..e7e7588 100644 --- a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx +++ b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx @@ -195,6 +195,14 @@ const ApproverStockTakeAll: React.FC = ({ calculateDifference, ]); + const sortedDetails = useMemo(() => { + return [...filteredDetails].sort((a, b) => { + const sectionA = (a.stockTakeSection || "").trim(); + const sectionB = (b.stockTakeSection || "").trim(); + return sectionA.localeCompare(sectionB, undefined, { numeric: true, sensitivity: "base" }); + }); + }, [filteredDetails]); + const handleSaveApproverStockTake = useCallback( async (detail: InventoryLotDetailResponse) => { if (mode === "approved") return; @@ -493,22 +501,24 @@ const ApproverStockTakeAll: React.FC = ({ {t("Stock Take Qty(include Bad Qty)= Available Qty")} + {t("Remark")} {t("Record Status")} + {t("Picker")} {t("Action")} - {filteredDetails.length === 0 ? ( + {sortedDetails.length === 0 ? ( - + {t("No data")} ) : ( - filteredDetails.map((detail) => { + sortedDetails.map((detail) => { const hasFirst = detail.firstStockTakeQty != null && detail.firstStockTakeQty >= 0; const hasSecond = @@ -519,8 +529,11 @@ const ApproverStockTakeAll: React.FC = ({ return ( - {detail.warehouseArea || "-"} - {detail.warehouseSlot || "-"} + + {detail.stockTakeSection || "-"} {detail.stockTakeSectionDescription || "-"} + + {detail.warehouseCode || "-"} + = ({ /> )} + {detail.stockTakerName || "-"} {mode === "pending" && detail.stockTakeRecordId && detail.stockTakeRecordStatus !== "notMatch" && ( diff --git a/src/i18n/zh/pickOrder.json b/src/i18n/zh/pickOrder.json index f6c307e..8325296 100644 --- a/src/i18n/zh/pickOrder.json +++ b/src/i18n/zh/pickOrder.json @@ -314,6 +314,7 @@ "QR code does not match any item in current orders.":"QR 碼不符合當前訂單中的任何貨品。", "Lot Number Mismatch":"批次號碼不符", "The scanned item matches the expected item, but the lot number is different. Do you want to proceed with this different lot?":"掃描的貨品與預期的貨品相同,但批次號碼不同。您是否要繼續使用不同的批次?", + "The scanned item matches the expected item, but the lot number is different. Scan again to confirm: scan the expected lot QR to keep the suggested lot, or scan the other lot QR again to switch.":"掃描貨品相同但批次不同。請再掃描一次以確認:掃描「建議批次」的 QR 可沿用該批次;再掃描「另一批次」的 QR 則切換為該批次。", "Expected Lot:":"預期批次:", "Scanned Lot:":"掃描批次:", "Confirm":"確認", @@ -324,6 +325,8 @@ "Print DN Label":"列印送貨單標籤", "Print All Draft" : "列印全部草稿", "If you confirm, the system will:":"如果您確認,系統將:", + "After you scan to choose, the system will update the pick line to the lot you confirmed.":"確認後,系統會將您選擇的批次套用到對應提料行。", + "Or use the Confirm button below if you cannot scan again (same as scanning the other lot again).":"若無法再掃描,可按下「確認」以切換為剛才掃描到的批次(與再掃一次該批次 QR 相同)。", "QR code verified.":"QR 碼驗證成功。", "Order Finished":"訂單完成", "Submitted Status":"提交狀態", From 7dc1fbf323af351ab5524e455bae11b083b74777 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 25 Mar 2026 11:35:19 +0800 Subject: [PATCH 05/12] update --- src/app/api/bom/client.ts | 42 +- .../ImportBom/ImportBomDetailTab.tsx | 387 +++++++++++++----- .../PickOrderSearch/AssignAndRelease.tsx | 3 +- .../ApproverStockTakeAll.tsx | 27 +- 4 files changed, 329 insertions(+), 130 deletions(-) diff --git a/src/app/api/bom/client.ts b/src/app/api/bom/client.ts index 2d17ffc..722030b 100644 --- a/src/app/api/bom/client.ts +++ b/src/app/api/bom/client.ts @@ -115,4 +115,44 @@ export async function fetchBomComboClient(): Promise { { params: { batchId } } ); return response.data; - } \ No newline at end of file + } + +/** Master `equipment` rows for BOM process editor (description/name → code). */ +export type EquipmentMasterRow = { + code: string; + name: string; + description: string; +}; + +/** Master `process` rows for BOM process editor (dropdown by code). */ +export type ProcessMasterRow = { + code: string; + name: string; +}; + +export async function fetchAllEquipmentsMasterClient(): Promise< + EquipmentMasterRow[] +> { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/Equipment`, + ); + const rows = Array.isArray(response.data) ? response.data : []; + return rows.map((r: any) => ({ + code: String(r?.code ?? "").trim(), + name: String(r?.name ?? "").trim(), + description: String(r?.description ?? "").trim(), + })); +} + +export async function fetchAllProcessesMasterClient(): Promise< + ProcessMasterRow[] +> { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/Process`, + ); + const rows = Array.isArray(response.data) ? response.data : []; + return rows.map((r: any) => ({ + code: String(r?.code ?? "").trim(), + name: String(r?.name ?? "").trim(), + })); +} \ No newline at end of file diff --git a/src/components/ImportBom/ImportBomDetailTab.tsx b/src/components/ImportBom/ImportBomDetailTab.tsx index f4754ac..227a980 100644 --- a/src/components/ImportBom/ImportBomDetailTab.tsx +++ b/src/components/ImportBom/ImportBomDetailTab.tsx @@ -27,6 +27,10 @@ import { editBomClient, fetchBomComboClient, fetchBomDetailClient, + fetchAllEquipmentsMasterClient, + fetchAllProcessesMasterClient, + type EquipmentMasterRow, + type ProcessMasterRow, } from "@/app/api/bom/client"; import type { SelectChangeEvent } from "@mui/material/Select"; import { useTranslation } from "react-i18next"; @@ -37,6 +41,26 @@ import SaveIcon from "@mui/icons-material/Save"; import CancelIcon from "@mui/icons-material/Cancel"; import DeleteIcon from "@mui/icons-material/Delete"; import EditIcon from "@mui/icons-material/Edit"; + +/** 以 description + "-" + name 對應 code,或同一筆設備的 description+name。 */ +function resolveEquipmentCode( + list: EquipmentMasterRow[], + description: string, + name: string, +): string | null { + const d = description.trim(); + const n = name.trim(); + if (!d && !n) return null; + if (!d || !n) return null; + const composite = `${d}-${n}`; + const byCode = list.find((e) => e.code === composite); + if (byCode) return byCode.code; + const byPair = list.find( + (e) => e.description === d && e.name === n, + ); + return byPair?.code ?? null; +} + const ImportBomDetailTab: React.FC = () => { const { t } = useTranslation( "common" ); const [bomList, setBomList] = useState([]); @@ -69,7 +93,9 @@ const ImportBomDetailTab: React.FC = () => { processCode?: string; processName?: string; description: string; - equipmentCode?: string; + /** 設備主檔 description(下拉),與 equipmentName 一併解析為 equipment.code */ + equipmentDescription: string; + equipmentName: string; durationInMinute: number; prepTimeInMinute: number; postProdTimeInMinute: number; @@ -96,17 +122,27 @@ const ImportBomDetailTab: React.FC = () => { const [editMaterials, setEditMaterials] = useState([]); const [editProcesses, setEditProcesses] = useState([]); - // Process add form (uses dropdown selections). + const [equipmentMasterList, setEquipmentMasterList] = useState< + EquipmentMasterRow[] + >([]); + const [processMasterList, setProcessMasterList] = useState< + ProcessMasterRow[] + >([]); + const [editMasterLoading, setEditMasterLoading] = useState(false); + + // Process add form (uses dropdown selections from master tables). const [processAddForm, setProcessAddForm] = useState<{ processCode: string; - equipmentCode: string; + equipmentDescription: string; + equipmentName: string; description: string; durationInMinute: number; prepTimeInMinute: number; postProdTimeInMinute: number; }>({ processCode: "", - equipmentCode: "", + equipmentDescription: "", + equipmentName: "", description: "", durationInMinute: 0, prepTimeInMinute: 0, @@ -115,19 +151,27 @@ const ImportBomDetailTab: React.FC = () => { const processCodeOptions = useMemo(() => { const codes = new Set(); - (detail?.processes ?? []).forEach((p) => { - if (p.processCode) codes.add(p.processCode); + processMasterList.forEach((p) => { + if (p.code) codes.add(p.code); }); - return Array.from(codes); - }, [detail]); + return Array.from(codes).sort(); + }, [processMasterList]); - const equipmentCodeOptions = useMemo(() => { - const codes = new Set(); - (detail?.processes ?? []).forEach((p) => { - if (p.equipmentCode) codes.add(p.equipmentCode); + const equipmentDescriptionOptions = useMemo(() => { + const s = new Set(); + equipmentMasterList.forEach((e) => { + if (e.description) s.add(e.description); }); - return Array.from(codes); - }, [detail]); + return Array.from(s).sort(); + }, [equipmentMasterList]); + + const equipmentNameOptions = useMemo(() => { + const s = new Set(); + equipmentMasterList.forEach((e) => { + if (e.name) s.add(e.name); + }); + return Array.from(s).sort(); + }, [equipmentMasterList]); useEffect(() => { const loadList = async () => { @@ -242,57 +286,82 @@ const ImportBomDetailTab: React.FC = () => { const genKey = () => Math.random().toString(36).slice(2); - const startEdit = useCallback(() => { + const startEdit = useCallback(async () => { if (!detail) return; setEditError(null); - setEditBasic({ - description: detail.description ?? "", - outputQty: detail.outputQty ?? 0, - outputQtyUom: detail.outputQtyUom ?? "", + setEditMasterLoading(true); + try { + const [equipments, processes] = await Promise.all([ + fetchAllEquipmentsMasterClient(), + fetchAllProcessesMasterClient(), + ]); + setEquipmentMasterList(equipments); + setProcessMasterList(processes); - isDark: detail.isDark ?? 0, - isFloat: detail.isFloat ?? 0, - isDense: detail.isDense ?? 0, - scrapRate: detail.scrapRate ?? 0, - allergicSubstances: detail.allergicSubstances ?? 0, - timeSequence: detail.timeSequence ?? 0, - complexity: detail.complexity ?? 0, - isDrink: detail.isDrink ?? false, - }); + setEditBasic({ + description: detail.description ?? "", + outputQty: detail.outputQty ?? 0, + outputQtyUom: detail.outputQtyUom ?? "", - setEditMaterials( - (detail.materials ?? []).map((m) => ({ - key: genKey(), - id: undefined, - itemCode: m.itemCode ?? "", - itemName: m.itemName ?? "", - qty: m.baseQty ?? 0, - isConsumable: m.isConsumable ?? false, - baseUom: m.baseUom, - stockQty: m.stockQty, - stockUom: m.stockUom, - salesQty: m.salesQty, - salesUom: m.salesUom, - })), - ); + isDark: detail.isDark ?? 0, + isFloat: detail.isFloat ?? 0, + isDense: detail.isDense ?? 0, + scrapRate: detail.scrapRate ?? 0, + allergicSubstances: detail.allergicSubstances ?? 0, + timeSequence: detail.timeSequence ?? 0, + complexity: detail.complexity ?? 0, + isDrink: detail.isDrink ?? false, + }); - setEditProcesses( - (detail.processes ?? []).map((p) => ({ - key: genKey(), - id: undefined, - seqNo: p.seqNo, - processCode: p.processCode ?? "", - processName: p.processName, - description: p.processDescription ?? "", - equipmentCode: p.equipmentCode ?? p.equipmentName ?? "", - durationInMinute: p.durationInMinute ?? 0, - prepTimeInMinute: p.prepTimeInMinute ?? 0, - postProdTimeInMinute: p.postProdTimeInMinute ?? 0, - })), - ); + setEditMaterials( + (detail.materials ?? []).map((m) => ({ + key: genKey(), + id: undefined, + itemCode: m.itemCode ?? "", + itemName: m.itemName ?? "", + qty: m.baseQty ?? 0, + isConsumable: m.isConsumable ?? false, + baseUom: m.baseUom, + stockQty: m.stockQty, + stockUom: m.stockUom, + salesQty: m.salesQty, + salesUom: m.salesUom, + })), + ); + + setEditProcesses( + (detail.processes ?? []).map((p) => { + const code = (p.equipmentCode ?? "").trim(); + const eq = code + ? equipments.find((e) => e.code === code) + : undefined; + return { + key: genKey(), + id: undefined, + seqNo: p.seqNo, + processCode: p.processCode ?? "", + processName: p.processName, + description: p.processDescription ?? "", + equipmentDescription: eq?.description ?? "", + equipmentName: eq?.name ?? "", + durationInMinute: p.durationInMinute ?? 0, + prepTimeInMinute: p.prepTimeInMinute ?? 0, + postProdTimeInMinute: p.postProdTimeInMinute ?? 0, + }; + }), + ); - setIsEditing(true); + setIsEditing(true); + } catch (e: unknown) { + const msg = + e && typeof e === "object" && "message" in e + ? String((e as { message?: string }).message) + : "載入製程/設備主檔失敗"; + setEditError(msg); + } finally { + setEditMasterLoading(false); + } }, [detail]); const cancelEdit = useCallback(() => { @@ -304,12 +373,15 @@ const ImportBomDetailTab: React.FC = () => { setEditProcesses([]); setProcessAddForm({ processCode: "", - equipmentCode: "", + equipmentDescription: "", + equipmentName: "", description: "", durationInMinute: 0, prepTimeInMinute: 0, postProdTimeInMinute: 0, }); + setEquipmentMasterList([]); + setProcessMasterList([]); }, []); const addMaterialRow = useCallback(() => { @@ -339,7 +411,8 @@ const ImportBomDetailTab: React.FC = () => { processCode: "", processName: "", description: "", - equipmentCode: "", + equipmentDescription: "", + equipmentName: "", durationInMinute: 0, prepTimeInMinute: 0, postProdTimeInMinute: 0, @@ -354,6 +427,22 @@ const ImportBomDetailTab: React.FC = () => { return; } + const ed = processAddForm.equipmentDescription.trim(); + const en = processAddForm.equipmentName.trim(); + if ((ed && !en) || (!ed && en)) { + setEditError("設備描述與名稱需同時選取,或同時留空(不適用)"); + return; + } + if (ed && en) { + const resolved = resolveEquipmentCode(equipmentMasterList, ed, en); + if (!resolved) { + setEditError( + `設備組合「${ed}-${en}」在主檔中找不到對應設備代碼,請確認後再試`, + ); + return; + } + } + setEditProcesses((prev) => [ ...prev, { @@ -362,7 +451,8 @@ const ImportBomDetailTab: React.FC = () => { processCode: pCode, processName: "", description: processAddForm.description ?? "", - equipmentCode: processAddForm.equipmentCode.trim(), + equipmentDescription: ed, + equipmentName: en, durationInMinute: processAddForm.durationInMinute ?? 0, prepTimeInMinute: processAddForm.prepTimeInMinute ?? 0, postProdTimeInMinute: processAddForm.postProdTimeInMinute ?? 0, @@ -371,14 +461,15 @@ const ImportBomDetailTab: React.FC = () => { setProcessAddForm({ processCode: "", - equipmentCode: "", + equipmentDescription: "", + equipmentName: "", description: "", durationInMinute: 0, prepTimeInMinute: 0, postProdTimeInMinute: 0, }); setEditError(null); - }, [processAddForm]); + }, [processAddForm, equipmentMasterList]); const deleteMaterialRow = useCallback((key: string) => { setEditMaterials((prev) => prev.filter((r) => r.key !== key)); @@ -398,6 +489,19 @@ const ImportBomDetailTab: React.FC = () => { if (!p.processCode?.trim()) { throw new Error("工序行 Process Code 不能为空"); } + const ed = p.equipmentDescription.trim(); + const en = p.equipmentName.trim(); + if ((ed && !en) || (!ed && en)) { + throw new Error("各製程行的設備描述與名稱需同時填寫或同時留空"); + } + if (ed && en) { + const resolved = resolveEquipmentCode(equipmentMasterList, ed, en); + if (!resolved) { + throw new Error( + `設備「${ed}-${en}」在主檔中無對應設備代碼,請修正後再儲存`, + ); + } + } } const payload: any = { @@ -413,16 +517,24 @@ const ImportBomDetailTab: React.FC = () => { timeSequence: editBasic.timeSequence, complexity: editBasic.complexity, isDrink: editBasic.isDrink, - processes: editProcesses.map((p) => ({ - id: p.id, - seqNo: p.seqNo, - processCode: p.processCode?.trim() || undefined, - equipmentCode: p.equipmentCode?.trim() || undefined, - description: p.description || undefined, - durationInMinute: p.durationInMinute, - prepTimeInMinute: p.prepTimeInMinute, - postProdTimeInMinute: p.postProdTimeInMinute, - })), + processes: editProcesses.map((p) => { + const ed = p.equipmentDescription.trim(); + const en = p.equipmentName.trim(); + const equipmentCode = + ed && en + ? resolveEquipmentCode(equipmentMasterList, ed, en) ?? undefined + : undefined; + return { + id: p.id, + seqNo: p.seqNo, + processCode: p.processCode?.trim() || undefined, + equipmentCode, + description: p.description || undefined, + durationInMinute: p.durationInMinute, + prepTimeInMinute: p.prepTimeInMinute, + postProdTimeInMinute: p.postProdTimeInMinute, + }; + }), }; const updated = await editBomClient(detail.id, payload); @@ -433,7 +545,7 @@ const ImportBomDetailTab: React.FC = () => { } finally { setEditLoading(false); } - }, [detail, editBasic, editProcesses]); + }, [detail, editBasic, editProcesses, equipmentMasterList]); return ( @@ -480,11 +592,18 @@ const ImportBomDetailTab: React.FC = () => { {!isEditing ? ( ) : ( @@ -770,6 +889,9 @@ const ImportBomDetailTab: React.FC = () => { })) } > + + 請選擇 + {processCodeOptions.map((c) => ( {c} @@ -779,19 +901,40 @@ const ImportBomDetailTab: React.FC = () => { - {t("Equipment Code")} + 設備說明 + + + + + 設備名稱 - setEditProcesses((prev) => - prev.map((x) => - x.key === p.key - ? { - ...x, - equipmentCode: String(e.target.value), - } - : x, - ), - ) - } - > - 不適用 - {equipmentCodeOptions.map((c) => ( - - {c} - - ))} - - + + + + + + + + = ({ filterArgs }) => { {/* Target Date - 只在第一个项目显示 */} {index === 0 ? ( - arrayToDayjs(item.targetDate) - .add(-1, "month") + arrayToDayjs(item.targetDate) .format(OUTPUT_DATE_FORMAT) ) : null} diff --git a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx index e7e7588..8b59227 100644 --- a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx +++ b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx @@ -181,6 +181,10 @@ const ApproverStockTakeAll: React.FC = ({ (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0 ? "second" : "first"); + // 避免 Approver 手动输入过程中被 variance 过滤掉,导致“输入后行消失无法提交” + if (selection === "approver") { + return true; + } const difference = calculateDifference(detail, selection); const bookQty = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); @@ -230,26 +234,9 @@ const ApproverStockTakeAll: React.FC = ({ finalQty = detail.secondStockTakeQty; finalBadQty = detail.secondBadQty || 0; } else { - const approverQtyValue = approverQty[detail.id]; - const approverBadQtyValue = approverBadQty[detail.id]; - - if ( - approverQtyValue === undefined || - approverQtyValue === null || - approverQtyValue === "" - ) { - onSnackbar(t("Please enter Approver QTY"), "error"); - return; - } - if ( - approverBadQtyValue === undefined || - approverBadQtyValue === null || - approverBadQtyValue === "" - ) { - onSnackbar(t("Please enter Approver Bad QTY"), "error"); - return; - } - + // 与 Picker 逻辑一致:Approver 输入为空时按 0 处理 + const approverQtyValue = approverQty[detail.id] || "0"; + const approverBadQtyValue = approverBadQty[detail.id] || "0"; finalQty = parseFloat(approverQtyValue) || 0; finalBadQty = parseFloat(approverBadQtyValue) || 0; } From bd92bf24923a433dd46f79f46dc0d3928b24fd9e Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 25 Mar 2026 15:19:01 +0800 Subject: [PATCH 06/12] update --- src/app/api/stockTake/actions.ts | 4 + .../ApproverStockTakeAll.tsx | 203 +++++++++++++++++- .../StockTakeManagement/PickerCardList.tsx | 78 ++++--- .../StockTakeManagement/PickerReStockTake.tsx | 8 +- .../StockTakeManagement/PickerStockTake.tsx | 8 +- .../StockTakeManagement/StockTakeTab.tsx | 8 +- src/i18n/zh/inventory.json | 1 + 7 files changed, 257 insertions(+), 53 deletions(-) diff --git a/src/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index 98f3233..676bdc3 100644 --- a/src/app/api/stockTake/actions.ts +++ b/src/app/api/stockTake/actions.ts @@ -44,6 +44,10 @@ export interface InventoryLotDetailResponse { stockTakeSection?: string | null; stockTakeSectionDescription?: string | null; stockTakerName?: string | null; + /** ISO string or backend LocalDateTime array */ + stockTakeEndTime?: string | string[] | null; + /** ISO string or backend LocalDateTime array */ + approverTime?: string | string[] | null; } export const getInventoryLotDetailsBySection = async ( diff --git a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx index 8b59227..c754d5e 100644 --- a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx +++ b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx @@ -17,6 +17,7 @@ import { TextField, Radio, TablePagination, + TableSortLabel, } from "@mui/material"; import { useState, useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; @@ -45,6 +46,25 @@ interface ApproverStockTakeAllProps { type QtySelectionType = "first" | "second" | "approver"; +type ApprovedSortKey = + | "stockTakeEndTime" + | "stockTakeSection" + | "item" + | "stockTakerName" + | "variance"; + +function parseDateTimeMs( + v: string | string[] | null | undefined +): number { + if (v == null) return 0; + if (Array.isArray(v)) { + const arr = v as unknown as number[]; + const [y, m, d, h = 0, min = 0, s = 0] = arr; + return dayjs(`${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")} ${h}:${min}:${s}`).valueOf(); + } + return dayjs(v as string).valueOf(); +} + const ApproverStockTakeAll: React.FC = ({ selectedSession, mode, @@ -66,6 +86,8 @@ const ApproverStockTakeAll: React.FC = ({ const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(50); const [total, setTotal] = useState(0); + const [approvedSortKey, setApprovedSortKey] = useState(null); + const [approvedSortDir, setApprovedSortDir] = useState<"asc" | "desc">("asc"); const currentUserId = session?.id ? parseInt(session.id) : undefined; @@ -200,12 +222,62 @@ const ApproverStockTakeAll: React.FC = ({ ]); const sortedDetails = useMemo(() => { - return [...filteredDetails].sort((a, b) => { - const sectionA = (a.stockTakeSection || "").trim(); - const sectionB = (b.stockTakeSection || "").trim(); - return sectionA.localeCompare(sectionB, undefined, { numeric: true, sensitivity: "base" }); + const list = [...filteredDetails]; + if (mode !== "approved") { + return list.sort((a, b) => + (a.stockTakeSection || "").localeCompare(b.stockTakeSection || "", undefined, { + numeric: true, + sensitivity: "base", + }) + ); + } + const key = approvedSortKey ?? "stockTakeSection"; + const mul = approvedSortDir === "asc" ? 1 : -1; + return list.sort((a, b) => { + let cmp = 0; + switch (key) { + case "stockTakeEndTime": + cmp = + parseDateTimeMs(a.approverTime ?? a.stockTakeEndTime) - + parseDateTimeMs(b.approverTime ?? b.stockTakeEndTime); + break; + case "stockTakeSection": + cmp = (a.stockTakeSection || "").localeCompare(b.stockTakeSection || "", undefined, { + numeric: true, + sensitivity: "base", + }); + break; + case "item": + cmp = `${a.itemCode || ""} ${a.itemName || ""}`.localeCompare( + `${b.itemCode || ""} ${b.itemName || ""}`, + undefined, + { numeric: true, sensitivity: "base" } + ); + break; + case "stockTakerName": + cmp = (a.stockTakerName || "").localeCompare(b.stockTakerName || "", undefined, { + numeric: true, + sensitivity: "base", + }); + break; + case "variance": + cmp = Number(a.varianceQty ?? 0) - Number(b.varianceQty ?? 0); + break; + default: + cmp = 0; + } + return cmp * mul; }); - }, [filteredDetails]); + }, [filteredDetails, mode, approvedSortKey, approvedSortDir]); + + const handleApprovedSort = useCallback((property: ApprovedSortKey) => { + if (approvedSortKey === property) { + setApprovedSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setApprovedSortKey(property); + setApprovedSortDir("asc"); + } + }, [approvedSortKey]); const handleSaveApproverStockTake = useCallback( async (detail: InventoryLotDetailResponse) => { @@ -410,6 +482,12 @@ const ApproverStockTakeAll: React.FC = ({ [inventoryLotDetails] ); + const formatRecordEndTime = (detail: InventoryLotDetailResponse) => { + const ms = parseDateTimeMs(detail.approverTime ?? detail.stockTakeEndTime); + if (!ms) return "-"; + return dayjs(ms).format("YYYY-MM-DD HH:mm"); + }; + return ( {onBack && ( @@ -482,23 +560,110 @@ const ApproverStockTakeAll: React.FC = ({ - {t("Warehouse Location")} - {t("Item-lotNo-ExpiryDate")} + {mode === "approved" && ( + + handleApprovedSort("stockTakeEndTime")} + > + {t("Approver Time")} + + + )} + + {mode === "approved" ? ( + handleApprovedSort("stockTakeSection")} + > + {t("Warehouse Location")} + + ) : ( + t("Warehouse Location") + )} + + + {mode === "approved" ? ( + handleApprovedSort("item")} + > + {t("Item-lotNo-ExpiryDate")} + + ) : ( + t("Item-lotNo-ExpiryDate") + )} + {t("UOM")} {t("Stock Take Qty(include Bad Qty)= Available Qty")} - + {mode === "approved" && ( + + handleApprovedSort("variance")} + > + {t("Variance")} + + + )} {t("Remark")} {t("Record Status")} - {t("Picker")} + + {mode === "approved" ? ( + handleApprovedSort("stockTakerName")} + > + {t("Picker")} + + ) : ( + t("Picker") + )} + {t("Action")} {sortedDetails.length === 0 ? ( - + {t("No data")} @@ -515,6 +680,16 @@ const ApproverStockTakeAll: React.FC = ({ return ( + {mode === "approved" && ( + + + + + {formatRecordEndTime(detail)} + + + + )} {detail.stockTakeSection || "-"} {detail.stockTakeSectionDescription || "-"} @@ -759,6 +934,14 @@ const ApproverStockTakeAll: React.FC = ({ )} + {mode === "approved" && ( + + + {formatNumber(detail.varianceQty)} + + + )} + {detail.remarks || "-"} diff --git a/src/components/StockTakeManagement/PickerCardList.tsx b/src/components/StockTakeManagement/PickerCardList.tsx index f1a766d..06980d8 100644 --- a/src/components/StockTakeManagement/PickerCardList.tsx +++ b/src/components/StockTakeManagement/PickerCardList.tsx @@ -37,20 +37,30 @@ import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; const PER_PAGE = 6; interface PickerCardListProps { + /** 由父層保存,從明細返回時仍回到同一頁 */ + page: number; + pageSize: number; + onListPageChange: (page: number) => void; onCardClick: (session: AllPickedStockTakeListReponse) => void; onReStockTakeClick: (session: AllPickedStockTakeListReponse) => void; } -const PickerCardList: React.FC = ({ onCardClick, onReStockTakeClick }) => { +const PickerCardList: React.FC = ({ + page, + pageSize, + onListPageChange, + onCardClick, + onReStockTakeClick, +}) => { const { t } = useTranslation(["inventory", "common"]); dayjs.extend(duration); const PER_PAGE = 6; const [loading, setLoading] = useState(false); const [stockTakeSessions, setStockTakeSessions] = useState([]); - const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(6); // 每页 6 条 -const [total, setTotal] = useState(0); + const [total, setTotal] = useState(0); + /** 建立盤點後若仍在 page 0,仍強制重新載入 */ + const [listRefreshNonce, setListRefreshNonce] = useState(0); const [creating, setCreating] = useState(false); const [openConfirmDialog, setOpenConfirmDialog] = useState(false); const [filterSectionDescription, setFilterSectionDescription] = useState("All"); @@ -106,41 +116,40 @@ const criteria: Criterion[] = [ const handleSearch = (inputs: Record) => { setFilterSectionDescription(inputs.sectionDescription || "All"); setFilterStockTakeSession(inputs.stockTakeSession || ""); - fetchStockTakeSessions(0, pageSize, { - sectionDescription: inputs.sectionDescription || "All", - stockTakeSections: inputs.stockTakeSession ?? "", - }); + onListPageChange(0); }; const handleResetSearch = () => { setFilterSectionDescription("All"); setFilterStockTakeSession(""); - fetchStockTakeSessions(0, pageSize, { - sectionDescription: "All", - stockTakeSections: "", - }); + onListPageChange(0); }; - const fetchStockTakeSessions = useCallback( - async (pageNum: number, size: number, filterOverrides?: { sectionDescription: string; stockTakeSections: string }) => { - setLoading(true); - try { - const res = await getStockTakeRecordsPaged(pageNum, size, filterOverrides); + + useEffect(() => { + let cancelled = false; + setLoading(true); + getStockTakeRecordsPaged(page, pageSize, { + sectionDescription: filterSectionDescription, + stockTakeSections: filterStockTakeSession, + }) + .then((res) => { + if (cancelled) return; setStockTakeSessions(Array.isArray(res.records) ? res.records : []); setTotal(res.total || 0); - setPage(pageNum); - } catch (e) { + }) + .catch((e) => { console.error(e); - setStockTakeSessions([]); - setTotal(0); - } finally { - setLoading(false); - } - }, - [] - ); - - useEffect(() => { - fetchStockTakeSessions(0, pageSize); - }, [fetchStockTakeSessions, pageSize]); + if (!cancelled) { + setStockTakeSessions([]); + setTotal(0); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [page, pageSize, filterSectionDescription, filterStockTakeSession, listRefreshNonce]); //const startIdx = page * PER_PAGE; //const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE); @@ -161,13 +170,14 @@ const handleResetSearch = () => { console.log(message); - await fetchStockTakeSessions(0, pageSize); + onListPageChange(0); + setListRefreshNonce((n) => n + 1); } catch (e) { console.error(e); } finally { setCreating(false); } - }, [fetchStockTakeSessions, t]); + }, [onListPageChange, t]); useEffect(() => { fetchStockTakeSections() .then((sections) => { @@ -376,7 +386,7 @@ const handleResetSearch = () => { page={page} rowsPerPage={pageSize} onPageChange={(e, newPage) => { - fetchStockTakeSessions(newPage, pageSize); + onListPageChange(newPage); }} rowsPerPageOptions={[pageSize]} // 如果暂时不让用户改 pageSize,就写死 /> diff --git a/src/components/StockTakeManagement/PickerReStockTake.tsx b/src/components/StockTakeManagement/PickerReStockTake.tsx index f8353e1..896b67f 100644 --- a/src/components/StockTakeManagement/PickerReStockTake.tsx +++ b/src/components/StockTakeManagement/PickerReStockTake.tsx @@ -599,13 +599,13 @@ const PickerReStockTake: React.FC = ({ { - const clean = sanitizeIntegerInput(e.target.value); + // const clean = sanitizeIntegerInput(e.target.value); setRecordInputs(prev => ({ ...prev, - [detail.id]: { ...(prev[detail.id] ?? defaultInputs), remark: clean } + [detail.id]: { ...(prev[detail.id] ?? defaultInputs), remark: e.target.value } })); }} sx={{ width: 150 }} diff --git a/src/components/StockTakeManagement/PickerStockTake.tsx b/src/components/StockTakeManagement/PickerStockTake.tsx index 4050919..53bd8c0 100644 --- a/src/components/StockTakeManagement/PickerStockTake.tsx +++ b/src/components/StockTakeManagement/PickerStockTake.tsx @@ -771,15 +771,15 @@ const PickerStockTake: React.FC = ({ { - const clean = sanitizeIntegerInput(e.target.value); + // const clean = sanitizeIntegerInput(e.target.value); setRecordInputs(prev => ({ ...prev, [detail.id]: { ...(prev[detail.id] ?? { firstQty: "", secondQty: "", firstBadQty: "", secondBadQty: "", remark: "" }), - remark: clean + remark: e.target.value } })); }} diff --git a/src/components/StockTakeManagement/StockTakeTab.tsx b/src/components/StockTakeManagement/StockTakeTab.tsx index fb371f5..bbb128b 100644 --- a/src/components/StockTakeManagement/StockTakeTab.tsx +++ b/src/components/StockTakeManagement/StockTakeTab.tsx @@ -19,6 +19,9 @@ const StockTakeTab: React.FC = () => { const [viewScope, setViewScope] = useState("picker"); const [approverSession, setApproverSession] = useState(null); const [approverLoading, setApproverLoading] = useState(false); + /** 從卡片列表進入明細後返回時保留分頁 */ + const [pickerListPage, setPickerListPage] = useState(0); + const [pickerListPageSize] = useState(6); const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; @@ -120,7 +123,10 @@ const StockTakeTab: React.FC = () => { {tabValue === 0 && ( - { setViewScope("picker"); handleCardClick(session); diff --git a/src/i18n/zh/inventory.json b/src/i18n/zh/inventory.json index 554090c..40bb45c 100644 --- a/src/i18n/zh/inventory.json +++ b/src/i18n/zh/inventory.json @@ -8,6 +8,7 @@ "UoM": "單位", "Approver Pending": "審核待處理", "Approver Approved": "審核通過", + "Approver Time": "審核時間", "mat": "物料", "variance": "差異", "Plan Start Date": "計劃開始日期", From 548548f453280a6244a8ae753b858bd689c8a60b Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Wed, 25 Mar 2026 22:27:28 +0800 Subject: [PATCH 07/12] adding onpack 2nd machine zip download, added DO syn test for single DO code --- src/app/(main)/testing/page.tsx | 528 ++++----------------- src/app/api/bagPrint/actions.ts | 18 + src/components/BagPrint/BagPrintSearch.tsx | 62 ++- 3 files changed, 159 insertions(+), 449 deletions(-) diff --git a/src/app/(main)/testing/page.tsx b/src/app/(main)/testing/page.tsx index 420986e..c6bc427 100644 --- a/src/app/(main)/testing/page.tsx +++ b/src/app/(main)/testing/page.tsx @@ -1,19 +1,12 @@ "use client"; import React, { useState } from "react"; -import { - Box, Grid, Paper, Typography, Button, Dialog, DialogTitle, - DialogContent, DialogActions, TextField, Stack, Table, - TableBody, TableCell, TableContainer, TableHead, TableRow, - Tabs, Tab // ← Added for tabs -} from "@mui/material"; -import { FileDownload, Print, SettingsEthernet, Lan, Router } from "@mui/icons-material"; -import dayjs from "dayjs"; +import { Box, Paper, Typography, Button, TextField, Stack, Tabs, Tab } from "@mui/material"; +import { FileDownload } from "@mui/icons-material"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; import * as XLSX from "xlsx"; -// Simple TabPanel component for conditional rendering interface TabPanelProps { children?: React.ReactNode; index: number; @@ -30,192 +23,29 @@ function TabPanel(props: TabPanelProps) { aria-labelledby={`simple-tab-${index}`} {...other} > - {value === index && ( - - {children} - - )} + {value === index && {children}} ); } export default function TestingPage() { - // Tab state const [tabValue, setTabValue] = useState(0); const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { setTabValue(newValue); }; - // --- 1. TSC Section States --- - const [tscConfig, setTscConfig] = useState({ ip: '192.168.1.100', port: '9100' }); - const [tscItems, setTscItems] = useState([ - { id: 1, itemCode: 'FG-001', itemName: 'Yellow Curry Sauce', lotNo: 'LOT-TSC-01', expiryDate: '2025-12-01' }, - { id: 2, itemCode: 'FG-002', itemName: 'Red Curry Paste', lotNo: 'LOT-TSC-02', expiryDate: '2025-12-05' }, - ]); - - // --- 2. DataFlex Section States --- - const [dfConfig, setDfConfig] = useState({ ip: '192.168.1.101', port: '9100' }); - const [dfItems, setDfItems] = useState([ - { id: 1, itemCode: 'DF-101', itemName: 'Instant Noodle A', lotNo: 'LOT-DF-01', expiryDate: '2026-01-10' }, - { id: 2, itemCode: 'DF-102', itemName: 'Instant Noodle B', lotNo: 'LOT-DF-02', expiryDate: '2026-01-15' }, - ]); - - // --- 3. OnPack Section States --- - const [isPrinterModalOpen, setIsPrinterModalOpen] = useState(false); - const [printerFormData, setPrinterFormData] = useState({ - itemCode: '', - lotNo: '', - expiryDate: dayjs().format('YYYY-MM-DD'), - productName: '' - }); - - // --- 4. Laser Section States --- - const [laserConfig, setLaserConfig] = useState({ ip: '192.168.1.102', port: '8080' }); - const [laserItems, setLaserItems] = useState([ - { id: 1, templateId: 'JOB_001', lotNo: 'L-LASER-01', expiryDate: '2025-12-31', power: '50' }, - ]); - - // --- 5. HANS600S-M Section States --- - const [hansConfig, setHansConfig] = useState({ ip: '192.168.76.10', port: '45678' }); - const [hansItems, setHansItems] = useState([ - { - id: 1, - textChannel3: 'SN-HANS-001-20260117', // channel 3 (e.g. serial / text1) - textChannel4: 'BATCH-HK-TEST-OK', // channel 4 (e.g. batch / text2) - text3ObjectName: 'Text3', // EZCAD object name for channel 3 - text4ObjectName: 'Text4' // EZCAD object name for channel 4 - }, - ]); - - // --- 6. GRN Preview (M18) --- + // --- 1. GRN Preview (M18) --- const [grnPreviewReceiptDate, setGrnPreviewReceiptDate] = useState("2026-03-16"); - // --- 7. M18 PO Sync by Code --- + // --- 2. M18 PO Sync by Code --- const [m18PoCode, setM18PoCode] = useState(""); const [isSyncingM18Po, setIsSyncingM18Po] = useState(false); const [m18PoSyncResult, setM18PoSyncResult] = useState(""); + // --- 3. M18 DO Sync by Code --- + const [m18DoCode, setM18DoCode] = useState(""); + const [isSyncingM18Do, setIsSyncingM18Do] = useState(false); + const [m18DoSyncResult, setM18DoSyncResult] = useState(""); - // Generic handler for inline table edits - const handleItemChange = (setter: any, id: number, field: string, value: string) => { - setter((prev: any[]) => prev.map(item => - item.id === id ? { ...item, [field]: value } : item - )); - }; - - // --- API CALLS --- - - // TSC Print (Section 1) - const handleTscPrint = async (row: any) => { - const payload = { ...row, printerIp: tscConfig.ip, printerPort: tscConfig.port }; - try { - const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-tsc`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - if (response.status === 401 || response.status === 403) return; - if (response.ok) alert(`TSC Print Command Sent for ${row.itemCode}!`); - else alert("TSC Print Failed"); - } catch (e) { console.error("TSC Error:", e); } - }; - - // DataFlex Print (Section 2) - const handleDfPrint = async (row: any) => { - const payload = { ...row, printerIp: dfConfig.ip, printerPort: dfConfig.port }; - try { - const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-dataflex`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - if (response.status === 401 || response.status === 403) return; - if (response.ok) alert(`DataFlex Print Command Sent for ${row.itemCode}!`); - else alert("DataFlex Print Failed"); - } catch (e) { console.error("DataFlex Error:", e); } - }; - - // OnPack Zip Download (Section 3) - const handleDownloadPrintJob = async () => { - const params = new URLSearchParams(printerFormData); - try { - const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/get-printer6?${params.toString()}`, { - method: 'GET', - }); - - if (response.status === 401 || response.status === 403) return; - if (!response.ok) throw new Error('Download failed'); - - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.setAttribute('download', `${printerFormData.lotNo || 'OnPack'}.zip`); - document.body.appendChild(link); - link.click(); - link.remove(); - window.URL.revokeObjectURL(url); - - setIsPrinterModalOpen(false); - } catch (e) { console.error("OnPack Error:", e); } - }; - - // Laser Print (Section 4 - original) - const handleLaserPrint = async (row: any) => { - const payload = { ...row, printerIp: laserConfig.ip, printerPort: laserConfig.port }; - try { - const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-laser`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - if (response.status === 401 || response.status === 403) return; - if (response.ok) alert(`Laser Command Sent: ${row.templateId}`); - } catch (e) { console.error(e); } - }; - - const handleLaserPreview = async (row: any) => { - const payload = { ...row, printerIp: laserConfig.ip, printerPort: parseInt(laserConfig.port) }; - try { - const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/preview-laser`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - if (response.status === 401 || response.status === 403) return; - if (response.ok) alert("Red light preview active!"); - } catch (e) { console.error("Preview Error:", e); } - }; - - // HANS600S-M TCP Print (Section 5) - const handleHansPrint = async (row: any) => { - const payload = { - printerIp: hansConfig.ip, - printerPort: hansConfig.port, - textChannel3: row.textChannel3, - textChannel4: row.textChannel4, - text3ObjectName: row.text3ObjectName, - text4ObjectName: row.text4ObjectName - }; - try { - const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-laser-tcp`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - if (response.status === 401 || response.status === 403) return; - const result = await response.text(); - if (response.ok) { - alert(`HANS600S-M Mark Success: ${result}`); - } else { - alert(`HANS600S-M Failed: ${result}`); - } - } catch (e) { - console.error("HANS600S-M Error:", e); - alert("HANS600S-M Connection Error"); - } - }; - - // GRN Preview CSV Download (Section 6) const handleDownloadGrnPreviewXlsx = async () => { try { const response = await clientAuthFetch( @@ -251,7 +81,6 @@ export default function TestingPage() { } }; - // M18 PO Sync By Code (Section 7) const handleSyncM18PoByCode = async () => { if (!m18PoCode.trim()) { alert("Please enter PO code."); @@ -278,258 +107,55 @@ export default function TestingPage() { } }; - // Layout Helper - const Section = ({ title, children }: { title: string, children?: React.ReactNode }) => ( - - + const handleSyncM18DoByCode = async () => { + if (!m18DoCode.trim()) { + alert("Please enter DO / shop PO code."); + return; + } + setIsSyncingM18Do(true); + setM18DoSyncResult(""); + try { + const response = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/m18/test/do-by-code?code=${encodeURIComponent(m18DoCode.trim())}`, + { method: "GET" }, + ); + if (response.status === 401 || response.status === 403) return; + const text = await response.text(); + setM18DoSyncResult(text); + if (!response.ok) { + alert(`Sync failed: ${response.status}`); + } + } catch (e) { + console.error("M18 DO Sync By Code Error:", e); + alert("M18 DO sync failed. Check console/network."); + } finally { + setIsSyncingM18Do(false); + } + }; + + const Section = ({ title, children }: { title: string; children?: React.ReactNode }) => ( + + {title} - {children || Waiting for implementation...} + {children || Waiting for implementation...} ); return ( - Printer Testing - - - - - - - - - + + Testing + + + + + + -
- - setTscConfig({...tscConfig, ip: e.target.value})} /> - setTscConfig({...tscConfig, port: e.target.value})} /> - - - -
- - - Code - Name - Lot - Expiry - Action - - - - {tscItems.map(row => ( - - handleItemChange(setTscItems, row.id, 'itemCode', e.target.value)} /> - handleItemChange(setTscItems, row.id, 'itemName', e.target.value)} /> - handleItemChange(setTscItems, row.id, 'lotNo', e.target.value)} /> - handleItemChange(setTscItems, row.id, 'expiryDate', e.target.value)} /> - - - ))} - -
- - - - - -
- - setDfConfig({...dfConfig, ip: e.target.value})} /> - setDfConfig({...dfConfig, port: e.target.value})} /> - - - - - - - Code - Name - Lot - Expiry - Action - - - - {dfItems.map(row => ( - - handleItemChange(setDfItems, row.id, 'itemCode', e.target.value)} /> - handleItemChange(setDfItems, row.id, 'itemName', e.target.value)} /> - handleItemChange(setDfItems, row.id, 'lotNo', e.target.value)} /> - handleItemChange(setDfItems, row.id, 'expiryDate', e.target.value)} /> - - - ))} - -
-
-
-
- - -
- - - Calls /plastic/get-printer6 to generate CoLOS .job bundle. - - - -
-
- - -
- - setLaserConfig({...laserConfig, ip: e.target.value})} /> - setLaserConfig({...laserConfig, port: e.target.value})} /> - - - - - - - - Template - Lot - Exp - Pwr% - Action - - - - {laserItems.map(row => ( - - handleItemChange(setLaserItems, row.id, 'templateId', e.target.value)} /> - handleItemChange(setLaserItems, row.id, 'lotNo', e.target.value)} /> - handleItemChange(setLaserItems, row.id, 'expiryDate', e.target.value)} /> - handleItemChange(setLaserItems, row.id, 'power', e.target.value)} /> - - - - - - - - ))} - -
-
- - Note: HANS Laser requires pre-saved templates on the controller. - -
-
- - -
- - setHansConfig({...hansConfig, ip: e.target.value})} - /> - setHansConfig({...hansConfig, port: e.target.value})} - /> - - - - - - - - Ch3 Text (SN) - Ch4 Text (Batch) - Obj3 Name - Obj4 Name - Action - - - - {hansItems.map(row => ( - - - handleItemChange(setHansItems, row.id, 'textChannel3', e.target.value)} - sx={{ minWidth: 180 }} - /> - - - handleItemChange(setHansItems, row.id, 'textChannel4', e.target.value)} - sx={{ minWidth: 140 }} - /> - - - handleItemChange(setHansItems, row.id, 'text3ObjectName', e.target.value)} - size="small" - /> - - - handleItemChange(setHansItems, row.id, 'text4ObjectName', e.target.value)} - size="small" - /> - - - - - - ))} - -
-
- - TCP Push to EZCAD3 (Ch3/Ch4 via E3_SetTextObject) | IP:192.168.76.10:45678 | Backend: /print-laser-tcp - -
-
- - -
+
- -
+ +
- @@ -592,22 +213,37 @@ export default function TestingPage() {
- {/* Dialog for OnPack */} - setIsPrinterModalOpen(false)} fullWidth maxWidth="sm"> - OnPack Printer Job Details - - - setPrinterFormData({ ...printerFormData, itemCode: e.target.value })} /> - setPrinterFormData({ ...printerFormData, lotNo: e.target.value })} /> - setPrinterFormData({ ...printerFormData, productName: e.target.value })} /> - setPrinterFormData({ ...printerFormData, expiryDate: e.target.value })} /> + +
+ + setM18DoCode(e.target.value)} + placeholder="e.g. same document code as M18 shop PO" + sx={{ minWidth: 320 }} + /> + - - - - - -
+ + Backend endpoint: /m18/test/do-by-code?code=YOUR_CODE + + {m18DoSyncResult ? ( + + ) : null} +
+
); -} \ No newline at end of file +} diff --git a/src/app/api/bagPrint/actions.ts b/src/app/api/bagPrint/actions.ts index b6bf3e1..e9ac655 100644 --- a/src/app/api/bagPrint/actions.ts +++ b/src/app/api/bagPrint/actions.ts @@ -80,3 +80,21 @@ export async function downloadOnPackQrZip( return res.blob(); } + +/** OnPack2023 檸檬機 — text QR template (`onpack2030_2`), no separate .bmp */ +export async function downloadOnPackTextQrZip( + request: OnPackQrDownloadRequest, +): Promise { + const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text`; + const res = await clientAuthFetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); + + if (!res.ok) { + throw new Error((await res.text()) || "Download failed"); + } + + return res.blob(); +} diff --git a/src/components/BagPrint/BagPrintSearch.tsx b/src/components/BagPrint/BagPrintSearch.tsx index c9a6418..8b63294 100644 --- a/src/components/BagPrint/BagPrintSearch.tsx +++ b/src/components/BagPrint/BagPrintSearch.tsx @@ -25,7 +25,13 @@ import ChevronRight from "@mui/icons-material/ChevronRight"; import Settings from "@mui/icons-material/Settings"; 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 { + checkPrinterStatus, + downloadOnPackQrZip, + downloadOnPackTextQrZip, + 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"; @@ -107,6 +113,7 @@ const BagPrintSearch: React.FC = () => { const [printerConnected, setPrinterConnected] = useState(false); const [printerMessage, setPrinterMessage] = useState("列印機未連接"); const [downloadingOnPack, setDownloadingOnPack] = useState(false); + const [downloadingOnPackText, setDownloadingOnPackText] = useState(false); useEffect(() => { setSettings(loadSettings()); @@ -306,6 +313,46 @@ const BagPrintSearch: React.FC = () => { } }; + const handleDownloadOnPackTextQr = async () => { + const onPackJobOrders = jobOrders + .map((jobOrder) => ({ + jobOrderId: jobOrder.id, + itemCode: jobOrder.itemCode?.trim() || "", + })) + .filter((jobOrder) => jobOrder.itemCode.length > 0); + + if (onPackJobOrders.length === 0) { + setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" }); + return; + } + + setDownloadingOnPackText(true); + try { + const blob = await downloadOnPackTextQrZip({ + jobOrders: onPackJobOrders, + }); + + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", `onpack2023_lemon_qr_${planDate}.zip`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + + setSnackbar({ open: true, message: "OnPack2023檸檬機 ZIP 已下載", severity: "success" }); + } catch (e) { + setSnackbar({ + open: true, + message: e instanceof Error ? e.message : "下載 OnPack2023檸檬機 失敗", + severity: "error", + }); + } finally { + setDownloadingOnPackText(false); + } + }; + return ( {/* Top: date nav + printer + settings */} @@ -360,15 +407,24 @@ const BagPrintSearch: React.FC = () => { {printerMessage} - + + From d2854953a8287a427f0417c9c784a80d9e1afbba Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 25 Mar 2026 22:40:11 +0800 Subject: [PATCH 08/12] update --- src/app/api/stockTake/actions.ts | 2 + .../ApproverStockTakeAll.tsx | 267 +++++++++++++----- 2 files changed, 204 insertions(+), 65 deletions(-) diff --git a/src/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index 676bdc3..a991294 100644 --- a/src/app/api/stockTake/actions.ts +++ b/src/app/api/stockTake/actions.ts @@ -41,6 +41,7 @@ export interface InventoryLotDetailResponse { approverBadQty: number | null; finalQty: number | null; bookQty: number | null; + lastSelect?: number | null; stockTakeSection?: string | null; stockTakeSectionDescription?: string | null; stockTakerName?: string | null; @@ -286,6 +287,7 @@ export interface SaveApproverStockTakeRecordRequest { approverId?: number | null; approverQty?: number | null; approverBadQty?: number | null; + lastSelect?: number | null; } export interface BatchSaveApproverStockTakeRecordRequest { diff --git a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx index c754d5e..1be6453 100644 --- a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx +++ b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx @@ -153,16 +153,76 @@ const ApproverStockTakeAll: React.FC = ({ loadDetails(page, pageSize); }, [page, pageSize, loadDetails]); + // 切换模式时,清空用户先前的选择与输入,approved 模式需要以后端结果为准。 useEffect(() => { - const newSelections: Record = {}; - inventoryLotDetails.forEach((detail) => { - if (!qtySelection[detail.id]) { - if (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0) { - newSelections[detail.id] = "second"; - } else { - newSelections[detail.id] = "first"; + setQtySelection({}); + setApproverQty({}); + setApproverBadQty({}); + }, [mode, selectedSession.stockTakeId]); + + useEffect(() => { + const inferSelection = ( + detail: InventoryLotDetailResponse + ): QtySelectionType => { + // 优先使用后端记录的 lastSelect(1=First, 2=Second, 3=Approver Input) + if (detail.lastSelect != null) { + if (detail.lastSelect === 1) return "first"; + if (detail.lastSelect === 2) return "second"; + if (detail.lastSelect === 3) return "approver"; + } + + // 目标:在 approved 模式下,即使后端把 approver 字段也回填了, + // 只要 finalQty 来自 first/second(picker 结果),就优先勾选 first/second。 + // 只有匹配不到 first/second 时,才推断为 approver。 + if (detail.finalQty != null) { + const eps = 1e-6; + const firstAvailable = detail.firstStockTakeQty; + const secondAvailable = detail.secondStockTakeQty; + + // 如果这一行确实有 approver 结果,那么 approved 时应该优先显示为 approver + // (尤其是:picker first 后又手动改 approver input 的情况) + if (detail.approverQty != null) { + const approverAvailable = + detail.approverQty - (detail.approverBadQty ?? 0); + if (Math.abs(approverAvailable - detail.finalQty) <= eps) { + return "approver"; + } + } + + if (secondAvailable != null && Math.abs(secondAvailable - detail.finalQty) <= eps) { + return "second"; + } + + if (firstAvailable != null && Math.abs(firstAvailable - detail.finalQty) <= eps) { + return "first"; + } + + // approver 字段口径可能是「available」或「total+bad」两种之一,这里同时尝试两种。 + if (detail.approverQty != null) { + const approverAvailable = detail.approverQty; + const approverAvailable2 = + detail.approverQty - (detail.approverBadQty ?? 0); + + if ( + Math.abs(approverAvailable - detail.finalQty) <= eps || + Math.abs(approverAvailable2 - detail.finalQty) <= eps + ) { + return "approver"; + } } } + + // pending/无法反推时:second 存在则默认 second,否则 first + if (detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0) { + return "second"; + } + return "first"; + }; + + const newSelections: Record = {}; + inventoryLotDetails.forEach((detail) => { + if (qtySelection[detail.id]) return; + newSelections[detail.id] = inferSelection(detail); }); if (Object.keys(newSelections).length > 0) { @@ -170,6 +230,33 @@ const ApproverStockTakeAll: React.FC = ({ } }, [inventoryLotDetails, qtySelection]); + // approved 模式下:把已保存的 approver 输入值回填到 TextField,避免“radio 显示了但输入框为空” + useEffect(() => { + if (mode !== "approved") return; + + const newApproverQty: Record = {}; + const newApproverBadQty: Record = {}; + + inventoryLotDetails.forEach((detail) => { + if (detail.approverQty != null && approverQty[detail.id] == null) { + newApproverQty[detail.id] = String(detail.approverQty); + } + if ( + detail.approverBadQty != null && + approverBadQty[detail.id] == null + ) { + newApproverBadQty[detail.id] = String(detail.approverBadQty); + } + }); + + if (Object.keys(newApproverQty).length > 0) { + setApproverQty((prev) => ({ ...prev, ...newApproverQty })); + } + if (Object.keys(newApproverBadQty).length > 0) { + setApproverBadQty((prev) => ({ ...prev, ...newApproverBadQty })); + } + }, [mode, inventoryLotDetails, approverQty, approverBadQty]); + const calculateDifference = useCallback( (detail: InventoryLotDetailResponse, selection: QtySelectionType): number => { let selectedQty = 0; @@ -200,7 +287,7 @@ const ApproverStockTakeAll: React.FC = ({ } const selection = qtySelection[detail.id] ?? - (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0 + (detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0 ? "second" : "first"); // 避免 Approver 手动输入过程中被 variance 过滤掉,导致“输入后行消失无法提交” @@ -322,6 +409,8 @@ const ApproverStockTakeAll: React.FC = ({ approverId: currentUserId, approverQty: selection === "approver" ? finalQty : null, approverBadQty: selection === "approver" ? finalBadQty : null, + // lastSelect: 1=First, 2=Second, 3=Approver Input + lastSelect: selection === "first" ? 1 : selection === "second" ? 2 : 3, }; await saveApproverStockTakeRecord(request, selectedSession.stockTakeId); @@ -678,6 +767,18 @@ const ApproverStockTakeAll: React.FC = ({ const selection = qtySelection[detail.id] || (hasSecond ? "second" : "first"); + // approved 视图下,只有存在已保存的 approver 结果才显示 approver 输入区块 + const canApprover = + mode === "pending" + ? true + : selection === "approver" && + (detail.approverQty != null || + detail.approverBadQty != null); + + // approved 模式下:即使 finalQty 已存在,也需要展示 radio 用于查看选择 + const showRadioBlock = + mode === "approved" || detail.finalQty == null; + return ( {mode === "approved" && ( @@ -719,35 +820,7 @@ const ApproverStockTakeAll: React.FC = ({ {detail.uom || "-"} - {detail.finalQty != null ? ( - - {(() => { - const bookQtyToUse = - detail.bookQty != null - ? detail.bookQty - : detail.availableQty || 0; - const finalDifference = - (detail.finalQty || 0) - bookQtyToUse; - const differenceColor = - detail.stockTakeRecordStatus === "completed" - ? "text.secondary" - : finalDifference !== 0 - ? "error.main" - : "success.main"; - - return ( - - {t("Difference")}: {formatNumber(detail.finalQty)} -{" "} - {formatNumber(bookQtyToUse)} ={" "} - {formatNumber(finalDifference)} - - ); - })()} - - ) : ( + {showRadioBlock ? ( {hasFirst && ( = ({ checked={selection === "first"} disabled={mode === "approved"} onChange={() => - setQtySelection({ ...qtySelection, [detail.id]: "first", @@ -808,7 +880,7 @@ const ApproverStockTakeAll: React.FC = ({ )} - {hasSecond && ( + {canApprover && ( = ({ }, }} placeholder={t("Stock Take Qty")} - disabled={selection !== "approver"} + disabled={mode === "approved" || selection !== "approver"} inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} /> @@ -874,7 +946,7 @@ const ApproverStockTakeAll: React.FC = ({ }, }} placeholder={t("Bad Qty")} - disabled={selection !== "approver"} + disabled={mode === "approved" || selection !== "approver"} inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} /> @@ -889,30 +961,98 @@ const ApproverStockTakeAll: React.FC = ({ )} + {detail.finalQty != null ? ( + + {(() => { + const bookQtyToUse = + detail.bookQty != null + ? detail.bookQty + : detail.availableQty || 0; + const finalDifference = + (detail.finalQty || 0) - bookQtyToUse; + const differenceColor = + detail.stockTakeRecordStatus === "completed" + ? "text.secondary" + : finalDifference !== 0 + ? "error.main" + : "success.main"; + + return ( + + {t("Difference")}:{" "} + {formatNumber(detail.finalQty)} -{" "} + {formatNumber(bookQtyToUse)} ={" "} + {formatNumber(finalDifference)} + + ); + })()} + + ) : ( + (() => { + let selectedQty = 0; + + if (selection === "first") { + selectedQty = detail.firstStockTakeQty || 0; + } else if (selection === "second") { + selectedQty = detail.secondStockTakeQty || 0; + } else if (selection === "approver") { + selectedQty = + (parseFloat(approverQty[detail.id] || "0") - + parseFloat( + approverBadQty[detail.id] || "0" + )) || 0; + } + + const bookQty = + detail.bookQty != null + ? detail.bookQty + : detail.availableQty || 0; + const difference = selectedQty - bookQty; + const differenceColor = + detail.stockTakeRecordStatus === "completed" + ? "text.secondary" + : difference !== 0 + ? "error.main" + : "success.main"; + + return ( + + {t("Difference")}:{" "} + {t("selected stock take qty")}( + {formatNumber(selectedQty)}) -{" "} + {t("book qty")}( + {formatNumber(bookQty)}) ={" "} + {formatNumber(difference)} + + ); + })() + )} + + ) : ( + {(() => { - let selectedQty = 0; - - if (selection === "first") { - selectedQty = detail.firstStockTakeQty || 0; - } else if (selection === "second") { - selectedQty = detail.secondStockTakeQty || 0; - } else if (selection === "approver") { - selectedQty = - (parseFloat(approverQty[detail.id] || "0") - - parseFloat( - approverBadQty[detail.id] || "0" - )) || 0; - } - - const bookQty = + const bookQtyToUse = detail.bookQty != null ? detail.bookQty : detail.availableQty || 0; - const difference = selectedQty - bookQty; + const finalDifference = + (detail.finalQty || 0) - bookQtyToUse; const differenceColor = detail.stockTakeRecordStatus === "completed" ? "text.secondary" - : difference !== 0 + : finalDifference !== 0 ? "error.main" : "success.main"; @@ -921,12 +1061,9 @@ const ApproverStockTakeAll: React.FC = ({ variant="body2" sx={{ fontWeight: "bold", color: differenceColor }} > - {t("Difference")}:{" "} - {t("selected stock take qty")}( - {formatNumber(selectedQty)}) -{" "} - {t("book qty")}( - {formatNumber(bookQty)}) ={" "} - {formatNumber(difference)} + {t("Difference")}: {formatNumber(detail.finalQty)} -{" "} + {formatNumber(bookQtyToUse)} ={" "} + {formatNumber(finalDifference)} ); })()} @@ -1003,7 +1140,7 @@ const ApproverStockTakeAll: React.FC = ({ size="small" variant="contained" onClick={() => handleSaveApproverStockTake(detail)} - disabled={saving} + disabled={saving ||detail.stockTakeRecordStatus === "notMatch"} > {t("Save")} From 3df19f9a0b7148bc59a238214037828a42ec0f4e Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Thu, 26 Mar 2026 02:01:23 +0800 Subject: [PATCH 09/12] no message --- src/app/(main)/laserPrint/page.tsx | 23 + src/app/api/laserPrint/actions.ts | 135 ++++++ src/components/Breadcrumb/Breadcrumb.tsx | 1 + .../LaserPrint/LaserPrintSearch.tsx | 431 ++++++++++++++++++ .../NavigationContent/NavigationContent.tsx | 7 + src/routes.ts | 1 + 6 files changed, 598 insertions(+) create mode 100644 src/app/(main)/laserPrint/page.tsx create mode 100644 src/app/api/laserPrint/actions.ts create mode 100644 src/components/LaserPrint/LaserPrintSearch.tsx diff --git a/src/app/(main)/laserPrint/page.tsx b/src/app/(main)/laserPrint/page.tsx new file mode 100644 index 0000000..081900e --- /dev/null +++ b/src/app/(main)/laserPrint/page.tsx @@ -0,0 +1,23 @@ +import LaserPrintSearch from "@/components/LaserPrint/LaserPrintSearch"; +import { Stack, Typography } from "@mui/material"; +import { Metadata } from "next"; +import React from "react"; + +export const metadata: Metadata = { + title: "檸檬機(激光機)", +}; + +const LaserPrintPage: React.FC = () => { + return ( + <> + + + 檸檬機(激光機) + + + + + ); +}; + +export default LaserPrintPage; diff --git a/src/app/api/laserPrint/actions.ts b/src/app/api/laserPrint/actions.ts new file mode 100644 index 0000000..8b54844 --- /dev/null +++ b/src/app/api/laserPrint/actions.ts @@ -0,0 +1,135 @@ +"use client"; + +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; + +export interface JobOrderListItem { + id: number; + code: string | null; + planStart: string | null; + itemCode: string | null; + itemName: string | null; + reqQty: number | null; + stockInLineId: number | null; + itemId: number | null; + lotNo: string | null; +} + +export interface LaserBag2Settings { + host: string; + port: number; + /** Comma-separated item codes; empty string = show all packaging job orders */ + itemCodes: string; +} + +export interface LaserBag2SendRequest { + itemId: number | null; + stockInLineId: number | null; + itemCode: string | null; + itemName: string | null; + printerIp?: string; + printerPort?: number; +} + +export interface LaserBag2SendResponse { + success: boolean; + message: string; + payloadSent?: string | null; +} + +/** + * Uses server LASER_PRINT.itemCodes filter. Calls public GET /py/laser-job-orders (same as Python Bag2 /py/job-orders), + * so it works without relying on authenticated /plastic routes. + */ +export async function fetchLaserJobOrders(planStart: string): Promise { + const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); + if (!base) { + throw new Error("NEXT_PUBLIC_API_URL is not set; cannot reach API."); + } + const url = `${base}/py/laser-job-orders?planStart=${encodeURIComponent(planStart)}`; + let res: Response; + try { + res = await clientAuthFetch(url, { method: "GET" }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error( + `無法連線 API(${url}):${msg}。請確認後端已啟動且 NEXT_PUBLIC_API_URL 指向正確(例如 http://localhost:8090/api)。`, + ); + } + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error( + `載入工單失敗(${res.status})${body ? `:${body.slice(0, 200)}` : ""}`, + ); + } + return res.json() as Promise; +} + +export async function fetchLaserBag2Settings(): Promise { + const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); + if (!base) { + throw new Error("NEXT_PUBLIC_API_URL is not set."); + } + const url = `${base}/plastic/laser-bag2-settings`; + let res: Response; + try { + res = await clientAuthFetch(url, { method: "GET" }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(`無法連線至 ${url}:${msg}`); + } + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`載入設定失敗(${res.status})${body ? body.slice(0, 200) : ""}`); + } + return res.json() as Promise; +} + +export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise { + const url = `${NEXT_PUBLIC_API_URL}/plastic/print-laser-bag2`; + const res = await clientAuthFetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = (await res.json()) as LaserBag2SendResponse; + if (!res.ok) { + return data; + } + return data; +} + +export interface PrinterStatusRequest { + printerType: "laser"; + printerIp?: string; + printerPort?: number; +} + +export interface PrinterStatusResponse { + connected: boolean; + message: string; +} + +export async function checkPrinterStatus(request: PrinterStatusRequest): Promise { + const url = `${NEXT_PUBLIC_API_URL}/plastic/check-printer`; + const res = await clientAuthFetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); + const data = (await res.json()) as PrinterStatusResponse; + return data; +} + +export async function patchSetting(name: string, value: string): Promise { + const url = `${NEXT_PUBLIC_API_URL}/settings/${encodeURIComponent(name)}`; + const res = await clientAuthFetch(url, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + if (!res.ok) { + const t = await res.text().catch(() => ""); + throw new Error(t || `Failed to save setting: ${res.status}`); + } +} diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 5456d5a..6818599 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -46,6 +46,7 @@ const pathToLabelMap: { [path: string]: string } = { "/stockIssue": "Stock Issue", "/report": "Report", "/bagPrint": "打袋機", + "/laserPrint": "檸檬機(激光機)", "/settings/itemPrice": "Price Inquiry", }; diff --git a/src/components/LaserPrint/LaserPrintSearch.tsx b/src/components/LaserPrint/LaserPrintSearch.tsx new file mode 100644 index 0000000..a0d62b7 --- /dev/null +++ b/src/components/LaserPrint/LaserPrintSearch.tsx @@ -0,0 +1,431 @@ +"use client"; + +import React, { useCallback, useEffect, useState } from "react"; +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Paper, + Snackbar, + Stack, + TextField, + Typography, +} from "@mui/material"; +import ChevronLeft from "@mui/icons-material/ChevronLeft"; +import ChevronRight from "@mui/icons-material/ChevronRight"; +import Settings from "@mui/icons-material/Settings"; +import { + checkPrinterStatus, + fetchLaserJobOrders, + fetchLaserBag2Settings, + JobOrderListItem, + patchSetting, + sendLaserBag2Job, +} from "@/app/api/laserPrint/actions"; +import dayjs from "dayjs"; + +const BG_TOP = "#E8F4FC"; +const BG_LIST = "#D4E8F7"; +const BG_ROW = "#C5E1F5"; +const BG_ROW_SELECTED = "#6BB5FF"; +const BG_STATUS_ERROR = "#FFCCCB"; +const BG_STATUS_OK = "#90EE90"; +const FG_STATUS_ERROR = "#B22222"; +const FG_STATUS_OK = "#006400"; + +const REFRESH_MS = 60 * 1000; +const PRINTER_CHECK_MS = 60 * 1000; +const PRINTER_RETRY_MS = 30 * 1000; +const LASER_SEND_COUNT = 3; +const BETWEEN_SEND_MS = 3000; +const SUCCESS_SIGNAL_MS = 3500; + +function formatQty(val: number | null | undefined): string { + if (val == null) return "—"; + try { + const n = Number(val); + if (Number.isInteger(n)) return n.toLocaleString(); + return n + .toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 }) + .replace(/\.?0+$/, ""); + } catch { + return String(val); + } +} + +function getBatch(jo: JobOrderListItem): string { + return (jo.lotNo || "—").trim() || "—"; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const LaserPrintSearch: React.FC = () => { + const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); + const [jobOrders, setJobOrders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [connected, setConnected] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [sendingJobId, setSendingJobId] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); + const [errorSnackbar, setErrorSnackbar] = useState<{ open: boolean; message: string }>({ + open: false, + message: "", + }); + const [successSignal, setSuccessSignal] = useState(null); + const [laserHost, setLaserHost] = useState("192.168.18.77"); + const [laserPort, setLaserPort] = useState("45678"); + const [laserItemCodes, setLaserItemCodes] = useState("PP1175"); + const [settingsLoaded, setSettingsLoaded] = useState(false); + const [printerConnected, setPrinterConnected] = useState(false); + const [printerMessage, setPrinterMessage] = useState("檸檬機(激光機)未連接"); + + const loadSystemSettings = useCallback(async () => { + try { + const s = await fetchLaserBag2Settings(); + setLaserHost(s.host); + setLaserPort(String(s.port)); + setLaserItemCodes(s.itemCodes ?? "PP1175"); + setSettingsLoaded(true); + } catch (e) { + setErrorSnackbar({ + open: true, + message: e instanceof Error ? e.message : "無法載入系統設定", + }); + setSettingsLoaded(true); + } + }, []); + + useEffect(() => { + void loadSystemSettings(); + }, [loadSystemSettings]); + + useEffect(() => { + if (!successSignal) return; + const t = setTimeout(() => setSuccessSignal(null), SUCCESS_SIGNAL_MS); + return () => clearTimeout(t); + }, [successSignal]); + + const loadJobOrders = useCallback( + async (fromUserChange = false) => { + setLoading(true); + setError(null); + try { + const data = await fetchLaserJobOrders(planDate); + setJobOrders(data); + setConnected(true); + if (fromUserChange) setSelectedId(null); + } catch (e) { + setError(e instanceof Error ? e.message : "連接不到服務器"); + setConnected(false); + setJobOrders([]); + } finally { + setLoading(false); + } + }, + [planDate], + ); + + useEffect(() => { + void loadJobOrders(true); + }, [planDate]); + + useEffect(() => { + if (!connected) return; + const id = setInterval(() => void loadJobOrders(false), REFRESH_MS); + return () => clearInterval(id); + }, [connected, loadJobOrders]); + + const checkLaser = useCallback(async () => { + const portNum = Number(laserPort || 45678); + try { + const result = await checkPrinterStatus({ + printerType: "laser", + printerIp: laserHost.trim(), + printerPort: Number.isFinite(portNum) ? portNum : 45678, + }); + setPrinterConnected(result.connected); + setPrinterMessage(result.message); + } catch (e) { + setPrinterConnected(false); + setPrinterMessage(e instanceof Error ? e.message : "檸檬機(激光機)狀態檢查失敗"); + } + }, [laserHost, laserPort]); + + useEffect(() => { + if (!settingsLoaded) return; + void checkLaser(); + }, [settingsLoaded, checkLaser]); + + useEffect(() => { + if (!settingsLoaded) return; + const intervalMs = printerConnected ? PRINTER_CHECK_MS : PRINTER_RETRY_MS; + const id = setInterval(() => { + void checkLaser(); + }, intervalMs); + return () => clearInterval(id); + }, [printerConnected, checkLaser, settingsLoaded]); + + const goPrevDay = () => { + setPlanDate((d) => dayjs(d).subtract(1, "day").format("YYYY-MM-DD")); + }; + + const goNextDay = () => { + setPlanDate((d) => dayjs(d).add(1, "day").format("YYYY-MM-DD")); + }; + + const sendOne = (jo: JobOrderListItem) => + sendLaserBag2Job({ + itemId: jo.itemId, + stockInLineId: jo.stockInLineId, + itemCode: jo.itemCode, + itemName: jo.itemName, + }); + + const handleRowClick = async (jo: JobOrderListItem) => { + if (sendingJobId !== null) return; + + if (!laserHost.trim()) { + setErrorSnackbar({ open: true, message: "請在系統設定中填寫檸檬機(激光機) IP。" }); + return; + } + + setSelectedId(jo.id); + setSendingJobId(jo.id); + try { + for (let i = 0; i < LASER_SEND_COUNT; i++) { + const r = await sendOne(jo); + if (!r.success) { + setErrorSnackbar({ + open: true, + message: r.message || "檸檬機(激光機)未收到指令", + }); + return; + } + if (i < LASER_SEND_COUNT - 1) { + await delay(BETWEEN_SEND_MS); + } + } + setSuccessSignal(`已送出 ${LASER_SEND_COUNT} 次至檸檬機(激光機)`); + } catch (e) { + setErrorSnackbar({ + open: true, + message: e instanceof Error ? e.message : "送出失敗", + }); + } finally { + setSendingJobId(null); + } + }; + + const saveSettings = async () => { + try { + await patchSetting("LASER_PRINT.host", laserHost.trim()); + await patchSetting("LASER_PRINT.port", laserPort.trim() || "45678"); + await patchSetting("LASER_PRINT.itemCodes", laserItemCodes.trim()); + setSuccessSignal("設定已儲存"); + setSettingsOpen(false); + void checkLaser(); + await loadSystemSettings(); + void loadJobOrders(false); + } catch (e) { + setErrorSnackbar({ + open: true, + message: e instanceof Error ? e.message : "儲存失敗", + }); + } + }; + + return ( + + {successSignal && ( + setSuccessSignal(null)}> + {successSignal} + + )} + + + + + + setPlanDate(e.target.value)} + size="small" + sx={{ width: 160 }} + InputLabelProps={{ shrink: true }} + disabled={sendingJobId !== null} + /> + + + + + + 檸檬機(激光機): + + + + + + + {loading ? ( + + + + ) : error ? ( + + {error} + + ) : jobOrders.length === 0 ? ( + + 當日無工單 + + ) : ( + + + {jobOrders.map((jo) => { + const batch = getBatch(jo); + const qtyStr = formatQty(jo.reqQty); + const isSelected = selectedId === jo.id; + const isSending = sendingJobId === jo.id; + return ( + void handleRowClick(jo)} + > + + + {batch} + + {qtyStr !== "—" && ( + + 數量:{qtyStr} + + )} + + + + {jo.code || "—"} + + + + + {jo.itemCode || "—"} + + + + + {jo.itemName || "—"} + + + {isSending && } + + ); + })} + + + )} + + + setSettingsOpen(false)} maxWidth="sm" fullWidth> + 檸檬機(激光機)(系統設定) + + + + 儲存後寫入資料庫,後端送出走此 IP/埠(預設 192.168.18.77:45678)。 + + setLaserHost(e.target.value)} + fullWidth + /> + setLaserPort(e.target.value)} + fullWidth + /> + setLaserItemCodes(e.target.value)} + fullWidth + placeholder="PP1175" + helperText="預設 PP1175;可輸入多個品號,例如 PP1175,AB999。留空則列表顯示當日全部包裝工單。" + /> + + + + + + + + + setErrorSnackbar((s) => ({ ...s, open: false }))} + message={errorSnackbar.message} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + /> + + ); +}; + +export default LaserPrintSearch; diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 37b79b9..065fa74 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -180,6 +180,13 @@ const NavigationContent: React.FC = () => { requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], isHidden: false, }, + { + icon: , + label: "檸檬機(激光機)", + path: "/laserPrint", + requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], + isHidden: false, + }, { icon: , label: "報告管理", diff --git a/src/routes.ts b/src/routes.ts index b85b0b8..6d66c9e 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -5,6 +5,7 @@ export const PRIVATE_ROUTES = [ "/jo/testing", "/ps", "/bagPrint", + "/laserPrint", "/report", "/invoice", "/projects", From 6d3583a938aa796479305d2beeba0843ba38ad57 Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Thu, 26 Mar 2026 13:40:22 +0800 Subject: [PATCH 10/12] no message --- src/app/api/bagPrint/actions.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/app/api/bagPrint/actions.ts b/src/app/api/bagPrint/actions.ts index e9ac655..60794ec 100644 --- a/src/app/api/bagPrint/actions.ts +++ b/src/app/api/bagPrint/actions.ts @@ -33,6 +33,29 @@ export interface OnPackQrDownloadRequest { }[]; } +/** Readable message when ZIP download returns non-OK (plain text, JSON error body, or generic). */ +async function zipDownloadError(res: Response): Promise { + const text = await res.text(); + const ct = res.headers.get("content-type") ?? ""; + if (ct.includes("application/json")) { + try { + const j = JSON.parse(text) as { message?: string; error?: string }; + if (typeof j.message === "string" && j.message.length > 0) { + return new Error(j.message); + } + if (typeof j.error === "string" && j.error.length > 0) { + return new Error(j.error); + } + } catch { + /* ignore parse */ + } + } + if (text && text.length > 0 && text.length < 800 && !text.trim().startsWith("{")) { + return new Error(text); + } + return new Error(`下載失敗(HTTP ${res.status})。請查看後端日誌或確認資料庫已執行 Liquibase 更新。`); +} + /** * Fetch job orders by plan date from GET /py/job-orders. * Client-side only; uses auth token from localStorage. @@ -75,7 +98,7 @@ export async function downloadOnPackQrZip( }); if (!res.ok) { - throw new Error((await res.text()) || "Download failed"); + throw await zipDownloadError(res); } return res.blob(); @@ -93,7 +116,7 @@ export async function downloadOnPackTextQrZip( }); if (!res.ok) { - throw new Error((await res.text()) || "Download failed"); + throw await zipDownloadError(res); } return res.blob(); From f1fe469ccbe5c001e21aa1d31e12021024131565 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Thu, 26 Mar 2026 15:43:37 +0800 Subject: [PATCH 11/12] update --- src/app/api/stockTake/actions.ts | 95 +++++++++++++++++-- .../FGPickOrderTicketReleaseTable.tsx | 4 +- .../Jodetail/newJobPickExecution.tsx | 1 + src/components/PickOrderSearch/LotTable.tsx | 6 +- .../PickOrderSearch/PickExecution.tsx | 19 +--- .../ProductionProcessList.tsx | 22 +++-- .../QrCodeScannerProvider.tsx | 6 +- src/i18n/zh/inventory.json | 3 + 8 files changed, 121 insertions(+), 35 deletions(-) diff --git a/src/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index a991294..2772bc9 100644 --- a/src/app/api/stockTake/actions.ts +++ b/src/app/api/stockTake/actions.ts @@ -3,6 +3,7 @@ import { cache } from 'react'; import { serverFetchJson } from "@/app/utils/fetchUtil"; // 改为 serverFetchJson import { BASE_API_URL } from "@/config/api"; +import { stockTakeDebugLog } from "@/components/StockTakeManagement/stockTakeDebugLog"; export interface RecordsRes { records: T[]; @@ -51,6 +52,31 @@ export interface InventoryLotDetailResponse { approverTime?: string | string[] | null; } +/** + * `approverInventoryLotDetailsAll*`: + * - `total` = 全域 `inventory_lot_line` 中 `status = available` 筆數(與 DB COUNT 一致) + * - `filteredRecordCount` = 目前 tab/篩選後筆數(分頁用) + */ +export interface ApproverInventoryLotDetailsRecordsRes extends RecordsRes { + filteredRecordCount?: number; + totalWaitingForApprover?: number; + totalApproved?: number; +} + +function normalizeApproverInventoryLotDetailsRes( + raw: ApproverInventoryLotDetailsRecordsRes +): ApproverInventoryLotDetailsRecordsRes { + const waiting = Number(raw.totalWaitingForApprover ?? 0) || 0; + const approved = Number(raw.totalApproved ?? 0) || 0; + return { + records: Array.isArray(raw.records) ? raw.records : [], + total: Number(raw.total ?? 0) || 0, + filteredRecordCount: Number(raw.filteredRecordCount ?? 0) || 0, + totalWaitingForApprover: waiting, + totalApproved: approved, + }; +} + export const getInventoryLotDetailsBySection = async ( stockTakeSection: string, stockTakeId?: number | null, @@ -122,13 +148,13 @@ export const getApproverInventoryLotDetailsAll = async ( } const url = `${BASE_API_URL}/stockTakeRecord/approverInventoryLotDetailsAll?${params.toString()}`; - const response = await serverFetchJson>( + const response = await serverFetchJson( url, { method: "GET", }, ); - return response; + return normalizeApproverInventoryLotDetailsRes(response); } export const getApproverInventoryLotDetailsAllPending = async ( stockTakeId?: number | null, @@ -142,7 +168,8 @@ export const getApproverInventoryLotDetailsAllPending = async ( params.append("stockTakeId", String(stockTakeId)); } const url = `${BASE_API_URL}/stockTakeRecord/approverInventoryLotDetailsAllPending?${params.toString()}`; - return serverFetchJson>(url, { method: "GET" }); + const response = await serverFetchJson(url, { method: "GET" }); + return normalizeApproverInventoryLotDetailsRes(response); } export const getApproverInventoryLotDetailsAllApproved = async ( stockTakeId?: number | null, @@ -156,7 +183,8 @@ export const getApproverInventoryLotDetailsAllApproved = async ( params.append("stockTakeId", String(stockTakeId)); } const url = `${BASE_API_URL}/stockTakeRecord/approverInventoryLotDetailsAllApproved?${params.toString()}`; - return serverFetchJson>(url, { method: "GET" }); + const response = await serverFetchJson(url, { method: "GET" }); + return normalizeApproverInventoryLotDetailsRes(response); } export const importStockTake = async (data: FormData) => { @@ -242,6 +270,20 @@ export const saveStockTakeRecord = async ( console.log('saveStockTakeRecord: request:', request); console.log('saveStockTakeRecord: stockTakeId:', stockTakeId); console.log('saveStockTakeRecord: stockTakerId:', stockTakerId); + // #region agent log + stockTakeDebugLog( + "actions.ts:saveStockTakeRecord", + "server action saveStockTakeRecord ok", + "H3", + { + stockTakeId, + stockTakerId, + inventoryLotLineId: request.inventoryLotLineId, + hasRecordId: request.stockTakeRecordId != null, + resultId: result?.id ?? null, + } + ); + // #endregion return result; } catch (error: any) { // 尝试从错误响应中提取消息 @@ -271,12 +313,26 @@ export interface BatchSaveStockTakeRecordResponse { errors: string[]; } export const batchSaveStockTakeRecords = cache(async (data: BatchSaveStockTakeRecordRequest) => { - return serverFetchJson(`${BASE_API_URL}/stockTakeRecord/batchSaveStockTakeRecords`, + const r = await serverFetchJson(`${BASE_API_URL}/stockTakeRecord/batchSaveStockTakeRecords`, { method: "POST", body: JSON.stringify(data), headers: { "Content-Type": "application/json" }, }) + // #region agent log + stockTakeDebugLog( + "actions.ts:batchSaveStockTakeRecords", + "server batch picker result", + "H4", + { + stockTakeId: data.stockTakeId, + stockTakeSection: data.stockTakeSection, + successCount: r.successCount, + errorCount: r.errorCount, + } + ); + // #endregion + return r }) // Add these interfaces and functions @@ -325,6 +381,19 @@ export const saveApproverStockTakeRecord = async ( body: JSON.stringify(request), }, ); + // #region agent log + stockTakeDebugLog( + "actions.ts:saveApproverStockTakeRecord", + "server action saveApproverStockTakeRecord ok", + "H3", + { + stockTakeId, + stockTakeRecordId: request.stockTakeRecordId ?? null, + lastSelect: request.lastSelect ?? null, + hasApproverQty: request.approverQty != null, + } + ); + // #endregion return result; } catch (error: any) { if (error?.response) { @@ -354,7 +423,7 @@ export const batchSaveApproverStockTakeRecords = cache(async (data: BatchSaveApp ) export const batchSaveApproverStockTakeRecordsAll = cache(async (data: BatchSaveApproverStockTakeAllRequest) => { - return serverFetchJson( + const r = await serverFetchJson( `${BASE_API_URL}/stockTakeRecord/batchSaveApproverStockTakeRecordsAll`, { method: "POST", @@ -362,6 +431,20 @@ export const batchSaveApproverStockTakeRecordsAll = cache(async (data: BatchSave headers: { "Content-Type": "application/json" }, } ) + // #region agent log + stockTakeDebugLog( + "actions.ts:batchSaveApproverStockTakeRecordsAll", + "server batch approver-all result", + "H4", + { + stockTakeId: data.stockTakeId, + approverId: data.approverId, + successCount: r.successCount, + errorCount: r.errorCount, + } + ); + // #endregion + return r }) export const updateStockTakeRecordStatusToNotMatch = async ( diff --git a/src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx b/src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx index 0f0aa4e..ffd6110 100644 --- a/src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx +++ b/src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx @@ -65,7 +65,9 @@ const FGPickOrderTicketReleaseTable: React.FC = () => { const { t } = useTranslation("ticketReleaseTable"); const { data: session } = useSession() as { data: SessionWithTokens | null }; const abilities = session?.abilities ?? session?.user?.abilities ?? []; - const canManageDoPickOps = abilities.includes(AUTH.ADMIN); + // 依照 DB `authority.authority = 'ADMIN'` 的逻辑:仅 abilities 明確包含 ADMIN 才允許操作 + // (避免 abilities 裡出現前後空白導致 includes 判斷失效) + const canManageDoPickOps = abilities.some((a) => a.trim() === AUTH.ADMIN); const [queryDate, setQueryDate] = useState(() => dayjs()); const [selectedFloor, setSelectedFloor] = useState(""); diff --git a/src/components/Jodetail/newJobPickExecution.tsx b/src/components/Jodetail/newJobPickExecution.tsx index 870adf7..4f63509 100644 --- a/src/components/Jodetail/newJobPickExecution.tsx +++ b/src/components/Jodetail/newJobPickExecution.tsx @@ -2763,6 +2763,7 @@ const sortedData = [...sourceData].sort((a, b) => { disabled={ (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) || lot.stockOutLineStatus === 'completed' || + lot.stockOutLineStatus === 'checked' || lot.noLot === true || !lot.lotId || (Number(lot.stockOutLineId) > 0 && diff --git a/src/components/PickOrderSearch/LotTable.tsx b/src/components/PickOrderSearch/LotTable.tsx index 7430a48..cf96baf 100644 --- a/src/components/PickOrderSearch/LotTable.tsx +++ b/src/components/PickOrderSearch/LotTable.tsx @@ -397,8 +397,8 @@ const LotTable: React.FC = ({ const { t } = useTranslation("pickOrder"); const calculateRemainingRequiredQty = useCallback((lot: LotPickData) => { const requiredQty = lot.requiredQty || 0; - const stockOutLineQty = lot.stockOutLineQty || 0; - return Math.max(0, requiredQty - stockOutLineQty); + const availableQty = lot.availableQty || 0; + return Math.max(0, requiredQty + availableQty); }, []); // Add QR scanner context const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); @@ -506,7 +506,7 @@ const LotTable: React.FC = ({ const stockOutLineUpdate = await updateStockOutLineStatus({ id: selectedLotForQr.stockOutLineId, status: 'checked', - qty: selectedLotForQr.stockOutLineQty || 0 + qty: 0 }); console.log(" Stock out line updated to 'checked':", stockOutLineUpdate); diff --git a/src/components/PickOrderSearch/PickExecution.tsx b/src/components/PickOrderSearch/PickExecution.tsx index 4eb827b..b86cbd6 100644 --- a/src/components/PickOrderSearch/PickExecution.tsx +++ b/src/components/PickOrderSearch/PickExecution.tsx @@ -361,13 +361,9 @@ const PickExecution: React.FC = ({ filterArgs }) => { try { // FIXED: 计算累计拣货数量 const totalPickedForThisLot = (selectedLot.actualPickQty || 0) + qty; - console.log(" DEBUG - Previous picked:", selectedLot.actualPickQty || 0); - console.log(" DEBUG - Current submit:", qty); - console.log(" DEBUG - Total picked:", totalPickedForThisLot); - console.log("�� DEBUG - Required qty:", selectedLot.requiredQty); - + // FIXED: 状态应该基于累计拣货数量 - let newStatus = 'partially_completed'; + let newStatus = 'completed'; if (totalPickedForThisLot >= selectedLot.requiredQty) { newStatus = 'completed'; } @@ -388,16 +384,7 @@ const PickExecution: React.FC = ({ filterArgs }) => { return; } - if (qty > 0) { - const inventoryLotLineUpdate = await updateInventoryLotLineQuantities({ - inventoryLotLineId: lotId, - qty: qty, - status: 'available', - operation: 'pick' - }); - - console.log("Inventory lot line updated:", inventoryLotLineUpdate); - } + // RE-ENABLE: Check if pick order should be completed if (newStatus === 'completed') { diff --git a/src/components/ProductionProcess/ProductionProcessList.tsx b/src/components/ProductionProcess/ProductionProcessList.tsx index ab5bf35..9522c30 100644 --- a/src/components/ProductionProcess/ProductionProcessList.tsx +++ b/src/components/ProductionProcess/ProductionProcessList.tsx @@ -32,6 +32,7 @@ import { SessionWithTokens } from "@/config/authConfig"; import dayjs from "dayjs"; import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox"; +import { AUTH } from "@/authorities"; import { @@ -103,6 +104,9 @@ const ProductProcessList: React.FC = ({ const [openModal, setOpenModal] = useState(false); const [modalInfo, setModalInfo] = useState(); const currentUserId = session?.id ? parseInt(session.id) : undefined; + const abilities = session?.abilities ?? session?.user?.abilities ?? []; + // 依照 DB `authority.authority = 'ADMIN'` 的逻辑:僅 abilities 明確包含 ADMIN 才能操作 + const canManageUpdateJo = abilities.some((a) => a.trim() === AUTH.ADMIN); type ProcessFilter = "all" | "drink" | "other"; const [suggestedLocationCode, setSuggestedLocationCode] = useState(null); @@ -275,6 +279,7 @@ const ProductProcessList: React.FC = ({ fetchProcesses(); }, [fetchProcesses]); const handleUpdateJo = useCallback(async (process: AllJoborderProductProcessInfoResponse) => { + if (!canManageUpdateJo) return; if (!process.jobOrderId) { alert(t("Invalid Job Order Id")); return; @@ -308,7 +313,7 @@ const ProductProcessList: React.FC = ({ } finally { setLoading(false); } - }, [t, fetchProcesses]); + }, [t, fetchProcesses, canManageUpdateJo]); const openConfirm = useCallback((message: string, action: () => Promise) => { setConfirmMessage(message); @@ -590,13 +595,16 @@ const ProductProcessList: React.FC = ({