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.

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