FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

1132 lines
43 KiB

  1. "use client";
  2. import React, { useState } from "react";
  3. import {
  4. Box,
  5. Typography,
  6. Skeleton,
  7. Alert,
  8. TextField,
  9. CircularProgress,
  10. Button,
  11. Stack,
  12. Grid,
  13. Autocomplete,
  14. Chip,
  15. Paper,
  16. Table,
  17. TableBody,
  18. TableCell,
  19. TableContainer,
  20. TableHead,
  21. TableRow,
  22. } from "@mui/material";
  23. import dynamic from "next/dynamic";
  24. import ShoppingCart from "@mui/icons-material/ShoppingCart";
  25. import TableChart from "@mui/icons-material/TableChart";
  26. import {
  27. fetchPurchaseOrderByStatus,
  28. fetchPurchaseOrderDetailsByStatus,
  29. fetchPurchaseOrderItems,
  30. fetchPurchaseOrderItemsByStatus,
  31. fetchPurchaseOrderFilterOptions,
  32. fetchPurchaseOrderEstimatedArrivalSummary,
  33. fetchPurchaseOrderEstimatedArrivalBreakdown,
  34. PurchaseOrderDetailByStatusRow,
  35. PurchaseOrderItemRow,
  36. PurchaseOrderChartFilters,
  37. PurchaseOrderFilterOptions,
  38. PurchaseOrderEstimatedArrivalRow,
  39. PurchaseOrderDrillQuery,
  40. PurchaseOrderEstimatedArrivalBreakdown,
  41. } from "@/app/api/chart/client";
  42. import ChartCard from "../_components/ChartCard";
  43. import { exportPurchaseChartMasterToFile } from "./exportPurchaseChartMaster";
  44. import dayjs from "dayjs";
  45. const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });
  46. const PAGE_TITLE = "採購";
  47. const DEFAULT_DRILL_STATUS = "completed";
  48. /** Must match backend `getPurchaseOrderByStatus` (orderDate). Using "complete" here desyncs drill-down from the donut counts. */
  49. const DRILL_DATE_FILTER = "order" as const;
  50. const EST_BUCKETS = ["delivered", "not_delivered", "cancelled", "other"] as const;
  51. /** 預計送貨 — 已送 / 未送 / 已取消 / 其他 */
  52. const ESTIMATE_DONUT_COLORS = ["#2e7d32", "#f57c00", "#78909c", "#7b1fa2"];
  53. /** 實際已送貨(依狀態)— 依序上色 */
  54. const STATUS_DONUT_COLORS = ["#1565c0", "#00838f", "#6a1b9a", "#c62828", "#5d4037", "#00695c"];
  55. /** ApexCharts + React: avoid updating state inside dataPointSelection synchronously (DOM getAttribute null). */
  56. function deferChartClick(fn: () => void) {
  57. window.setTimeout(fn, 0);
  58. }
  59. /** UI labels only; API still uses English status values. */
  60. function poStatusLabelZh(status: string): string {
  61. const s = status.trim().toLowerCase();
  62. switch (s) {
  63. case "pending":
  64. return "待處理";
  65. case "completed":
  66. return "已完成";
  67. case "receiving":
  68. return "收貨中";
  69. default:
  70. return status;
  71. }
  72. }
  73. function bucketLabelZh(bucket: string): string {
  74. switch (bucket) {
  75. case "delivered":
  76. return "已送";
  77. case "not_delivered":
  78. return "未送";
  79. case "cancelled":
  80. return "已取消";
  81. case "other":
  82. return "其他";
  83. default:
  84. return bucket;
  85. }
  86. }
  87. function emptyFilterOptions(): PurchaseOrderFilterOptions {
  88. return { suppliers: [], items: [], poNos: [] };
  89. }
  90. export default function PurchaseChartPage() {
  91. const [poTargetDate, setPoTargetDate] = useState<string>(() => dayjs().format("YYYY-MM-DD"));
  92. const [error, setError] = useState<string | null>(null);
  93. const [chartData, setChartData] = useState<{ status: string; count: number }[]>([]);
  94. const [estimatedArrivalData, setEstimatedArrivalData] = useState<PurchaseOrderEstimatedArrivalRow[]>([]);
  95. const [loading, setLoading] = useState(true);
  96. const [estimatedLoading, setEstimatedLoading] = useState(true);
  97. const [filterOptions, setFilterOptions] = useState<PurchaseOrderFilterOptions>(emptyFilterOptions);
  98. const [filterOptionsLoading, setFilterOptionsLoading] = useState(false);
  99. const [filterSupplierIds, setFilterSupplierIds] = useState<number[]>([]);
  100. const [filterItemCodes, setFilterItemCodes] = useState<string[]>([]);
  101. const [filterPoNos, setFilterPoNos] = useState<string[]>([]);
  102. const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
  103. /** Prefer id (shop row); code-only used when supplierId missing */
  104. const [selectedSupplierId, setSelectedSupplierId] = useState<number | null>(null);
  105. const [selectedSupplierCode, setSelectedSupplierCode] = useState<string | null>(null);
  106. const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
  107. /** 預計送貨 donut — filters lower charts via API */
  108. const [selectedEstimatedBucket, setSelectedEstimatedBucket] = useState<string | null>(null);
  109. const [poDetails, setPoDetails] = useState<PurchaseOrderDetailByStatusRow[]>([]);
  110. const [poDetailsLoading, setPoDetailsLoading] = useState(false);
  111. const [selectedPo, setSelectedPo] = useState<PurchaseOrderDetailByStatusRow | null>(null);
  112. const [itemsSummary, setItemsSummary] = useState<PurchaseOrderItemRow[]>([]);
  113. const [itemsSummaryLoading, setItemsSummaryLoading] = useState(false);
  114. const [poLineItems, setPoLineItems] = useState<PurchaseOrderItemRow[]>([]);
  115. const [poLineItemsLoading, setPoLineItemsLoading] = useState(false);
  116. const [masterExportLoading, setMasterExportLoading] = useState(false);
  117. const [eaBreakdown, setEaBreakdown] = useState<PurchaseOrderEstimatedArrivalBreakdown | null>(null);
  118. const [eaBreakdownLoading, setEaBreakdownLoading] = useState(false);
  119. const effectiveStatus = selectedStatus ?? DEFAULT_DRILL_STATUS;
  120. /** Top charts (實際已送貨 + 預計送貨): date + multi-select only — no drill-down from lower charts. */
  121. const barFilters = React.useMemo((): PurchaseOrderChartFilters => {
  122. return {
  123. supplierIds: filterSupplierIds.length ? filterSupplierIds : undefined,
  124. itemCodes: filterItemCodes.length ? filterItemCodes : undefined,
  125. purchaseOrderNos: filterPoNos.length ? filterPoNos : undefined,
  126. };
  127. }, [filterSupplierIds, filterItemCodes, filterPoNos]);
  128. /** Drill-down: bar filters ∩ supplier/貨品 chart selection. */
  129. const drillFilters = React.useMemo((): PurchaseOrderChartFilters | null => {
  130. if (
  131. selectedSupplierId != null &&
  132. selectedSupplierId > 0 &&
  133. filterSupplierIds.length > 0 &&
  134. !filterSupplierIds.includes(selectedSupplierId)
  135. ) {
  136. return null;
  137. }
  138. if (
  139. selectedItemCode?.trim() &&
  140. filterItemCodes.length > 0 &&
  141. !filterItemCodes.includes(selectedItemCode.trim())
  142. ) {
  143. return null;
  144. }
  145. if (selectedSupplierCode?.trim() && filterSupplierIds.length > 0) {
  146. const opt = filterOptions.suppliers.find((s) => s.code === selectedSupplierCode.trim());
  147. if (!opt || opt.supplierId <= 0 || !filterSupplierIds.includes(opt.supplierId)) {
  148. return null;
  149. }
  150. }
  151. const out: PurchaseOrderChartFilters = {
  152. supplierIds: filterSupplierIds.length ? [...filterSupplierIds] : undefined,
  153. itemCodes: filterItemCodes.length ? [...filterItemCodes] : undefined,
  154. purchaseOrderNos: filterPoNos.length ? [...filterPoNos] : undefined,
  155. };
  156. if (selectedSupplierId != null && selectedSupplierId > 0) {
  157. if (!out.supplierIds?.length) {
  158. out.supplierIds = [selectedSupplierId];
  159. } else if (out.supplierIds.includes(selectedSupplierId)) {
  160. out.supplierIds = [selectedSupplierId];
  161. }
  162. out.supplierCode = undefined;
  163. } else if (selectedSupplierCode?.trim()) {
  164. const code = selectedSupplierCode.trim();
  165. const opt = filterOptions.suppliers.find((s) => s.code === code);
  166. if (out.supplierIds?.length) {
  167. if (opt && opt.supplierId > 0 && out.supplierIds.includes(opt.supplierId)) {
  168. out.supplierIds = [opt.supplierId];
  169. } else {
  170. return null;
  171. }
  172. } else {
  173. out.supplierIds = undefined;
  174. out.supplierCode = code;
  175. }
  176. }
  177. if (selectedItemCode?.trim()) {
  178. const ic = selectedItemCode.trim();
  179. if (!out.itemCodes?.length) {
  180. out.itemCodes = [ic];
  181. } else if (out.itemCodes.includes(ic)) {
  182. out.itemCodes = [ic];
  183. }
  184. }
  185. return out;
  186. }, [
  187. filterSupplierIds,
  188. filterItemCodes,
  189. filterPoNos,
  190. selectedSupplierId,
  191. selectedSupplierCode,
  192. selectedItemCode,
  193. filterOptions.suppliers,
  194. ]);
  195. const drillQueryOpts = React.useMemo((): PurchaseOrderDrillQuery | null => {
  196. if (drillFilters === null) return null;
  197. return {
  198. ...drillFilters,
  199. estimatedArrivalBucket: selectedEstimatedBucket ?? undefined,
  200. };
  201. }, [drillFilters, selectedEstimatedBucket]);
  202. React.useEffect(() => {
  203. setFilterOptionsLoading(true);
  204. fetchPurchaseOrderFilterOptions(poTargetDate)
  205. .then(setFilterOptions)
  206. .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
  207. .finally(() => setFilterOptionsLoading(false));
  208. }, [poTargetDate]);
  209. React.useEffect(() => {
  210. setLoading(true);
  211. fetchPurchaseOrderByStatus(poTargetDate, barFilters)
  212. .then((data) => setChartData(data as { status: string; count: number }[]))
  213. .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
  214. .finally(() => setLoading(false));
  215. }, [poTargetDate, barFilters]);
  216. React.useEffect(() => {
  217. setEstimatedLoading(true);
  218. fetchPurchaseOrderEstimatedArrivalSummary(poTargetDate, barFilters)
  219. .then(setEstimatedArrivalData)
  220. .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
  221. .finally(() => setEstimatedLoading(false));
  222. }, [poTargetDate, barFilters]);
  223. React.useEffect(() => {
  224. if (!selectedEstimatedBucket || !poTargetDate) {
  225. setEaBreakdown(null);
  226. return;
  227. }
  228. setEaBreakdownLoading(true);
  229. fetchPurchaseOrderEstimatedArrivalBreakdown(poTargetDate, selectedEstimatedBucket, barFilters)
  230. .then(setEaBreakdown)
  231. .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
  232. .finally(() => setEaBreakdownLoading(false));
  233. }, [selectedEstimatedBucket, poTargetDate, barFilters]);
  234. React.useEffect(() => {
  235. if (drillQueryOpts === null) {
  236. setPoDetails([]);
  237. return;
  238. }
  239. setPoDetailsLoading(true);
  240. fetchPurchaseOrderDetailsByStatus(effectiveStatus, poTargetDate, {
  241. dateFilter: DRILL_DATE_FILTER,
  242. ...drillQueryOpts,
  243. })
  244. .then((rows) => setPoDetails(rows))
  245. .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
  246. .finally(() => setPoDetailsLoading(false));
  247. }, [effectiveStatus, poTargetDate, drillQueryOpts]);
  248. React.useEffect(() => {
  249. if (selectedPo) return;
  250. if (drillQueryOpts === null) {
  251. setItemsSummary([]);
  252. return;
  253. }
  254. setItemsSummaryLoading(true);
  255. fetchPurchaseOrderItemsByStatus(effectiveStatus, poTargetDate, {
  256. dateFilter: DRILL_DATE_FILTER,
  257. ...drillQueryOpts,
  258. })
  259. .then((rows) => setItemsSummary(rows))
  260. .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
  261. .finally(() => setItemsSummaryLoading(false));
  262. }, [selectedPo, effectiveStatus, poTargetDate, drillQueryOpts]);
  263. React.useEffect(() => {
  264. if (selectedPo) return;
  265. setPoLineItems([]);
  266. setPoLineItemsLoading(false);
  267. }, [selectedPo]);
  268. const handleStatusClick = (status: string) => {
  269. const normalized = status.trim().toLowerCase();
  270. setSelectedStatus((prev) => (prev === normalized ? null : normalized));
  271. /** 與「預計送貨」圓環互斥:只顯示一則上方圓環篩選說明 */
  272. setSelectedEstimatedBucket(null);
  273. setSelectedPo(null);
  274. setSelectedSupplierId(null);
  275. setSelectedSupplierCode(null);
  276. setSelectedItemCode(null);
  277. setPoLineItems([]);
  278. };
  279. const handleEstimatedBucketClick = (index: number) => {
  280. const bucket = EST_BUCKETS[index];
  281. if (!bucket) return;
  282. setSelectedEstimatedBucket((prev) => (prev === bucket ? null : bucket));
  283. /** 與「實際已送貨」圓環互斥:只顯示一則上方圓環篩選說明 */
  284. setSelectedStatus(null);
  285. setSelectedPo(null);
  286. setPoLineItems([]);
  287. };
  288. const handleClearFilters = () => {
  289. setFilterSupplierIds([]);
  290. setFilterItemCodes([]);
  291. setFilterPoNos([]);
  292. setSelectedSupplierId(null);
  293. setSelectedSupplierCode(null);
  294. setSelectedItemCode(null);
  295. setSelectedEstimatedBucket(null);
  296. setSelectedStatus(null);
  297. setSelectedPo(null);
  298. setPoLineItems([]);
  299. };
  300. const handleItemSummaryClick = (index: number) => {
  301. const row = itemsSummary[index];
  302. if (!row?.itemCode) return;
  303. setSelectedItemCode((prev) => (prev === row.itemCode ? null : row.itemCode));
  304. setSelectedPo(null);
  305. setPoLineItems([]);
  306. };
  307. const handleSupplierClick = (row: {
  308. supplierId: number | null;
  309. supplierCode: string;
  310. }) => {
  311. if (row.supplierId != null && row.supplierId > 0) {
  312. setSelectedSupplierId((prev) => (prev === row.supplierId ? null : row.supplierId));
  313. setSelectedSupplierCode(null);
  314. } else if (row.supplierCode.trim()) {
  315. setSelectedSupplierCode((prev) => (prev === row.supplierCode ? null : row.supplierCode));
  316. setSelectedSupplierId(null);
  317. }
  318. setSelectedPo(null);
  319. setPoLineItems([]);
  320. };
  321. const handlePoClick = async (row: PurchaseOrderDetailByStatusRow) => {
  322. setSelectedPo(row);
  323. setPoLineItems([]);
  324. setPoLineItemsLoading(true);
  325. try {
  326. const rows = await fetchPurchaseOrderItems(row.purchaseOrderId);
  327. setPoLineItems(rows);
  328. } catch (err) {
  329. setError(err instanceof Error ? err.message : "Request failed");
  330. } finally {
  331. setPoLineItemsLoading(false);
  332. }
  333. };
  334. const supplierChartData = React.useMemo(() => {
  335. const map = new Map<
  336. string,
  337. {
  338. supplier: string;
  339. supplierId: number | null;
  340. supplierCode: string;
  341. count: number;
  342. totalQty: number;
  343. }
  344. >();
  345. poDetails.forEach((row) => {
  346. const sid = row.supplierId != null && row.supplierId > 0 ? row.supplierId : null;
  347. const code = String(row.supplierCode ?? "").trim();
  348. const name = String(row.supplierName ?? "").trim();
  349. const label =
  350. `${code} ${name}`.trim() || (sid != null ? `(Supplier #${sid})` : "(Unknown supplier)");
  351. const key = sid != null ? `sid:${sid}` : `code:${code}|name:${name}`;
  352. const curr = map.get(key) ?? {
  353. supplier: label,
  354. supplierId: sid,
  355. supplierCode: code,
  356. count: 0,
  357. totalQty: 0,
  358. };
  359. curr.count += 1;
  360. curr.totalQty += Number(row.totalQty ?? 0);
  361. map.set(key, curr);
  362. });
  363. return Array.from(map.values()).sort((a, b) => b.totalQty - a.totalQty);
  364. }, [poDetails]);
  365. const estimatedChartSeries = React.useMemo(() => {
  366. const m = new Map(estimatedArrivalData.map((r) => [r.bucket, r.count]));
  367. return EST_BUCKETS.map((b) => m.get(b) ?? 0);
  368. }, [estimatedArrivalData]);
  369. const handleExportPurchaseMaster = React.useCallback(async () => {
  370. setMasterExportLoading(true);
  371. try {
  372. const exportedAtIso = new Date().toISOString();
  373. const filterSupplierText =
  374. filterOptions.suppliers
  375. .filter((s) => filterSupplierIds.includes(s.supplierId))
  376. .map((s) => `${s.code} ${s.name}`.trim())
  377. .join(";") || "(未選)";
  378. const filterItemText =
  379. filterOptions.items
  380. .filter((i) => filterItemCodes.includes(i.itemCode))
  381. .map((i) => `${i.itemCode} ${i.itemName}`.trim())
  382. .join(";") || "(未選)";
  383. const filterPoText = filterPoNos.length ? filterPoNos.join(";") : "(未選)";
  384. const metaRows: Record<string, unknown>[] = [
  385. { 項目: "匯出時間_UTC", 值: exportedAtIso },
  386. { 項目: "訂單日期", 值: poTargetDate },
  387. { 項目: "多選_供應商", 值: filterSupplierText },
  388. { 項目: "多選_貨品", 值: filterItemText },
  389. { 項目: "多選_採購單號", 值: filterPoText },
  390. {
  391. 項目: "預計送貨圓環_點選",
  392. 值: selectedEstimatedBucket ? bucketLabelZh(selectedEstimatedBucket) : "(未選)",
  393. },
  394. {
  395. 項目: "實際已送貨圓環_點選狀態",
  396. 值:
  397. selectedStatus != null
  398. ? poStatusLabelZh(selectedStatus)
  399. : `(未選;下方圖表預設狀態 ${poStatusLabelZh(DEFAULT_DRILL_STATUS)})`,
  400. },
  401. { 項目: "下方圖表套用狀態_英文", 值: effectiveStatus },
  402. { 項目: "下方圖表套用狀態_中文", 值: poStatusLabelZh(effectiveStatus) },
  403. {
  404. 項目: "圓環篩選_供應商",
  405. 值:
  406. selectedSupplierId != null && selectedSupplierId > 0
  407. ? `supplierId=${selectedSupplierId}`
  408. : selectedSupplierCode?.trim()
  409. ? `supplierCode=${selectedSupplierCode.trim()}`
  410. : "(未選)",
  411. },
  412. { 項目: "圓環篩選_貨品", 值: selectedItemCode?.trim() ? selectedItemCode.trim() : "(未選)" },
  413. {
  414. 項目: "下方查詢是否有效",
  415. 值: drillQueryOpts === null ? "否(篩選交集無效,下方表為空)" : "是",
  416. },
  417. {
  418. 項目: "圖表說明",
  419. 值:
  420. "預計送貨圖:預計到貨日=訂單日期。實際已送貨圖:訂單日期。貨品/供應商/採購單表:依下方查詢與預計送貨扇形。採購單行明細:匯出當前列表中每張採購單之全部行。",
  421. },
  422. ];
  423. const estimatedDonutRows = EST_BUCKETS.map((b, i) => ({
  424. 類別: bucketLabelZh(b),
  425. bucket代碼: b,
  426. 數量: estimatedChartSeries[i] ?? 0,
  427. }));
  428. const actualStatusDonutRows = chartData.map((p) => ({
  429. 狀態中文: poStatusLabelZh(p.status),
  430. status代碼: p.status,
  431. 數量: p.count,
  432. }));
  433. const itemSummaryRows = itemsSummary.map((i) => ({
  434. 貨品: i.itemCode,
  435. 名稱: i.itemName,
  436. 訂購數量: i.orderedQty,
  437. 已收貨: i.receivedQty,
  438. 待收貨: i.pendingQty,
  439. UOM: i.uom,
  440. }));
  441. const supplierDistributionRows = supplierChartData.map((s) => ({
  442. 供應商: s.supplier,
  443. 供應商編號: s.supplierCode,
  444. supplierId: s.supplierId ?? "",
  445. 採購單數: s.count,
  446. 總數量: s.totalQty,
  447. }));
  448. const purchaseOrderListRows = poDetails.map((p) => ({
  449. 採購單號: p.purchaseOrderNo,
  450. 狀態: poStatusLabelZh(p.status),
  451. status代碼: p.status,
  452. 訂單日期: p.orderDate,
  453. 預計到貨日: p.estimatedArrivalDate,
  454. 供應商編號: p.supplierCode,
  455. 供應商名稱: p.supplierName,
  456. supplierId: p.supplierId ?? "",
  457. 項目數: p.itemCount,
  458. 總數量: p.totalQty,
  459. }));
  460. const purchaseOrderLineRows: Record<string, unknown>[] = [];
  461. if (poDetails.length > 0) {
  462. const lineBatches = await Promise.all(
  463. poDetails.map((po) =>
  464. fetchPurchaseOrderItems(po.purchaseOrderId).then((lines) =>
  465. lines.map((line) => ({
  466. 採購單號: po.purchaseOrderNo,
  467. 採購單ID: po.purchaseOrderId,
  468. 狀態: poStatusLabelZh(po.status),
  469. 訂單日期: po.orderDate,
  470. 預計到貨日: po.estimatedArrivalDate,
  471. 供應商編號: po.supplierCode,
  472. 供應商名稱: po.supplierName,
  473. 貨品: line.itemCode,
  474. 品名: line.itemName,
  475. UOM: line.uom,
  476. 訂購數量: line.orderedQty,
  477. 已收貨: line.receivedQty,
  478. 待收貨: line.pendingQty,
  479. }))
  480. )
  481. )
  482. );
  483. lineBatches.flat().forEach((row) => purchaseOrderLineRows.push(row));
  484. }
  485. exportPurchaseChartMasterToFile(
  486. {
  487. exportedAtIso,
  488. metaRows,
  489. estimatedDonutRows,
  490. actualStatusDonutRows,
  491. itemSummaryRows,
  492. supplierDistributionRows,
  493. purchaseOrderListRows,
  494. purchaseOrderLineRows,
  495. },
  496. `採購圖表總表_${poTargetDate}_${dayjs().format("HHmmss")}`
  497. );
  498. } catch (err) {
  499. setError(err instanceof Error ? err.message : "總表匯出失敗");
  500. } finally {
  501. setMasterExportLoading(false);
  502. }
  503. }, [
  504. poTargetDate,
  505. filterOptions.suppliers,
  506. filterOptions.items,
  507. filterSupplierIds,
  508. filterItemCodes,
  509. filterPoNos,
  510. selectedEstimatedBucket,
  511. selectedStatus,
  512. effectiveStatus,
  513. drillQueryOpts,
  514. estimatedChartSeries,
  515. chartData,
  516. itemsSummary,
  517. supplierChartData,
  518. poDetails,
  519. selectedSupplierId,
  520. selectedSupplierCode,
  521. selectedItemCode,
  522. ]);
  523. const itemChartKey = `${effectiveStatus}|${poTargetDate}|${DRILL_DATE_FILTER}|${JSON.stringify(drillQueryOpts)}|ea:${selectedEstimatedBucket ?? ""}|s`;
  524. const supplierChartKey = `${itemChartKey}|${selectedItemCode ?? ""}|sup`;
  525. const poChartKey = `${supplierChartKey}|po`;
  526. /** 下方三張圖:僅選預計送貨扇形時,資料依 bucket 不再套用「實際已送貨」單一狀態。 */
  527. const lowerChartsTitlePrefix = React.useMemo(() => {
  528. if (selectedEstimatedBucket) {
  529. return `預計送貨「${bucketLabelZh(selectedEstimatedBucket)}」`;
  530. }
  531. return `實際已送貨 ${poStatusLabelZh(effectiveStatus)}`;
  532. }, [selectedEstimatedBucket, effectiveStatus]);
  533. const lowerChartsDefaultStatusHint = React.useMemo(() => {
  534. if (selectedEstimatedBucket) return "";
  535. if (selectedStatus) return "";
  536. return `(預設狀態:${poStatusLabelZh(DEFAULT_DRILL_STATUS)})`;
  537. }, [selectedEstimatedBucket, selectedStatus]);
  538. const filterHint = [
  539. selectedItemCode ? `貨品: ${selectedItemCode}` : null,
  540. selectedSupplierId != null && selectedSupplierId > 0
  541. ? `供應商 id: ${selectedSupplierId}`
  542. : selectedSupplierCode
  543. ? `供應商 code: ${selectedSupplierCode}`
  544. : null,
  545. ]
  546. .filter(Boolean)
  547. .join(" · ");
  548. const hasBarFilters = filterSupplierIds.length > 0 || filterItemCodes.length > 0 || filterPoNos.length > 0;
  549. const hasChartDrill =
  550. selectedStatus != null ||
  551. selectedItemCode ||
  552. selectedSupplierId != null ||
  553. selectedSupplierCode ||
  554. selectedEstimatedBucket ||
  555. selectedPo;
  556. return (
  557. <Box sx={{ maxWidth: 1200, mx: "auto" }}>
  558. <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
  559. <ShoppingCart /> {PAGE_TITLE}
  560. </Typography>
  561. {error && (
  562. <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
  563. {error}
  564. </Alert>
  565. )}
  566. <Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: "wrap", alignItems: "center" }}>
  567. <Typography variant="body2" color="text.secondary">
  568. 上方「預計送貨」與「實際已送貨」依查詢日期與篩選條件;點擊圓環可篩選下方圖表(與其他條件交集)。
  569. </Typography>
  570. <Button
  571. size="small"
  572. variant="contained"
  573. color="primary"
  574. startIcon={masterExportLoading ? <CircularProgress size={16} color="inherit" /> : <TableChart />}
  575. disabled={masterExportLoading}
  576. onClick={() => void handleExportPurchaseMaster()}
  577. >
  578. 匯出總表 Excel
  579. </Button>
  580. {(hasBarFilters || hasChartDrill) && (
  581. <Button size="small" variant="outlined" onClick={handleClearFilters}>
  582. 清除篩選
  583. </Button>
  584. )}
  585. </Stack>
  586. <Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: "wrap", alignItems: "center" }} useFlexGap>
  587. <TextField
  588. size="small"
  589. label="查詢日期"
  590. type="date"
  591. value={poTargetDate}
  592. onChange={(e) => setPoTargetDate(e.target.value)}
  593. InputLabelProps={{ shrink: true }}
  594. sx={{ minWidth: 160 }}
  595. />
  596. <Autocomplete
  597. multiple
  598. size="small"
  599. sx={{ minWidth: 220, maxWidth: 360 }}
  600. loading={filterOptionsLoading}
  601. options={filterOptions.suppliers}
  602. getOptionLabel={(o) => `${o.code} ${o.name}`.trim() || String(o.supplierId)}
  603. value={filterOptions.suppliers.filter((s) => filterSupplierIds.includes(s.supplierId))}
  604. onChange={(_, v) => setFilterSupplierIds(v.map((x) => x.supplierId))}
  605. renderTags={(tagValue, getTagProps) =>
  606. tagValue.map((option, index) => (
  607. <Chip {...getTagProps({ index })} key={option.supplierId} size="small" label={option.code || option.supplierId} />
  608. ))
  609. }
  610. renderInput={(params) => <TextField {...params} label="供應商" placeholder="多選" />}
  611. />
  612. <Autocomplete
  613. multiple
  614. size="small"
  615. sx={{ minWidth: 220, maxWidth: 360 }}
  616. loading={filterOptionsLoading}
  617. options={filterOptions.items}
  618. getOptionLabel={(o) => `${o.itemCode} ${o.itemName}`.trim()}
  619. value={filterOptions.items.filter((i) => filterItemCodes.includes(i.itemCode))}
  620. onChange={(_, v) => setFilterItemCodes(v.map((x) => x.itemCode))}
  621. renderTags={(tagValue, getTagProps) =>
  622. tagValue.map((option, index) => (
  623. <Chip {...getTagProps({ index })} key={option.itemCode} size="small" label={option.itemCode} />
  624. ))
  625. }
  626. renderInput={(params) => <TextField {...params} label="貨品" placeholder="多選" />}
  627. />
  628. <Autocomplete
  629. multiple
  630. size="small"
  631. sx={{ minWidth: 200, maxWidth: 360 }}
  632. loading={filterOptionsLoading}
  633. options={filterOptions.poNos}
  634. getOptionLabel={(o) => o.poNo}
  635. value={filterOptions.poNos.filter((p) => filterPoNos.includes(p.poNo))}
  636. onChange={(_, v) => setFilterPoNos(v.map((x) => x.poNo))}
  637. renderTags={(tagValue, getTagProps) =>
  638. tagValue.map((option, index) => (
  639. <Chip {...getTagProps({ index })} key={option.poNo} size="small" label={option.poNo} />
  640. ))
  641. }
  642. renderInput={(params) => <TextField {...params} label="採購單號" placeholder="多選" />}
  643. />
  644. </Stack>
  645. <Grid container spacing={2} sx={{ mb: 1 }}>
  646. <Grid item xs={12} md={6}>
  647. <ChartCard
  648. title="預計送貨(依預計到貨日)"
  649. exportFilename="採購_預計送貨"
  650. exportData={EST_BUCKETS.map((b, i) => ({
  651. 類別: bucketLabelZh(b),
  652. 數量: estimatedChartSeries[i] ?? 0,
  653. }))}
  654. >
  655. {estimatedLoading ? (
  656. <Skeleton variant="rectangular" height={320} />
  657. ) : (
  658. <ApexCharts
  659. options={{
  660. chart: {
  661. type: "donut",
  662. events: {
  663. dataPointSelection: (_event: unknown, _chartContext: unknown, config: { dataPointIndex?: number }) => {
  664. const idx = config?.dataPointIndex ?? -1;
  665. if (idx < 0 || idx >= EST_BUCKETS.length) return;
  666. deferChartClick(() => handleEstimatedBucketClick(idx));
  667. },
  668. },
  669. animations: { enabled: false },
  670. },
  671. labels: EST_BUCKETS.map(bucketLabelZh),
  672. colors: ESTIMATE_DONUT_COLORS,
  673. legend: { position: "bottom" },
  674. plotOptions: {
  675. pie: {
  676. donut: {
  677. labels: {
  678. show: true,
  679. total: {
  680. show: true,
  681. label: "預計送貨",
  682. },
  683. },
  684. },
  685. },
  686. },
  687. }}
  688. series={estimatedChartSeries}
  689. type="donut"
  690. width="100%"
  691. height={320}
  692. />
  693. )}
  694. </ChartCard>
  695. </Grid>
  696. <Grid item xs={12} md={6}>
  697. <ChartCard
  698. title="實際已送貨(依預計到貨日或實收日)"
  699. exportFilename="採購_實際已送貨"
  700. exportData={chartData.map((p) => ({ 狀態: poStatusLabelZh(p.status), 數量: p.count }))}
  701. >
  702. {loading ? (
  703. <Skeleton variant="rectangular" height={320} />
  704. ) : (
  705. <ApexCharts
  706. options={{
  707. chart: {
  708. type: "donut",
  709. events: {
  710. dataPointSelection: (_event: unknown, _chartContext: unknown, config: { dataPointIndex?: number }) => {
  711. const idx = config?.dataPointIndex ?? -1;
  712. if (idx < 0 || idx >= chartData.length) return;
  713. const row = chartData[idx];
  714. if (!row?.status) return;
  715. const status = row.status;
  716. deferChartClick(() => handleStatusClick(status));
  717. },
  718. },
  719. animations: { enabled: false },
  720. },
  721. labels: chartData.map((p) => poStatusLabelZh(p.status)),
  722. colors: chartData.map((_, i) => STATUS_DONUT_COLORS[i % STATUS_DONUT_COLORS.length]),
  723. legend: { position: "bottom" },
  724. }}
  725. series={chartData.map((p) => p.count)}
  726. type="donut"
  727. width="100%"
  728. height={320}
  729. />
  730. )}
  731. </ChartCard>
  732. </Grid>
  733. </Grid>
  734. {selectedEstimatedBucket && (
  735. <Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
  736. <Typography variant="subtitle1" fontWeight={600} gutterBottom>
  737. 「{bucketLabelZh(selectedEstimatedBucket)}」關聯對象
  738. </Typography>
  739. <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
  740. 條件與左側「預計送貨」圓環一致:預計到貨日 = 查詢日期({poTargetDate}),並含上方供應商/貨品/採購單號多選。
  741. </Typography>
  742. {eaBreakdownLoading ? (
  743. <Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
  744. <CircularProgress size={28} />
  745. </Box>
  746. ) : eaBreakdown ? (
  747. <Grid container spacing={2}>
  748. <Grid item xs={12} md={4}>
  749. <Typography variant="subtitle2" color="primary" gutterBottom>
  750. 供應商
  751. </Typography>
  752. <TableContainer sx={{ maxHeight: 280 }}>
  753. <Table size="small" stickyHeader>
  754. <TableHead>
  755. <TableRow>
  756. <TableCell>編號</TableCell>
  757. <TableCell>名稱</TableCell>
  758. <TableCell align="right">採購單數</TableCell>
  759. </TableRow>
  760. </TableHead>
  761. <TableBody>
  762. {eaBreakdown.suppliers.length === 0 ? (
  763. <TableRow>
  764. <TableCell colSpan={3}>
  765. <Typography variant="body2" color="text.secondary">
  766. </Typography>
  767. </TableCell>
  768. </TableRow>
  769. ) : (
  770. eaBreakdown.suppliers.map((s, i) => (
  771. <TableRow key={`sup-${s.supplierId ?? "null"}-${i}`}>
  772. <TableCell>{s.supplierCode}</TableCell>
  773. <TableCell>{s.supplierName}</TableCell>
  774. <TableCell align="right">{s.poCount}</TableCell>
  775. </TableRow>
  776. ))
  777. )}
  778. </TableBody>
  779. </Table>
  780. </TableContainer>
  781. </Grid>
  782. <Grid item xs={12} md={4}>
  783. <Typography variant="subtitle2" color="primary" gutterBottom>
  784. 貨品
  785. </Typography>
  786. <TableContainer sx={{ maxHeight: 280 }}>
  787. <Table size="small" stickyHeader>
  788. <TableHead>
  789. <TableRow>
  790. <TableCell>貨品編號</TableCell>
  791. <TableCell>名稱</TableCell>
  792. <TableCell align="right">採購單數</TableCell>
  793. <TableCell align="right">總數量</TableCell>
  794. </TableRow>
  795. </TableHead>
  796. <TableBody>
  797. {eaBreakdown.items.length === 0 ? (
  798. <TableRow>
  799. <TableCell colSpan={4}>
  800. <Typography variant="body2" color="text.secondary">
  801. </Typography>
  802. </TableCell>
  803. </TableRow>
  804. ) : (
  805. eaBreakdown.items.map((it, i) => (
  806. <TableRow key={`it-${it.itemCode}-${i}`}>
  807. <TableCell>{it.itemCode}</TableCell>
  808. <TableCell>{it.itemName}</TableCell>
  809. <TableCell align="right">{it.poCount}</TableCell>
  810. <TableCell align="right">{it.totalQty}</TableCell>
  811. </TableRow>
  812. ))
  813. )}
  814. </TableBody>
  815. </Table>
  816. </TableContainer>
  817. </Grid>
  818. <Grid item xs={12} md={4}>
  819. <Typography variant="subtitle2" color="primary" gutterBottom>
  820. 採購單
  821. </Typography>
  822. <TableContainer sx={{ maxHeight: 280 }}>
  823. <Table size="small" stickyHeader>
  824. <TableHead>
  825. <TableRow>
  826. <TableCell>採購單號</TableCell>
  827. <TableCell>供應商</TableCell>
  828. <TableCell>狀態</TableCell>
  829. <TableCell>訂單日期</TableCell>
  830. </TableRow>
  831. </TableHead>
  832. <TableBody>
  833. {eaBreakdown.purchaseOrders.length === 0 ? (
  834. <TableRow>
  835. <TableCell colSpan={4}>
  836. <Typography variant="body2" color="text.secondary">
  837. </Typography>
  838. </TableCell>
  839. </TableRow>
  840. ) : (
  841. eaBreakdown.purchaseOrders.map((po) => (
  842. <TableRow key={po.purchaseOrderId}>
  843. <TableCell>{po.purchaseOrderNo}</TableCell>
  844. <TableCell>{`${po.supplierCode} ${po.supplierName}`.trim()}</TableCell>
  845. <TableCell>{poStatusLabelZh(po.status)}</TableCell>
  846. <TableCell>{po.orderDate}</TableCell>
  847. </TableRow>
  848. ))
  849. )}
  850. </TableBody>
  851. </Table>
  852. </TableContainer>
  853. </Grid>
  854. </Grid>
  855. ) : null}
  856. </Paper>
  857. )}
  858. {(selectedEstimatedBucket || selectedStatus != null) && (
  859. <Stack spacing={1} sx={{ mb: 2 }}>
  860. {selectedEstimatedBucket && (
  861. <Alert severity="info" variant="outlined">
  862. 下方圖表已依「預計送貨」篩選:{bucketLabelZh(selectedEstimatedBucket)}
  863. (預計到貨日 = 查詢日期,並含多選;此時不再套用右側「實際已送貨」狀態;再點同一扇形可取消)。
  864. </Alert>
  865. )}
  866. {selectedStatus != null && (
  867. <Alert severity="info" variant="outlined">
  868. 下方圖表已依「實際已送貨」所選狀態:{poStatusLabelZh(selectedStatus)}(再點同一狀態可取消)。
  869. </Alert>
  870. )}
  871. </Stack>
  872. )}
  873. <ChartCard
  874. title={`${lowerChartsTitlePrefix} 的貨品摘要(code / 名稱)${lowerChartsDefaultStatusHint}${
  875. selectedItemCode ? ` — 已選貨品:${selectedItemCode}` : ""
  876. }`}
  877. exportFilename={`採購單_貨品摘要_${selectedEstimatedBucket ?? effectiveStatus}`}
  878. exportData={itemsSummary.map((i) => ({
  879. 貨品: i.itemCode,
  880. 名稱: i.itemName,
  881. 訂購數量: i.orderedQty,
  882. 已收貨: i.receivedQty,
  883. UOM: i.uom,
  884. }))}
  885. >
  886. {drillQueryOpts === null ? (
  887. <Typography color="text.secondary">無符合交集的篩選(請調整上方條件或圖表點選)</Typography>
  888. ) : itemsSummaryLoading ? (
  889. <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
  890. <CircularProgress size={28} />
  891. </Box>
  892. ) : itemsSummary.length === 0 ? (
  893. <Typography color="text.secondary">
  894. 無資料(請確認訂單日期{selectedEstimatedBucket ? "與篩選" : "與狀態"})
  895. </Typography>
  896. ) : (
  897. <ApexCharts
  898. key={itemChartKey}
  899. options={{
  900. chart: {
  901. type: "donut",
  902. events: {
  903. dataPointSelection: (_event: unknown, _chartContext: unknown, config: { dataPointIndex?: number }) => {
  904. const idx = config?.dataPointIndex ?? -1;
  905. if (idx < 0 || idx >= itemsSummary.length) return;
  906. deferChartClick(() => handleItemSummaryClick(idx));
  907. },
  908. },
  909. animations: { enabled: false },
  910. },
  911. labels: itemsSummary.map((i) => `${i.itemCode} ${i.itemName}`.trim()),
  912. legend: { position: "bottom" },
  913. plotOptions: {
  914. pie: {
  915. donut: {
  916. labels: {
  917. show: true,
  918. total: {
  919. show: true,
  920. label: "貨品",
  921. },
  922. },
  923. },
  924. },
  925. },
  926. }}
  927. series={itemsSummary.map((i) => i.orderedQty)}
  928. type="donut"
  929. width="100%"
  930. height={380}
  931. />
  932. )}
  933. </ChartCard>
  934. <ChartCard
  935. title={`${lowerChartsTitlePrefix} 的供應商分佈${filterHint ? `(${filterHint})` : ""}`}
  936. exportFilename={`採購單_供應商_${selectedEstimatedBucket ?? effectiveStatus}`}
  937. exportData={supplierChartData.map((s) => ({
  938. 供應商: s.supplier,
  939. 採購單數: s.count,
  940. 總數量: s.totalQty,
  941. }))}
  942. >
  943. {drillQueryOpts === null ? (
  944. <Typography color="text.secondary">無符合交集的篩選</Typography>
  945. ) : poDetailsLoading ? (
  946. <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
  947. <CircularProgress size={28} />
  948. </Box>
  949. ) : supplierChartData.length === 0 ? (
  950. <Typography color="text.secondary">無供應商資料(請先確認上方貨品篩選或日期)</Typography>
  951. ) : (
  952. <ApexCharts
  953. key={supplierChartKey}
  954. options={{
  955. chart: {
  956. type: "donut",
  957. events: {
  958. dataPointSelection: (_event: unknown, _chartContext: unknown, config: { dataPointIndex?: number }) => {
  959. const idx = config?.dataPointIndex ?? -1;
  960. if (idx < 0 || idx >= supplierChartData.length) return;
  961. const row = supplierChartData[idx];
  962. if (!row.supplierId && !row.supplierCode?.trim()) return;
  963. deferChartClick(() => handleSupplierClick(row));
  964. },
  965. },
  966. animations: { enabled: false },
  967. },
  968. labels: supplierChartData.map((s) => s.supplier),
  969. legend: { position: "bottom" },
  970. }}
  971. series={supplierChartData.map((s) => s.totalQty)}
  972. type="donut"
  973. width="100%"
  974. height={360}
  975. />
  976. )}
  977. </ChartCard>
  978. <ChartCard
  979. title={`${lowerChartsTitlePrefix} 的採購單(點擊柱可看明細)${lowerChartsDefaultStatusHint}`}
  980. exportFilename={`採購單_PO_${selectedEstimatedBucket ?? effectiveStatus}`}
  981. exportData={poDetails.map((p) => ({
  982. 採購單號: p.purchaseOrderNo,
  983. 供應商: `${p.supplierCode} ${p.supplierName}`.trim(),
  984. 總數量: p.totalQty,
  985. 項目數: p.itemCount,
  986. 訂單日期: p.orderDate,
  987. }))}
  988. >
  989. {drillQueryOpts === null ? (
  990. <Typography color="text.secondary">無符合交集的篩選</Typography>
  991. ) : poDetailsLoading ? (
  992. <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
  993. <CircularProgress size={28} />
  994. </Box>
  995. ) : poDetails.length === 0 ? (
  996. <Typography color="text.secondary">
  997. 無採購單。請確認該「訂單日期」是否有此狀態的採購單。
  998. </Typography>
  999. ) : (
  1000. <ApexCharts
  1001. key={poChartKey}
  1002. options={{
  1003. chart: {
  1004. type: "bar",
  1005. events: {
  1006. dataPointSelection: (_event: unknown, _chartContext: unknown, config: { dataPointIndex?: number }) => {
  1007. const idx = config?.dataPointIndex ?? -1;
  1008. if (idx < 0 || idx >= poDetails.length) return;
  1009. const po = poDetails[idx];
  1010. deferChartClick(() => void handlePoClick(po));
  1011. },
  1012. },
  1013. animations: { enabled: false },
  1014. },
  1015. xaxis: { categories: poDetails.map((p) => p.purchaseOrderNo) },
  1016. dataLabels: { enabled: false },
  1017. }}
  1018. series={[
  1019. {
  1020. name: "總數量",
  1021. data: poDetails.map((p) => p.totalQty),
  1022. },
  1023. ]}
  1024. type="bar"
  1025. width="100%"
  1026. height={360}
  1027. />
  1028. )}
  1029. </ChartCard>
  1030. {selectedPo && (
  1031. <ChartCard
  1032. title={`採購單 ${selectedPo.purchaseOrderNo} 行明細(貨品)`}
  1033. exportFilename={`採購單_貨品_${selectedPo.purchaseOrderNo}`}
  1034. exportData={poLineItems.map((i) => ({
  1035. 貨品: i.itemCode,
  1036. 名稱: i.itemName,
  1037. 訂購數量: i.orderedQty,
  1038. 已收貨: i.receivedQty,
  1039. 待收貨: i.pendingQty,
  1040. UOM: i.uom,
  1041. }))}
  1042. >
  1043. {poLineItemsLoading ? (
  1044. <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
  1045. <CircularProgress size={28} />
  1046. </Box>
  1047. ) : (
  1048. <ApexCharts
  1049. options={{
  1050. chart: { type: "bar" },
  1051. xaxis: { categories: poLineItems.map((i) => `${i.itemCode} ${i.itemName}`.trim()) },
  1052. plotOptions: { bar: { horizontal: true } },
  1053. dataLabels: { enabled: false },
  1054. }}
  1055. series={[
  1056. { name: "訂購數量", data: poLineItems.map((i) => i.orderedQty) },
  1057. { name: "待收貨", data: poLineItems.map((i) => i.pendingQty) },
  1058. ]}
  1059. type="bar"
  1060. width="100%"
  1061. height={Math.max(320, poLineItems.length * 38)}
  1062. />
  1063. )}
  1064. </ChartCard>
  1065. )}
  1066. </Box>
  1067. );
  1068. }