|
- "use client";
-
- import { useCallback, useEffect, useMemo, useState } from "react";
- import {
- Alert,
- Box,
- Button,
- CircularProgress,
- Grid,
- MenuItem,
- Paper,
- Stack,
- Table,
- TableBody,
- TableCell,
- TableContainer,
- TableHead,
- TableRow,
- TextField,
- Typography,
- } from "@mui/material";
- import type { ApexOptions } from "apexcharts";
- import dayjs from "dayjs";
- import * as XLSX from "xlsx-js-style";
- import {
- CompletedDoPickOrderResponse,
- fetchCompletedDoPickOrdersAll,
- fetchCompletedDoPickOrdersWorkbenchAll,
- } from "@/app/api/pickOrder/actions";
- import SafeApexCharts from "@/components/charts/SafeApexCharts";
-
- type FloorFilter = "all" | "2/F" | "4/F";
-
- type DailySummaryRow = {
- date: string;
- floor2F: number;
- floor4F: number;
- truckX: number;
- total: number;
- };
-
- type Props = {
- mode?: "normal" | "workbench";
- };
-
- const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => {
- const [floor, setFloor] = useState<FloorFilter>("all");
- const [date, setDate] = useState<string>(dayjs().format("YYYY-MM-DD"));
- const [loading, setLoading] = useState(false);
- const [isExporting, setIsExporting] = useState(false);
- const [error, setError] = useState<string>("");
- const [records, setRecords] = useState<CompletedDoPickOrderResponse[]>([]);
-
- const loadData = useCallback(async () => {
- setLoading(true);
- setError("");
- try {
- const data =
- mode === "workbench"
- ? await fetchCompletedDoPickOrdersWorkbenchAll(
- date ? { targetDate: date } : undefined,
- )
- : await fetchCompletedDoPickOrdersAll(
- date ? { targetDate: date } : undefined,
- );
- setRecords(data);
- } catch (err) {
- console.error("Failed to load finished good carton dashboard data", err);
- setError("載入成品出倉出箱數量失敗,請稍後再試。");
- setRecords([]);
- } finally {
- setLoading(false);
- }
- }, [date, mode]);
-
- useEffect(() => {
- loadData();
- }, [loadData]);
-
- const rows = useMemo<DailySummaryRow[]>(() => {
- const filtered =
- floor === "all" ? records : records.filter((record) => record.storeId === floor);
-
- const summary = new Map<string, DailySummaryRow>();
-
- filtered.forEach((record) => {
- const day = dayjs(record.deliveryDate).isValid()
- ? dayjs(record.deliveryDate).format("YYYY-MM-DD")
- : "-";
- const cartonQty = Number(record.numberOfCartons ?? 0);
-
- const current = summary.get(day) ?? {
- date: day,
- floor2F: 0,
- floor4F: 0,
- truckX: 0,
- total: 0,
- };
-
- if (record.storeId === "2/F") {
- current.floor2F += cartonQty;
- }
- if (record.storeId === "4/F") {
- current.floor4F += cartonQty;
- }
- if (String(record.truckLanceCode ?? "").trim() === "車線-X") {
- current.truckX += cartonQty;
- }
-
- current.total += cartonQty;
- summary.set(day, current);
- });
-
- return Array.from(summary.values()).sort((a, b) => b.date.localeCompare(a.date));
- }, [records, floor]);
-
- const chartOptions = useMemo<ApexOptions>(
- () => ({
- chart: {
- type: "bar",
- toolbar: { show: false },
- },
- colors: ["#1976d2", "#9c27b0", "#ff9800", "#2e7d32"],
- dataLabels: { enabled: false },
- stroke: { show: true, width: 1, colors: ["transparent"] },
- plotOptions: {
- bar: {
- horizontal: false,
- borderRadius: 3,
- columnWidth: "55%",
- },
- },
- xaxis: {
- categories: rows.map((row) => row.date),
- title: { text: "日期" },
- },
- yaxis: {
- title: { text: "箱數" },
- labels: {
- formatter: (val) => Number(val || 0).toLocaleString("zh-HK"),
- },
- },
- tooltip: {
- y: {
- formatter: (val) => `${Number(val || 0).toLocaleString("zh-HK")} 箱`,
- },
- },
- legend: {
- position: "top",
- },
- noData: {
- text: "沒有圖表資料",
- },
- }),
- [rows],
- );
-
- const chartSeries = useMemo(
- () => [
- { name: "2/F", data: rows.map((row) => row.floor2F) },
- { name: "4/F", data: rows.map((row) => row.floor4F) },
- { name: "車線-X", data: rows.map((row) => row.truckX) },
- { name: "總數", data: rows.map((row) => row.total) },
- ],
- [rows],
- );
-
- const summary = useMemo(() => {
- return rows.reduce(
- (acc, row) => {
- acc.floor2F += row.floor2F;
- acc.floor4F += row.floor4F;
- acc.truckX += row.truckX;
- acc.total += row.total;
- return acc;
- },
- { floor2F: 0, floor4F: 0, truckX: 0, total: 0 },
- );
- }, [rows]);
-
- const buildDailyRowsFromRecords = useCallback(
- (
- sourceRecords: CompletedDoPickOrderResponse[],
- startDate: dayjs.Dayjs,
- endDate: dayjs.Dayjs,
- selectedFloor: FloorFilter,
- ): DailySummaryRow[] => {
- const summaryMap = new Map<string, DailySummaryRow>();
- const start = startDate.startOf("day");
- const end = endDate.endOf("day");
-
- sourceRecords.forEach((record) => {
- if (selectedFloor !== "all" && record.storeId !== selectedFloor) {
- return;
- }
-
- const deliveryDay = dayjs(record.deliveryDate, ["YYYY-MM-DD", "YYYYMMDD"], true);
- if (!deliveryDay.isValid() || deliveryDay.isBefore(start) || deliveryDay.isAfter(end)) {
- return;
- }
-
- const dayKey = deliveryDay.format("YYYY-MM-DD");
- const cartonQty = Number(record.numberOfCartons ?? 0);
- const current = summaryMap.get(dayKey) ?? {
- date: dayKey,
- floor2F: 0,
- floor4F: 0,
- truckX: 0,
- total: 0,
- };
-
- if (record.storeId === "2/F") current.floor2F += cartonQty;
- if (record.storeId === "4/F") current.floor4F += cartonQty;
- if (String(record.truckLanceCode ?? "").trim() === "車線-X") current.truckX += cartonQty;
-
- current.total += cartonQty;
- summaryMap.set(dayKey, current);
- });
-
- return Array.from(summaryMap.values()).sort((a, b) => a.date.localeCompare(b.date));
- },
- [],
- );
-
- const calcSummary = useCallback((dailyRows: DailySummaryRow[]) => {
- return dailyRows.reduce(
- (acc, row) => {
- acc.floor2F += row.floor2F;
- acc.floor4F += row.floor4F;
- acc.truckX += row.truckX;
- acc.total += row.total;
- return acc;
- },
- { floor2F: 0, floor4F: 0, truckX: 0, total: 0 },
- );
- }, []);
-
- const styleWorksheet = useCallback((worksheet: XLSX.WorkSheet, dataRowsCount: number) => {
- const summaryTitleRow = 4 + dataRowsCount;
- const summaryStartRow = 5 + dataRowsCount;
-
- worksheet["!cols"] = [{ wch: 16 }, { wch: 14 }, { wch: 14 }, { wch: 16 }, { wch: 14 }];
- worksheet["!merges"] = [
- { s: { r: 0, c: 0 }, e: { r: 0, c: 4 } },
- { s: { r: summaryTitleRow, c: 0 }, e: { r: summaryTitleRow, c: 4 } },
- ];
-
- const titleStyle = {
- font: { bold: true, sz: 14, color: { rgb: "1F2D3D" } },
- alignment: { horizontal: "center", vertical: "center" },
- fill: { fgColor: { rgb: "EAF3FF" } },
- };
- const headerStyle = {
- font: { bold: true, color: { rgb: "FFFFFF" } },
- fill: { fgColor: { rgb: "1976D2" } },
- alignment: { horizontal: "center", vertical: "center" },
- border: {
- top: { style: "thin", color: { rgb: "B0BEC5" } },
- bottom: { style: "thin", color: { rgb: "B0BEC5" } },
- left: { style: "thin", color: { rgb: "B0BEC5" } },
- right: { style: "thin", color: { rgb: "B0BEC5" } },
- },
- };
- const cellStyle = {
- alignment: { vertical: "center" },
- border: {
- top: { style: "thin", color: { rgb: "D0D7DE" } },
- bottom: { style: "thin", color: { rgb: "D0D7DE" } },
- left: { style: "thin", color: { rgb: "D0D7DE" } },
- right: { style: "thin", color: { rgb: "D0D7DE" } },
- },
- };
- const numberStyle = {
- ...cellStyle,
- alignment: { horizontal: "right", vertical: "center" },
- numFmt: "#,##0",
- };
- const summaryTitleStyle = {
- font: { bold: true, color: { rgb: "1F2D3D" } },
- fill: { fgColor: { rgb: "F1F8E9" } },
- alignment: { horizontal: "left", vertical: "center" },
- };
-
- for (let c = 0; c <= 4; c += 1) {
- const headerCell = XLSX.utils.encode_cell({ r: 2, c });
- if (worksheet[headerCell]) worksheet[headerCell].s = headerStyle;
- }
-
- for (let r = 3; r < 3 + dataRowsCount; r += 1) {
- for (let c = 0; c <= 4; c += 1) {
- const addr = XLSX.utils.encode_cell({ r, c });
- if (!worksheet[addr]) continue;
- worksheet[addr].s = c === 0 ? cellStyle : numberStyle;
- }
- }
-
- for (let r = summaryStartRow; r <= summaryStartRow + 3; r += 1) {
- const labelAddr = XLSX.utils.encode_cell({ r, c: 0 });
- const valueAddr = XLSX.utils.encode_cell({ r, c: 1 });
- if (worksheet[labelAddr]) worksheet[labelAddr].s = cellStyle;
- if (worksheet[valueAddr]) worksheet[valueAddr].s = numberStyle;
- }
-
- if (worksheet["A1"]) worksheet["A1"].s = titleStyle;
- const summaryTitleAddr = XLSX.utils.encode_cell({ r: summaryTitleRow, c: 0 });
- if (worksheet[summaryTitleAddr]) worksheet[summaryTitleAddr].s = summaryTitleStyle;
- }, []);
-
- const addReportSheet = useCallback(
- (
- workbook: XLSX.WorkBook,
- sheetName: string,
- reportTitle: string,
- dailyRows: DailySummaryRow[],
- ) => {
- const reportSummary = calcSummary(dailyRows);
- const aoa: (string | number)[][] = [
- [reportTitle, "", "", "", ""],
- ["", "", "", "", ""],
- ["日期", "2/F 出箱數", "4/F 出箱數", "車線-X 出箱數", "總出箱數"],
- ...dailyRows.map((row) => [row.date, row.floor2F, row.floor4F, row.truckX, row.total]),
- ["", "", "", "", ""],
- ["彙總", "", "", "", ""],
- ["2/F 出箱數", reportSummary.floor2F, "", "", ""],
- ["4/F 出箱數", reportSummary.floor4F, "", "", ""],
- ["車線-X 出箱數", reportSummary.truckX, "", "", ""],
- ["總出箱數", reportSummary.total, "", "", ""],
- ];
-
- const worksheet = XLSX.utils.aoa_to_sheet(aoa);
- styleWorksheet(worksheet, dailyRows.length);
- XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
- },
- [calcSummary, styleWorksheet],
- );
-
- const handleDownloadExcel = useCallback(async () => {
- setIsExporting(true);
- try {
- const allRecords =
- mode === "workbench"
- ? await fetchCompletedDoPickOrdersWorkbenchAll()
- : await fetchCompletedDoPickOrdersAll();
-
- const baseDate = dayjs(date || undefined).isValid() ? dayjs(date) : dayjs();
- const floorLabel = floor === "all" ? "全部樓層" : floor;
- const dateLabel = baseDate.format("YYYY-MM-DD");
-
- const last7Rows = buildDailyRowsFromRecords(
- allRecords,
- baseDate.subtract(6, "day"),
- baseDate,
- floor,
- );
- const monthRows = buildDailyRowsFromRecords(
- allRecords,
- baseDate.startOf("month"),
- baseDate.endOf("month"),
- floor,
- );
- const yearRows = buildDailyRowsFromRecords(
- allRecords,
- baseDate.startOf("year"),
- baseDate.endOf("year"),
- floor,
- );
-
- const workbook = XLSX.utils.book_new();
- addReportSheet(
- workbook,
- "最近7天",
- `成品出倉出箱數量(最近7天)- ${floorLabel} - 基準日 ${dateLabel}`,
- last7Rows,
- );
- addReportSheet(
- workbook,
- "本月",
- `成品出倉出箱數量(本月)- ${floorLabel} - ${baseDate.format("YYYY年MM月")}`,
- monthRows,
- );
- addReportSheet(
- workbook,
- "本年",
- `成品出倉出箱數量(本年)- ${floorLabel} - ${baseDate.format("YYYY年")}`,
- yearRows,
- );
-
- XLSX.writeFile(workbook, `成品出倉出箱數量_多時段報表_${floorLabel.replace("/", "")}_${dateLabel}.xlsx`);
- } finally {
- setIsExporting(false);
- }
- }, [mode, date, floor, buildDailyRowsFromRecords, addReportSheet]);
-
- return (
- <Box sx={{ width: "100%" }}>
- <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
- <Typography variant="h6">成品出倉出箱數量</Typography>
- <Button
- variant="contained"
- onClick={handleDownloadExcel}
- disabled={loading || isExporting}
- >
- {isExporting ? "匯出中..." : "下載 Excel"}
- </Button>
- </Stack>
-
- {error && (
- <Alert severity="error" sx={{ mb: 2 }}>
- {error}
- </Alert>
- )}
-
- {loading ? (
- <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}>
- <CircularProgress />
- </Box>
- ) : (
- <Stack spacing={2}>
- <Grid container spacing={1.5}>
- <Grid item xs={12} md={6}>
- <TextField
- select
- fullWidth
- label="樓層"
- value={floor}
- onChange={(event) => setFloor(event.target.value as FloorFilter)}
- >
- <MenuItem value="all">全部</MenuItem>
- <MenuItem value="2/F">2/F</MenuItem>
- <MenuItem value="4/F">4/F</MenuItem>
- </TextField>
- </Grid>
- <Grid item xs={12} md={6}>
- <TextField
- fullWidth
- label="日期"
- type="date"
- value={date}
- InputLabelProps={{ shrink: true }}
- onChange={(event) => setDate(event.target.value)}
- />
- </Grid>
- </Grid>
-
- <Grid container spacing={1.5} alignItems="stretch">
- <Grid item xs={12} md={4}>
- <TableContainer component={Paper} sx={{ height: "100%" }}>
- <Table size="small">
- <TableBody>
- <TableRow>
- <TableCell>2/F 出箱數</TableCell>
- <TableCell align="right">{summary.floor2F.toLocaleString("zh-HK")}</TableCell>
- </TableRow>
- <TableRow>
- <TableCell>4/F 出箱數</TableCell>
- <TableCell align="right">{summary.floor4F.toLocaleString("zh-HK")}</TableCell>
- </TableRow>
- <TableRow>
- <TableCell>車線-X 出箱數</TableCell>
- <TableCell align="right">{summary.truckX.toLocaleString("zh-HK")}</TableCell>
- </TableRow>
- <TableRow>
- <TableCell>總出箱數</TableCell>
- <TableCell align="right">{summary.total.toLocaleString("zh-HK")}</TableCell>
- </TableRow>
- </TableBody>
- </Table>
- </TableContainer>
- </Grid>
- <Grid item xs={12} md={8}>
- <Paper sx={{ p: 1.5, height: "100%" }}>
- <SafeApexCharts
- type="bar"
- height={240}
- options={chartOptions}
- series={chartSeries}
- chartRevision={`${floor}-${date}-${rows.length}`}
- />
- </Paper>
- </Grid>
- </Grid>
- </Stack>
- )}
- </Box>
- );
- };
-
- export default FinishedGoodCartonDashboardTab;
|