From 7415dbe4b65600f8fbe80c95220ea2b6704299c4 Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Tue, 24 Mar 2026 13:03:39 +0800 Subject: [PATCH] added some purchase chart --- .../purchase/exportPurchaseChartMaster.ts | 54 + src/app/(main)/chart/purchase/page.tsx | 1103 ++++++++++++++++- src/app/api/chart/client.ts | 308 ++++- 3 files changed, 1438 insertions(+), 27 deletions(-) create mode 100644 src/app/(main)/chart/purchase/exportPurchaseChartMaster.ts diff --git a/src/app/(main)/chart/purchase/exportPurchaseChartMaster.ts b/src/app/(main)/chart/purchase/exportPurchaseChartMaster.ts new file mode 100644 index 0000000..536e1f9 --- /dev/null +++ b/src/app/(main)/chart/purchase/exportPurchaseChartMaster.ts @@ -0,0 +1,54 @@ +/** + * Multi-sheet 總表 export for the 採購 chart page — mirrors on-screen charts and drill-down data. + */ +import { exportMultiSheetToXlsx, type MultiSheetSpec } from "../_components/exportChartToXlsx"; + +export type PurchaseChartMasterExportPayload = { + /** ISO timestamp for audit */ + exportedAtIso: string; + /** 篩選與情境 — key-value rows */ + metaRows: Record[]; + /** 預計送貨 donut (依預計到貨日、上方篩選) */ + estimatedDonutRows: Record[]; + /** 實際已送貨 donut (依訂單日期、上方篩選) */ + actualStatusDonutRows: Record[]; + /** 貨品摘要表 (當前 drill) */ + itemSummaryRows: Record[]; + /** 供應商分佈 (由採購單明細彙總) */ + supplierDistributionRows: Record[]; + /** 採購單列表 */ + purchaseOrderListRows: Record[]; + /** 全量採購單行明細 (每張 PO 所有行) */ + purchaseOrderLineRows: Record[]; +}; + +function sheetOrPlaceholder(name: string, rows: Record[], emptyMessage: string): MultiSheetSpec { + if (rows.length > 0) return { name, rows }; + return { + name, + rows: [{ 說明: emptyMessage }], + }; +} + +/** + * Build worksheet specs (used by {@link exportPurchaseChartMasterToFile}). + */ +export function buildPurchaseChartMasterSheets(payload: PurchaseChartMasterExportPayload): MultiSheetSpec[] { + return [ + { name: "篩選條件與情境", rows: payload.metaRows }, + sheetOrPlaceholder("預計送貨", payload.estimatedDonutRows, "無資料(請確認訂單日期與篩選)"), + sheetOrPlaceholder("實際已送貨", payload.actualStatusDonutRows, "無資料"), + sheetOrPlaceholder("貨品摘要", payload.itemSummaryRows, "無資料(可能為篩選交集為空或未載入)"), + sheetOrPlaceholder("供應商分佈", payload.supplierDistributionRows, "無資料"), + sheetOrPlaceholder("採購單列表", payload.purchaseOrderListRows, "無採購單明細可匯出"), + sheetOrPlaceholder("採購單行明細", payload.purchaseOrderLineRows, "無行資料(採購單列表為空)"), + ]; +} + +export function exportPurchaseChartMasterToFile( + payload: PurchaseChartMasterExportPayload, + filenameBase: string +): void { + const sheets = buildPurchaseChartMasterSheets(payload); + exportMultiSheetToXlsx(sheets, filenameBase); +} diff --git a/src/app/(main)/chart/purchase/page.tsx b/src/app/(main)/chart/purchase/page.tsx index 6ccab29..2fd31a3 100644 --- a/src/app/(main)/chart/purchase/page.tsx +++ b/src/app/(main)/chart/purchase/page.tsx @@ -1,30 +1,608 @@ "use client"; import React, { useState } from "react"; -import { Box, Typography, Skeleton, Alert, TextField } from "@mui/material"; +import { + Box, + Typography, + Skeleton, + Alert, + TextField, + CircularProgress, + Button, + Stack, + Grid, + Autocomplete, + Chip, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from "@mui/material"; import dynamic from "next/dynamic"; import ShoppingCart from "@mui/icons-material/ShoppingCart"; -import { fetchPurchaseOrderByStatus } from "@/app/api/chart/client"; +import TableChart from "@mui/icons-material/TableChart"; +import { + fetchPurchaseOrderByStatus, + fetchPurchaseOrderDetailsByStatus, + fetchPurchaseOrderItems, + fetchPurchaseOrderItemsByStatus, + fetchPurchaseOrderFilterOptions, + fetchPurchaseOrderEstimatedArrivalSummary, + fetchPurchaseOrderEstimatedArrivalBreakdown, + PurchaseOrderDetailByStatusRow, + PurchaseOrderItemRow, + PurchaseOrderChartFilters, + PurchaseOrderFilterOptions, + PurchaseOrderEstimatedArrivalRow, + PurchaseOrderDrillQuery, + PurchaseOrderEstimatedArrivalBreakdown, +} from "@/app/api/chart/client"; import ChartCard from "../_components/ChartCard"; +import { exportPurchaseChartMasterToFile } from "./exportPurchaseChartMaster"; import dayjs from "dayjs"; const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); const PAGE_TITLE = "採購"; +const DEFAULT_DRILL_STATUS = "completed"; +/** Must match backend `getPurchaseOrderByStatus` (orderDate). Using "complete" here desyncs drill-down from the donut counts. */ +const DRILL_DATE_FILTER = "order" as const; + +const EST_BUCKETS = ["delivered", "not_delivered", "cancelled", "other"] as const; + +/** 預計送貨 — 已送 / 未送 / 已取消 / 其他 */ +const ESTIMATE_DONUT_COLORS = ["#2e7d32", "#f57c00", "#78909c", "#7b1fa2"]; + +/** 實際已送貨(依狀態)— 依序上色 */ +const STATUS_DONUT_COLORS = ["#1565c0", "#00838f", "#6a1b9a", "#c62828", "#5d4037", "#00695c"]; + +/** ApexCharts + React: avoid updating state inside dataPointSelection synchronously (DOM getAttribute null). */ +function deferChartClick(fn: () => void) { + window.setTimeout(fn, 0); +} + +/** UI labels only; API still uses English status values. */ +function poStatusLabelZh(status: string): string { + const s = status.trim().toLowerCase(); + switch (s) { + case "pending": + return "待處理"; + case "completed": + return "已完成"; + case "receiving": + return "收貨中"; + default: + return status; + } +} + +function bucketLabelZh(bucket: string): string { + switch (bucket) { + case "delivered": + return "已送"; + case "not_delivered": + return "未送"; + case "cancelled": + return "已取消"; + case "other": + return "其他"; + default: + return bucket; + } +} + +function emptyFilterOptions(): PurchaseOrderFilterOptions { + return { suppliers: [], items: [], poNos: [] }; +} export default function PurchaseChartPage() { const [poTargetDate, setPoTargetDate] = useState(() => dayjs().format("YYYY-MM-DD")); const [error, setError] = useState(null); const [chartData, setChartData] = useState<{ status: string; count: number }[]>([]); + const [estimatedArrivalData, setEstimatedArrivalData] = useState([]); const [loading, setLoading] = useState(true); + const [estimatedLoading, setEstimatedLoading] = useState(true); + const [filterOptions, setFilterOptions] = useState(emptyFilterOptions); + const [filterOptionsLoading, setFilterOptionsLoading] = useState(false); + + const [filterSupplierIds, setFilterSupplierIds] = useState([]); + const [filterItemCodes, setFilterItemCodes] = useState([]); + const [filterPoNos, setFilterPoNos] = useState([]); + + const [selectedStatus, setSelectedStatus] = useState(null); + /** Prefer id (shop row); code-only used when supplierId missing */ + const [selectedSupplierId, setSelectedSupplierId] = useState(null); + const [selectedSupplierCode, setSelectedSupplierCode] = useState(null); + const [selectedItemCode, setSelectedItemCode] = useState(null); + /** 預計送貨 donut — filters lower charts via API */ + const [selectedEstimatedBucket, setSelectedEstimatedBucket] = useState(null); + const [poDetails, setPoDetails] = useState([]); + const [poDetailsLoading, setPoDetailsLoading] = useState(false); + const [selectedPo, setSelectedPo] = useState(null); + const [itemsSummary, setItemsSummary] = useState([]); + const [itemsSummaryLoading, setItemsSummaryLoading] = useState(false); + const [poLineItems, setPoLineItems] = useState([]); + const [poLineItemsLoading, setPoLineItemsLoading] = useState(false); + const [masterExportLoading, setMasterExportLoading] = useState(false); + const [eaBreakdown, setEaBreakdown] = useState(null); + const [eaBreakdownLoading, setEaBreakdownLoading] = useState(false); + + const effectiveStatus = selectedStatus ?? DEFAULT_DRILL_STATUS; + + /** Top charts (實際已送貨 + 預計送貨): date + multi-select only — no drill-down from lower charts. */ + const barFilters = React.useMemo((): PurchaseOrderChartFilters => { + return { + supplierIds: filterSupplierIds.length ? filterSupplierIds : undefined, + itemCodes: filterItemCodes.length ? filterItemCodes : undefined, + purchaseOrderNos: filterPoNos.length ? filterPoNos : undefined, + }; + }, [filterSupplierIds, filterItemCodes, filterPoNos]); + + /** Drill-down: bar filters ∩ supplier/貨品 chart selection. */ + const drillFilters = React.useMemo((): PurchaseOrderChartFilters | null => { + if ( + selectedSupplierId != null && + selectedSupplierId > 0 && + filterSupplierIds.length > 0 && + !filterSupplierIds.includes(selectedSupplierId) + ) { + return null; + } + if ( + selectedItemCode?.trim() && + filterItemCodes.length > 0 && + !filterItemCodes.includes(selectedItemCode.trim()) + ) { + return null; + } + if (selectedSupplierCode?.trim() && filterSupplierIds.length > 0) { + const opt = filterOptions.suppliers.find((s) => s.code === selectedSupplierCode.trim()); + if (!opt || opt.supplierId <= 0 || !filterSupplierIds.includes(opt.supplierId)) { + return null; + } + } + + const out: PurchaseOrderChartFilters = { + supplierIds: filterSupplierIds.length ? [...filterSupplierIds] : undefined, + itemCodes: filterItemCodes.length ? [...filterItemCodes] : undefined, + purchaseOrderNos: filterPoNos.length ? [...filterPoNos] : undefined, + }; + + if (selectedSupplierId != null && selectedSupplierId > 0) { + if (!out.supplierIds?.length) { + out.supplierIds = [selectedSupplierId]; + } else if (out.supplierIds.includes(selectedSupplierId)) { + out.supplierIds = [selectedSupplierId]; + } + out.supplierCode = undefined; + } else if (selectedSupplierCode?.trim()) { + const code = selectedSupplierCode.trim(); + const opt = filterOptions.suppliers.find((s) => s.code === code); + if (out.supplierIds?.length) { + if (opt && opt.supplierId > 0 && out.supplierIds.includes(opt.supplierId)) { + out.supplierIds = [opt.supplierId]; + } else { + return null; + } + } else { + out.supplierIds = undefined; + out.supplierCode = code; + } + } + + if (selectedItemCode?.trim()) { + const ic = selectedItemCode.trim(); + if (!out.itemCodes?.length) { + out.itemCodes = [ic]; + } else if (out.itemCodes.includes(ic)) { + out.itemCodes = [ic]; + } + } + + return out; + }, [ + filterSupplierIds, + filterItemCodes, + filterPoNos, + selectedSupplierId, + selectedSupplierCode, + selectedItemCode, + filterOptions.suppliers, + ]); + + const drillQueryOpts = React.useMemo((): PurchaseOrderDrillQuery | null => { + if (drillFilters === null) return null; + return { + ...drillFilters, + estimatedArrivalBucket: selectedEstimatedBucket ?? undefined, + }; + }, [drillFilters, selectedEstimatedBucket]); + + React.useEffect(() => { + setFilterOptionsLoading(true); + fetchPurchaseOrderFilterOptions(poTargetDate) + .then(setFilterOptions) + .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) + .finally(() => setFilterOptionsLoading(false)); + }, [poTargetDate]); React.useEffect(() => { setLoading(true); - fetchPurchaseOrderByStatus(poTargetDate) + fetchPurchaseOrderByStatus(poTargetDate, barFilters) .then((data) => setChartData(data as { status: string; count: number }[])) .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) .finally(() => setLoading(false)); - }, [poTargetDate]); + }, [poTargetDate, barFilters]); + + React.useEffect(() => { + setEstimatedLoading(true); + fetchPurchaseOrderEstimatedArrivalSummary(poTargetDate, barFilters) + .then(setEstimatedArrivalData) + .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) + .finally(() => setEstimatedLoading(false)); + }, [poTargetDate, barFilters]); + + React.useEffect(() => { + if (!selectedEstimatedBucket || !poTargetDate) { + setEaBreakdown(null); + return; + } + setEaBreakdownLoading(true); + fetchPurchaseOrderEstimatedArrivalBreakdown(poTargetDate, selectedEstimatedBucket, barFilters) + .then(setEaBreakdown) + .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) + .finally(() => setEaBreakdownLoading(false)); + }, [selectedEstimatedBucket, poTargetDate, barFilters]); + + React.useEffect(() => { + if (drillQueryOpts === null) { + setPoDetails([]); + return; + } + setPoDetailsLoading(true); + fetchPurchaseOrderDetailsByStatus(effectiveStatus, poTargetDate, { + dateFilter: DRILL_DATE_FILTER, + ...drillQueryOpts, + }) + .then((rows) => setPoDetails(rows)) + .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) + .finally(() => setPoDetailsLoading(false)); + }, [effectiveStatus, poTargetDate, drillQueryOpts]); + + React.useEffect(() => { + if (selectedPo) return; + if (drillQueryOpts === null) { + setItemsSummary([]); + return; + } + setItemsSummaryLoading(true); + fetchPurchaseOrderItemsByStatus(effectiveStatus, poTargetDate, { + dateFilter: DRILL_DATE_FILTER, + ...drillQueryOpts, + }) + .then((rows) => setItemsSummary(rows)) + .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) + .finally(() => setItemsSummaryLoading(false)); + }, [selectedPo, effectiveStatus, poTargetDate, drillQueryOpts]); + + React.useEffect(() => { + if (selectedPo) return; + setPoLineItems([]); + setPoLineItemsLoading(false); + }, [selectedPo]); + + const handleStatusClick = (status: string) => { + const normalized = status.trim().toLowerCase(); + setSelectedStatus((prev) => (prev === normalized ? null : normalized)); + /** 與「預計送貨」圓環互斥:只顯示一則上方圓環篩選說明 */ + setSelectedEstimatedBucket(null); + setSelectedPo(null); + setSelectedSupplierId(null); + setSelectedSupplierCode(null); + setSelectedItemCode(null); + setPoLineItems([]); + }; + + const handleEstimatedBucketClick = (index: number) => { + const bucket = EST_BUCKETS[index]; + if (!bucket) return; + setSelectedEstimatedBucket((prev) => (prev === bucket ? null : bucket)); + /** 與「實際已送貨」圓環互斥:只顯示一則上方圓環篩選說明 */ + setSelectedStatus(null); + setSelectedPo(null); + setPoLineItems([]); + }; + + const handleClearFilters = () => { + setFilterSupplierIds([]); + setFilterItemCodes([]); + setFilterPoNos([]); + setSelectedSupplierId(null); + setSelectedSupplierCode(null); + setSelectedItemCode(null); + setSelectedEstimatedBucket(null); + setSelectedStatus(null); + setSelectedPo(null); + setPoLineItems([]); + }; + + const handleItemSummaryClick = (index: number) => { + const row = itemsSummary[index]; + if (!row?.itemCode) return; + setSelectedItemCode((prev) => (prev === row.itemCode ? null : row.itemCode)); + setSelectedPo(null); + setPoLineItems([]); + }; + + const handleSupplierClick = (row: { + supplierId: number | null; + supplierCode: string; + }) => { + if (row.supplierId != null && row.supplierId > 0) { + setSelectedSupplierId((prev) => (prev === row.supplierId ? null : row.supplierId)); + setSelectedSupplierCode(null); + } else if (row.supplierCode.trim()) { + setSelectedSupplierCode((prev) => (prev === row.supplierCode ? null : row.supplierCode)); + setSelectedSupplierId(null); + } + setSelectedPo(null); + setPoLineItems([]); + }; + + const handlePoClick = async (row: PurchaseOrderDetailByStatusRow) => { + setSelectedPo(row); + setPoLineItems([]); + setPoLineItemsLoading(true); + try { + const rows = await fetchPurchaseOrderItems(row.purchaseOrderId); + setPoLineItems(rows); + } catch (err) { + setError(err instanceof Error ? err.message : "Request failed"); + } finally { + setPoLineItemsLoading(false); + } + }; + + const supplierChartData = React.useMemo(() => { + const map = new Map< + string, + { + supplier: string; + supplierId: number | null; + supplierCode: string; + count: number; + totalQty: number; + } + >(); + poDetails.forEach((row) => { + const sid = row.supplierId != null && row.supplierId > 0 ? row.supplierId : null; + const code = String(row.supplierCode ?? "").trim(); + const name = String(row.supplierName ?? "").trim(); + const label = + `${code} ${name}`.trim() || (sid != null ? `(Supplier #${sid})` : "(Unknown supplier)"); + const key = sid != null ? `sid:${sid}` : `code:${code}|name:${name}`; + const curr = map.get(key) ?? { + supplier: label, + supplierId: sid, + supplierCode: code, + count: 0, + totalQty: 0, + }; + curr.count += 1; + curr.totalQty += Number(row.totalQty ?? 0); + map.set(key, curr); + }); + return Array.from(map.values()).sort((a, b) => b.totalQty - a.totalQty); + }, [poDetails]); + + const estimatedChartSeries = React.useMemo(() => { + const m = new Map(estimatedArrivalData.map((r) => [r.bucket, r.count])); + return EST_BUCKETS.map((b) => m.get(b) ?? 0); + }, [estimatedArrivalData]); + + const handleExportPurchaseMaster = React.useCallback(async () => { + setMasterExportLoading(true); + try { + const exportedAtIso = new Date().toISOString(); + const filterSupplierText = + filterOptions.suppliers + .filter((s) => filterSupplierIds.includes(s.supplierId)) + .map((s) => `${s.code} ${s.name}`.trim()) + .join(";") || "(未選)"; + const filterItemText = + filterOptions.items + .filter((i) => filterItemCodes.includes(i.itemCode)) + .map((i) => `${i.itemCode} ${i.itemName}`.trim()) + .join(";") || "(未選)"; + const filterPoText = filterPoNos.length ? filterPoNos.join(";") : "(未選)"; + + const metaRows: Record[] = [ + { 項目: "匯出時間_UTC", 值: exportedAtIso }, + { 項目: "訂單日期", 值: poTargetDate }, + { 項目: "多選_供應商", 值: filterSupplierText }, + { 項目: "多選_貨品", 值: filterItemText }, + { 項目: "多選_採購單號", 值: filterPoText }, + { + 項目: "預計送貨圓環_點選", + 值: selectedEstimatedBucket ? bucketLabelZh(selectedEstimatedBucket) : "(未選)", + }, + { + 項目: "實際已送貨圓環_點選狀態", + 值: + selectedStatus != null + ? poStatusLabelZh(selectedStatus) + : `(未選;下方圖表預設狀態 ${poStatusLabelZh(DEFAULT_DRILL_STATUS)})`, + }, + { 項目: "下方圖表套用狀態_英文", 值: effectiveStatus }, + { 項目: "下方圖表套用狀態_中文", 值: poStatusLabelZh(effectiveStatus) }, + { + 項目: "圓環篩選_供應商", + 值: + selectedSupplierId != null && selectedSupplierId > 0 + ? `supplierId=${selectedSupplierId}` + : selectedSupplierCode?.trim() + ? `supplierCode=${selectedSupplierCode.trim()}` + : "(未選)", + }, + { 項目: "圓環篩選_貨品", 值: selectedItemCode?.trim() ? selectedItemCode.trim() : "(未選)" }, + { + 項目: "下方查詢是否有效", + 值: drillQueryOpts === null ? "否(篩選交集無效,下方表為空)" : "是", + }, + { + 項目: "圖表說明", + 值: + "預計送貨圖:預計到貨日=訂單日期。實際已送貨圖:訂單日期。貨品/供應商/採購單表:依下方查詢與預計送貨扇形。採購單行明細:匯出當前列表中每張採購單之全部行。", + }, + ]; + + const estimatedDonutRows = EST_BUCKETS.map((b, i) => ({ + 類別: bucketLabelZh(b), + bucket代碼: b, + 數量: estimatedChartSeries[i] ?? 0, + })); + + const actualStatusDonutRows = chartData.map((p) => ({ + 狀態中文: poStatusLabelZh(p.status), + status代碼: p.status, + 數量: p.count, + })); + + const itemSummaryRows = itemsSummary.map((i) => ({ + 貨品: i.itemCode, + 名稱: i.itemName, + 訂購數量: i.orderedQty, + 已收貨: i.receivedQty, + 待收貨: i.pendingQty, + UOM: i.uom, + })); + + const supplierDistributionRows = supplierChartData.map((s) => ({ + 供應商: s.supplier, + 供應商編號: s.supplierCode, + supplierId: s.supplierId ?? "", + 採購單數: s.count, + 總數量: s.totalQty, + })); + + const purchaseOrderListRows = poDetails.map((p) => ({ + 採購單號: p.purchaseOrderNo, + 狀態: poStatusLabelZh(p.status), + status代碼: p.status, + 訂單日期: p.orderDate, + 預計到貨日: p.estimatedArrivalDate, + 供應商編號: p.supplierCode, + 供應商名稱: p.supplierName, + supplierId: p.supplierId ?? "", + 項目數: p.itemCount, + 總數量: p.totalQty, + })); + + const purchaseOrderLineRows: Record[] = []; + if (poDetails.length > 0) { + const lineBatches = await Promise.all( + poDetails.map((po) => + fetchPurchaseOrderItems(po.purchaseOrderId).then((lines) => + lines.map((line) => ({ + 採購單號: po.purchaseOrderNo, + 採購單ID: po.purchaseOrderId, + 狀態: poStatusLabelZh(po.status), + 訂單日期: po.orderDate, + 預計到貨日: po.estimatedArrivalDate, + 供應商編號: po.supplierCode, + 供應商名稱: po.supplierName, + 貨品: line.itemCode, + 品名: line.itemName, + UOM: line.uom, + 訂購數量: line.orderedQty, + 已收貨: line.receivedQty, + 待收貨: line.pendingQty, + })) + ) + ) + ); + lineBatches.flat().forEach((row) => purchaseOrderLineRows.push(row)); + } + + exportPurchaseChartMasterToFile( + { + exportedAtIso, + metaRows, + estimatedDonutRows, + actualStatusDonutRows, + itemSummaryRows, + supplierDistributionRows, + purchaseOrderListRows, + purchaseOrderLineRows, + }, + `採購圖表總表_${poTargetDate}_${dayjs().format("HHmmss")}` + ); + } catch (err) { + setError(err instanceof Error ? err.message : "總表匯出失敗"); + } finally { + setMasterExportLoading(false); + } + }, [ + poTargetDate, + filterOptions.suppliers, + filterOptions.items, + filterSupplierIds, + filterItemCodes, + filterPoNos, + selectedEstimatedBucket, + selectedStatus, + effectiveStatus, + drillQueryOpts, + estimatedChartSeries, + chartData, + itemsSummary, + supplierChartData, + poDetails, + selectedSupplierId, + selectedSupplierCode, + selectedItemCode, + ]); + + const itemChartKey = `${effectiveStatus}|${poTargetDate}|${DRILL_DATE_FILTER}|${JSON.stringify(drillQueryOpts)}|ea:${selectedEstimatedBucket ?? ""}|s`; + const supplierChartKey = `${itemChartKey}|${selectedItemCode ?? ""}|sup`; + const poChartKey = `${supplierChartKey}|po`; + + /** 下方三張圖:僅選預計送貨扇形時,資料依 bucket 不再套用「實際已送貨」單一狀態。 */ + const lowerChartsTitlePrefix = React.useMemo(() => { + if (selectedEstimatedBucket) { + return `預計送貨「${bucketLabelZh(selectedEstimatedBucket)}」`; + } + return `實際已送貨 ${poStatusLabelZh(effectiveStatus)}`; + }, [selectedEstimatedBucket, effectiveStatus]); + + const lowerChartsDefaultStatusHint = React.useMemo(() => { + if (selectedEstimatedBucket) return ""; + if (selectedStatus) return ""; + return `(預設狀態:${poStatusLabelZh(DEFAULT_DRILL_STATUS)})`; + }, [selectedEstimatedBucket, selectedStatus]); + + const filterHint = [ + selectedItemCode ? `貨品: ${selectedItemCode}` : null, + selectedSupplierId != null && selectedSupplierId > 0 + ? `供應商 id: ${selectedSupplierId}` + : selectedSupplierCode + ? `供應商 code: ${selectedSupplierCode}` + : null, + ] + .filter(Boolean) + .join(" · "); + + const hasBarFilters = filterSupplierIds.length > 0 || filterItemCodes.length > 0 || filterPoNos.length > 0; + const hasChartDrill = + selectedStatus != null || + selectedItemCode || + selectedSupplierId != null || + selectedSupplierCode || + selectedEstimatedBucket || + selectedPo; return ( @@ -37,38 +615,517 @@ export default function PurchaseChartPage() { )} + + + 上方「預計送貨」與「實際已送貨」依訂單日期與篩選條件;點擊圓環可篩選下方圖表(與其他條件交集)。 + + + {(hasBarFilters || hasChartDrill) && ( + + )} + + + + setPoTargetDate(e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ minWidth: 160 }} + /> + `${o.code} ${o.name}`.trim() || String(o.supplierId)} + value={filterOptions.suppliers.filter((s) => filterSupplierIds.includes(s.supplierId))} + onChange={(_, v) => setFilterSupplierIds(v.map((x) => x.supplierId))} + renderTags={(tagValue, getTagProps) => + tagValue.map((option, index) => ( + + )) + } + renderInput={(params) => } + /> + `${o.itemCode} ${o.itemName}`.trim()} + value={filterOptions.items.filter((i) => filterItemCodes.includes(i.itemCode))} + onChange={(_, v) => setFilterItemCodes(v.map((x) => x.itemCode))} + renderTags={(tagValue, getTagProps) => + tagValue.map((option, index) => ( + + )) + } + renderInput={(params) => } + /> + o.poNo} + value={filterOptions.poNos.filter((p) => filterPoNos.includes(p.poNo))} + onChange={(_, v) => setFilterPoNos(v.map((x) => x.poNo))} + renderTags={(tagValue, getTagProps) => + tagValue.map((option, index) => ( + + )) + } + renderInput={(params) => } + /> + + + + + ({ + 類別: bucketLabelZh(b), + 數量: estimatedChartSeries[i] ?? 0, + }))} + > + {estimatedLoading ? ( + + ) : ( + { + const idx = config?.dataPointIndex ?? -1; + if (idx < 0 || idx >= EST_BUCKETS.length) return; + deferChartClick(() => handleEstimatedBucketClick(idx)); + }, + }, + animations: { enabled: false }, + }, + labels: EST_BUCKETS.map(bucketLabelZh), + colors: ESTIMATE_DONUT_COLORS, + legend: { position: "bottom" }, + plotOptions: { + pie: { + donut: { + labels: { + show: true, + total: { + show: true, + label: "預計送貨", + }, + }, + }, + }, + }, + }} + series={estimatedChartSeries} + type="donut" + width="100%" + height={320} + /> + )} + + + + ({ 狀態: poStatusLabelZh(p.status), 數量: p.count }))} + > + {loading ? ( + + ) : ( + { + const idx = config?.dataPointIndex ?? -1; + if (idx < 0 || idx >= chartData.length) return; + const row = chartData[idx]; + if (!row?.status) return; + const status = row.status; + deferChartClick(() => handleStatusClick(status)); + }, + }, + animations: { enabled: false }, + }, + labels: chartData.map((p) => poStatusLabelZh(p.status)), + colors: chartData.map((_, i) => STATUS_DONUT_COLORS[i % STATUS_DONUT_COLORS.length]), + legend: { position: "bottom" }, + }} + series={chartData.map((p) => p.count)} + type="donut" + width="100%" + height={320} + /> + )} + + + + + {selectedEstimatedBucket && ( + + + 「{bucketLabelZh(selectedEstimatedBucket)}」關聯對象 + + + 條件與左側「預計送貨」圓環一致:預計到貨日 = 訂單日期({poTargetDate}),並含上方供應商/貨品/採購單號多選。 + + {eaBreakdownLoading ? ( + + + + ) : eaBreakdown ? ( + + + + 供應商 + + + + + + 編號 + 名稱 + 採購單數 + + + + {eaBreakdown.suppliers.length === 0 ? ( + + + + 無 + + + + ) : ( + eaBreakdown.suppliers.map((s, i) => ( + + {s.supplierCode} + {s.supplierName} + {s.poCount} + + )) + )} + +
+
+
+ + + 貨品 + + + + + + 貨品編號 + 名稱 + 採購單數 + 總數量 + + + + {eaBreakdown.items.length === 0 ? ( + + + + 無 + + + + ) : ( + eaBreakdown.items.map((it, i) => ( + + {it.itemCode} + {it.itemName} + {it.poCount} + {it.totalQty} + + )) + )} + +
+
+
+ + + 採購單 + + + + + + 採購單號 + 供應商 + 狀態 + 訂單日期 + + + + {eaBreakdown.purchaseOrders.length === 0 ? ( + + + + 無 + + + + ) : ( + eaBreakdown.purchaseOrders.map((po) => ( + + {po.purchaseOrderNo} + {`${po.supplierCode} ${po.supplierName}`.trim()} + {poStatusLabelZh(po.status)} + {po.orderDate} + + )) + )} + +
+
+
+
+ ) : null} +
+ )} + + {(selectedEstimatedBucket || selectedStatus != null) && ( + + {selectedEstimatedBucket && ( + + 下方圖表已依「預計送貨」篩選:{bucketLabelZh(selectedEstimatedBucket)} + (預計到貨日 = 訂單日期,並含多選;此時不再套用右側「實際已送貨」狀態;再點同一扇形可取消)。 + + )} + {selectedStatus != null && ( + + 下方圖表已依「實際已送貨」所選狀態:{poStatusLabelZh(selectedStatus)}(再點同一狀態可取消)。 + + )} + + )} + ({ 狀態: p.status, 數量: p.count }))} - filters={ - setPoTargetDate(e.target.value)} - InputLabelProps={{ shrink: true }} - sx={{ minWidth: 160 }} + title={`${lowerChartsTitlePrefix} 的貨品摘要(code / 名稱)${lowerChartsDefaultStatusHint}${ + selectedItemCode ? ` — 已選貨品:${selectedItemCode}` : "" + }`} + exportFilename={`採購單_貨品摘要_${selectedEstimatedBucket ?? effectiveStatus}`} + exportData={itemsSummary.map((i) => ({ + 貨品: i.itemCode, + 名稱: i.itemName, + 訂購數量: i.orderedQty, + 已收貨: i.receivedQty, + UOM: i.uom, + }))} + > + {drillQueryOpts === null ? ( + 無符合交集的篩選(請調整上方條件或圖表點選) + ) : itemsSummaryLoading ? ( + + + + ) : itemsSummary.length === 0 ? ( + + 無資料(請確認訂單日期{selectedEstimatedBucket ? "與篩選" : "與狀態"}) + + ) : ( + { + const idx = config?.dataPointIndex ?? -1; + if (idx < 0 || idx >= itemsSummary.length) return; + deferChartClick(() => handleItemSummaryClick(idx)); + }, + }, + animations: { enabled: false }, + }, + labels: itemsSummary.map((i) => `${i.itemCode} ${i.itemName}`.trim()), + legend: { position: "bottom" }, + plotOptions: { + pie: { + donut: { + labels: { + show: true, + total: { + show: true, + label: "貨品", + }, + }, + }, + }, + }, + }} + series={itemsSummary.map((i) => i.orderedQty)} + type="donut" + width="100%" + height={380} /> - } + )} + + + ({ + 供應商: s.supplier, + 採購單數: s.count, + 總數量: s.totalQty, + }))} > - {loading ? ( - + {drillQueryOpts === null ? ( + 無符合交集的篩選 + ) : poDetailsLoading ? ( + + + + ) : supplierChartData.length === 0 ? ( + 無供應商資料(請先確認上方貨品篩選或日期) ) : ( p.status), + chart: { + type: "donut", + events: { + dataPointSelection: (_event: unknown, _chartContext: unknown, config: { dataPointIndex?: number }) => { + const idx = config?.dataPointIndex ?? -1; + if (idx < 0 || idx >= supplierChartData.length) return; + const row = supplierChartData[idx]; + if (!row.supplierId && !row.supplierCode?.trim()) return; + deferChartClick(() => handleSupplierClick(row)); + }, + }, + animations: { enabled: false }, + }, + labels: supplierChartData.map((s) => s.supplier), legend: { position: "bottom" }, }} - series={chartData.map((p) => p.count)} + series={supplierChartData.map((s) => s.totalQty)} type="donut" width="100%" - height={320} + height={360} + /> + )} + + + ({ + 採購單號: p.purchaseOrderNo, + 供應商: `${p.supplierCode} ${p.supplierName}`.trim(), + 總數量: p.totalQty, + 項目數: p.itemCount, + 訂單日期: p.orderDate, + }))} + > + {drillQueryOpts === null ? ( + 無符合交集的篩選 + ) : poDetailsLoading ? ( + + + + ) : poDetails.length === 0 ? ( + + 無採購單。請確認該「訂單日期」是否有此狀態的採購單。 + + ) : ( + { + const idx = config?.dataPointIndex ?? -1; + if (idx < 0 || idx >= poDetails.length) return; + const po = poDetails[idx]; + deferChartClick(() => void handlePoClick(po)); + }, + }, + animations: { enabled: false }, + }, + xaxis: { categories: poDetails.map((p) => p.purchaseOrderNo) }, + dataLabels: { enabled: false }, + }} + series={[ + { + name: "總數量", + data: poDetails.map((p) => p.totalQty), + }, + ]} + type="bar" + width="100%" + height={360} /> )} + + {selectedPo && ( + ({ + 貨品: i.itemCode, + 名稱: i.itemName, + 訂購數量: i.orderedQty, + 已收貨: i.receivedQty, + 待收貨: i.pendingQty, + UOM: i.uom, + }))} + > + {poLineItemsLoading ? ( + + + + ) : ( + `${i.itemCode} ${i.itemName}`.trim()) }, + plotOptions: { bar: { horizontal: true } }, + dataLabels: { enabled: false }, + }} + series={[ + { name: "訂購數量", data: poLineItems.map((i) => i.orderedQty) }, + { name: "待收貨", data: poLineItems.map((i) => i.pendingQty) }, + ]} + type="bar" + width="100%" + height={Math.max(320, poLineItems.length * 38)} + /> + )} + + )}
); } diff --git a/src/app/api/chart/client.ts b/src/app/api/chart/client.ts index 89bb878..ca0c245 100644 --- a/src/app/api/chart/client.ts +++ b/src/app/api/chart/client.ts @@ -29,6 +29,81 @@ export interface PurchaseOrderByStatusRow { count: number; } +/** Multi-select filters for purchase charts (repeated `supplierId` / `itemCode` / `purchaseOrderNo` query params). */ +export type PurchaseOrderChartFilters = { + supplierIds?: number[]; + itemCodes?: string[]; + purchaseOrderNos?: string[]; + /** Single supplier code (drill when row has no supplier id); not used with `supplierIds`. */ + supplierCode?: string; +}; + +function appendPurchaseOrderListParams(p: URLSearchParams, filters?: PurchaseOrderChartFilters) { + (filters?.supplierIds ?? []).forEach((id) => { + if (Number.isFinite(id) && id > 0) p.append("supplierId", String(id)); + }); + (filters?.itemCodes ?? []).forEach((c) => { + const t = String(c).trim(); + if (t) p.append("itemCode", t); + }); + (filters?.purchaseOrderNos ?? []).forEach((n) => { + const t = String(n).trim(); + if (t) p.append("purchaseOrderNo", t); + }); + const sc = filters?.supplierCode?.trim(); + if (sc) p.set("supplierCode", sc); +} + +export interface PoFilterSupplierOption { + supplierId: number; + code: string; + name: string; +} + +export interface PoFilterItemOption { + itemCode: string; + itemName: string; +} + +export interface PoFilterPoNoOption { + poNo: string; +} + +export interface PurchaseOrderFilterOptions { + suppliers: PoFilterSupplierOption[]; + items: PoFilterItemOption[]; + poNos: PoFilterPoNoOption[]; +} + +export interface PurchaseOrderEstimatedArrivalRow { + bucket: string; + count: number; +} + +export interface PurchaseOrderDetailByStatusRow { + purchaseOrderId: number; + purchaseOrderNo: string; + status: string; + orderDate: string; + estimatedArrivalDate: string; + /** Shop / supplier FK; use for grouping when code is blank */ + supplierId: number | null; + supplierCode: string; + supplierName: string; + itemCount: number; + totalQty: number; +} + +export interface PurchaseOrderItemRow { + purchaseOrderLineId: number; + itemCode: string; + itemName: string; + orderedQty: number; + uom: string; + receivedQty: number; + pendingQty: number; +} + export interface StockInOutByDateRow { date: string; inQty: number; @@ -317,11 +392,13 @@ export async function fetchDeliveryOrderByDate( } export async function fetchPurchaseOrderByStatus( - targetDate?: string + targetDate?: string, + filters?: PurchaseOrderChartFilters ): Promise { - const q = targetDate - ? buildParams({ targetDate }) - : ""; + const p = new URLSearchParams(); + if (targetDate) p.set("targetDate", targetDate); + appendPurchaseOrderListParams(p, filters); + const q = p.toString(); const res = await clientAuthFetch( q ? `${BASE}/purchase-order-by-status?${q}` : `${BASE}/purchase-order-by-status` ); @@ -333,6 +410,229 @@ export async function fetchPurchaseOrderByStatus( })); } +export async function fetchPurchaseOrderFilterOptions( + targetDate?: string +): Promise { + const p = new URLSearchParams(); + if (targetDate) p.set("targetDate", targetDate); + const q = p.toString(); + const res = await clientAuthFetch( + q ? `${BASE}/purchase-order-filter-options?${q}` : `${BASE}/purchase-order-filter-options` + ); + if (!res.ok) throw new Error("Failed to fetch purchase order filter options"); + const data = await res.json(); + const row = (data ?? {}) as Record; + const suppliers = (Array.isArray(row.suppliers) ? row.suppliers : []) as Record[]; + const items = (Array.isArray(row.items) ? row.items : []) as Record[]; + const poNos = (Array.isArray(row.poNos) ? row.poNos : []) as Record[]; + return { + suppliers: suppliers.map((r) => ({ + supplierId: Number(r.supplierId ?? r.supplierid ?? 0), + code: String(r.code ?? ""), + name: String(r.name ?? ""), + })), + items: items.map((r) => ({ + itemCode: String(r.itemCode ?? r.itemcode ?? ""), + itemName: String(r.itemName ?? r.itemname ?? ""), + })), + poNos: poNos.map((r) => ({ + poNo: String(r.poNo ?? r.pono ?? ""), + })), + }; +} + +export async function fetchPurchaseOrderEstimatedArrivalSummary( + targetDate?: string, + filters?: PurchaseOrderChartFilters +): Promise { + const p = new URLSearchParams(); + if (targetDate) p.set("targetDate", targetDate); + appendPurchaseOrderListParams(p, filters); + const q = p.toString(); + const res = await clientAuthFetch( + q + ? `${BASE}/purchase-order-estimated-arrival-summary?${q}` + : `${BASE}/purchase-order-estimated-arrival-summary` + ); + if (!res.ok) throw new Error("Failed to fetch estimated arrival summary"); + const data = await res.json(); + return ((Array.isArray(data) ? data : []) as Record[]).map((r: Record) => ({ + bucket: String(r.bucket ?? ""), + count: Number(r.count ?? 0), + })); +} + +export interface EstimatedArrivalBreakdownSupplierRow { + supplierId: number | null; + supplierCode: string; + supplierName: string; + poCount: number; +} + +export interface EstimatedArrivalBreakdownItemRow { + itemCode: string; + itemName: string; + poCount: number; + totalQty: number; +} + +export interface EstimatedArrivalBreakdownPoRow { + purchaseOrderId: number; + purchaseOrderNo: string; + status: string; + orderDate: string; + supplierId: number | null; + supplierCode: string; + supplierName: string; +} + +export interface PurchaseOrderEstimatedArrivalBreakdown { + suppliers: EstimatedArrivalBreakdownSupplierRow[]; + items: EstimatedArrivalBreakdownItemRow[]; + purchaseOrders: EstimatedArrivalBreakdownPoRow[]; +} + +/** Related suppliers / items / POs for one 預計送貨 bucket (same bar filters as the donut). */ +export async function fetchPurchaseOrderEstimatedArrivalBreakdown( + targetDate: string, + estimatedArrivalBucket: string, + filters?: PurchaseOrderChartFilters +): Promise { + const p = new URLSearchParams(); + p.set("targetDate", targetDate); + p.set("estimatedArrivalBucket", estimatedArrivalBucket.trim().toLowerCase()); + appendPurchaseOrderListParams(p, filters); + const res = await clientAuthFetch(`${BASE}/purchase-order-estimated-arrival-breakdown?${p.toString()}`); + if (!res.ok) throw new Error("Failed to fetch estimated arrival breakdown"); + const data = await res.json(); + const row = (data ?? {}) as Record; + const suppliers = (Array.isArray(row.suppliers) ? row.suppliers : []) as Record[]; + const items = (Array.isArray(row.items) ? row.items : []) as Record[]; + const purchaseOrders = (Array.isArray(row.purchaseOrders) ? row.purchaseOrders : []) as Record[]; + return { + suppliers: suppliers.map((r) => ({ + supplierId: (() => { + const v = r.supplierId ?? r.supplierid; + if (v == null || v === "") return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; + })(), + supplierCode: String(r.supplierCode ?? r.suppliercode ?? ""), + supplierName: String(r.supplierName ?? r.suppliername ?? ""), + poCount: Number(r.poCount ?? r.pocount ?? 0), + })), + items: items.map((r) => ({ + itemCode: String(r.itemCode ?? r.itemcode ?? ""), + itemName: String(r.itemName ?? r.itemname ?? ""), + poCount: Number(r.poCount ?? r.pocount ?? 0), + totalQty: Number(r.totalQty ?? r.totalqty ?? 0), + })), + purchaseOrders: purchaseOrders.map((r) => ({ + purchaseOrderId: Number(r.purchaseOrderId ?? r.purchaseorderid ?? 0), + purchaseOrderNo: String(r.purchaseOrderNo ?? r.purchaseorderno ?? ""), + status: String(r.status ?? ""), + orderDate: String(r.orderDate ?? r.orderdate ?? ""), + supplierId: (() => { + const v = r.supplierId ?? r.supplierid; + if (v == null || v === "") return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; + })(), + supplierCode: String(r.supplierCode ?? r.suppliercode ?? ""), + supplierName: String(r.supplierName ?? r.suppliername ?? ""), + })), + }; +} + +export type PurchaseOrderDrillQuery = PurchaseOrderChartFilters & { + /** order = PO order date; complete = PO complete date (for received/completed on a day) */ + dateFilter?: "order" | "complete"; + /** delivered | not_delivered | cancelled | other — same as 預計送貨 donut buckets */ + estimatedArrivalBucket?: string; +}; + +export async function fetchPurchaseOrderDetailsByStatus( + status: string, + targetDate?: string, + opts?: PurchaseOrderDrillQuery +): Promise { + const p = new URLSearchParams(); + p.set("status", status.trim().toLowerCase()); + if (targetDate) p.set("targetDate", targetDate); + if (opts?.dateFilter) p.set("dateFilter", opts.dateFilter); + if (opts?.estimatedArrivalBucket?.trim()) { + p.set("estimatedArrivalBucket", opts.estimatedArrivalBucket.trim().toLowerCase()); + } + appendPurchaseOrderListParams(p, opts); + const q = p.toString(); + const res = await clientAuthFetch(`${BASE}/purchase-order-details-by-status?${q}`); + if (!res.ok) throw new Error("Failed to fetch purchase order details by status"); + const data = await res.json(); + return ((Array.isArray(data) ? data : []) as Record[]).map((r: Record) => ({ + purchaseOrderId: Number(r.purchaseOrderId ?? 0), + purchaseOrderNo: String(r.purchaseOrderNo ?? ""), + status: String(r.status ?? ""), + orderDate: String(r.orderDate ?? ""), + estimatedArrivalDate: String(r.estimatedArrivalDate ?? ""), + supplierId: (() => { + const v = r.supplierId; + if (v == null || v === "") return null; + const n = Number(v); + return Number.isFinite(n) && n > 0 ? n : null; + })(), + supplierCode: String(r.supplierCode ?? ""), + supplierName: String(r.supplierName ?? ""), + itemCount: Number(r.itemCount ?? 0), + totalQty: Number(r.totalQty ?? 0), + })); +} + +export async function fetchPurchaseOrderItems( + purchaseOrderId: number +): Promise { + const q = buildParams({ purchaseOrderId }); + const res = await clientAuthFetch(`${BASE}/purchase-order-items?${q}`); + if (!res.ok) throw new Error("Failed to fetch purchase order items"); + const data = await res.json(); + return ((Array.isArray(data) ? data : []) as Record[]).map((r: Record) => ({ + purchaseOrderLineId: Number(r.purchaseOrderLineId ?? 0), + itemCode: String(r.itemCode ?? ""), + itemName: String(r.itemName ?? ""), + orderedQty: Number(r.orderedQty ?? 0), + uom: String(r.uom ?? ""), + receivedQty: Number(r.receivedQty ?? 0), + pendingQty: Number(r.pendingQty ?? 0), + })); +} + +export async function fetchPurchaseOrderItemsByStatus( + status: string, + targetDate?: string, + opts?: PurchaseOrderDrillQuery +): Promise { + const p = new URLSearchParams(); + p.set("status", status.trim().toLowerCase()); + if (targetDate) p.set("targetDate", targetDate); + if (opts?.dateFilter) p.set("dateFilter", opts.dateFilter); + if (opts?.estimatedArrivalBucket?.trim()) { + p.set("estimatedArrivalBucket", opts.estimatedArrivalBucket.trim().toLowerCase()); + } + appendPurchaseOrderListParams(p, opts); + const q = p.toString(); + const res = await clientAuthFetch(`${BASE}/purchase-order-items-by-status?${q}`); + if (!res.ok) throw new Error("Failed to fetch purchase order items by status"); + const data = await res.json(); + return ((Array.isArray(data) ? data : []) as Record[]).map((r: Record) => ({ + purchaseOrderLineId: 0, + itemCode: String(r.itemCode ?? ""), + itemName: String(r.itemName ?? ""), + orderedQty: Number(r.orderedQty ?? 0), + uom: String(r.uom ?? ""), + receivedQty: Number(r.receivedQty ?? 0), + pendingQty: Number(r.pendingQty ?? 0), + })); +} + export async function fetchStockInOutByDate( startDate?: string, endDate?: string