diff --git a/src/app/api/doworkbench/actions.ts b/src/app/api/doworkbench/actions.ts index 65af3d6e..60a0040d 100644 --- a/src/app/api/doworkbench/actions.ts +++ b/src/app/api/doworkbench/actions.ts @@ -216,7 +216,6 @@ export async function fetchWorkbenchStoreLaneSummary( return serverFetchJson(url, { method: "GET", cache: "no-store", - next: { revalidate: 0 }, }); } @@ -235,7 +234,6 @@ export async function fetchWorkbenchEtraLaneSummary( const data = await serverFetchJson(url, { method: "GET", cache: "no-store", - next: { revalidate: 0 }, }); return Array.isArray(data) ? data : []; } diff --git a/src/app/api/pickOrder/actions.ts b/src/app/api/pickOrder/actions.ts index f21a60b4..25409697 100644 --- a/src/app/api/pickOrder/actions.ts +++ b/src/app/api/pickOrder/actions.ts @@ -632,7 +632,6 @@ export async function fetchStoreLaneSummary(storeId: string, requiredDate?: stri const response = await serverFetchJson(url, { method: "GET", cache: "no-store", - next: { revalidate: 0 }, }); console.timeEnd(label); return response; @@ -856,7 +855,6 @@ export const fetchFGPickOrdersByUserIdWorkbench = async (userId: number) => { method: "GET", // Must be fresh: determines whether shell shows Floor/Lane panel or Detail. cache: "no-store", - next: { revalidate: 0 }, }, ); }; diff --git a/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx b/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx index cdf8d0e2..ba895bdb 100644 --- a/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx +++ b/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx @@ -1,8 +1,9 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, + Autocomplete, Box, Button, CircularProgress, @@ -10,13 +11,8 @@ import { MenuItem, Paper, Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, TextField, + Tooltip, Typography, } from "@mui/material"; import type { ApexOptions } from "apexcharts"; @@ -31,6 +27,7 @@ import SafeApexCharts from "@/components/charts/SafeApexCharts"; import { useTranslation } from "react-i18next"; type FloorFilter = "all" | "2/F" | "4/F"; +type BreakdownDimension = "lane" | "shop" | "floor"; type DailySummaryRow = { date: string; @@ -40,19 +37,143 @@ type DailySummaryRow = { total: number; }; +type BreakdownRow = { + key: string; + label: string; + qty: number; +}; + type Props = { mode?: "normal" | "workbench"; }; +const TRUCK_X_LANE = "車線-X"; +const ALL = "all"; +const CHART_ROW_H = 38; + +type FilterOption = { value: string; label: string }; + +function normalizeTruckLane(raw: string | null | undefined): string { + const value = String(raw ?? "").trim(); + return value || TRUCK_X_LANE; +} + +function shortLaneLabel(raw: string | null | undefined): string { + const value = normalizeTruckLane(raw); + const stripped = value + .replace(/^(車線)[-–—]?\s*/u, "") + .replace(/^(truck)\s*[-–—]?\s*/i, "") + .trim(); + return stripped || value; +} + +function normalizeShopCode(raw: string | null | undefined): string { + return String(raw ?? "").trim(); +} + +function shopGroup(raw: string | null | undefined): string { + const code = normalizeShopCode(raw).toUpperCase(); + if (!code) return ""; + return code.slice(0, 2); +} + +function isShopGroupValue(shop: string): boolean { + if (shop === ALL) return false; + const code = normalizeShopCode(shop).toUpperCase(); + return code.length > 0 && code === shopGroup(code); +} + +function recordMatchesShop( + recordShop: string | null | undefined, + shop: string, +): boolean { + if (shop === ALL) return true; + const code = normalizeShopCode(recordShop).toUpperCase(); + if (!code) return false; + const selected = normalizeShopCode(shop).toUpperCase(); + if (isShopGroupValue(selected)) { + return shopGroup(code) === selected; + } + return code === selected; +} + +function recordMatchesFilters( + record: CompletedDoPickOrderResponse, + floor: FloorFilter, + lane: string, + shop: string, +): boolean { + if (floor !== ALL && record.storeId !== floor) return false; + if (lane !== ALL && normalizeTruckLane(record.truckLanceCode) !== lane) return false; + if (!recordMatchesShop(record.shopCode, shop)) return false; + return true; +} + +function resolveBreakdownDimension(lane: string, shop: string): BreakdownDimension { + if (isShopGroupValue(shop)) return "shop"; + if (shop !== ALL && lane !== ALL) return "floor"; + if (lane !== ALL) return "shop"; + if (shop !== ALL) return "lane"; + return "lane"; +} + +type SearchableFilterProps = { + label: string; + options: FilterOption[]; + value: string; + onChange: (next: string) => void; +}; + +function SearchableFilterSelect({ label, options, value, onChange }: SearchableFilterProps) { + const selectable = options.filter((option) => option.value !== ALL); + const selected = selectable.find((option) => option.value === value) ?? null; + const placeholder = options.find((option) => option.value === ALL)?.label ?? ""; + + return ( + onChange(option?.value ?? ALL)} + getOptionLabel={(option) => option?.label ?? ""} + isOptionEqualToValue={(a, b) => a?.value === b?.value} + selectOnFocus + autoHighlight + handleHomeEndKeys + autoComplete + includeInputInList + size="small" + sx={{ width: "100%" }} + renderInput={(params) => ( + + )} + /> + ); +} + const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => { const { t, i18n } = useTranslation(); const numberLocale = i18n.language?.startsWith("zh") ? "zh-HK" : "en-US"; - const [floor, setFloor] = useState("all"); + const [floor, setFloor] = useState(ALL); + const [lane, setLane] = useState(ALL); + const [shop, setShop] = useState(ALL); const [date, setDate] = useState(dayjs().format("YYYY-MM-DD")); const [loading, setLoading] = useState(false); const [isExporting, setIsExporting] = useState(false); + const [isExportingFiltered, setIsExportingFiltered] = useState(false); + const exportInFlightRef = useRef(false); + const filteredExportInFlightRef = useRef(false); const [error, setError] = useState(""); const [records, setRecords] = useState([]); + const todayDate = dayjs().format("YYYY-MM-DD"); + const filtersAreActive = + floor !== ALL || lane !== ALL || shop !== ALL || date !== todayDate; + + const resetFilters = useCallback(() => { + setFloor(ALL); + setLane(ALL); + setShop(ALL); + setDate(dayjs().format("YYYY-MM-DD")); + }, []); const loadData = useCallback(async () => { setLoading(true); @@ -80,13 +201,61 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => loadData(); }, [loadData]); - const rows = useMemo(() => { - const filtered = - floor === "all" ? records : records.filter((record) => record.storeId === floor); + const laneOptions = useMemo(() => { + const byRaw = new Map(); + records.forEach((record) => { + if (floor !== ALL && record.storeId !== floor) return; + if (!recordMatchesShop(record.shopCode, shop)) return; + const raw = normalizeTruckLane(record.truckLanceCode); + if (!byRaw.has(raw)) byRaw.set(raw, shortLaneLabel(raw)); + }); + return Array.from(byRaw.entries()) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => { + if (a.value === TRUCK_X_LANE) return 1; + if (b.value === TRUCK_X_LANE) return -1; + return a.label.localeCompare(b.label, "zh-Hant"); + }); + }, [records, floor, shop]); + const shopOptions = useMemo(() => { + const groups = new Set(); + records.forEach((record) => { + if (floor !== ALL && record.storeId !== floor) return; + if (lane !== ALL && normalizeTruckLane(record.truckLanceCode) !== lane) return; + const group = shopGroup(record.shopCode); + if (group) groups.add(group); + }); + return Array.from(groups) + .sort((a, b) => a.localeCompare(b, "zh-Hant")) + .map((group) => ({ value: group, label: group })); + }, [records, floor, lane]); + + const laneFilterOptions = useMemo(() => { + const options: FilterOption[] = [{ value: ALL, label: t("All lanes") }, ...laneOptions]; + if (lane !== ALL && !options.some((option) => option.value === lane)) { + options.splice(1, 0, { value: lane, label: shortLaneLabel(lane) }); + } + return options; + }, [laneOptions, lane, t]); + + const shopFilterOptions = useMemo(() => { + const options: FilterOption[] = [{ value: ALL, label: t("All shops") }, ...shopOptions]; + if (shop !== ALL && !options.some((option) => option.value === shop)) { + options.splice(1, 0, { value: shop, label: shop }); + } + return options; + }, [shopOptions, shop, t]); + + const filteredRecords = useMemo( + () => records.filter((record) => recordMatchesFilters(record, floor, lane, shop)), + [records, floor, lane, shop], + ); + + const rows = useMemo(() => { const summary = new Map(); - filtered.forEach((record) => { + filteredRecords.forEach((record) => { const day = dayjs(record.deliveryDate).isValid() ? dayjs(record.deliveryDate).format("YYYY-MM-DD") : "-"; @@ -106,7 +275,7 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => if (record.storeId === "4/F") { current.floor4F += cartonQty; } - if (String(record.truckLanceCode ?? "").trim() === "車線-X") { + if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) { current.truckX += cartonQty; } @@ -115,88 +284,252 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => }); return Array.from(summary.values()).sort((a, b) => b.date.localeCompare(a.date)); - }, [records, floor]); + }, [filteredRecords]); + + const breakdownDimension = resolveBreakdownDimension(lane, shop); + + const breakdownRows = useMemo(() => { + const toSorted = (slices: BreakdownRow[]) => + slices.sort((a, b) => b.qty - a.qty || a.label.localeCompare(b.label, "zh-Hant")); + + if (breakdownDimension === "floor") { + let floor2F = 0; + let floor4F = 0; + let truckX = 0; + filteredRecords.forEach((record) => { + const cartonQty = Number(record.numberOfCartons ?? 0); + if (record.storeId === "2/F") floor2F += cartonQty; + if (record.storeId === "4/F") floor4F += cartonQty; + if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) truckX += cartonQty; + }); + return toSorted( + [ + { key: "2/F", label: "2/F", qty: floor2F }, + { key: "4/F", label: "4/F", qty: floor4F }, + { key: TRUCK_X_LANE, label: shortLaneLabel(TRUCK_X_LANE), qty: truckX }, + ].filter((row) => { + if (row.qty > 0) return true; + if (row.key === "2/F" || row.key === "4/F") return floor === row.key; + return lane === TRUCK_X_LANE && row.key === TRUCK_X_LANE; + }), + ); + } + + const grouped = new Map(); + filteredRecords.forEach((record) => { + const cartonQty = Number(record.numberOfCartons ?? 0); + if (breakdownDimension === "lane") { + const key = normalizeTruckLane(record.truckLanceCode); + const current = grouped.get(key) ?? { + key, + label: shortLaneLabel(key), + qty: 0, + }; + current.qty += cartonQty; + grouped.set(key, current); + return; + } + + const key = + shop !== ALL + ? normalizeShopCode(record.shopCode).toUpperCase() + : shopGroup(record.shopCode); + if (!key) return; + const current = grouped.get(key) ?? { key, label: key, qty: 0 }; + current.qty += cartonQty; + grouped.set(key, current); + }); + + return toSorted( + Array.from(grouped.values()).filter((row) => { + if (row.qty > 0) return true; + if (breakdownDimension === "shop") return shop !== ALL && row.key === shop; + return lane !== ALL && row.key === lane; + }), + ); + }, [breakdownDimension, filteredRecords, floor, lane, shop]); + + const breakdownCaption = + breakdownDimension === "shop" + ? shop !== ALL + ? t("Cartons by shop") + : t("Cartons by shop group") + : breakdownDimension === "floor" + ? t("Cartons by floor") + : t("Cartons by lane"); + + const applyBreakdownClick = useCallback( + (row: BreakdownRow) => { + if (breakdownDimension === "lane") { + setLane((prev) => (prev === row.key ? ALL : row.key)); + return; + } + if (breakdownDimension === "shop") { + setShop((prev) => { + if (prev === row.key) { + const group = shopGroup(row.key); + return group && group !== row.key ? group : ALL; + } + return row.key; + }); + return; + } + if (row.key === "2/F" || row.key === "4/F") { + setFloor((prev) => (prev === row.key ? ALL : row.key)); + return; + } + if (row.key === TRUCK_X_LANE) { + setLane((prev) => (prev === TRUCK_X_LANE ? ALL : TRUCK_X_LANE)); + } + }, + [breakdownDimension], + ); + + const isBreakdownRowActive = useCallback( + (row: BreakdownRow) => { + if (breakdownDimension === "lane") return lane === row.key; + if (breakdownDimension === "shop") return shop === row.key; + if (row.key === "2/F" || row.key === "4/F") return floor === row.key; + return lane === TRUCK_X_LANE && row.key === TRUCK_X_LANE; + }, + [breakdownDimension, floor, lane, shop], + ); + + 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 chartMaxQty = Math.max(1, ...breakdownRows.map((row) => row.qty)); + const chartHeight = Math.max(CHART_ROW_H, breakdownRows.length * CHART_ROW_H); const chartOptions = useMemo( () => ({ chart: { type: "bar", toolbar: { show: false }, + sparkline: { enabled: false }, + parentHeightOffset: 0, + offsetY: 0, + animations: { + enabled: true, + easing: "easeinout", + speed: 450, + animateGradually: { enabled: true, delay: 40 }, + }, + selection: { enabled: false }, + zoom: { enabled: false }, + }, + states: { + hover: { filter: { type: "none" } }, + active: { filter: { type: "none" } }, + }, + colors: ["#1976d2"], + dataLabels: { + enabled: true, + formatter: (val) => Number(val || 0).toLocaleString(numberLocale), + offsetX: 12, + textAnchor: "start", + style: { + fontSize: "15px", + fontWeight: 800, + colors: ["#ffffff"], + }, + background: { + enabled: true, + foreColor: "#102a43", + padding: 6, + borderRadius: 4, + opacity: 1, + borderWidth: 1, + borderColor: "#90a4ae", + dropShadow: { + enabled: true, + color: "#000000", + top: 1, + left: 0, + blur: 3, + opacity: 0.22, + }, + }, }, - colors: ["#1976d2", "#9c27b0", "#ff9800", "#2e7d32"], - dataLabels: { enabled: false }, stroke: { show: true, width: 1, colors: ["transparent"] }, plotOptions: { bar: { - horizontal: false, - borderRadius: 3, - columnWidth: "55%", + horizontal: true, + borderRadius: 4, + barHeight: 22, + dataLabels: { + position: "top", + }, }, }, + grid: { + show: true, + borderColor: "#eceff1", + padding: { top: -8, bottom: -8, left: 4, right: 64 }, + xaxis: { lines: { show: true } }, + yaxis: { lines: { show: false } }, + }, xaxis: { - categories: rows.map((row) => row.date), - title: { text: t("Date") }, + categories: breakdownRows.map((row) => row.label), + labels: { show: false }, + axisBorder: { show: false }, + axisTicks: { show: false }, + min: 0, + max: Math.max(chartMaxQty * 1.4, chartMaxQty + 12), }, yaxis: { - title: { text: t("Cartons") }, - labels: { - formatter: (val) => Number(val || 0).toLocaleString(numberLocale), - }, + labels: { show: false }, }, tooltip: { + x: { + formatter: (_val, opts) => + breakdownRows[opts?.dataPointIndex ?? -1]?.label ?? String(_val ?? ""), + }, y: { - formatter: (val) => - t("{{count}} cartons", { - count: Number(val || 0).toLocaleString(numberLocale), - }), + formatter: (val, opts) => { + const qty = Number(val || 0); + const share = summary.total > 0 ? (qty / summary.total) * 100 : 0; + const label = breakdownRows[opts?.dataPointIndex ?? -1]?.label ?? ""; + return `${label}: ${qty.toLocaleString(numberLocale)} (${share.toFixed(1)}%)`; + }, }, }, - legend: { - position: "top", - }, - noData: { - text: t("No chart data"), - }, + legend: { show: false }, + noData: { text: t("No chart data") }, }), - [rows, t, numberLocale], + [breakdownRows, chartMaxQty, numberLocale, summary.total, t], ); const chartSeries = useMemo( - () => [ - { name: "2/F", data: rows.map((row) => row.floor2F) }, - { name: "4/F", data: rows.map((row) => row.floor4F) }, - { name: t("Truck X"), data: rows.map((row) => row.truckX) }, - { name: t("Total"), data: rows.map((row) => row.total) }, - ], - [rows, t], + () => [{ name: t("Cartons"), data: breakdownRows.map((row) => row.qty) }], + [breakdownRows, t], ); - 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, + selectedLane: string, + selectedShop: string, ): DailySummaryRow[] => { const summaryMap = new Map(); const start = startDate.startOf("day"); const end = endDate.endOf("day"); sourceRecords.forEach((record) => { - if (selectedFloor !== "all" && record.storeId !== selectedFloor) { + if (!recordMatchesFilters(record, selectedFloor, selectedLane, selectedShop)) { return; } @@ -217,7 +550,7 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => 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; + if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) current.truckX += cartonQty; current.total += cartonQty; summaryMap.set(dayKey, current); @@ -346,7 +679,84 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => [calcSummary, styleWorksheet, t], ); + const addBreakdownSheet = useCallback( + ( + workbook: XLSX.WorkBook, + sheetName: string, + reportTitle: string, + categoryLabel: string, + slices: BreakdownRow[], + totalQty: number, + ) => { + const aoa: (string | number)[][] = [ + [reportTitle, "", ""], + ["", "", ""], + [categoryLabel, t("Cartons"), t("Share")], + ...slices.map((row) => { + const share = totalQty > 0 ? (row.qty / totalQty) * 100 : 0; + return [row.label, row.qty, `${share.toFixed(1)}%`]; + }), + ["", "", ""], + [t("Total carton qty"), totalQty, ""], + ]; + + const worksheet = XLSX.utils.aoa_to_sheet(aoa); + worksheet["!cols"] = [{ wch: 28 }, { wch: 14 }, { wch: 12 }]; + worksheet["!merges"] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }]; + + 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" }, + }; + 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", + }; + + if (worksheet["A1"]) worksheet["A1"].s = titleStyle; + for (let c = 0; c <= 2; c += 1) { + const headerCell = XLSX.utils.encode_cell({ r: 2, c }); + if (worksheet[headerCell]) worksheet[headerCell].s = headerStyle; + } + slices.forEach((_, index) => { + const r = 3 + index; + const labelAddr = XLSX.utils.encode_cell({ r, c: 0 }); + const qtyAddr = XLSX.utils.encode_cell({ r, c: 1 }); + const shareAddr = XLSX.utils.encode_cell({ r, c: 2 }); + if (worksheet[labelAddr]) worksheet[labelAddr].s = cellStyle; + if (worksheet[qtyAddr]) worksheet[qtyAddr].s = numberStyle; + if (worksheet[shareAddr]) worksheet[shareAddr].s = cellStyle; + }); + const totalRow = 4 + slices.length; + const totalLabelAddr = XLSX.utils.encode_cell({ r: totalRow, c: 0 }); + const totalValueAddr = XLSX.utils.encode_cell({ r: totalRow, c: 1 }); + if (worksheet[totalLabelAddr]) worksheet[totalLabelAddr].s = cellStyle; + if (worksheet[totalValueAddr]) worksheet[totalValueAddr].s = numberStyle; + + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName.slice(0, 31)); + }, + [t], + ); + const handleDownloadExcel = useCallback(async () => { + if (exportInFlightRef.current || filteredExportInFlightRef.current) return; + exportInFlightRef.current = true; setIsExporting(true); try { const allRecords = @@ -355,7 +765,9 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => : await fetchCompletedDoPickOrdersAll(); const baseDate = dayjs(date || undefined).isValid() ? dayjs(date) : dayjs(); - const floorLabel = floor === "all" ? t("All floors") : floor; + const floorLabel = floor === ALL ? t("All floors") : floor; + const laneLabel = lane === ALL ? t("All lanes") : lane; + const shopLabel = shop === ALL ? t("All shops") : shop; const dateLabel = baseDate.format("YYYY-MM-DD"); const monthPeriod = i18n.language?.startsWith("zh") ? baseDate.format("YYYY年MM月") @@ -369,18 +781,24 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => baseDate.subtract(6, "day"), baseDate, floor, + lane, + shop, ); const monthRows = buildDailyRowsFromRecords( allRecords, baseDate.startOf("month"), baseDate.endOf("month"), floor, + lane, + shop, ); const yearRows = buildDailyRowsFromRecords( allRecords, baseDate.startOf("year"), baseDate.endOf("year"), floor, + lane, + shop, ); const workbook = XLSX.utils.book_new(); @@ -403,26 +821,131 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => yearRows, ); - XLSX.writeFile( + const fileBits = [ + "FG_carton_qty", + floorLabel.replace("/", ""), + lane === ALL ? "" : laneLabel, + shop === ALL ? "" : shopLabel, + dateLabel, + ].filter(Boolean); + + XLSX.writeFile(workbook, `${fileBits.join("_")}.xlsx`); + } finally { + setIsExporting(false); + exportInFlightRef.current = false; + } + }, [mode, date, floor, lane, shop, buildDailyRowsFromRecords, addReportSheet, t, i18n.language]); + + const handleDownloadFilteredExcel = useCallback(async () => { + if (filteredExportInFlightRef.current || exportInFlightRef.current) return; + filteredExportInFlightRef.current = true; + setIsExportingFiltered(true); + try { + const baseDate = dayjs(date || undefined).isValid() ? dayjs(date) : dayjs(); + const floorLabel = floor === ALL ? t("All floors") : floor; + const laneLabel = lane === ALL ? t("All lanes") : shortLaneLabel(lane); + const shopLabel = shop === ALL ? t("All shops") : shop; + const dateLabel = baseDate.format("YYYY-MM-DD"); + const reportTitle = t("FG carton qty filtered title", { + floor: floorLabel, + lane: laneLabel, + shop: shopLabel, + date: dateLabel, + }); + const categoryLabel = + breakdownDimension === "shop" + ? t("Shop Code") + : breakdownDimension === "floor" + ? t("Floor") + : t("Lane"); + + const dailyRows = buildDailyRowsFromRecords( + records, + baseDate.startOf("day"), + baseDate.endOf("day"), + floor, + lane, + shop, + ); + const filteredTotal = calcSummary(dailyRows).total; + + const workbook = XLSX.utils.book_new(); + addReportSheet(workbook, t("Filtered"), reportTitle, dailyRows); + addBreakdownSheet( workbook, - `FG_carton_qty_${floorLabel.replace("/", "")}_${dateLabel}.xlsx`, + t("Breakdown"), + breakdownCaption, + categoryLabel, + breakdownRows, + filteredTotal, ); + + const fileBits = [ + "FG_carton_qty_filtered", + floorLabel.replace("/", ""), + lane === ALL ? "" : laneLabel, + shop === ALL ? "" : shopLabel, + dateLabel, + ].filter(Boolean); + + XLSX.writeFile(workbook, `${fileBits.join("_")}.xlsx`); } finally { - setIsExporting(false); + setIsExportingFiltered(false); + filteredExportInFlightRef.current = false; } - }, [mode, date, floor, buildDailyRowsFromRecords, addReportSheet, t, i18n.language]); + }, [ + date, + floor, + lane, + shop, + records, + breakdownDimension, + breakdownCaption, + breakdownRows, + buildDailyRowsFromRecords, + addReportSheet, + addBreakdownSheet, + calcSummary, + t, + ]); + + const isAnyExporting = isExporting || isExportingFiltered; return ( {t("FG Carton Qty")} - + + + + + + + + + + + + + {error && ( @@ -431,77 +954,183 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => )} - {loading ? ( - - - - ) : ( - - - - setFloor(event.target.value as FloorFilter)} - > - {t("All")} - 2/F - 4/F - - - - setDate(event.target.value)} - /> - + + + + setFloor(event.target.value as FloorFilter)} + > + {t("All")} + 2/F + 4/F + - - - - - - - - {t("2/F carton qty")} - {summary.floor2F.toLocaleString(numberLocale)} - - - {t("4/F carton qty")} - {summary.floor4F.toLocaleString(numberLocale)} - - - {t("Truck X carton qty")} - {summary.truckX.toLocaleString(numberLocale)} - - - {t("Total carton qty")} - {summary.total.toLocaleString(numberLocale)} - - -
-
-
- - - - - + + -
- )} + + + + + setDate(event.target.value)} + /> + +
+ + {loading ? ( + + + + ) : ( + + + + {[ + { label: t("2/F carton qty"), value: summary.floor2F }, + { label: t("4/F carton qty"), value: summary.floor4F }, + { label: t("Truck X carton qty"), value: summary.truckX }, + { label: t("Total carton qty"), value: summary.total }, + ].map((kpi, index) => ( + + + {kpi.label} + + + {kpi.value.toLocaleString(numberLocale)} + + + ))} + + + + + + {breakdownCaption} + + + {t("Click a row to filter")} + + {breakdownRows.length === 0 ? ( + + {t("No data available")} + + ) : ( + + + {breakdownRows.map((row) => { + const active = isBreakdownRowActive(row); + return ( + applyBreakdownClick(row)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + applyBreakdownClick(row); + } + }} + sx={{ + height: CHART_ROW_H, + display: "flex", + alignItems: "center", + minWidth: 0, + cursor: "pointer", + borderRadius: 1, + px: 0.5, + bgcolor: active ? "action.selected" : "transparent", + "&:hover": { bgcolor: "action.hover" }, + }} + > + + {row.label} + + + ); + })} + + { + const rect = event.currentTarget.getBoundingClientRect(); + const index = Math.floor((event.clientY - rect.top) / CHART_ROW_H); + const row = breakdownRows[index]; + if (row) applyBreakdownClick(row); + }} + > + + + + )} + + + )} +
); }; diff --git a/src/components/charts/SafeApexCharts.tsx b/src/components/charts/SafeApexCharts.tsx index 9b29f4b1..0e41126b 100644 --- a/src/components/charts/SafeApexCharts.tsx +++ b/src/components/charts/SafeApexCharts.tsx @@ -150,6 +150,34 @@ function buildApexConfig( const EMPTY_MESSAGE = "暫無圖表資料(後端無法連線或此區間無資料)。"; +/** Apex mutates `options` in place (circular refs). Never throw during render. */ +function safeStringify(value: unknown): string { + const seen = new WeakSet(); + try { + return JSON.stringify(value, (_key, nested) => { + if (typeof nested === "function") return undefined; + if (typeof nested === "object" && nested !== null) { + if (seen.has(nested)) return undefined; + seen.add(nested); + } + return nested; + }); + } catch { + return ""; + } +} + +function destroyChart(chartRef: { current: { destroy: () => void } | null }) { + const current = chartRef.current; + chartRef.current = null; + if (!current) return; + try { + current.destroy(); + } catch { + /* ignore */ + } +} + export default function SafeApexCharts(props: SafeApexChartsProps) { const { type, series, options, height, width, chartRevision, allowZeroSeries = false, ...rest } = props; const containerRef = useRef(null); @@ -162,18 +190,11 @@ export default function SafeApexCharts(props: SafeApexChartsProps) { sanitized = alignSeriesToCategoryCount(sanitized, cats.length); } - if (shouldShowPlaceholder(type, options, sanitized, allowZeroSeries)) { - return ( - - {EMPTY_MESSAGE} - - ); - } - let chartOptions = options; let renderSeries: ApexChartProps["series"] = sanitized; + let showPlaceholder = shouldShowPlaceholder(type, options, sanitized, allowZeroSeries); - if (isRadialChart(type) && isNumberArray(sanitized)) { + if (!showPlaceholder && isRadialChart(type) && isNumberArray(sanitized)) { const prev = Array.isArray(options?.labels) ? (options!.labels as unknown[]) : []; const pairs = sanitized.map((v, i) => { const n = Number.isFinite(v) ? v : 0; @@ -183,20 +204,31 @@ export default function SafeApexCharts(props: SafeApexChartsProps) { }); const nonzero = pairs.filter((p) => p.v > 0); if (nonzero.length === 0) { - return ( - - {EMPTY_MESSAGE} - - ); + showPlaceholder = true; + } else { + renderSeries = nonzero.map((p) => p.v); + chartOptions = { ...options, labels: nonzero.map((p) => p.label) }; } - renderSeries = nonzero.map((p) => p.v); - chartOptions = { ...options, labels: nonzero.map((p) => p.label) }; } const chartType = String(type ?? "line"); - const configSnapshot = `${String(chartRevision ?? "")}|${chartType}|${JSON.stringify(renderSeries ?? null)}|${JSON.stringify(chartOptions ?? {})}|${String(height ?? "")}|${String(width ?? "")}`; + const configSnapshot = [ + String(chartRevision ?? ""), + chartType, + String(showPlaceholder), + safeStringify(renderSeries ?? null), + safeStringify(cats ?? null), + safeStringify(chartOptions?.labels ?? null), + String(height ?? ""), + String(width ?? ""), + ].join("|"); useEffect(() => { + if (showPlaceholder) { + destroyChart(chartRef); + return; + } + const el = containerRef.current; if (!el) return; @@ -219,12 +251,7 @@ export default function SafeApexCharts(props: SafeApexChartsProps) { if (disposed || containerRef.current !== el) { if (chartRef.current === instance) { - try { - instance.destroy(); - } catch { - /* ignore */ - } - chartRef.current = null; + destroyChart(chartRef); } return; } @@ -236,21 +263,21 @@ export default function SafeApexCharts(props: SafeApexChartsProps) { return () => { disposed = true; - const c = chartRef.current; - chartRef.current = null; - if (c) { - try { - c.destroy(); - } catch { - /* ignore */ - } - } + destroyChart(chartRef); }; }, [configSnapshot]); const minH = typeof height === "number" ? height : typeof height === "string" ? height : 240; const dom = rest as { className?: string; id?: string; style?: CSSProperties; sx?: object }; + if (showPlaceholder) { + return ( + + {EMPTY_MESSAGE} + + ); + } + return (