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.
 
 

502 regels
18 KiB

  1. "use client";
  2. import React, { useCallback, useMemo, useState } from "react";
  3. import {
  4. Box,
  5. Typography,
  6. Skeleton,
  7. Alert,
  8. TextField,
  9. FormControl,
  10. InputLabel,
  11. Select,
  12. MenuItem,
  13. Autocomplete,
  14. Chip,
  15. } from "@mui/material";
  16. import LocalShipping from "@mui/icons-material/LocalShipping";
  17. import {
  18. fetchDeliveryOrderByDate,
  19. fetchTopDeliveryItems,
  20. fetchTopDeliveryItemsItemOptions,
  21. fetchStaffDeliveryPerformance,
  22. fetchStaffDeliveryPerformanceHandlers,
  23. type StaffDeliveryPerformanceStoreFilter,
  24. type StaffOption,
  25. type TopDeliveryItemOption,
  26. } from "@/app/api/chart/client";
  27. import ChartCard from "../_components/ChartCard";
  28. import DateRangeSelect from "../_components/DateRangeSelect";
  29. import { toDateRange, DEFAULT_RANGE_DAYS, TOP_ITEMS_LIMIT_OPTIONS } from "../_components/constants";
  30. import SafeApexCharts from "@/components/charts/SafeApexCharts";
  31. const PAGE_TITLE = "發貨與配送";
  32. const STAFF_PERF_STORE_FILTER_OPTIONS: {
  33. value: StaffDeliveryPerformanceStoreFilter;
  34. label: string;
  35. }[] = [
  36. { value: "all", label: "全部" },
  37. { value: "2/F", label: "2/F" },
  38. { value: "4/F", label: "4/F" },
  39. { value: "null_only", label: "車線-X" },
  40. ];
  41. type Criteria = {
  42. delivery: { rangeDays: number };
  43. topItems: { rangeDays: number; limit: number };
  44. staffPerf: {
  45. rangeDays: number;
  46. startDate: string;
  47. endDate: string;
  48. storeFilter: StaffDeliveryPerformanceStoreFilter;
  49. };
  50. };
  51. const defaultStaffPerfDateRange = toDateRange(DEFAULT_RANGE_DAYS);
  52. const defaultCriteria: Criteria = {
  53. delivery: { rangeDays: DEFAULT_RANGE_DAYS },
  54. topItems: { rangeDays: DEFAULT_RANGE_DAYS, limit: 10 },
  55. staffPerf: {
  56. rangeDays: DEFAULT_RANGE_DAYS,
  57. startDate: defaultStaffPerfDateRange.startDate,
  58. endDate: defaultStaffPerfDateRange.endDate,
  59. storeFilter: "all",
  60. },
  61. };
  62. /** FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 */
  63. export default function DeliveryChartPage() {
  64. const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
  65. const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]);
  66. const [topItemOptions, setTopItemOptions] = useState<TopDeliveryItemOption[]>([]);
  67. const [staffSelected, setStaffSelected] = useState<StaffOption[]>([]);
  68. const [staffOptions, setStaffOptions] = useState<StaffOption[]>([]);
  69. const [error, setError] = useState<string | null>(null);
  70. const [chartData, setChartData] = useState<{
  71. delivery: { date: string; orderCount: number; totalQty: number }[];
  72. topItems: { itemCode: string; itemName: string; totalQty: number }[];
  73. staffPerf: {
  74. date: string;
  75. staffName: string;
  76. orderCount: number;
  77. totalMinutes: number;
  78. itemKindCount: number;
  79. itemQtyPicked: number;
  80. }[];
  81. }>({ delivery: [], topItems: [], staffPerf: [] });
  82. const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});
  83. const updateCriteria = useCallback(
  84. <K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => {
  85. setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) }));
  86. },
  87. []
  88. );
  89. const setChartLoading = useCallback((key: string, value: boolean) => {
  90. setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));
  91. }, []);
  92. React.useEffect(() => {
  93. const { startDate: s, endDate: e } = toDateRange(criteria.delivery.rangeDays);
  94. setChartLoading("delivery", true);
  95. fetchDeliveryOrderByDate(s, e)
  96. .then((data) =>
  97. setChartData((prev) => ({
  98. ...prev,
  99. delivery: data as { date: string; orderCount: number; totalQty: number }[],
  100. }))
  101. )
  102. .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
  103. .finally(() => setChartLoading("delivery", false));
  104. }, [criteria.delivery, setChartLoading]);
  105. React.useEffect(() => {
  106. const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays);
  107. setChartLoading("topItems", true);
  108. fetchTopDeliveryItems(
  109. s,
  110. e,
  111. criteria.topItems.limit,
  112. topItemsSelected.length > 0 ? topItemsSelected.map((o) => o.itemCode) : undefined
  113. )
  114. .then((data) =>
  115. setChartData((prev) => ({
  116. ...prev,
  117. topItems: data as { itemCode: string; itemName: string; totalQty: number }[],
  118. }))
  119. )
  120. .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
  121. .finally(() => setChartLoading("topItems", false));
  122. }, [criteria.topItems, topItemsSelected, setChartLoading]);
  123. React.useEffect(() => {
  124. const s = criteria.staffPerf.startDate;
  125. const e = criteria.staffPerf.endDate;
  126. if (!s || !e) {
  127. setChartData((prev) => ({ ...prev, staffPerf: [] }));
  128. return;
  129. }
  130. if (s > e) {
  131. setError("員工發貨績效的起始日期不能晚於結束日期");
  132. setChartData((prev) => ({ ...prev, staffPerf: [] }));
  133. return;
  134. }
  135. const staffNos = staffSelected.length > 0 ? staffSelected.map((o) => o.staffNo) : undefined;
  136. setChartLoading("staffPerf", true);
  137. fetchStaffDeliveryPerformance(s, e, staffNos, criteria.staffPerf.storeFilter)
  138. .then((data) =>
  139. setChartData((prev) => ({
  140. ...prev,
  141. staffPerf: data as {
  142. date: string;
  143. staffName: string;
  144. orderCount: number;
  145. totalMinutes: number;
  146. itemKindCount: number;
  147. itemQtyPicked: number;
  148. }[],
  149. }))
  150. )
  151. .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
  152. .finally(() => setChartLoading("staffPerf", false));
  153. }, [criteria.staffPerf, staffSelected, setChartLoading]);
  154. React.useEffect(() => {
  155. fetchStaffDeliveryPerformanceHandlers()
  156. .then(setStaffOptions)
  157. .catch(() => setStaffOptions([]));
  158. }, []);
  159. React.useEffect(() => {
  160. const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays);
  161. fetchTopDeliveryItemsItemOptions(s, e).then(setTopItemOptions).catch(() => setTopItemOptions([]));
  162. }, [criteria.topItems.rangeDays]);
  163. const staffPerfByStaff = useMemo(() => {
  164. const map = new Map<
  165. string,
  166. { orderCount: number; totalMinutes: number; itemKindCount: number; itemQtyPicked: number }
  167. >();
  168. for (const r of chartData.staffPerf) {
  169. const name = r.staffName || "Unknown";
  170. const cur = map.get(name) ?? {
  171. orderCount: 0,
  172. totalMinutes: 0,
  173. itemKindCount: 0,
  174. itemQtyPicked: 0,
  175. };
  176. map.set(name, {
  177. orderCount: cur.orderCount + r.orderCount,
  178. totalMinutes: cur.totalMinutes + r.totalMinutes,
  179. itemKindCount: cur.itemKindCount + r.itemKindCount,
  180. itemQtyPicked: cur.itemQtyPicked + r.itemQtyPicked,
  181. });
  182. }
  183. return Array.from(map.entries()).map(([staffName, v]) => ({
  184. staffName,
  185. orderCount: v.orderCount,
  186. itemKindCount: v.itemKindCount,
  187. itemQtyPicked: v.itemQtyPicked,
  188. totalMinutes: v.totalMinutes,
  189. avgMinutesPerOrder: v.orderCount > 0 ? Math.round(v.totalMinutes / v.orderCount) : 0,
  190. }));
  191. }, [chartData.staffPerf]);
  192. return (
  193. <Box sx={{ maxWidth: 1200, mx: "auto" }}>
  194. <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
  195. <LocalShipping /> {PAGE_TITLE}
  196. </Typography>
  197. {error && (
  198. <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
  199. {error}
  200. </Alert>
  201. )}
  202. <ChartCard
  203. title="按日期發貨單數量"
  204. exportFilename="發貨單數量_按日期"
  205. exportData={chartData.delivery.map((d) => ({ 日期: d.date, 單數: d.orderCount }))}
  206. filters={
  207. <DateRangeSelect
  208. value={criteria.delivery.rangeDays}
  209. onChange={(v) => updateCriteria("delivery", (c) => ({ ...c, rangeDays: v }))}
  210. />
  211. }
  212. >
  213. {loadingCharts.delivery ? (
  214. <Skeleton variant="rectangular" height={320} />
  215. ) : (
  216. <SafeApexCharts
  217. options={{
  218. chart: { type: "bar" },
  219. xaxis: { categories: chartData.delivery.map((d) => d.date) },
  220. yaxis: { title: { text: "單數" } },
  221. plotOptions: { bar: { horizontal: false, columnWidth: "60%" } },
  222. dataLabels: { enabled: false },
  223. }}
  224. series={[{ name: "單數", data: chartData.delivery.map((d) => d.orderCount) }]}
  225. type="bar"
  226. width="100%"
  227. height={320}
  228. />
  229. )}
  230. </ChartCard>
  231. <ChartCard
  232. title="發貨數量排行(按物料)"
  233. exportFilename="發貨數量排行_按物料"
  234. exportData={chartData.topItems.map((i) => ({ 物料編碼: i.itemCode, 物料名稱: i.itemName, 數量: i.totalQty }))}
  235. filters={
  236. <>
  237. <DateRangeSelect
  238. value={criteria.topItems.rangeDays}
  239. onChange={(v) => updateCriteria("topItems", (c) => ({ ...c, rangeDays: v }))}
  240. />
  241. <FormControl size="small" sx={{ minWidth: 100 }}>
  242. <InputLabel>顯示</InputLabel>
  243. <Select
  244. value={criteria.topItems.limit}
  245. label="顯示"
  246. onChange={(e) => updateCriteria("topItems", (c) => ({ ...c, limit: Number(e.target.value) }))}
  247. >
  248. {TOP_ITEMS_LIMIT_OPTIONS.map((n) => (
  249. <MenuItem key={n} value={n}>
  250. {n} 條
  251. </MenuItem>
  252. ))}
  253. </Select>
  254. </FormControl>
  255. <Autocomplete
  256. multiple
  257. size="small"
  258. options={topItemOptions}
  259. value={topItemsSelected}
  260. onChange={(_, v) => setTopItemsSelected(v)}
  261. getOptionLabel={(opt) => [opt.itemCode, opt.itemName].filter(Boolean).join(" - ") || opt.itemCode}
  262. isOptionEqualToValue={(a, b) => a.itemCode === b.itemCode}
  263. renderInput={(params) => (
  264. <TextField {...params} label="物料" placeholder="不選則全部" />
  265. )}
  266. renderTags={(value, getTagProps) =>
  267. value.map((option, index) => {
  268. const { key: _key, ...tagProps } = getTagProps({ index });
  269. return (
  270. <Chip
  271. key={option.itemCode}
  272. label={[option.itemCode, option.itemName].filter(Boolean).join(" - ")}
  273. size="small"
  274. {...tagProps}
  275. />
  276. );
  277. })
  278. }
  279. sx={{ minWidth: 280 }}
  280. />
  281. </>
  282. }
  283. >
  284. {loadingCharts.topItems ? (
  285. <Skeleton variant="rectangular" height={320} />
  286. ) : (
  287. <SafeApexCharts
  288. options={{
  289. chart: { type: "bar" },
  290. xaxis: {
  291. categories: chartData.topItems.map((i) => `${i.itemCode} ${i.itemName}`.trim()),
  292. },
  293. plotOptions: { bar: { horizontal: true, barHeight: "70%" } },
  294. dataLabels: { enabled: true },
  295. }}
  296. series={[{ name: "數量", data: chartData.topItems.map((i) => i.totalQty) }]}
  297. type="bar"
  298. width="100%"
  299. height={Math.max(320, chartData.topItems.length * 36)}
  300. />
  301. )}
  302. </ChartCard>
  303. <ChartCard
  304. title="員工發貨績效(每日揀貨數量與耗時)"
  305. exportFilename="員工發貨績效"
  306. exportData={chartData.staffPerf.map((r) => ({
  307. 日期: r.date,
  308. 員工: r.staffName,
  309. 揀單數: r.orderCount,
  310. 總揀貨款數: r.itemKindCount,
  311. 總揀貨件數: r.itemQtyPicked,
  312. 總分鐘: r.totalMinutes,
  313. }))}
  314. filters={
  315. <>
  316. <DateRangeSelect
  317. value={criteria.staffPerf.rangeDays}
  318. onChange={(v) =>
  319. updateCriteria("staffPerf", (c) => {
  320. const { startDate, endDate } = toDateRange(v);
  321. return { ...c, rangeDays: v, startDate, endDate };
  322. })
  323. }
  324. />
  325. <TextField
  326. size="small"
  327. label="開始日期"
  328. type="date"
  329. value={criteria.staffPerf.startDate}
  330. onChange={(e) =>
  331. updateCriteria("staffPerf", (c) => ({ ...c, startDate: e.target.value }))
  332. }
  333. InputLabelProps={{ shrink: true }}
  334. />
  335. <TextField
  336. size="small"
  337. label="結束日期"
  338. type="date"
  339. value={criteria.staffPerf.endDate}
  340. onChange={(e) =>
  341. updateCriteria("staffPerf", (c) => ({ ...c, endDate: e.target.value }))
  342. }
  343. InputLabelProps={{ shrink: true }}
  344. />
  345. <FormControl size="small" sx={{ minWidth: 120 }}>
  346. <InputLabel>倉別</InputLabel>
  347. <Select
  348. label="倉別"
  349. value={criteria.staffPerf.storeFilter}
  350. onChange={(e) =>
  351. updateCriteria("staffPerf", (c) => ({
  352. ...c,
  353. storeFilter: e.target.value as StaffDeliveryPerformanceStoreFilter,
  354. }))
  355. }
  356. >
  357. {STAFF_PERF_STORE_FILTER_OPTIONS.map((opt) => (
  358. <MenuItem key={opt.value} value={opt.value}>
  359. {opt.label}
  360. </MenuItem>
  361. ))}
  362. </Select>
  363. </FormControl>
  364. <Autocomplete
  365. multiple
  366. size="small"
  367. options={staffOptions}
  368. value={staffSelected}
  369. onChange={(_, v) => setStaffSelected(v)}
  370. getOptionLabel={(opt) => [opt.staffNo, opt.name].filter(Boolean).join(" - ") || opt.staffNo}
  371. isOptionEqualToValue={(a, b) => a.staffNo === b.staffNo}
  372. renderInput={(params) => (
  373. <TextField {...params} label="員工" placeholder="不選則全部" />
  374. )}
  375. renderTags={(value, getTagProps) =>
  376. value.map((option, index) => {
  377. const { key: _key, ...tagProps } = getTagProps({ index });
  378. return (
  379. <Chip
  380. key={option.staffNo}
  381. label={[option.staffNo, option.name].filter(Boolean).join(" - ")}
  382. size="small"
  383. {...tagProps}
  384. />
  385. );
  386. })
  387. }
  388. sx={{ minWidth: 260 }}
  389. />
  390. </>
  391. }
  392. >
  393. {loadingCharts.staffPerf ? (
  394. <Skeleton variant="rectangular" height={320} />
  395. ) : chartData.staffPerf.length === 0 ? (
  396. <Typography color="text.secondary" sx={{ py: 3 }}>
  397. 此日期範圍內尚無完成之發貨單,或無揀貨人資料。請更換日期範圍或確認發貨單(DO)已由員工完成並有紀錄揀貨時間。
  398. </Typography>
  399. ) : (
  400. <>
  401. <Box sx={{ mb: 2 }}>
  402. <Typography variant="subtitle2" color="text.secondary" gutterBottom>
  403. 週期內每人揀單數、總揀貨款數、總揀貨件數及總耗時(首揀至完成)
  404. </Typography>
  405. <Box
  406. component="table"
  407. sx={{
  408. width: "100%",
  409. borderCollapse: "collapse",
  410. "& th, & td": {
  411. border: "1px solid",
  412. borderColor: "divider",
  413. px: 1.5,
  414. py: 1,
  415. textAlign: "left",
  416. },
  417. "& th": { bgcolor: "action.hover", fontWeight: 600 },
  418. }}
  419. >
  420. <thead>
  421. <tr>
  422. <th>員工</th>
  423. <th>揀單數</th>
  424. <th>總揀貨款數</th>
  425. <th>總揀貨件數</th>
  426. <th>總分鐘</th>
  427. <th>平均分鐘/單</th>
  428. </tr>
  429. </thead>
  430. <tbody>
  431. {staffPerfByStaff.length === 0 ? (
  432. <tr>
  433. <td colSpan={6}>無數據</td>
  434. </tr>
  435. ) : (
  436. staffPerfByStaff.map((row) => (
  437. <tr key={row.staffName}>
  438. <td>{row.staffName}</td>
  439. <td>{row.orderCount}</td>
  440. <td>{row.itemKindCount}</td>
  441. <td>{row.itemQtyPicked}</td>
  442. <td>{row.totalMinutes}</td>
  443. <td>{row.avgMinutesPerOrder}</td>
  444. </tr>
  445. ))
  446. )}
  447. </tbody>
  448. </Box>
  449. </Box>
  450. <Typography variant="subtitle2" color="text.secondary" gutterBottom>
  451. 每日按員工單數
  452. </Typography>
  453. <SafeApexCharts
  454. options={{
  455. chart: { type: "bar", stacked: true },
  456. xaxis: {
  457. categories: Array.from(new Set(chartData.staffPerf.map((r) => r.date))).sort(),
  458. },
  459. yaxis: { title: { text: "單數" } },
  460. plotOptions: { bar: { columnWidth: "60%" } },
  461. dataLabels: { enabled: false },
  462. legend: { position: "top" },
  463. }}
  464. series={(() => {
  465. const staffNames = Array.from(new Set(chartData.staffPerf.map((r) => r.staffName))).filter(Boolean).sort();
  466. const dates = Array.from(new Set(chartData.staffPerf.map((r) => r.date))).sort();
  467. return staffNames.map((name) => ({
  468. name: name || "Unknown",
  469. data: dates.map((d) => {
  470. const row = chartData.staffPerf.find((r) => r.date === d && r.staffName === name);
  471. return row ? row.orderCount : 0;
  472. }),
  473. }));
  474. })()}
  475. type="bar"
  476. width="100%"
  477. height={320}
  478. />
  479. </>
  480. )}
  481. </ChartCard>
  482. </Box>
  483. );
  484. }