| @@ -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<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[]>([]); | |||
| @@ -175,11 +178,231 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => | |||
| ); | |||
| }, [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%" }}> | |||
| <Typography variant="h6" sx={{ mb: 2 }}> | |||
| 成品出倉出箱數量 | |||
| </Typography> | |||
| <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 }}> | |||