| @@ -216,7 +216,6 @@ export async function fetchWorkbenchStoreLaneSummary( | |||||
| return serverFetchJson<StoreLaneSummary>(url, { | return serverFetchJson<StoreLaneSummary>(url, { | ||||
| method: "GET", | method: "GET", | ||||
| cache: "no-store", | cache: "no-store", | ||||
| next: { revalidate: 0 }, | |||||
| }); | }); | ||||
| } | } | ||||
| @@ -235,7 +234,6 @@ export async function fetchWorkbenchEtraLaneSummary( | |||||
| const data = await serverFetchJson<WorkbenchEtraShopLaneGroup[]>(url, { | const data = await serverFetchJson<WorkbenchEtraShopLaneGroup[]>(url, { | ||||
| method: "GET", | method: "GET", | ||||
| cache: "no-store", | cache: "no-store", | ||||
| next: { revalidate: 0 }, | |||||
| }); | }); | ||||
| return Array.isArray(data) ? data : []; | return Array.isArray(data) ? data : []; | ||||
| } | } | ||||
| @@ -632,7 +632,6 @@ export async function fetchStoreLaneSummary(storeId: string, requiredDate?: stri | |||||
| const response = await serverFetchJson<StoreLaneSummary>(url, { | const response = await serverFetchJson<StoreLaneSummary>(url, { | ||||
| method: "GET", | method: "GET", | ||||
| cache: "no-store", | cache: "no-store", | ||||
| next: { revalidate: 0 }, | |||||
| }); | }); | ||||
| console.timeEnd(label); | console.timeEnd(label); | ||||
| return response; | return response; | ||||
| @@ -856,7 +855,6 @@ export const fetchFGPickOrdersByUserIdWorkbench = async (userId: number) => { | |||||
| method: "GET", | method: "GET", | ||||
| // Must be fresh: determines whether shell shows Floor/Lane panel or Detail. | // Must be fresh: determines whether shell shows Floor/Lane panel or Detail. | ||||
| cache: "no-store", | cache: "no-store", | ||||
| next: { revalidate: 0 }, | |||||
| }, | }, | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -150,6 +150,34 @@ function buildApexConfig( | |||||
| const EMPTY_MESSAGE = "暫無圖表資料(後端無法連線或此區間無資料)。"; | const EMPTY_MESSAGE = "暫無圖表資料(後端無法連線或此區間無資料)。"; | ||||
| /** Apex mutates `options` in place (circular refs). Never throw during render. */ | |||||
| function safeStringify(value: unknown): string { | |||||
| const seen = new WeakSet<object>(); | |||||
| 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) { | export default function SafeApexCharts(props: SafeApexChartsProps) { | ||||
| const { type, series, options, height, width, chartRevision, allowZeroSeries = false, ...rest } = props; | const { type, series, options, height, width, chartRevision, allowZeroSeries = false, ...rest } = props; | ||||
| const containerRef = useRef<HTMLDivElement>(null); | const containerRef = useRef<HTMLDivElement>(null); | ||||
| @@ -162,18 +190,11 @@ export default function SafeApexCharts(props: SafeApexChartsProps) { | |||||
| sanitized = alignSeriesToCategoryCount(sanitized, cats.length); | sanitized = alignSeriesToCategoryCount(sanitized, cats.length); | ||||
| } | } | ||||
| if (shouldShowPlaceholder(type, options, sanitized, allowZeroSeries)) { | |||||
| return ( | |||||
| <Typography color="text.secondary" sx={{ py: 3 }}> | |||||
| {EMPTY_MESSAGE} | |||||
| </Typography> | |||||
| ); | |||||
| } | |||||
| let chartOptions = options; | let chartOptions = options; | ||||
| let renderSeries: ApexChartProps["series"] = sanitized; | 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 prev = Array.isArray(options?.labels) ? (options!.labels as unknown[]) : []; | ||||
| const pairs = sanitized.map((v, i) => { | const pairs = sanitized.map((v, i) => { | ||||
| const n = Number.isFinite(v) ? v : 0; | 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); | const nonzero = pairs.filter((p) => p.v > 0); | ||||
| if (nonzero.length === 0) { | if (nonzero.length === 0) { | ||||
| return ( | |||||
| <Typography color="text.secondary" sx={{ py: 3 }}> | |||||
| {EMPTY_MESSAGE} | |||||
| </Typography> | |||||
| ); | |||||
| 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 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(() => { | useEffect(() => { | ||||
| if (showPlaceholder) { | |||||
| destroyChart(chartRef); | |||||
| return; | |||||
| } | |||||
| const el = containerRef.current; | const el = containerRef.current; | ||||
| if (!el) return; | if (!el) return; | ||||
| @@ -219,12 +251,7 @@ export default function SafeApexCharts(props: SafeApexChartsProps) { | |||||
| if (disposed || containerRef.current !== el) { | if (disposed || containerRef.current !== el) { | ||||
| if (chartRef.current === instance) { | if (chartRef.current === instance) { | ||||
| try { | |||||
| instance.destroy(); | |||||
| } catch { | |||||
| /* ignore */ | |||||
| } | |||||
| chartRef.current = null; | |||||
| destroyChart(chartRef); | |||||
| } | } | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -236,21 +263,21 @@ export default function SafeApexCharts(props: SafeApexChartsProps) { | |||||
| return () => { | return () => { | ||||
| disposed = true; | disposed = true; | ||||
| const c = chartRef.current; | |||||
| chartRef.current = null; | |||||
| if (c) { | |||||
| try { | |||||
| c.destroy(); | |||||
| } catch { | |||||
| /* ignore */ | |||||
| } | |||||
| } | |||||
| destroyChart(chartRef); | |||||
| }; | }; | ||||
| }, [configSnapshot]); | }, [configSnapshot]); | ||||
| const minH = typeof height === "number" ? height : typeof height === "string" ? height : 240; | const minH = typeof height === "number" ? height : typeof height === "string" ? height : 240; | ||||
| const dom = rest as { className?: string; id?: string; style?: CSSProperties; sx?: object }; | const dom = rest as { className?: string; id?: string; style?: CSSProperties; sx?: object }; | ||||
| if (showPlaceholder) { | |||||
| return ( | |||||
| <Typography color="text.secondary" sx={{ py: 3 }}> | |||||
| {EMPTY_MESSAGE} | |||||
| </Typography> | |||||
| ); | |||||
| } | |||||
| return ( | return ( | ||||
| <Box | <Box | ||||
| ref={containerRef} | ref={containerRef} | ||||
| @@ -60,7 +60,28 @@ | |||||
| "Truck Routing Summary (Workbench)": "Truck Routing Summary (Workbench)", | "Truck Routing Summary (Workbench)": "Truck Routing Summary (Workbench)", | ||||
| "2/F or 4/F": "2/F or 4/F", | "2/F or 4/F": "2/F or 4/F", | ||||
| "Lane": "Lane", | "Lane": "Lane", | ||||
| "Shop Code": "Shop group", | |||||
| "All lanes": "All lanes", | |||||
| "All shops": "All shop groups", | |||||
| "Cartons by lane": "Cartons by lane", | |||||
| "Cartons by shop": "Cartons by shop", | |||||
| "Cartons by shop group": "Cartons by shop group", | |||||
| "Cartons by floor": "Cartons by floor", | |||||
| "Share": "Share", | |||||
| "Click a row to filter": "Click a row to filter", | |||||
| "Click a bar to filter": "Click a bar to filter", | |||||
| "Reset filters": "Reset filters", | |||||
| "Cartons": "Cartons", | |||||
| "Date": "Date", | "Date": "Date", | ||||
| "Download Excel": "Download Excel", | |||||
| "Download this view Excel": "Download Excel (Selected)", | |||||
| "Download period Excel": "Download Excel (All)", | |||||
| "Download this view Excel hint": "Excel of the current floor, lane, shop, and date, plus the chart breakdown.", | |||||
| "Download period Excel hint": "Excel with last 7 days, this month, and this year. Not limited to the current view.", | |||||
| "Filtered": "Filtered", | |||||
| "Breakdown": "Breakdown", | |||||
| "All floors": "All floors", | |||||
| "FG carton qty filtered title": "FG carton qty (filtered) - {{floor}} - {{lane}} - {{shop}} - {{date}}", | |||||
| "Generating...": "Generating...", | "Generating...": "Generating...", | ||||
| "Download report (PDF)": "Download report (PDF)", | "Download report (PDF)": "Download report (PDF)", | ||||
| "Unpicked orders confirm download": "This lane still has {{count}} unpicked order(s).\nPrint / download the truck routing summary anyway?", | "Unpicked orders confirm download": "This lane still has {{count}} unpicked order(s).\nPrint / download the truck routing summary anyway?", | ||||
| @@ -624,9 +624,27 @@ | |||||
| "FG carton qty this year title": "FG carton qty (this year) - {{floor}} - {{period}}", | "FG carton qty this year title": "FG carton qty (this year) - {{floor}} - {{period}}", | ||||
| "Exporting...": "Exporting...", | "Exporting...": "Exporting...", | ||||
| "Download Excel": "Download Excel", | "Download Excel": "Download Excel", | ||||
| "Download this view Excel": "Download Excel (Selected)", | |||||
| "Download period Excel": "Download Excel (All)", | |||||
| "Download this view Excel hint": "Excel of the current floor, lane, shop, and date, plus the chart breakdown.", | |||||
| "Download period Excel hint": "Excel with last 7 days, this month, and this year. Not limited to the current view.", | |||||
| "Filtered": "Filtered", | |||||
| "Breakdown": "Breakdown", | |||||
| "FG carton qty filtered title": "FG carton qty (filtered) - {{floor}} - {{lane}} - {{shop}} - {{date}}", | |||||
| "Truck Routing Summary (Workbench)": "Truck Routing Summary (Workbench)", | "Truck Routing Summary (Workbench)": "Truck Routing Summary (Workbench)", | ||||
| "2/F or 4/F": "2/F or 4/F", | "2/F or 4/F": "2/F or 4/F", | ||||
| "Lane": "Lane", | "Lane": "Lane", | ||||
| "Shop Code": "Shop group", | |||||
| "All lanes": "All lanes", | |||||
| "All shops": "All shop groups", | |||||
| "Cartons by lane": "Cartons by lane", | |||||
| "Cartons by shop": "Cartons by shop", | |||||
| "Cartons by shop group": "Cartons by shop group", | |||||
| "Cartons by floor": "Cartons by floor", | |||||
| "Share": "Share", | |||||
| "Click a row to filter": "Click a row to filter", | |||||
| "Click a bar to filter": "Click a bar to filter", | |||||
| "Reset filters": "Reset filters", | |||||
| "Generating...": "Generating...", | "Generating...": "Generating...", | ||||
| "Download report (PDF)": "Download report (PDF)", | "Download report (PDF)": "Download report (PDF)", | ||||
| "Unpicked orders confirm download": "This lane still has {{count}} unpicked order(s).\nPrint / download the truck routing summary anyway?", | "Unpicked orders confirm download": "This lane still has {{count}} unpicked order(s).\nPrint / download the truck routing summary anyway?", | ||||
| @@ -55,7 +55,28 @@ | |||||
| "Truck Routing Summary (Workbench)": "送貨路線摘要 (Workbench)", | "Truck Routing Summary (Workbench)": "送貨路線摘要 (Workbench)", | ||||
| "2/F or 4/F": "2/F 或 4/F", | "2/F or 4/F": "2/F 或 4/F", | ||||
| "Lane": "車線", | "Lane": "車線", | ||||
| "Shop Code": "店鋪組別", | |||||
| "All lanes": "全部車線", | |||||
| "All shops": "全部店鋪組別", | |||||
| "Cartons by lane": "按車線統計箱數", | |||||
| "Cartons by shop": "按店鋪編號統計箱數", | |||||
| "Cartons by shop group": "按店鋪組別統計箱數", | |||||
| "Cartons by floor": "按樓層統計箱數", | |||||
| "Share": "佔比", | |||||
| "Click a row to filter": "點選列以篩選", | |||||
| "Click a bar to filter": "點選長條以篩選", | |||||
| "Reset filters": "重設篩選", | |||||
| "Cartons": "箱數", | |||||
| "Date": "日期", | "Date": "日期", | ||||
| "Download Excel": "下載 Excel", | |||||
| "Download this view Excel": "下載 Excel(已選)", | |||||
| "Download period Excel": "下載 Excel(全部)", | |||||
| "Download this view Excel hint": "匯出目前樓層、車線、店鋪組別、日期的箱數,以及圖表分項。", | |||||
| "Download period Excel hint": "匯出近7天、本月、本年的箱數,不限目前篩選畫面。", | |||||
| "Filtered": "篩選", | |||||
| "Breakdown": "分項", | |||||
| "All floors": "全部樓層", | |||||
| "FG carton qty filtered title": "成品出倉出箱數量(篩選)- {{floor}} - {{lane}} - {{shop}} - {{date}}", | |||||
| "Generating...": "生成中...", | "Generating...": "生成中...", | ||||
| "Download report (PDF)": "下載報告 (PDF)", | "Download report (PDF)": "下載報告 (PDF)", | ||||
| "Unpicked orders confirm download": "此車線仍有 {{count}} 張訂單未執拾。\n是否仍要列印 / 下載送貨路線摘要?", | "Unpicked orders confirm download": "此車線仍有 {{count}} 張訂單未執拾。\n是否仍要列印 / 下載送貨路線摘要?", | ||||
| @@ -636,9 +636,27 @@ | |||||
| "FG carton qty this year title": "成品出倉出箱數量(本年)- {{floor}} - {{period}}", | "FG carton qty this year title": "成品出倉出箱數量(本年)- {{floor}} - {{period}}", | ||||
| "Exporting...": "匯出中...", | "Exporting...": "匯出中...", | ||||
| "Download Excel": "下載 Excel", | "Download Excel": "下載 Excel", | ||||
| "Download this view Excel": "下載 Excel(已選)", | |||||
| "Download period Excel": "下載 Excel(全部)", | |||||
| "Download this view Excel hint": "匯出目前樓層、車線、店鋪組別、日期的箱數,以及圖表分項。", | |||||
| "Download period Excel hint": "匯出近7天、本月、本年的箱數,不限目前篩選畫面。", | |||||
| "Filtered": "篩選", | |||||
| "Breakdown": "分項", | |||||
| "FG carton qty filtered title": "成品出倉出箱數量(篩選)- {{floor}} - {{lane}} - {{shop}} - {{date}}", | |||||
| "Truck Routing Summary (Workbench)": "送貨路線摘要 (Workbench)", | "Truck Routing Summary (Workbench)": "送貨路線摘要 (Workbench)", | ||||
| "2/F or 4/F": "2/F 或 4/F", | "2/F or 4/F": "2/F 或 4/F", | ||||
| "Lane": "車線", | "Lane": "車線", | ||||
| "Shop Code": "店鋪組別", | |||||
| "All lanes": "全部車線", | |||||
| "All shops": "全部店鋪組別", | |||||
| "Cartons by lane": "按車線統計箱數", | |||||
| "Cartons by shop": "按店鋪編號統計箱數", | |||||
| "Cartons by shop group": "按店鋪組別統計箱數", | |||||
| "Cartons by floor": "按樓層統計箱數", | |||||
| "Share": "佔比", | |||||
| "Click a row to filter": "點選列以篩選", | |||||
| "Click a bar to filter": "點選長條以篩選", | |||||
| "Reset filters": "重設篩選", | |||||
| "Generating...": "生成中...", | "Generating...": "生成中...", | ||||
| "Download report (PDF)": "下載報告 (PDF)", | "Download report (PDF)": "下載報告 (PDF)", | ||||
| "Unpicked orders confirm download": "此車線仍有 {{count}} 張訂單未執拾。\n是否仍要列印 / 下載送貨路線摘要?", | "Unpicked orders confirm download": "此車線仍有 {{count}} 張訂單未執拾。\n是否仍要列印 / 下載送貨路線摘要?", | ||||