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..753a03b 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/(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/(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' ? ( <> - - - - - -
- - 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..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,25 @@ export async function downloadOnPackQrZip( }); if (!res.ok) { - throw new Error((await res.text()) || "Download failed"); + throw await zipDownloadError(res); + } + + 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 await zipDownloadError(res); } return res.blob(); 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/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 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/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index 2cee98e..bf9b425 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[]; @@ -41,6 +42,39 @@ export interface InventoryLotDetailResponse { approverBadQty: number | null; finalQty: number | null; bookQty: number | null; + lastSelect?: number | null; + 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; +} + +/** + * `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 ( @@ -114,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, @@ -134,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, @@ -148,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) => { @@ -234,6 +270,7 @@ export const saveStockTakeRecord = async ( console.log('saveStockTakeRecord: request:', request); console.log('saveStockTakeRecord: stockTakeId:', stockTakeId); console.log('saveStockTakeRecord: stockTakerId:', stockTakerId); + return result; } catch (error: any) { // 尝试从错误响应中提取消息 @@ -263,12 +300,14 @@ 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" }, }) + + return r }) // Add these interfaces and functions @@ -279,6 +318,7 @@ export interface SaveApproverStockTakeRecordRequest { approverId?: number | null; approverQty?: number | null; approverBadQty?: number | null; + lastSelect?: number | null; } export interface BatchSaveApproverStockTakeRecordRequest { @@ -316,6 +356,7 @@ export const saveApproverStockTakeRecord = async ( body: JSON.stringify(request), }, ); + return result; } catch (error: any) { if (error?.response) { @@ -345,7 +386,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", @@ -353,6 +394,8 @@ export const batchSaveApproverStockTakeRecordsAll = cache(async (data: BatchSave headers: { "Content-Type": "application/json" }, } ) + + return r }) export const updateStockTakeRecordStatusToNotMatch = async ( 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} - + + diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 022dc0c..47c0843 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -47,6 +47,7 @@ const pathToLabelMap: { [path: string]: string } = { "/stockIssue": "Stock Issue", "/report": "Report", "/bagPrint": "打袋機", + "/laserPrint": "檸檬機(激光機)", "/settings/itemPrice": "Price Inquiry", }; diff --git a/src/components/DoSearch/DoSearch.tsx b/src/components/DoSearch/DoSearch.tsx index 278f97b..fa99cb0 100644 --- a/src/components/DoSearch/DoSearch.tsx +++ b/src/components/DoSearch/DoSearch.tsx @@ -58,6 +58,7 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea const formProps = useForm({ defaultValues: {}, }); + const { setValue } = formProps; const errors = formProps.formState.errors; @@ -68,8 +69,8 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea console.log("🔍 DoSearch - session:", session); console.log("🔍 DoSearch - currentUserId:", currentUserId); const [searchTimeout, setSearchTimeout] = useState(null); - const [rowSelectionModel, setRowSelectionModel] = - useState([]); + /** 使用者明確取消勾選的送貨單 id;未在此集合中的搜尋結果視為「已選」以便跨頁記憶 */ + const [excludedRowIds, setExcludedRowIds] = useState([]); const [searchAllDos, setSearchAllDos] = useState([]); const [totalCount, setTotalCount] = useState(0); @@ -101,6 +102,37 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea const [hasSearched, setHasSearched] = useState(false); const [hasResults, setHasResults] = useState(false); + const excludedIdSet = useMemo(() => new Set(excludedRowIds), [excludedRowIds]); + + const rowSelectionModel = useMemo(() => { + return searchAllDos + .map((r) => r.id) + .filter((id) => !excludedIdSet.has(id)); + }, [searchAllDos, excludedIdSet]); + + const applyRowSelectionChange = useCallback( + (newModel: GridRowSelectionModel) => { + const pageIds = searchAllDos.map((r) => r.id); + const selectedSet = new Set( + newModel.map((id) => (typeof id === "string" ? Number(id) : id)), + ); + setExcludedRowIds((prev) => { + const next = new Set(prev); + for (const id of pageIds) { + next.delete(id); + } + for (const id of pageIds) { + if (!selectedSet.has(id)) { + next.add(id); + } + } + return Array.from(next); + }); + setValue("ids", newModel); + }, + [searchAllDos, setValue], + ); + // 当搜索条件变化时,重置到第一页 useEffect(() => { setPagingController(p => ({ @@ -140,6 +172,7 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea setTotalCount(0); setHasSearched(false); setHasResults(false); + setExcludedRowIds([]); setPagingController({ pageNum: 1, pageSize: 10 }); } catch (error) { @@ -289,6 +322,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { setTotalCount(response.total); // 设置总记录数 setHasSearched(true); setHasResults(response.records.length > 0); + setExcludedRowIds([]); } catch (error) { console.error("Error: ", error); @@ -296,6 +330,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { setTotalCount(0); setHasSearched(true); setHasResults(false); + setExcludedRowIds([]); } }, [pagingController]); @@ -494,6 +529,20 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { }); return; } + + const idsToRelease = allMatchingDos + .map((d) => d.id) + .filter((id) => !excludedIdSet.has(id)); + + if (idsToRelease.length === 0) { + await Swal.fire({ + icon: "warning", + title: t("No Records"), + text: t("No delivery orders selected for batch release. Uncheck orders you want to exclude, or search again to reset selection."), + confirmButtonText: t("OK"), + }); + return; + } // 显示确认对话框 const result = await Swal.fire({ @@ -501,7 +550,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { title: t("Batch Release"), html: `
-

