diff --git a/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx b/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx index f61d2a3..4b23a8d 100644 --- a/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx +++ b/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Alert, Box, + Button, CircularProgress, Grid, MenuItem, @@ -20,6 +21,7 @@ import { } from "@mui/material"; import type { ApexOptions } from "apexcharts"; import dayjs from "dayjs"; +import * as XLSX from "xlsx-js-style"; import { CompletedDoPickOrderResponse, fetchCompletedDoPickOrdersAll, @@ -45,6 +47,7 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => const [floor, setFloor] = useState("all"); const [date, setDate] = useState(dayjs().format("YYYY-MM-DD")); const [loading, setLoading] = useState(false); + const [isExporting, setIsExporting] = useState(false); const [error, setError] = useState(""); const [records, setRecords] = useState([]); @@ -175,11 +178,231 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => ); }, [rows]); + const buildDailyRowsFromRecords = useCallback( + ( + sourceRecords: CompletedDoPickOrderResponse[], + startDate: dayjs.Dayjs, + endDate: dayjs.Dayjs, + selectedFloor: FloorFilter, + ): DailySummaryRow[] => { + const summaryMap = new Map(); + 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 ( - - 成品出倉出箱數量 - + + 成品出倉出箱數量 + + {error && (