FPSMS-frontend
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 

489 wiersze
15 KiB

  1. "use client";
  2. import { useCallback, useEffect, useMemo, useState } from "react";
  3. import {
  4. Alert,
  5. Box,
  6. Button,
  7. CircularProgress,
  8. Grid,
  9. MenuItem,
  10. Paper,
  11. Stack,
  12. Table,
  13. TableBody,
  14. TableCell,
  15. TableContainer,
  16. TableHead,
  17. TableRow,
  18. TextField,
  19. Typography,
  20. } from "@mui/material";
  21. import type { ApexOptions } from "apexcharts";
  22. import dayjs from "dayjs";
  23. import * as XLSX from "xlsx-js-style";
  24. import {
  25. CompletedDoPickOrderResponse,
  26. fetchCompletedDoPickOrdersAll,
  27. fetchCompletedDoPickOrdersWorkbenchAll,
  28. } from "@/app/api/pickOrder/actions";
  29. import SafeApexCharts from "@/components/charts/SafeApexCharts";
  30. type FloorFilter = "all" | "2/F" | "4/F";
  31. type DailySummaryRow = {
  32. date: string;
  33. floor2F: number;
  34. floor4F: number;
  35. truckX: number;
  36. total: number;
  37. };
  38. type Props = {
  39. mode?: "normal" | "workbench";
  40. };
  41. const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => {
  42. const [floor, setFloor] = useState<FloorFilter>("all");
  43. const [date, setDate] = useState<string>(dayjs().format("YYYY-MM-DD"));
  44. const [loading, setLoading] = useState(false);
  45. const [isExporting, setIsExporting] = useState(false);
  46. const [error, setError] = useState<string>("");
  47. const [records, setRecords] = useState<CompletedDoPickOrderResponse[]>([]);
  48. const loadData = useCallback(async () => {
  49. setLoading(true);
  50. setError("");
  51. try {
  52. const data =
  53. mode === "workbench"
  54. ? await fetchCompletedDoPickOrdersWorkbenchAll(
  55. date ? { targetDate: date } : undefined,
  56. )
  57. : await fetchCompletedDoPickOrdersAll(
  58. date ? { targetDate: date } : undefined,
  59. );
  60. setRecords(data);
  61. } catch (err) {
  62. console.error("Failed to load finished good carton dashboard data", err);
  63. setError("載入成品出倉出箱數量失敗,請稍後再試。");
  64. setRecords([]);
  65. } finally {
  66. setLoading(false);
  67. }
  68. }, [date, mode]);
  69. useEffect(() => {
  70. loadData();
  71. }, [loadData]);
  72. const rows = useMemo<DailySummaryRow[]>(() => {
  73. const filtered =
  74. floor === "all" ? records : records.filter((record) => record.storeId === floor);
  75. const summary = new Map<string, DailySummaryRow>();
  76. filtered.forEach((record) => {
  77. const day = dayjs(record.deliveryDate).isValid()
  78. ? dayjs(record.deliveryDate).format("YYYY-MM-DD")
  79. : "-";
  80. const cartonQty = Number(record.numberOfCartons ?? 0);
  81. const current = summary.get(day) ?? {
  82. date: day,
  83. floor2F: 0,
  84. floor4F: 0,
  85. truckX: 0,
  86. total: 0,
  87. };
  88. if (record.storeId === "2/F") {
  89. current.floor2F += cartonQty;
  90. }
  91. if (record.storeId === "4/F") {
  92. current.floor4F += cartonQty;
  93. }
  94. if (String(record.truckLanceCode ?? "").trim() === "車線-X") {
  95. current.truckX += cartonQty;
  96. }
  97. current.total += cartonQty;
  98. summary.set(day, current);
  99. });
  100. return Array.from(summary.values()).sort((a, b) => b.date.localeCompare(a.date));
  101. }, [records, floor]);
  102. const chartOptions = useMemo<ApexOptions>(
  103. () => ({
  104. chart: {
  105. type: "bar",
  106. toolbar: { show: false },
  107. },
  108. colors: ["#1976d2", "#9c27b0", "#ff9800", "#2e7d32"],
  109. dataLabels: { enabled: false },
  110. stroke: { show: true, width: 1, colors: ["transparent"] },
  111. plotOptions: {
  112. bar: {
  113. horizontal: false,
  114. borderRadius: 3,
  115. columnWidth: "55%",
  116. },
  117. },
  118. xaxis: {
  119. categories: rows.map((row) => row.date),
  120. title: { text: "日期" },
  121. },
  122. yaxis: {
  123. title: { text: "箱數" },
  124. labels: {
  125. formatter: (val) => Number(val || 0).toLocaleString("zh-HK"),
  126. },
  127. },
  128. tooltip: {
  129. y: {
  130. formatter: (val) => `${Number(val || 0).toLocaleString("zh-HK")} 箱`,
  131. },
  132. },
  133. legend: {
  134. position: "top",
  135. },
  136. noData: {
  137. text: "沒有圖表資料",
  138. },
  139. }),
  140. [rows],
  141. );
  142. const chartSeries = useMemo(
  143. () => [
  144. { name: "2/F", data: rows.map((row) => row.floor2F) },
  145. { name: "4/F", data: rows.map((row) => row.floor4F) },
  146. { name: "車線-X", data: rows.map((row) => row.truckX) },
  147. { name: "總數", data: rows.map((row) => row.total) },
  148. ],
  149. [rows],
  150. );
  151. const summary = useMemo(() => {
  152. return rows.reduce(
  153. (acc, row) => {
  154. acc.floor2F += row.floor2F;
  155. acc.floor4F += row.floor4F;
  156. acc.truckX += row.truckX;
  157. acc.total += row.total;
  158. return acc;
  159. },
  160. { floor2F: 0, floor4F: 0, truckX: 0, total: 0 },
  161. );
  162. }, [rows]);
  163. const buildDailyRowsFromRecords = useCallback(
  164. (
  165. sourceRecords: CompletedDoPickOrderResponse[],
  166. startDate: dayjs.Dayjs,
  167. endDate: dayjs.Dayjs,
  168. selectedFloor: FloorFilter,
  169. ): DailySummaryRow[] => {
  170. const summaryMap = new Map<string, DailySummaryRow>();
  171. const start = startDate.startOf("day");
  172. const end = endDate.endOf("day");
  173. sourceRecords.forEach((record) => {
  174. if (selectedFloor !== "all" && record.storeId !== selectedFloor) {
  175. return;
  176. }
  177. const deliveryDay = dayjs(record.deliveryDate, ["YYYY-MM-DD", "YYYYMMDD"], true);
  178. if (!deliveryDay.isValid() || deliveryDay.isBefore(start) || deliveryDay.isAfter(end)) {
  179. return;
  180. }
  181. const dayKey = deliveryDay.format("YYYY-MM-DD");
  182. const cartonQty = Number(record.numberOfCartons ?? 0);
  183. const current = summaryMap.get(dayKey) ?? {
  184. date: dayKey,
  185. floor2F: 0,
  186. floor4F: 0,
  187. truckX: 0,
  188. total: 0,
  189. };
  190. if (record.storeId === "2/F") current.floor2F += cartonQty;
  191. if (record.storeId === "4/F") current.floor4F += cartonQty;
  192. if (String(record.truckLanceCode ?? "").trim() === "車線-X") current.truckX += cartonQty;
  193. current.total += cartonQty;
  194. summaryMap.set(dayKey, current);
  195. });
  196. return Array.from(summaryMap.values()).sort((a, b) => a.date.localeCompare(b.date));
  197. },
  198. [],
  199. );
  200. const calcSummary = useCallback((dailyRows: DailySummaryRow[]) => {
  201. return dailyRows.reduce(
  202. (acc, row) => {
  203. acc.floor2F += row.floor2F;
  204. acc.floor4F += row.floor4F;
  205. acc.truckX += row.truckX;
  206. acc.total += row.total;
  207. return acc;
  208. },
  209. { floor2F: 0, floor4F: 0, truckX: 0, total: 0 },
  210. );
  211. }, []);
  212. const styleWorksheet = useCallback((worksheet: XLSX.WorkSheet, dataRowsCount: number) => {
  213. const summaryTitleRow = 4 + dataRowsCount;
  214. const summaryStartRow = 5 + dataRowsCount;
  215. worksheet["!cols"] = [{ wch: 16 }, { wch: 14 }, { wch: 14 }, { wch: 16 }, { wch: 14 }];
  216. worksheet["!merges"] = [
  217. { s: { r: 0, c: 0 }, e: { r: 0, c: 4 } },
  218. { s: { r: summaryTitleRow, c: 0 }, e: { r: summaryTitleRow, c: 4 } },
  219. ];
  220. const titleStyle = {
  221. font: { bold: true, sz: 14, color: { rgb: "1F2D3D" } },
  222. alignment: { horizontal: "center", vertical: "center" },
  223. fill: { fgColor: { rgb: "EAF3FF" } },
  224. };
  225. const headerStyle = {
  226. font: { bold: true, color: { rgb: "FFFFFF" } },
  227. fill: { fgColor: { rgb: "1976D2" } },
  228. alignment: { horizontal: "center", vertical: "center" },
  229. border: {
  230. top: { style: "thin", color: { rgb: "B0BEC5" } },
  231. bottom: { style: "thin", color: { rgb: "B0BEC5" } },
  232. left: { style: "thin", color: { rgb: "B0BEC5" } },
  233. right: { style: "thin", color: { rgb: "B0BEC5" } },
  234. },
  235. };
  236. const cellStyle = {
  237. alignment: { vertical: "center" },
  238. border: {
  239. top: { style: "thin", color: { rgb: "D0D7DE" } },
  240. bottom: { style: "thin", color: { rgb: "D0D7DE" } },
  241. left: { style: "thin", color: { rgb: "D0D7DE" } },
  242. right: { style: "thin", color: { rgb: "D0D7DE" } },
  243. },
  244. };
  245. const numberStyle = {
  246. ...cellStyle,
  247. alignment: { horizontal: "right", vertical: "center" },
  248. numFmt: "#,##0",
  249. };
  250. const summaryTitleStyle = {
  251. font: { bold: true, color: { rgb: "1F2D3D" } },
  252. fill: { fgColor: { rgb: "F1F8E9" } },
  253. alignment: { horizontal: "left", vertical: "center" },
  254. };
  255. for (let c = 0; c <= 4; c += 1) {
  256. const headerCell = XLSX.utils.encode_cell({ r: 2, c });
  257. if (worksheet[headerCell]) worksheet[headerCell].s = headerStyle;
  258. }
  259. for (let r = 3; r < 3 + dataRowsCount; r += 1) {
  260. for (let c = 0; c <= 4; c += 1) {
  261. const addr = XLSX.utils.encode_cell({ r, c });
  262. if (!worksheet[addr]) continue;
  263. worksheet[addr].s = c === 0 ? cellStyle : numberStyle;
  264. }
  265. }
  266. for (let r = summaryStartRow; r <= summaryStartRow + 3; r += 1) {
  267. const labelAddr = XLSX.utils.encode_cell({ r, c: 0 });
  268. const valueAddr = XLSX.utils.encode_cell({ r, c: 1 });
  269. if (worksheet[labelAddr]) worksheet[labelAddr].s = cellStyle;
  270. if (worksheet[valueAddr]) worksheet[valueAddr].s = numberStyle;
  271. }
  272. if (worksheet["A1"]) worksheet["A1"].s = titleStyle;
  273. const summaryTitleAddr = XLSX.utils.encode_cell({ r: summaryTitleRow, c: 0 });
  274. if (worksheet[summaryTitleAddr]) worksheet[summaryTitleAddr].s = summaryTitleStyle;
  275. }, []);
  276. const addReportSheet = useCallback(
  277. (
  278. workbook: XLSX.WorkBook,
  279. sheetName: string,
  280. reportTitle: string,
  281. dailyRows: DailySummaryRow[],
  282. ) => {
  283. const reportSummary = calcSummary(dailyRows);
  284. const aoa: (string | number)[][] = [
  285. [reportTitle, "", "", "", ""],
  286. ["", "", "", "", ""],
  287. ["日期", "2/F 出箱數", "4/F 出箱數", "車線-X 出箱數", "總出箱數"],
  288. ...dailyRows.map((row) => [row.date, row.floor2F, row.floor4F, row.truckX, row.total]),
  289. ["", "", "", "", ""],
  290. ["彙總", "", "", "", ""],
  291. ["2/F 出箱數", reportSummary.floor2F, "", "", ""],
  292. ["4/F 出箱數", reportSummary.floor4F, "", "", ""],
  293. ["車線-X 出箱數", reportSummary.truckX, "", "", ""],
  294. ["總出箱數", reportSummary.total, "", "", ""],
  295. ];
  296. const worksheet = XLSX.utils.aoa_to_sheet(aoa);
  297. styleWorksheet(worksheet, dailyRows.length);
  298. XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
  299. },
  300. [calcSummary, styleWorksheet],
  301. );
  302. const handleDownloadExcel = useCallback(async () => {
  303. setIsExporting(true);
  304. try {
  305. const allRecords =
  306. mode === "workbench"
  307. ? await fetchCompletedDoPickOrdersWorkbenchAll()
  308. : await fetchCompletedDoPickOrdersAll();
  309. const baseDate = dayjs(date || undefined).isValid() ? dayjs(date) : dayjs();
  310. const floorLabel = floor === "all" ? "全部樓層" : floor;
  311. const dateLabel = baseDate.format("YYYY-MM-DD");
  312. const last7Rows = buildDailyRowsFromRecords(
  313. allRecords,
  314. baseDate.subtract(6, "day"),
  315. baseDate,
  316. floor,
  317. );
  318. const monthRows = buildDailyRowsFromRecords(
  319. allRecords,
  320. baseDate.startOf("month"),
  321. baseDate.endOf("month"),
  322. floor,
  323. );
  324. const yearRows = buildDailyRowsFromRecords(
  325. allRecords,
  326. baseDate.startOf("year"),
  327. baseDate.endOf("year"),
  328. floor,
  329. );
  330. const workbook = XLSX.utils.book_new();
  331. addReportSheet(
  332. workbook,
  333. "最近7天",
  334. `成品出倉出箱數量(最近7天)- ${floorLabel} - 基準日 ${dateLabel}`,
  335. last7Rows,
  336. );
  337. addReportSheet(
  338. workbook,
  339. "本月",
  340. `成品出倉出箱數量(本月)- ${floorLabel} - ${baseDate.format("YYYY年MM月")}`,
  341. monthRows,
  342. );
  343. addReportSheet(
  344. workbook,
  345. "本年",
  346. `成品出倉出箱數量(本年)- ${floorLabel} - ${baseDate.format("YYYY年")}`,
  347. yearRows,
  348. );
  349. XLSX.writeFile(workbook, `成品出倉出箱數量_多時段報表_${floorLabel.replace("/", "")}_${dateLabel}.xlsx`);
  350. } finally {
  351. setIsExporting(false);
  352. }
  353. }, [mode, date, floor, buildDailyRowsFromRecords, addReportSheet]);
  354. return (
  355. <Box sx={{ width: "100%" }}>
  356. <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
  357. <Typography variant="h6">成品出倉出箱數量</Typography>
  358. <Button
  359. variant="contained"
  360. onClick={handleDownloadExcel}
  361. disabled={loading || isExporting}
  362. >
  363. {isExporting ? "匯出中..." : "下載 Excel"}
  364. </Button>
  365. </Stack>
  366. {error && (
  367. <Alert severity="error" sx={{ mb: 2 }}>
  368. {error}
  369. </Alert>
  370. )}
  371. {loading ? (
  372. <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}>
  373. <CircularProgress />
  374. </Box>
  375. ) : (
  376. <Stack spacing={2}>
  377. <Grid container spacing={1.5}>
  378. <Grid item xs={12} md={6}>
  379. <TextField
  380. select
  381. fullWidth
  382. label="樓層"
  383. value={floor}
  384. onChange={(event) => setFloor(event.target.value as FloorFilter)}
  385. >
  386. <MenuItem value="all">全部</MenuItem>
  387. <MenuItem value="2/F">2/F</MenuItem>
  388. <MenuItem value="4/F">4/F</MenuItem>
  389. </TextField>
  390. </Grid>
  391. <Grid item xs={12} md={6}>
  392. <TextField
  393. fullWidth
  394. label="日期"
  395. type="date"
  396. value={date}
  397. InputLabelProps={{ shrink: true }}
  398. onChange={(event) => setDate(event.target.value)}
  399. />
  400. </Grid>
  401. </Grid>
  402. <Grid container spacing={1.5} alignItems="stretch">
  403. <Grid item xs={12} md={4}>
  404. <TableContainer component={Paper} sx={{ height: "100%" }}>
  405. <Table size="small">
  406. <TableBody>
  407. <TableRow>
  408. <TableCell>2/F 出箱數</TableCell>
  409. <TableCell align="right">{summary.floor2F.toLocaleString("zh-HK")}</TableCell>
  410. </TableRow>
  411. <TableRow>
  412. <TableCell>4/F 出箱數</TableCell>
  413. <TableCell align="right">{summary.floor4F.toLocaleString("zh-HK")}</TableCell>
  414. </TableRow>
  415. <TableRow>
  416. <TableCell>車線-X 出箱數</TableCell>
  417. <TableCell align="right">{summary.truckX.toLocaleString("zh-HK")}</TableCell>
  418. </TableRow>
  419. <TableRow>
  420. <TableCell>總出箱數</TableCell>
  421. <TableCell align="right">{summary.total.toLocaleString("zh-HK")}</TableCell>
  422. </TableRow>
  423. </TableBody>
  424. </Table>
  425. </TableContainer>
  426. </Grid>
  427. <Grid item xs={12} md={8}>
  428. <Paper sx={{ p: 1.5, height: "100%" }}>
  429. <SafeApexCharts
  430. type="bar"
  431. height={240}
  432. options={chartOptions}
  433. series={chartSeries}
  434. chartRevision={`${floor}-${date}-${rows.length}`}
  435. />
  436. </Paper>
  437. </Grid>
  438. </Grid>
  439. </Stack>
  440. )}
  441. </Box>
  442. );
  443. };
  444. export default FinishedGoodCartonDashboardTab;