FPSMS-frontend
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 

517 行
19 KiB

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