|
- "use client";
-
- import React, { useState } from "react";
- 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 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<string>(() => dayjs().format("YYYY-MM-DD"));
- const [error, setError] = useState<string | null>(null);
- const [chartData, setChartData] = useState<{ status: string; count: number }[]>([]);
- const [estimatedArrivalData, setEstimatedArrivalData] = useState<PurchaseOrderEstimatedArrivalRow[]>([]);
- const [loading, setLoading] = useState(true);
- const [estimatedLoading, setEstimatedLoading] = useState(true);
- const [filterOptions, setFilterOptions] = useState<PurchaseOrderFilterOptions>(emptyFilterOptions);
- const [filterOptionsLoading, setFilterOptionsLoading] = useState(false);
-
- const [filterSupplierIds, setFilterSupplierIds] = useState<number[]>([]);
- const [filterItemCodes, setFilterItemCodes] = useState<string[]>([]);
- const [filterPoNos, setFilterPoNos] = useState<string[]>([]);
-
- const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
- /** Prefer id (shop row); code-only used when supplierId missing */
- const [selectedSupplierId, setSelectedSupplierId] = useState<number | null>(null);
- const [selectedSupplierCode, setSelectedSupplierCode] = useState<string | null>(null);
- const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
- /** 預計送貨 donut — filters lower charts via API */
- const [selectedEstimatedBucket, setSelectedEstimatedBucket] = useState<string | null>(null);
- const [poDetails, setPoDetails] = useState<PurchaseOrderDetailByStatusRow[]>([]);
- const [poDetailsLoading, setPoDetailsLoading] = useState(false);
- const [selectedPo, setSelectedPo] = useState<PurchaseOrderDetailByStatusRow | null>(null);
- const [itemsSummary, setItemsSummary] = useState<PurchaseOrderItemRow[]>([]);
- const [itemsSummaryLoading, setItemsSummaryLoading] = useState(false);
- const [poLineItems, setPoLineItems] = useState<PurchaseOrderItemRow[]>([]);
- const [poLineItemsLoading, setPoLineItemsLoading] = useState(false);
- const [masterExportLoading, setMasterExportLoading] = useState(false);
- const [eaBreakdown, setEaBreakdown] = useState<PurchaseOrderEstimatedArrivalBreakdown | null>(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, barFilters)
- .then((data) => setChartData(data as { status: string; count: number }[]))
- .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
- .finally(() => setLoading(false));
- }, [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<string, unknown>[] = [
- { 項目: "匯出時間_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<string, unknown>[] = [];
- 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 (
- <Box sx={{ maxWidth: 1200, mx: "auto" }}>
- <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
- <ShoppingCart /> {PAGE_TITLE}
- </Typography>
- {error && (
- <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
- {error}
- </Alert>
- )}
-
- <Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: "wrap", alignItems: "center" }}>
- <Typography variant="body2" color="text.secondary">
- 上方「預計送貨」與「實際已送貨」依查詢日期與篩選條件;點擊圓環可篩選下方圖表(與其他條件交集)。
- </Typography>
- <Button
- size="small"
- variant="contained"
- color="primary"
- startIcon={masterExportLoading ? <CircularProgress size={16} color="inherit" /> : <TableChart />}
- disabled={masterExportLoading}
- onClick={() => void handleExportPurchaseMaster()}
- >
- 匯出總表 Excel
- </Button>
- {(hasBarFilters || hasChartDrill) && (
- <Button size="small" variant="outlined" onClick={handleClearFilters}>
- 清除篩選
- </Button>
- )}
- </Stack>
-
- <Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: "wrap", alignItems: "center" }} useFlexGap>
- <TextField
- size="small"
- label="查詢日期"
- type="date"
- value={poTargetDate}
- onChange={(e) => setPoTargetDate(e.target.value)}
- InputLabelProps={{ shrink: true }}
- sx={{ minWidth: 160 }}
- />
- <Autocomplete
- multiple
- size="small"
- sx={{ minWidth: 220, maxWidth: 360 }}
- loading={filterOptionsLoading}
- options={filterOptions.suppliers}
- getOptionLabel={(o) => `${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) => (
- <Chip {...getTagProps({ index })} key={option.supplierId} size="small" label={option.code || option.supplierId} />
- ))
- }
- renderInput={(params) => <TextField {...params} label="供應商" placeholder="多選" />}
- />
- <Autocomplete
- multiple
- size="small"
- sx={{ minWidth: 220, maxWidth: 360 }}
- loading={filterOptionsLoading}
- options={filterOptions.items}
- getOptionLabel={(o) => `${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) => (
- <Chip {...getTagProps({ index })} key={option.itemCode} size="small" label={option.itemCode} />
- ))
- }
- renderInput={(params) => <TextField {...params} label="貨品" placeholder="多選" />}
- />
- <Autocomplete
- multiple
- size="small"
- sx={{ minWidth: 200, maxWidth: 360 }}
- loading={filterOptionsLoading}
- options={filterOptions.poNos}
- getOptionLabel={(o) => 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) => (
- <Chip {...getTagProps({ index })} key={option.poNo} size="small" label={option.poNo} />
- ))
- }
- renderInput={(params) => <TextField {...params} label="採購單號" placeholder="多選" />}
- />
- </Stack>
-
- <Grid container spacing={2} sx={{ mb: 1 }}>
- <Grid item xs={12} md={6}>
- <ChartCard
- title="預計送貨(依預計到貨日)"
- exportFilename="採購_預計送貨"
- exportData={EST_BUCKETS.map((b, i) => ({
- 類別: bucketLabelZh(b),
- 數量: estimatedChartSeries[i] ?? 0,
- }))}
- >
- {estimatedLoading ? (
- <Skeleton variant="rectangular" height={320} />
- ) : (
- <ApexCharts
- options={{
- chart: {
- type: "donut",
- events: {
- dataPointSelection: (_event: unknown, _chartContext: unknown, config: { dataPointIndex?: number }) => {
- 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}
- />
- )}
- </ChartCard>
- </Grid>
- <Grid item xs={12} md={6}>
- <ChartCard
- title="實際已送貨(依預計到貨日或實收日)"
- exportFilename="採購_實際已送貨"
- exportData={chartData.map((p) => ({ 狀態: poStatusLabelZh(p.status), 數量: p.count }))}
- >
- {loading ? (
- <Skeleton variant="rectangular" height={320} />
- ) : (
- <ApexCharts
- options={{
- chart: {
- type: "donut",
- events: {
- dataPointSelection: (_event: unknown, _chartContext: unknown, config: { dataPointIndex?: number }) => {
- 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}
- />
- )}
- </ChartCard>
- </Grid>
- </Grid>
-
- {selectedEstimatedBucket && (
- <Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
- <Typography variant="subtitle1" fontWeight={600} gutterBottom>
- 「{bucketLabelZh(selectedEstimatedBucket)}」關聯對象
- </Typography>
- <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
- 條件與左側「預計送貨」圓環一致:預計到貨日 = 查詢日期({poTargetDate}),並含上方供應商/貨品/採購單號多選。
- </Typography>
- {eaBreakdownLoading ? (
- <Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
- <CircularProgress size={28} />
- </Box>
- ) : eaBreakdown ? (
- <Grid container spacing={2}>
- <Grid item xs={12} md={4}>
- <Typography variant="subtitle2" color="primary" gutterBottom>
- 供應商
- </Typography>
- <TableContainer sx={{ maxHeight: 280 }}>
- <Table size="small" stickyHeader>
- <TableHead>
- <TableRow>
- <TableCell>編號</TableCell>
- <TableCell>名稱</TableCell>
- <TableCell align="right">採購單數</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {eaBreakdown.suppliers.length === 0 ? (
- <TableRow>
- <TableCell colSpan={3}>
- <Typography variant="body2" color="text.secondary">
- 無
- </Typography>
- </TableCell>
- </TableRow>
- ) : (
- eaBreakdown.suppliers.map((s, i) => (
- <TableRow key={`sup-${s.supplierId ?? "null"}-${i}`}>
- <TableCell>{s.supplierCode}</TableCell>
- <TableCell>{s.supplierName}</TableCell>
- <TableCell align="right">{s.poCount}</TableCell>
- </TableRow>
- ))
- )}
- </TableBody>
- </Table>
- </TableContainer>
- </Grid>
- <Grid item xs={12} md={4}>
- <Typography variant="subtitle2" color="primary" gutterBottom>
- 貨品
- </Typography>
- <TableContainer sx={{ maxHeight: 280 }}>
- <Table size="small" stickyHeader>
- <TableHead>
- <TableRow>
- <TableCell>貨品編號</TableCell>
- <TableCell>名稱</TableCell>
- <TableCell align="right">採購單數</TableCell>
- <TableCell align="right">總數量</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {eaBreakdown.items.length === 0 ? (
- <TableRow>
- <TableCell colSpan={4}>
- <Typography variant="body2" color="text.secondary">
- 無
- </Typography>
- </TableCell>
- </TableRow>
- ) : (
- eaBreakdown.items.map((it, i) => (
- <TableRow key={`it-${it.itemCode}-${i}`}>
- <TableCell>{it.itemCode}</TableCell>
- <TableCell>{it.itemName}</TableCell>
- <TableCell align="right">{it.poCount}</TableCell>
- <TableCell align="right">{it.totalQty}</TableCell>
- </TableRow>
- ))
- )}
- </TableBody>
- </Table>
- </TableContainer>
- </Grid>
- <Grid item xs={12} md={4}>
- <Typography variant="subtitle2" color="primary" gutterBottom>
- 採購單
- </Typography>
- <TableContainer sx={{ maxHeight: 280 }}>
- <Table size="small" stickyHeader>
- <TableHead>
- <TableRow>
- <TableCell>採購單號</TableCell>
- <TableCell>供應商</TableCell>
- <TableCell>狀態</TableCell>
- <TableCell>訂單日期</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {eaBreakdown.purchaseOrders.length === 0 ? (
- <TableRow>
- <TableCell colSpan={4}>
- <Typography variant="body2" color="text.secondary">
- 無
- </Typography>
- </TableCell>
- </TableRow>
- ) : (
- eaBreakdown.purchaseOrders.map((po) => (
- <TableRow key={po.purchaseOrderId}>
- <TableCell>{po.purchaseOrderNo}</TableCell>
- <TableCell>{`${po.supplierCode} ${po.supplierName}`.trim()}</TableCell>
- <TableCell>{poStatusLabelZh(po.status)}</TableCell>
- <TableCell>{po.orderDate}</TableCell>
- </TableRow>
- ))
- )}
- </TableBody>
- </Table>
- </TableContainer>
- </Grid>
- </Grid>
- ) : null}
- </Paper>
- )}
-
- {(selectedEstimatedBucket || selectedStatus != null) && (
- <Stack spacing={1} sx={{ mb: 2 }}>
- {selectedEstimatedBucket && (
- <Alert severity="info" variant="outlined">
- 下方圖表已依「預計送貨」篩選:{bucketLabelZh(selectedEstimatedBucket)}
- (預計到貨日 = 查詢日期,並含多選;此時不再套用右側「實際已送貨」狀態;再點同一扇形可取消)。
- </Alert>
- )}
- {selectedStatus != null && (
- <Alert severity="info" variant="outlined">
- 下方圖表已依「實際已送貨」所選狀態:{poStatusLabelZh(selectedStatus)}(再點同一狀態可取消)。
- </Alert>
- )}
- </Stack>
- )}
-
- <ChartCard
- 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 ? (
- <Typography color="text.secondary">無符合交集的篩選(請調整上方條件或圖表點選)</Typography>
- ) : itemsSummaryLoading ? (
- <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
- <CircularProgress size={28} />
- </Box>
- ) : itemsSummary.length === 0 ? (
- <Typography color="text.secondary">
- 無資料(請確認訂單日期{selectedEstimatedBucket ? "與篩選" : "與狀態"})
- </Typography>
- ) : (
- <ApexCharts
- key={itemChartKey}
- options={{
- chart: {
- type: "donut",
- events: {
- dataPointSelection: (_event: unknown, _chartContext: unknown, config: { dataPointIndex?: number }) => {
- 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}
- />
- )}
- </ChartCard>
-
- <ChartCard
- title={`${lowerChartsTitlePrefix} 的供應商分佈${filterHint ? `(${filterHint})` : ""}`}
- exportFilename={`採購單_供應商_${selectedEstimatedBucket ?? effectiveStatus}`}
- exportData={supplierChartData.map((s) => ({
- 供應商: s.supplier,
- 採購單數: s.count,
- 總數量: s.totalQty,
- }))}
- >
- {drillQueryOpts === null ? (
- <Typography color="text.secondary">無符合交集的篩選</Typography>
- ) : poDetailsLoading ? (
- <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
- <CircularProgress size={28} />
- </Box>
- ) : supplierChartData.length === 0 ? (
- <Typography color="text.secondary">無供應商資料(請先確認上方貨品篩選或日期)</Typography>
- ) : (
- <ApexCharts
- key={supplierChartKey}
- options={{
- 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={supplierChartData.map((s) => s.totalQty)}
- type="donut"
- width="100%"
- height={360}
- />
- )}
- </ChartCard>
-
- <ChartCard
- title={`${lowerChartsTitlePrefix} 的採購單(點擊柱可看明細)${lowerChartsDefaultStatusHint}`}
- exportFilename={`採購單_PO_${selectedEstimatedBucket ?? effectiveStatus}`}
- exportData={poDetails.map((p) => ({
- 採購單號: p.purchaseOrderNo,
- 供應商: `${p.supplierCode} ${p.supplierName}`.trim(),
- 總數量: p.totalQty,
- 項目數: p.itemCount,
- 訂單日期: p.orderDate,
- }))}
- >
- {drillQueryOpts === null ? (
- <Typography color="text.secondary">無符合交集的篩選</Typography>
- ) : poDetailsLoading ? (
- <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
- <CircularProgress size={28} />
- </Box>
- ) : poDetails.length === 0 ? (
- <Typography color="text.secondary">
- 無採購單。請確認該「訂單日期」是否有此狀態的採購單。
- </Typography>
- ) : (
- <ApexCharts
- key={poChartKey}
- options={{
- chart: {
- type: "bar",
- events: {
- dataPointSelection: (_event: unknown, _chartContext: unknown, config: { dataPointIndex?: number }) => {
- 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}
- />
- )}
- </ChartCard>
-
- {selectedPo && (
- <ChartCard
- title={`採購單 ${selectedPo.purchaseOrderNo} 行明細(貨品)`}
- exportFilename={`採購單_貨品_${selectedPo.purchaseOrderNo}`}
- exportData={poLineItems.map((i) => ({
- 貨品: i.itemCode,
- 名稱: i.itemName,
- 訂購數量: i.orderedQty,
- 已收貨: i.receivedQty,
- 待收貨: i.pendingQty,
- UOM: i.uom,
- }))}
- >
- {poLineItemsLoading ? (
- <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
- <CircularProgress size={28} />
- </Box>
- ) : (
- <ApexCharts
- options={{
- chart: { type: "bar" },
- xaxis: { categories: poLineItems.map((i) => `${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)}
- />
- )}
- </ChartCard>
- )}
- </Box>
- );
- }
|