| @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import { | import { | ||||
| Alert, | Alert, | ||||
| Box, | Box, | ||||
| Button, | |||||
| CircularProgress, | CircularProgress, | ||||
| Grid, | Grid, | ||||
| MenuItem, | MenuItem, | ||||
| @@ -20,6 +21,7 @@ import { | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import type { ApexOptions } from "apexcharts"; | import type { ApexOptions } from "apexcharts"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import * as XLSX from "xlsx-js-style"; | |||||
| import { | import { | ||||
| CompletedDoPickOrderResponse, | CompletedDoPickOrderResponse, | ||||
| fetchCompletedDoPickOrdersAll, | fetchCompletedDoPickOrdersAll, | ||||
| @@ -45,6 +47,7 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => | |||||
| const [floor, setFloor] = useState<FloorFilter>("all"); | const [floor, setFloor] = useState<FloorFilter>("all"); | ||||
| const [date, setDate] = useState<string>(dayjs().format("YYYY-MM-DD")); | const [date, setDate] = useState<string>(dayjs().format("YYYY-MM-DD")); | ||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| const [isExporting, setIsExporting] = useState(false); | |||||
| const [error, setError] = useState<string>(""); | const [error, setError] = useState<string>(""); | ||||
| const [records, setRecords] = useState<CompletedDoPickOrderResponse[]>([]); | const [records, setRecords] = useState<CompletedDoPickOrderResponse[]>([]); | ||||
| @@ -175,11 +178,231 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => | |||||
| ); | ); | ||||
| }, [rows]); | }, [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 ( | return ( | ||||
| <Box sx={{ width: "100%" }}> | <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 && ( | {error && ( | ||||
| <Alert severity="error" sx={{ mb: 2 }}> | <Alert severity="error" sx={{ mb: 2 }}> | ||||