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.
 
 

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