${t("Selected Shop(s): ")}${allMatchingDos.length}

+

${t("Selected Shop(s): ")}${idsToRelease.length}

${currentSearchParams.code ? `${t("Code")}: ${currentSearchParams.code} ` : ""} @@ -519,8 +568,6 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { }); if (result.isConfirmed) { - const idsToRelease = allMatchingDos.map(d => d.id); - try { const startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); const jobId = startRes?.entity?.jobId; @@ -595,7 +642,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { confirmButtonText: t("OK") }); } - }, [t, currentUserId, currentSearchParams, handleSearch]); + }, [t, currentUserId, currentSearchParams, handleSearch, excludedIdSet]); return ( <> @@ -629,10 +676,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { columns={columns} checkboxSelection rowSelectionModel={rowSelectionModel} - onRowSelectionModelChange={(newRowSelectionModel) => { - setRowSelectionModel(newRowSelectionModel); - formProps.setValue("ids", newRowSelectionModel); - }} + onRowSelectionModelChange={applyRowSelectionChange} slots={{ footer: FooterToolbar, noRowsOverlay: NoRowsOverlay, 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/FinishedGoodSearch/GoodPickExecutiondetail.tsx b/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx index f07576e..0639b44 100644 --- a/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx +++ b/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx @@ -80,6 +80,23 @@ interface Props { onSwitchToRecordTab?: () => void; onRefreshReleasedOrderCount?: () => void; } + +/** 同物料多行时,优先对「有建议批次号」的行做替换,避免误选「无批次/不足」行 */ +function pickExpectedLotForSubstitution(activeSuggestedLots: any[]): any | null { + if (!activeSuggestedLots?.length) return null; + const withLotNo = activeSuggestedLots.filter( + (l) => l.lotNo != null && String(l.lotNo).trim() !== "" + ); + if (withLotNo.length === 1) return withLotNo[0]; + if (withLotNo.length > 1) { + const pending = withLotNo.find( + (l) => (l.stockOutLineStatus || "").toLowerCase() === "pending" + ); + return pending || withLotNo[0]; + } + return activeSuggestedLots[0]; +} + // QR Code Modal Component (from LotTable) const QrCodeModal: React.FC<{ open: boolean; @@ -513,6 +530,22 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); const [originalCombinedData, setOriginalCombinedData] = useState([]); // issue form 里填的 actualPickQty(用于 batch submit 只提交实际拣到数量,而不是补拣到 required) const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState>({}); + const applyLocalStockOutLineUpdate = useCallback(( + stockOutLineId: number, + status: string, + actualPickQty?: number + ) => { + setCombinedLotData(prev => prev.map((lot) => { + if (Number(lot.stockOutLineId) !== Number(stockOutLineId)) return lot; + return { + ...lot, + stockOutLineStatus: status, + ...(typeof actualPickQty === "number" + ? { actualPickQty, stockOutLineQty: actualPickQty } + : {}), + }; + })); + }, []); // 防止重复点击(Submit / Just Completed / Issue) const [actionBusyBySolId, setActionBusyBySolId] = useState>({}); @@ -571,12 +604,11 @@ const [isConfirmingLot, setIsConfirmingLot] = useState(false); const lastProcessedQrRef = useRef(''); // Store callbacks in refs to avoid useEffect dependency issues - const processOutsideQrCodeRef = useRef<((latestQr: string) => Promise) | null>(null); + const processOutsideQrCodeRef = useRef< + ((latestQr: string, qrScanCountAtInvoke?: number) => Promise) | null + >(null); const resetScanRef = useRef<(() => void) | null>(null); const lotConfirmOpenedQrCountRef = useRef(0); - const lotConfirmOpenedQrValueRef = useRef(''); - const lotConfirmInitialSameQrSkippedRef = useRef(false); - const autoConfirmInProgressRef = useRef(false); @@ -651,11 +683,14 @@ const [isConfirmingLot, setIsConfirmingLot] = useState(false); } }, []); - const handleLotMismatch = useCallback((expectedLot: any, scannedLot: any) => { + const handleLotMismatch = useCallback((expectedLot: any, scannedLot: any, qrScanCountAtOpen?: number) => { const mismatchStartTime = performance.now(); console.log(`⏱️ [HANDLE LOT MISMATCH START]`); console.log(`⏰ Start time: ${new Date().toISOString()}`); console.log("Lot mismatch detected:", { expectedLot, scannedLot }); + + lotConfirmOpenedQrCountRef.current = + typeof qrScanCountAtOpen === "number" ? qrScanCountAtOpen : 1; // ✅ Use setTimeout to avoid flushSync warning - schedule modal update in next tick const setTimeoutStartTime = performance.now(); @@ -1299,34 +1334,6 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO return false; }, [lotConfirmationOpen, selectedLotForQr, expectedLotData, scannedLotData, parseQrPayload, handleLotConfirmation, clearLotConfirmationState]); - useEffect(() => { - if (!lotConfirmationOpen || !expectedLotData || !scannedLotData || !selectedLotForQr) { - autoConfirmInProgressRef.current = false; - return; - } - - if (autoConfirmInProgressRef.current || isConfirmingLot) { - return; - } - - autoConfirmInProgressRef.current = true; - handleLotConfirmation() - .catch((error) => { - console.error("Auto confirm lot substitution failed:", error); - }) - .finally(() => { - autoConfirmInProgressRef.current = false; - }); - }, [lotConfirmationOpen, expectedLotData, scannedLotData, selectedLotForQr, isConfirmingLot, handleLotConfirmation]); - - useEffect(() => { - if (lotConfirmationOpen) { - // 记录弹窗打开时的扫码数量,避免把“触发弹窗的同一次扫码”当作二次确认 - lotConfirmOpenedQrCountRef.current = qrValues.length; - lotConfirmOpenedQrValueRef.current = qrValues[qrValues.length - 1] || ''; - lotConfirmInitialSameQrSkippedRef.current = true; - } - }, [lotConfirmationOpen, qrValues.length]); const handleQrCodeSubmit = useCallback(async (lotNo: string) => { console.log(` Processing QR Code for lot: ${lotNo}`); @@ -1624,7 +1631,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // Store resetScan in ref for immediate access (update on every render) resetScanRef.current = resetScan; - const processOutsideQrCode = useCallback(async (latestQr: string) => { + const processOutsideQrCode = useCallback(async (latestQr: string, qrScanCountAtInvoke?: number) => { const totalStartTime = performance.now(); console.log(`⏱️ [PROCESS OUTSIDE QR START] QR: ${latestQr.substring(0, 50)}...`); console.log(`⏰ Start time: ${new Date().toISOString()}`); @@ -1742,7 +1749,14 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO lot.lotAvailability === 'rejected' || lot.lotAvailability === 'status_unavailable' ); - const expectedLot = rejectedLot || allLotsForItem[0]; // Use rejected lot if exists, otherwise first lot + const expectedLot = + rejectedLot || + pickExpectedLotForSubstitution( + allLotsForItem.filter( + (l: any) => l.lotNo != null && String(l.lotNo).trim() !== "" + ) + ) || + allLotsForItem[0]; // ✅ Always open confirmation modal when no active lots (user needs to confirm switching) // handleLotMismatch will fetch lotNo from backend using stockInLineId if needed @@ -1760,7 +1774,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO itemName: expectedLot.itemName, inventoryLotLineId: scannedLot?.lotId || null, stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo - } + }, + qrScanCountAtInvoke ); return; } @@ -1785,7 +1800,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // Also handle case where scanned lot is not in allLotsForItem (scannedLot is undefined) if (!exactMatch) { // Scanned lot is not in active suggested lots, open confirmation modal - const expectedLot = activeSuggestedLots[0] || allLotsForItem[0]; // Use first active lot or first lot as expected + const expectedLot = + pickExpectedLotForSubstitution(activeSuggestedLots) || allLotsForItem[0]; if (expectedLot) { // Check if scanned lot is different from expected, or if scannedLot is undefined (not in allLotsForItem) const shouldOpenModal = !scannedLot || (scannedLot.stockInLineId !== expectedLot.stockInLineId); @@ -1804,7 +1820,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO itemName: expectedLot.itemName, inventoryLotLineId: scannedLot?.lotId || null, stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo - } + }, + qrScanCountAtInvoke ); return; } @@ -1925,9 +1942,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO const mismatchCheckTime = performance.now() - mismatchCheckStartTime; console.log(`⏱️ [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`); - // 取第一个活跃的 lot 作为期望的 lot + // 取应被替换的活跃行(同物料多行时优先有建议批次的行) const expectedLotStartTime = performance.now(); - const expectedLot = activeSuggestedLots[0]; + const expectedLot = pickExpectedLotForSubstitution(activeSuggestedLots); if (!expectedLot) { console.error("Could not determine expected lot for confirmation"); startTransition(() => { @@ -1963,7 +1980,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO itemName: expectedLot.itemName, inventoryLotLineId: null, stockInLineId: scannedStockInLineId // ✅ 传递 stockInLineId - } + }, + qrScanCountAtInvoke ); const handleMismatchTime = performance.now() - handleMismatchStartTime; console.log(`⏱️ [PERF] Handle mismatch call time: ${handleMismatchTime.toFixed(2)}ms`); @@ -2048,7 +2066,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // ✅ Process immediately (bypass QR scanner delay) if (processOutsideQrCodeRef.current) { - processOutsideQrCodeRef.current(simulatedQr).then(() => { + processOutsideQrCodeRef.current(simulatedQr, qrValues.length).then(() => { const testTime = performance.now() - testStartTime; console.log(`⏱️ [TEST QR] Total processing time: ${testTime.toFixed(2)}ms (${(testTime / 1000).toFixed(3)}s)`); console.log(`⏱️ [TEST QR] End time: ${new Date().toISOString()}`); @@ -2074,9 +2092,24 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO } } - // lot confirm 弹窗打开时,允许通过“再次扫码”决定走向(切换或继续原 lot) + // 批次确认弹窗:须第二次扫码选择沿用建议批次或切换(不再自动确认) if (lotConfirmationOpen) { - // 已改回自动确认:弹窗打开时不再等待二次扫码 + if (isConfirmingLot) { + return; + } + if (qrValues.length <= lotConfirmOpenedQrCountRef.current) { + return; + } + void (async () => { + try { + const handled = await handleLotConfirmationByRescan(latestQr); + if (handled && resetScanRef.current) { + resetScanRef.current(); + } + } catch (e) { + console.error("Lot confirmation rescan failed:", e); + } + })(); return; } @@ -2171,7 +2204,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // Use ref to avoid dependency issues const processCallStartTime = performance.now(); if (processOutsideQrCodeRef.current) { - processOutsideQrCodeRef.current(latestQr).then(() => { + processOutsideQrCodeRef.current(latestQr, qrValues.length).then(() => { const processCallTime = performance.now() - processCallStartTime; const totalProcessingTime = performance.now() - processingStartTime; console.log(`⏱️ [QR PROCESS] processOutsideQrCode call time: ${processCallTime.toFixed(2)}ms`); @@ -2203,7 +2236,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO qrProcessingTimeoutRef.current = null; } }; - }, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, lotConfirmationOpen, manualLotConfirmationOpen, handleLotConfirmationByRescan]); + }, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, lotConfirmationOpen, manualLotConfirmationOpen, handleLotConfirmationByRescan, isConfirmingLot]); const renderCountRef = useRef(0); const renderStartTimeRef = useRef(null); @@ -2550,16 +2583,16 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe try { if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true })); - // Special case: If submitQty is 0 and all values are 0, mark as completed with qty: 0 + // Just Complete: mark checked only, real posting happens in batch submit if (submitQty === 0) { console.log(`=== SUBMITTING ALL ZEROS CASE ===`); console.log(`Lot: ${lot.lotNo}`); console.log(`Stock Out Line ID: ${lot.stockOutLineId}`); - console.log(`Setting status to 'completed' with qty: 0`); + console.log(`Setting status to 'checked' with qty: 0`); const updateResult = await updateStockOutLineStatus({ id: lot.stockOutLineId, - status: 'completed', + status: 'checked', qty: 0 }); @@ -2575,29 +2608,10 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe console.error('Failed to update stock out line status:', updateResult); throw new Error('Failed to update stock out line status'); } + applyLocalStockOutLineUpdate(Number(lot.stockOutLineId), "checked", Number(lot.actualPickQty || 0)); - // Check if pick order is completed - if (lot.pickOrderConsoCode) { - console.log(` Lot ${lot.lotNo} completed (all zeros), checking if pick order ${lot.pickOrderConsoCode} is complete...`); - - try { - const completionResponse = await checkAndCompletePickOrderByConsoCode(lot.pickOrderConsoCode); - console.log(` Pick order completion check result:`, completionResponse); - - if (completionResponse.code === "SUCCESS") { - console.log(` Pick order ${lot.pickOrderConsoCode} completed successfully!`); - } else if (completionResponse.message === "not completed") { - console.log(`⏳ Pick order not completed yet, more lines remaining`); - } else { - console.error(` Error checking completion: ${completionResponse.message}`); - } - } catch (error) { - console.error("Error checking pick order completion:", error); - } - } - - await fetchAllCombinedLotData(); - console.log("All zeros submission completed successfully!"); + void fetchAllCombinedLotData(); + console.log("Just Complete marked as checked successfully (waiting for batch submit)."); setTimeout(() => { checkAndAutoAssignNext(); @@ -2635,6 +2649,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe status: newStatus, qty: cumulativeQty // Use cumulative quantity }); + applyLocalStockOutLineUpdate(Number(lot.stockOutLineId), newStatus, cumulativeQty); if (submitQty > 0) { await updateInventoryLotLineQuantities({ @@ -2665,7 +2680,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe } } - await fetchAllCombinedLotData(); + void fetchAllCombinedLotData(); console.log("Pick quantity submitted successfully!"); setTimeout(() => { @@ -2677,16 +2692,31 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe } finally { if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); } -}, [fetchAllCombinedLotData, checkAndAutoAssignNext, actionBusyBySolId]); +}, [fetchAllCombinedLotData, checkAndAutoAssignNext, actionBusyBySolId, applyLocalStockOutLineUpdate]); const handleSkip = useCallback(async (lot: any) => { try { - console.log("Skip clicked, submit lot required qty for lot:", lot.lotNo); - await handleSubmitPickQtyWithQty(lot, lot.requiredQty); + console.log("Just Complete clicked, mark checked with 0 qty for lot:", lot.lotNo); + await handleSubmitPickQtyWithQty(lot, 0); } catch (err) { console.error("Error in Skip:", err); } }, [handleSubmitPickQtyWithQty]); +const hasPendingBatchSubmit = useMemo(() => { + return combinedLotData.some((lot) => { + const status = String(lot.stockOutLineStatus || "").toLowerCase(); + return status === "checked" || status === "pending" || status === "partially_completed" || status === "partially_complete"; + }); +}, [combinedLotData]); +useEffect(() => { + if (!hasPendingBatchSubmit) return; + const handler = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); +}, [hasPendingBatchSubmit]); const handleStartScan = useCallback(() => { const startTime = performance.now(); console.log(`⏱️ [START SCAN] Called at: ${new Date().toISOString()}`); @@ -2890,6 +2920,10 @@ const handleSubmitAllScanned = useCallback(async () => { const scannedLots = combinedLotData.filter(lot => { const status = lot.stockOutLineStatus; + const statusLower = String(status || "").toLowerCase(); + if (statusLower === "completed" || statusLower === "complete") { + return false; + } // ✅ noLot 情况:允许 checked / pending / partially_completed / PARTIALLY_COMPLETE if (lot.noLot === true) { return status === 'checked' || @@ -3021,6 +3055,10 @@ const handleSubmitAllScanned = useCallback(async () => { const scannedItemsCount = useMemo(() => { const filtered = combinedLotData.filter(lot => { const status = lot.stockOutLineStatus; + const statusLower = String(status || "").toLowerCase(); + if (statusLower === "completed" || statusLower === "complete") { + return false; + } // ✅ 与 handleSubmitAllScanned 完全保持一致 if (lot.noLot === true) { return status === 'checked' || @@ -3528,6 +3566,9 @@ paginatedData.map((lot, index) => { onClick={() => handleSkip(lot)} disabled={ lot.stockOutLineStatus === 'completed' || + lot.stockOutLineStatus === 'checked' || + lot.stockOutLineStatus === 'partially_completed' || + // 使用 issue form 後,禁用「Just Completed」(避免再次点击造成重复提交) (Number(lot.stockOutLineId) > 0 && issuePickedQtyBySolId[Number(lot.stockOutLineId)] !== undefined) || (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) diff --git a/src/components/FinishedGoodSearch/LotConfirmationModal.tsx b/src/components/FinishedGoodSearch/LotConfirmationModal.tsx index d5c60eb..405976c 100644 --- a/src/components/FinishedGoodSearch/LotConfirmationModal.tsx +++ b/src/components/FinishedGoodSearch/LotConfirmationModal.tsx @@ -52,7 +52,7 @@ const LotConfirmationModal: React.FC = ({ - {t("The scanned item matches the expected item, but the lot number is different. Do you want to proceed with this different lot?")} + {t("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.")} @@ -92,13 +92,10 @@ const LotConfirmationModal: React.FC = ({ - {t("If you confirm, the system will:")} -

    -
  • {t("Update your suggested lot to the this scanned lot")}
  • -
+ {t("After you scan to choose, the system will update the pick line to the lot you confirmed.")} - {t("You can also scan again to confirm: scan the scanned lot again to switch, or scan the expected lot to continue with current lot.")} + {t("Or use the Confirm button below if you cannot scan again (same as scanning the other lot again).")} 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, onBackToList }) => { const [searchQuery, setSearchQuery] = useState>({}); // issue form 里填的 actualPickQty(用于 submit/batch submit 不补拣到 required) const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState>({}); + const [localSolStatusById, setLocalSolStatusById] = useState>({}); // 防止同一行(以 stockOutLineId/solId 识别)被重复点击提交/完成 const [actionBusyBySolId, setActionBusyBySolId] = useState>({}); @@ -646,20 +647,22 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { // 前端覆盖:issue form/submit0 不会立刻改写后端 qty 时,用本地缓存让 UI 与 batch submit 计算一致 return lots.map((lot: any) => { const solId = Number(lot.stockOutLineId) || 0; - if (solId > 0 && Object.prototype.hasOwnProperty.call(issuePickedQtyBySolId, solId)) { - const picked = Number(issuePickedQtyBySolId[solId] ?? 0); - const status = String(lot.stockOutLineStatus || '').toLowerCase(); + if (solId > 0) { + const hasPickedOverride = Object.prototype.hasOwnProperty.call(issuePickedQtyBySolId, solId); + const picked = Number(issuePickedQtyBySolId[solId] ?? lot.actualPickQty ?? 0); + const statusRaw = localSolStatusById[solId] ?? lot.stockOutLineStatus ?? ""; + const status = String(statusRaw).toLowerCase(); const isEnded = status === 'completed' || status === 'rejected'; return { ...lot, - actualPickQty: picked, - stockOutLineQty: picked, - stockOutLineStatus: isEnded ? lot.stockOutLineStatus : 'checked', + actualPickQty: hasPickedOverride ? picked : lot.actualPickQty, + stockOutLineQty: hasPickedOverride ? picked : lot.stockOutLineQty, + stockOutLineStatus: isEnded ? statusRaw : (statusRaw || "checked"), }; } return lot; }); - }, [jobOrderData, getAllLotsFromHierarchical, issuePickedQtyBySolId]); + }, [jobOrderData, getAllLotsFromHierarchical, issuePickedQtyBySolId, localSolStatusById]); const originalCombinedData = useMemo(() => { return getAllLotsFromHierarchical(jobOrderData); @@ -1802,6 +1805,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { console.error("No stock out line found for this lot"); return; } + const solId = Number(lot.stockOutLineId) || 0; try { if (currentUserId && lot.pickOrderId && lot.itemId) { @@ -1842,13 +1846,13 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { // 记录该 SOL 的“目标实际拣货量=0”,让 batch submit 走 onlyComplete(不补拣到 required) - const solId = Number(lot.stockOutLineId) || 0; if (solId > 0) { setIssuePickedQtyBySolId(prev => ({ ...prev, [solId]: 0 })); + setLocalSolStatusById(prev => ({ ...prev, [solId]: 'checked' })); } const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; - await fetchJobOrderData(pickOrderId); + void fetchJobOrderData(pickOrderId); console.log("All zeros submission marked as checked successfully (waiting for batch submit)."); setTimeout(() => { @@ -1887,6 +1891,10 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { status: newStatus, qty: cumulativeQty }); + if (solId > 0) { + setIssuePickedQtyBySolId(prev => ({ ...prev, [solId]: cumulativeQty })); + setLocalSolStatusById(prev => ({ ...prev, [solId]: newStatus })); + } if (submitQty > 0) { await updateInventoryLotLineQuantities({ @@ -1923,7 +1931,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { } const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; - await fetchJobOrderData(pickOrderId); + void fetchJobOrderData(pickOrderId); console.log("Pick quantity submitted successfully!"); setTimeout(() => { @@ -1936,15 +1944,34 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { }, [fetchJobOrderData, checkAndAutoAssignNext, filterArgs]); const handleSkip = useCallback(async (lot: any) => { try { - console.log("Skip clicked, submit 0 qty for lot:", lot.lotNo); + console.log("Just Complete clicked, mark checked with 0 qty for lot:", lot.lotNo); await handleSubmitPickQtyWithQty(lot, 0); } catch (err) { console.error("Error in Skip:", err); } }, [handleSubmitPickQtyWithQty]); + const hasPendingBatchSubmit = useMemo(() => { + return combinedLotData.some((lot) => { + const status = String(lot.stockOutLineStatus || "").toLowerCase(); + return status === "checked" || status === "pending" || status === "partially_completed" || status === "partially_complete"; + }); + }, [combinedLotData]); + useEffect(() => { + if (!hasPendingBatchSubmit) return; + const handler = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [hasPendingBatchSubmit]); const handleSubmitAllScanned = useCallback(async () => { const scannedLots = combinedLotData.filter(lot => { const status = lot.stockOutLineStatus; + const statusLower = String(status || "").toLowerCase(); + if (statusLower === "completed" || statusLower === "complete") { + return false; + } console.log("lot.noLot:", lot.noLot); console.log("lot.status:", lot.stockOutLineStatus); // ✅ no-lot:允許 pending / checked / partially_completed / PARTIALLY_COMPLETE @@ -2093,6 +2120,10 @@ if (onlyComplete) { const scannedItemsCount = useMemo(() => { return combinedLotData.filter(lot => { const status = lot.stockOutLineStatus; + const statusLower = String(status || "").toLowerCase(); + if (statusLower === "completed" || statusLower === "complete") { + return false; + } const isNoLot = lot.noLot === true || !lot.lotId; if (isNoLot) { @@ -2722,7 +2753,7 @@ const sortedData = [...sourceData].sort((a, b) => { console.error("❌ Error updating handler (non-critical):", error); } } - await handleSubmitPickQtyWithQty(lot, lot.requiredQty || lot.pickOrderLineRequiredQty || 0); + await handleSubmitPickQtyWithQty(lot, 0); } finally { if (solId > 0) { setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); @@ -2732,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/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/components/PickOrderSearch/AssignAndRelease.tsx b/src/components/PickOrderSearch/AssignAndRelease.tsx index 191fa5d..c7a5398 100644 --- a/src/components/PickOrderSearch/AssignAndRelease.tsx +++ b/src/components/PickOrderSearch/AssignAndRelease.tsx @@ -497,8 +497,7 @@ const AssignAndRelease: React.FC = ({ filterArgs }) => { {/* Target Date - 只在第一个项目显示 */} {index === 0 ? ( - arrayToDayjs(item.targetDate) - .add(-1, "month") + arrayToDayjs(item.targetDate) .format(OUTPUT_DATE_FORMAT) ) : null} 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 = ({ )} diff --git a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx index 861d152..1be6453 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; @@ -131,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) { @@ -148,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; @@ -178,9 +287,13 @@ 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 过滤掉,导致“输入后行消失无法提交” + if (selection === "approver") { + return true; + } const difference = calculateDifference(detail, selection); const bookQty = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); @@ -195,6 +308,64 @@ const ApproverStockTakeAll: React.FC = ({ calculateDifference, ]); + const sortedDetails = useMemo(() => { + 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, 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) => { if (mode === "approved") return; @@ -222,26 +393,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; } @@ -255,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); @@ -415,6 +571,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 && ( @@ -487,28 +649,117 @@ 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")} + + {mode === "approved" ? ( + handleApprovedSort("stockTakerName")} + > + {t("Picker")} + + ) : ( + 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 = @@ -516,11 +767,36 @@ 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" && ( + + + + + {formatRecordEndTime(detail)} + + + + )} - {detail.warehouseArea || "-"} - {detail.warehouseSlot || "-"} + + {detail.stockTakeSection || "-"} {detail.stockTakeSectionDescription || "-"} + + {detail.warehouseCode || "-"} + = ({ {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", @@ -633,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]*" }} /> @@ -699,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]*" }} /> @@ -714,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"; @@ -746,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)} ); })()} @@ -759,6 +1071,14 @@ const ApproverStockTakeAll: React.FC = ({ )} + {mode === "approved" && ( + + + {formatNumber(detail.varianceQty)} + + + )} + {detail.remarks || "-"} @@ -792,6 +1112,7 @@ const ApproverStockTakeAll: React.FC = ({ /> )} + {detail.stockTakerName || "-"} {mode === "pending" && detail.stockTakeRecordId && detail.stockTakeRecordStatus !== "notMatch" && ( @@ -819,7 +1140,7 @@ const ApproverStockTakeAll: React.FC = ({ size="small" variant="contained" onClick={() => handleSaveApproverStockTake(detail)} - disabled={saving} + disabled={saving ||detail.stockTakeRecordStatus === "notMatch"} > {t("Save")} 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..80a4ab6 100644 --- a/src/i18n/zh/inventory.json +++ b/src/i18n/zh/inventory.json @@ -8,6 +8,10 @@ "UoM": "單位", "Approver Pending": "審核待處理", "Approver Approved": "審核通過", + "Approver Time": "審核時間", + "Total need stock take": "總需盤點數量", + "Waiting for Approver": "待審核數量", + "Total Approved": "已審核數量", "mat": "物料", "variance": "差異", "Plan Start Date": "計劃開始日期", 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":"提交狀態", diff --git a/src/routes.ts b/src/routes.ts index fe75f56..4e87f95 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -6,6 +6,7 @@ export const PRIVATE_ROUTES = [ "/po/workbench", "/ps", "/bagPrint", + "/laserPrint", "/report", "/invoice", "/projects",