FPSMS-frontend
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 

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