From 65329be227c3a3e2b0bdbbdd4ca2bd148fac1a9f Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Sun, 29 Mar 2026 01:44:02 +0800 Subject: [PATCH] added jo process and job order board as chart --- .../(main)/chart/chartBoardRefreshPrefs.ts | 88 ++ src/app/(main)/chart/delivery/page.tsx | 10 +- src/app/(main)/chart/equipment/board/page.tsx | 535 +++++++ src/app/(main)/chart/forecast/page.tsx | 8 +- src/app/(main)/chart/joborder/board/page.tsx | 1047 +++++++++++++ src/app/(main)/chart/joborder/page.tsx | 45 +- src/app/(main)/chart/process/board/page.tsx | 1309 +++++++++++++++++ src/app/(main)/chart/purchase/page.tsx | 16 +- .../(main)/chart/useChartBoardRefreshPrefs.ts | 61 + src/app/(main)/chart/warehouse/page.tsx | 14 +- src/app/api/chart/client.ts | 232 +++ src/components/Breadcrumb/Breadcrumb.tsx | 1 + .../chart/ApplicationCompletionChart.tsx | 5 +- .../chart/DashboardLineChart.tsx | 5 +- .../chart/DashboardProgressChart.tsx | 8 +- .../chart/OrderCompletionChart.tsx | 5 +- .../chart/PendingInspectionChart.tsx | 5 +- .../chart/PendingStorageChart.tsx | 5 +- .../GoodsReceiptStatusNew.tsx | 2 +- .../truckSchedule/TruckScheduleDashboard.tsx | 2 +- .../FGPickOrderTicketReleaseTable.tsx | 2 +- .../Jodetail/MaterialPickStatusTable.tsx | 2 +- .../NavigationContent/NavigationContent.tsx | 6 + .../EquipmentStatusDashboard.tsx | 2 +- .../ProductionProcess/JobProcessStatus.tsx | 2 +- .../OperatorKpiDashboard.tsx | 2 +- src/components/charts/SafeApexCharts.tsx | 265 ++++ src/config/authConfig.ts | 3 +- 28 files changed, 3618 insertions(+), 69 deletions(-) create mode 100644 src/app/(main)/chart/chartBoardRefreshPrefs.ts create mode 100644 src/app/(main)/chart/equipment/board/page.tsx create mode 100644 src/app/(main)/chart/joborder/board/page.tsx create mode 100644 src/app/(main)/chart/process/board/page.tsx create mode 100644 src/app/(main)/chart/useChartBoardRefreshPrefs.ts create mode 100644 src/components/charts/SafeApexCharts.tsx diff --git a/src/app/(main)/chart/chartBoardRefreshPrefs.ts b/src/app/(main)/chart/chartBoardRefreshPrefs.ts new file mode 100644 index 0000000..13bcbd9 --- /dev/null +++ b/src/app/(main)/chart/chartBoardRefreshPrefs.ts @@ -0,0 +1,88 @@ +export type ChartBoardId = "joborder" | "process" | "equipment"; + +export interface ChartBoardRefreshPrefs { + autoRefreshOn: boolean; + refreshIntervalSec: number; +} + +export const CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS = [30, 45, 60, 90, 120, 300] as const; +export const CHART_BOARD_DEFAULT_REFRESH_INTERVAL_SEC = 45; + +const ALLOWED_INTERVALS = new Set(CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS); + +function storageKeySession(boardId: ChartBoardId): string { + return `fpsms:chartBoardRefresh:${boardId}`; +} + +function storageKeyUser(boardId: ChartBoardId, userKey: string): string { + return `fpsms:chartBoardRefresh:${boardId}:user:${userKey}`; +} + +export function sanitizeChartBoardRefreshInterval(sec: number): number { + const n = Number(sec); + if (ALLOWED_INTERVALS.has(n)) return n; + return CHART_BOARD_DEFAULT_REFRESH_INTERVAL_SEC; +} + +function parsePrefs(raw: string | null): ChartBoardRefreshPrefs | null { + if (!raw) return null; + try { + const p = JSON.parse(raw) as Partial; + return { + autoRefreshOn: Boolean(p.autoRefreshOn), + refreshIntervalSec: sanitizeChartBoardRefreshInterval(Number(p.refreshIntervalSec)), + }; + } catch { + return null; + } +} + +/** + * Logged in: read/write **localStorage** per account key. + * Not logged in: **sessionStorage** for this browser tab/session. + */ +export function loadChartBoardRefreshPrefs( + boardId: ChartBoardId, + userKeyPart: string | undefined, +): ChartBoardRefreshPrefs { + const defaults: ChartBoardRefreshPrefs = { + autoRefreshOn: false, + refreshIntervalSec: CHART_BOARD_DEFAULT_REFRESH_INTERVAL_SEC, + }; + if (typeof window === "undefined") { + return defaults; + } + try { + if (userKeyPart) { + const u = parsePrefs(localStorage.getItem(storageKeyUser(boardId, userKeyPart))); + if (u) return u; + } + const s = parsePrefs(sessionStorage.getItem(storageKeySession(boardId))); + if (s) return s; + } catch { + /* ignore quota / private mode */ + } + return defaults; +} + +export function saveChartBoardRefreshPrefs( + boardId: ChartBoardId, + userKeyPart: string | undefined, + prefs: ChartBoardRefreshPrefs, +): void { + if (typeof window === "undefined") return; + const payload = JSON.stringify({ + autoRefreshOn: prefs.autoRefreshOn, + refreshIntervalSec: sanitizeChartBoardRefreshInterval(prefs.refreshIntervalSec), + }); + try { + if (userKeyPart) { + localStorage.setItem(storageKeyUser(boardId, userKeyPart), payload); + sessionStorage.removeItem(storageKeySession(boardId)); + } else { + sessionStorage.setItem(storageKeySession(boardId), payload); + } + } catch { + /* ignore */ + } +} diff --git a/src/app/(main)/chart/delivery/page.tsx b/src/app/(main)/chart/delivery/page.tsx index a3fb02b..fd0f8b1 100644 --- a/src/app/(main)/chart/delivery/page.tsx +++ b/src/app/(main)/chart/delivery/page.tsx @@ -14,7 +14,6 @@ import { Autocomplete, Chip, } from "@mui/material"; -import dynamic from "next/dynamic"; import LocalShipping from "@mui/icons-material/LocalShipping"; import { fetchDeliveryOrderByDate, @@ -28,8 +27,7 @@ import { import ChartCard from "../_components/ChartCard"; import DateRangeSelect from "../_components/DateRangeSelect"; import { toDateRange, DEFAULT_RANGE_DAYS, TOP_ITEMS_LIMIT_OPTIONS } from "../_components/constants"; - -const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); +import SafeApexCharts from "@/components/charts/SafeApexCharts"; const PAGE_TITLE = "發貨與配送"; @@ -175,7 +173,7 @@ export default function DeliveryChartPage() { {loadingCharts.delivery ? ( ) : ( - d.date) }, @@ -247,7 +245,7 @@ export default function DeliveryChartPage() { {loadingCharts.topItems ? ( ) : ( - 每日按員工單數 - 0) return `id:${r.equipmentId}`; + const c = (r.equipmentCode ?? "").trim(); + const n = (r.equipmentName ?? "").trim(); + return `txt:${c}\u0001${n}`; +} + +/** Single display line when code/name may duplicate (equipment or process). */ +function formatCodeNameLine(code: string, name: string): string { + const c = (code ?? "").trim(); + const n = (name ?? "").trim(); + if (!c && !n) return "—"; + if (!c) return n; + if (!n) return c; + if (c.toLowerCase() === n.toLowerCase()) return n; + if (n.toLowerCase().startsWith(`${c.toLowerCase()} `) || n.toLowerCase().startsWith(`${c.toLowerCase()} `)) return n; + if (n.length > c.length && n.toLowerCase().startsWith(c.toLowerCase())) { + const after = n.slice(c.length, c.length + 1); + if (/[\s\-–—::·.|//]/.test(after)) return n; + } + return `${c} ${n}`; +} + +function formatUsageMinutes(m: number): string { + if (!Number.isFinite(m) || m <= 0) return "—"; + const rounded = Math.round(m * 10) / 10; + if (rounded < 60) return `${rounded} 分`; + const h = Math.floor(rounded / 60); + const mm = Math.round((rounded % 60) * 10) / 10; + return mm > 0 ? `${h} 小時 ${mm} 分` : `${h} 小時`; +} + +export default function EquipmentUsageBoardPage() { + const theme = useTheme(); + /** Calendar day for API (local). Default: today. */ + const [viewDate, setViewDate] = useState(() => dayjs().format("YYYY-MM-DD")); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(""); + /** null = all equipment; otherwise rowEquipmentKey */ + const [selectedEquipmentKey, setSelectedEquipmentKey] = useState(null); + const { autoRefreshOn, setAutoRefreshOn, refreshIntervalSec, setRefreshIntervalSec } = + useChartBoardRefreshPrefs("equipment"); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await fetchEquipmentUsageBoard(viewDate); + setRows(data); + setLastUpdated(dayjs().format("YYYY-MM-DD HH:mm:ss")); + } catch (e) { + setError(e instanceof Error ? e.message : "Request failed"); + } finally { + setLoading(false); + } + }, [viewDate]); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (!autoRefreshOn || refreshIntervalSec < 10) return; + const t = setInterval(() => void load(), refreshIntervalSec * 1000); + return () => clearInterval(t); + }, [load, autoRefreshOn, refreshIntervalSec]); + + useEffect(() => { + setSelectedEquipmentKey(null); + }, [viewDate]); + + useEffect(() => { + const onKey = (ev: KeyboardEvent) => { + const t = ev.target as HTMLElement | null; + if ( + t && + (t.tagName === "INPUT" || + t.tagName === "TEXTAREA" || + t.tagName === "SELECT" || + t.isContentEditable) + ) { + return; + } + if (ev.ctrlKey || ev.metaKey || ev.altKey) return; + if (ev.key === "t" || ev.key === "T") { + ev.preventDefault(); + setViewDate(dayjs().format("YYYY-MM-DD")); + } + if (ev.key === "y" || ev.key === "Y") { + ev.preventDefault(); + setViewDate(dayjs().subtract(1, "day").format("YYYY-MM-DD")); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + const equipmentUsageChart = useMemo(() => { + const map = new Map(); + rows.forEach((r) => { + const k = rowEquipmentKey(r); + const label = formatCodeNameLine(r.equipmentCode, r.equipmentName); + const add = Number.isFinite(r.usageMinutes) ? r.usageMinutes : 0; + const cur = map.get(k) ?? { key: k, label, minutes: 0 }; + cur.minutes += add; + map.set(k, cur); + }); + const list = Array.from(map.values()) + .filter((x) => x.minutes > 0) + .sort((a, b) => b.minutes - a.minutes) + .slice(0, EQUIPMENT_CHART_MAX); + return { + keys: list.map((x) => x.key), + categories: list.map((x) => (x.label.length > 34 ? `${x.label.slice(0, 32)}…` : x.label)), + data: list.map((x) => Math.round(x.minutes * 10) / 10), + }; + }, [rows]); + + const barClickRef = useRef<(index: number) => void>(() => {}); + barClickRef.current = (index: number) => { + const key = equipmentUsageChart.keys[index]; + if (key == null) return; + setSelectedEquipmentKey((prev) => (prev === key ? null : key)); + }; + + const barOptions = useMemo( + () => ({ + chart: { + type: "bar" as const, + toolbar: { show: false }, + events: { + dataPointSelection: (_e: unknown, _ctx: unknown, cfg: { dataPointIndex?: number }) => { + const i = cfg?.dataPointIndex; + if (typeof i !== "number" || i < 0) return; + barClickRef.current(i); + }, + }, + }, + plotOptions: { + bar: { + horizontal: true, + barHeight: "72%", + borderRadius: 4, + distributed: true, + }, + }, + colors: ["#1976d2", "#0288d1", "#0097a7", "#00838f", "#00695c", "#2e7d32", "#558b2f", "#827717"], + dataLabels: { enabled: true, formatter: (val: number) => (Number.isFinite(val) ? `${val} 分` : "") }, + xaxis: { categories: equipmentUsageChart.categories, title: { text: "分鐘" } }, + yaxis: { labels: { maxWidth: 260 } }, + legend: { show: false }, + tooltip: { y: { formatter: (val: number) => `${val} 分鐘` } }, + }), + [equipmentUsageChart.categories], + ); + + const displayRows = useMemo(() => { + if (!selectedEquipmentKey) return rows; + return rows.filter((r) => rowEquipmentKey(r) === selectedEquipmentKey); + }, [rows, selectedEquipmentKey]); + + const selectedLabel = useMemo(() => { + if (!selectedEquipmentKey) return ""; + const hit = rows.find((r) => rowEquipmentKey(r) === selectedEquipmentKey); + return hit ? formatCodeNameLine(hit.equipmentCode, hit.equipmentName) : selectedEquipmentKey; + }, [rows, selectedEquipmentKey]); + + const stats = useMemo(() => { + const working = displayRows.filter((r) => r.workingNow === 1).length; + const eqKeys = new Set(displayRows.map((r) => rowEquipmentKey(r))); + const totalMins = displayRows.reduce((s, r) => s + (Number.isFinite(r.usageMinutes) ? r.usageMinutes : 0), 0); + return { working, sessions: displayRows.length, equipmentTouched: eqKeys.size, totalMins }; + }, [displayRows]); + + const isToday = viewDate === dayjs().format("YYYY-MM-DD"); + const weekdayZh = ["日", "一", "二", "三", "四", "五", "六"][dayjs(viewDate).day()] ?? ""; + + return ( + + + 設備使用看板 + + + 資料來源與工單編輯/工藝流程一致。上方使用時間(分鐘)為各設備當日明細加總(有起訖則相減;產線 Pass/無完工時間時用預設生產分鐘)。 + 點長條圖可篩選下方列表,再點同一項取消。 + 快捷鍵(不在輸入框內時):T{" "} + 今日、Y 昨日。 + {autoRefreshOn + ? ` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)` + : " 預設不自動更新,可開啟「自動重新整理」並選擇間隔。"} + {" 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。"} + {lastUpdated ? ` · 最後更新 ${lastUpdated}` : ""} + + + {error && ( + setError(null)}> + {error} + + )} + + + + + 查詢與列表 + + + + 歸屬日 {viewDate}(週{weekdayZh}) + {!isToday && } + + + + + + + + + setViewDate(e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ minWidth: 178 }} + /> + + + + + + + + 其他看板 + + + + + + + + + + + 自動重新整理 + + + setAutoRefreshOn(v)} + inputProps={{ "aria-label": "自動重新整理" }} + /> + } + label="開啟" + sx={{ ml: 0, mr: 0 }} + /> + + 間隔(秒) + + + + + + + {selectedEquipmentKey && ( + + + setSelectedEquipmentKey(null)} + color="primary" + variant="outlined" + /> + + )} + + {!loading && rows.length > 0 && equipmentUsageChart.data.length > 0 && ( + + + 使用時間(分鐘)— {viewDate} + + + 點擊長條篩選下方明細(最多顯示 {EQUIPMENT_CHART_MAX} 台,依分鐘數由高到低)。 + + + + )} + + {!loading && rows.length > 0 && equipmentUsageChart.data.length === 0 && ( + + 當日有明細但無法加總使用分鐘(多數為缺開/完工時間且無預設生產分鐘)。仍可在下方表格檢視。 + + )} + + {!loading && ( + + + + {selectedEquipmentKey ? "篩選後筆數" : "該日總筆數"} + + {stats.sessions} + + + + 使用分鐘合計(篩選範圍) + + {formatUsageMinutes(stats.totalMins)} + + + + 涉及設備數(篩選範圍) + + {stats.equipmentTouched} + + {stats.working > 0 && ( + + + 設備工時未結案 + + {stats.working} + + )} + + )} + + {loading && rows.length === 0 ? ( + + + + ) : rows.length === 0 ? ( + + + 此日期沒有符合歸屬日的設備使用紀錄(含工藝流程明細),或該日尚無已完工且已填設備的步驟。 + + + ) : displayRows.length === 0 ? ( + + + 此篩選下沒有明細。 + + + + ) : ( + + + + + 狀態 + 設備 + 使用(分) + 工單 + 工序 + 工單計劃開始 + 開工時間 + 完工時間 + 操作員 + 開啟 + + + + {displayRows.map((r) => ( + + + {r.workingNow === 1 ? ( + + ) : !r.operatingStart?.trim() && !r.operatingEnd?.trim() ? ( + + ) : ( + + )} + + {formatCodeNameLine(r.equipmentCode, r.equipmentName)} + {formatUsageMinutes(r.usageMinutes)} + {r.jobOrderCode || "—"} + {formatCodeNameLine(r.processCode, r.processName)} + {r.jobPlanStart || "—"} + {r.operatingStart || "—"} + {r.operatingEnd || "—"} + {r.operatorName || r.operatorUsername || "—"} + + + + + ))} + +
+
+ )} +
+ ); +} diff --git a/src/app/(main)/chart/forecast/page.tsx b/src/app/(main)/chart/forecast/page.tsx index 2a42f06..537e3d1 100644 --- a/src/app/(main)/chart/forecast/page.tsx +++ b/src/app/(main)/chart/forecast/page.tsx @@ -13,7 +13,6 @@ import { Checkbox, ListItemText, } from "@mui/material"; -import dynamic from "next/dynamic"; import TrendingUp from "@mui/icons-material/TrendingUp"; import { fetchProductionScheduleByDate, @@ -22,8 +21,7 @@ import { import ChartCard from "../_components/ChartCard"; import DateRangeSelect from "../_components/DateRangeSelect"; import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants"; - -const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); +import SafeApexCharts from "@/components/charts/SafeApexCharts"; const PAGE_TITLE = "預測與計劃"; @@ -255,7 +253,7 @@ export default function ForecastChartPage() { 此日期範圍內尚無排程資料。 ) : ( - ) : ( - d.date) }, diff --git a/src/app/(main)/chart/joborder/board/page.tsx b/src/app/(main)/chart/joborder/board/page.tsx new file mode 100644 index 0000000..17dbe0c --- /dev/null +++ b/src/app/(main)/chart/joborder/board/page.tsx @@ -0,0 +1,1047 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Box, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + TextField, + Alert, + CircularProgress, + IconButton, + Collapse, + Stack, + Tooltip, + Button, + LinearProgress, + Chip, + FormControl, + FormControlLabel, + InputLabel, + MenuItem, + Select, + Switch, +} from "@mui/material"; +import Link from "next/link"; +import dayjs from "dayjs"; +import { alpha, useTheme } from "@mui/material/styles"; +import ViewKanban from "@mui/icons-material/ViewKanban"; +import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown"; +import KeyboardArrowUp from "@mui/icons-material/KeyboardArrowUp"; +import EditCalendar from "@mui/icons-material/EditCalendar"; +import Schedule from "@mui/icons-material/Schedule"; +import Inventory2 from "@mui/icons-material/Inventory2"; +import PrecisionManufacturing from "@mui/icons-material/PrecisionManufacturing"; +import Microwave from "@mui/icons-material/Microwave"; +import VerifiedUser from "@mui/icons-material/VerifiedUser"; +import Warehouse from "@mui/icons-material/Warehouse"; +import CheckCircle from "@mui/icons-material/CheckCircle"; +import HelpOutline from "@mui/icons-material/HelpOutline"; +import { fetchJobOrderBoard, type JobOrderBoardRow } from "@/app/api/chart/client"; +import SafeApexCharts from "@/components/charts/SafeApexCharts"; +import { CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS } from "@/app/(main)/chart/chartBoardRefreshPrefs"; +import { useChartBoardRefreshPrefs } from "@/app/(main)/chart/useChartBoardRefreshPrefs"; + +const PIPELINE = [ + { status: "planning", label: "計劃" }, + { status: "pending", label: "待開" }, + { status: "packaging", label: "包裝" }, + { status: "processing", label: "製程" }, + { status: "pendingqc", label: "QC" }, + { status: "storing", label: "上架" }, + { status: "completed", label: "完成" }, +] as const; + +const JOB_STATUS_ZH: Record = { + planning: "計劃中", + pending: "待開工", + packaging: "包裝", + processing: "製程中", + pendingqc: "待質檢", + storing: "上架中", + completed: "已完成", + unknown: "未知", +}; + +/** 與摘要堆疊長條圖 series 順序一致:QC 中 → 已驗待入 → 已入庫 */ +const FG_SUMMARY_SERIES_BUCKETS = ["qc", "ready", "stocked"] as const; +type FgSummaryStockBucket = (typeof FG_SUMMARY_SERIES_BUCKETS)[number]; + +const FG_SUMMARY_BUCKET_LABEL: Record = { + qc: "入庫 QC 中(有量)", + ready: "已驗待入(有量)", + stocked: "已入庫(有量)", +}; + +function normStatus(s: string | undefined): string { + return (s ?? "").trim().toLowerCase(); +} + +function statusLabelZh(status: string): string { + return JOB_STATUS_ZH[normStatus(status)] ?? status; +} + +function isKnownJobStatus(status: string): boolean { + return normStatus(status) in JOB_STATUS_ZH; +} + +function StatusIcon({ status }: { status: string }) { + const s = normStatus(status); + const sx = { fontSize: 28, opacity: 0.95 }; + switch (s) { + case "planning": + return ; + case "pending": + return ; + case "packaging": + return ; + case "processing": + return ; + case "pendingqc": + return ; + case "storing": + return ; + case "completed": + return ; + default: + return ; + } +} + +function statusStepIndex(status: string): number { + const s = normStatus(status); + const i = PIPELINE.findIndex((p) => p.status === s); + return i >= 0 ? i : 0; +} + +/** Dot chain — current step ring highlight */ +function PipelineDots({ status }: { status: string }) { + const idx = statusStepIndex(status); + const allDone = normStatus(status) === "completed"; + return ( + + {PIPELINE.map((step, i) => { + const done = allDone || i < idx; + const current = !allDone && i === idx; + return ( + + + + ); + })} + + ); +} + +function formatQty(n: number): string { + if (!Number.isFinite(n) || n === 0) return "0"; + const t = Number(n); + return Math.abs(t - Math.round(t)) < 1e-6 ? String(Math.round(t)) : t.toFixed(2); +} + +/** 與工藝流程摘要:開始時間 + 僅生產分鐘(不含備料/轉換) */ +function formatAssumeEndMmDdHhMm(processStart: string, planProcessingMins: number): string { + if (!processStart?.trim() || !planProcessingMins) return "—"; + const d = dayjs(processStart.replace(" ", "T")); + if (!d.isValid()) return "—"; + return d.add(planProcessingMins, "minute").format("MM-DD HH:mm"); +} + +/** Backend sends decimal minutes (from summed seconds ÷ 60). */ +function formatDurationMins(mins: number): string { + if (!Number.isFinite(mins) || mins <= 0) return "—"; + const secs = Math.round(mins * 60); + if (secs < 60) return `${secs} 秒`; + if (mins < 10) return `${mins.toFixed(1)} 分`; + return `${Math.round(mins)} 分`; +} + +/** Avoid "CODE CODE" or "X X" when DB code/name duplicate or name already includes code. */ +function formatCurrentProcessLabel(code: string, name: string): string { + const c = (code ?? "").trim(); + const n = (name ?? "").trim(); + if (!c && !n) return "—"; + if (!c) return n; + if (!n) return c; + const lc = c.toLowerCase(); + const ln = n.toLowerCase(); + if (lc === ln) return n; + if (ln.startsWith(`${lc} `) || ln.startsWith(`${lc} `)) return n; + if (n.length > c.length && ln.startsWith(lc)) { + const after = n.slice(c.length, c.length + 1); + if (/[\s\-–—::·.|//]/.test(after)) return n; + } + return `${c} ${n}`; +} + +function MaterialBar({ pending, picked }: { pending: number; picked: number }) { + const total = pending + picked; + if (total <= 0) { + return ( + + 無領料行 + + ); + } + const pct = (100 * picked) / total; + return ( + + = 100 ? "success" : "primary"} + /> + + 已揀 {picked} / 共 {total} + + + ); +} + +function ProcessBar({ done, total }: { done: number; total: number }) { + if (total <= 0) { + return ( + + 無工序 + + ); + } + const pct = (100 * done) / total; + return ( + + = total ? "success" : "info"} + /> + + {done}/{total} + + + ); +} + +/** 成品/半成品入庫:QC 中 | 已驗待入 | 已入庫 — by qty */ +function FgStockBar({ qc, ready, stocked }: { qc: number; ready: number; stocked: number }) { + const sum = qc + ready + stocked; + if (sum <= 0) { + return ( + + 無入庫數量資料 + + ); + } + const pQc = (100 * qc) / sum; + const pReady = (100 * ready) / sum; + const pStocked = 100 - pQc - pReady; + return ( + + + + + + + + + + + + + + QC {formatQty(qc)} · 待入 {formatQty(ready)} · 已入 {formatQty(stocked)} + + + ); +} + +const COLSPAN = 8; + +function DetailLine({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + {label} + + + {children} + + + ); +} + +export default function JobOrderBoardPage() { + const theme = useTheme(); + const [targetDate, setTargetDate] = useState(() => dayjs().format("YYYY-MM-DD")); + /** true: load all non-completed JOs (no plan-start date); false: filter by targetDate */ + const [allIncompleteOpen, setAllIncompleteOpen] = useState(false); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(""); + const [openId, setOpenId] = useState(null); + /** Normalized status key from donut (e.g. pending); null = not filtering by stage */ + const [tableStatusKey, setTableStatusKey] = useState(null); + /** Summary bar: filter rows that have FG/WIP stock-in in that bucket; mutually exclusive with tableStatusKey in UI */ + const [tableStockBucket, setTableStockBucket] = useState(null); + const { autoRefreshOn, setAutoRefreshOn, refreshIntervalSec, setRefreshIntervalSec } = + useChartBoardRefreshPrefs("joborder"); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = allIncompleteOpen + ? await fetchJobOrderBoard(undefined, { incompleteOnly: true }) + : await fetchJobOrderBoard(targetDate); + setRows(data); + setLastUpdated(dayjs().format("YYYY-MM-DD HH:mm:ss")); + } catch (e) { + setError(e instanceof Error ? e.message : "Request failed"); + } finally { + setLoading(false); + } + }, [allIncompleteOpen, targetDate]); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (!autoRefreshOn || refreshIntervalSec < 10) return; + const t = setInterval(() => void load(), refreshIntervalSec * 1000); + return () => clearInterval(t); + }, [load, autoRefreshOn, refreshIntervalSec]); + + useEffect(() => { + setTableStatusKey(null); + setTableStockBucket(null); + }, [targetDate, allIncompleteOpen]); + + useEffect(() => { + setOpenId(null); + }, [tableStatusKey, tableStockBucket]); + + useEffect(() => { + const onKey = (ev: KeyboardEvent) => { + const t = ev.target as HTMLElement | null; + if ( + t && + (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable) + ) { + return; + } + if (ev.ctrlKey || ev.metaKey || ev.altKey) return; + if (ev.key === "t" || ev.key === "T") { + ev.preventDefault(); + setTargetDate(dayjs().format("YYYY-MM-DD")); + } + if (ev.key === "y" || ev.key === "Y") { + ev.preventDefault(); + setTargetDate(dayjs().subtract(1, "day").format("YYYY-MM-DD")); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + const statusDonut = useMemo(() => { + const map = new Map(); + rows.forEach((r) => { + const k = normStatus(r.status) || "unknown"; + map.set(k, (map.get(k) ?? 0) + 1); + }); + const keysAll = Array.from(map.keys()); + const pairs = keysAll + .map((k) => { + const v = map.get(k) ?? 0; + const n = Number.isFinite(v) && v > 0 ? v : 0; + return { key: k, label: JOB_STATUS_ZH[k] ?? k, v: n }; + }) + .filter((p) => p.v > 0); + return { + keys: pairs.map((p) => p.key), + labels: pairs.map((p) => p.label), + series: pairs.map((p) => p.v), + }; + }, [rows]); + + const displayRows = useMemo(() => { + const fin = (n: number) => (Number.isFinite(n) ? n : 0); + if (tableStockBucket) { + return rows.filter((r) => { + if (tableStockBucket === "qc") return fin(r.fgInQcQty) > 0 || (r.fgInQcLineCount ?? 0) > 0; + if (tableStockBucket === "ready") return fin(r.fgReadyToStockInQty) > 0 || (r.fgReadyToStockInCount ?? 0) > 0; + if (tableStockBucket === "stocked") return fin(r.fgStockedQty) > 0; + return true; + }); + } + if (tableStatusKey) { + return rows.filter((r) => (normStatus(r.status) || "unknown") === tableStatusKey); + } + return rows; + }, [rows, tableStatusKey, tableStockBucket]); + + const donutSliceClickRef = useRef<(index: number) => void>(() => {}); + donutSliceClickRef.current = (index: number) => { + setTableStockBucket(null); + const key = statusDonut.keys[index]; + if (key == null) return; + setTableStatusKey((prev) => (prev === key ? null : key)); + }; + + const fgBarSeriesClickRef = useRef<(seriesIndex: number) => void>(() => {}); + fgBarSeriesClickRef.current = (seriesIndex: number) => { + if (typeof seriesIndex !== "number" || seriesIndex < 0 || seriesIndex >= FG_SUMMARY_SERIES_BUCKETS.length) return; + setTableStatusKey(null); + const bucket = FG_SUMMARY_SERIES_BUCKETS[seriesIndex]; + setTableStockBucket((prev) => (prev === bucket ? null : bucket)); + }; + + const statusDonutChartOptions = useMemo( + () => ({ + chart: { + type: "donut" as const, + toolbar: { show: false }, + events: { + dataPointSelection: (_e: unknown, _ctx: unknown, cfg: { dataPointIndex?: number }) => { + const i = cfg?.dataPointIndex; + if (typeof i !== "number" || i < 0) return; + donutSliceClickRef.current(i); + }, + /** Donut/pie: legend index matches slice index */ + legendClick: (_chartContext: unknown, seriesIndex: number) => { + if (typeof seriesIndex !== "number" || seriesIndex < 0) return false; + donutSliceClickRef.current(seriesIndex); + return false; + }, + }, + }, + labels: statusDonut.labels, + legend: { + position: "bottom" as const, + onItemClick: { toggleDataSeries: false }, + }, + plotOptions: { pie: { donut: { size: "62%" } } }, + dataLabels: { enabled: true }, + }), + [statusDonut.labels], + ); + + const fgSummaryChartOptions = useMemo( + () => ({ + chart: { + type: "bar" as const, + stacked: true, + toolbar: { show: false }, + events: { + dataPointSelection: (_e: unknown, _ctx: unknown, cfg: { seriesIndex?: number; dataPointIndex?: number }) => { + const si = cfg?.seriesIndex; + if (typeof si !== "number") return; + fgBarSeriesClickRef.current(si); + }, + legendClick: (_chartContext: unknown, seriesIndex: number) => { + if (typeof seriesIndex !== "number" || seriesIndex < 0) return false; + fgBarSeriesClickRef.current(seriesIndex); + return false; + }, + }, + }, + plotOptions: { bar: { horizontal: true, barHeight: "48%" } }, + xaxis: { categories: ["合計"] }, + colors: ["#ed6c02", "#0288d1", "#2e7d32"], + legend: { + position: "top" as const, + onItemClick: { toggleDataSeries: false }, + }, + dataLabels: { enabled: false }, + }), + [], + ); + + const materialSummary = useMemo(() => { + let p = 0; + let k = 0; + rows.forEach((r) => { + p += r.materialPendingCount; + k += r.materialPickedCount; + }); + return { pending: p, picked: k }; + }, [rows]); + + const fgTotals = useMemo(() => { + const fin = (n: number) => (Number.isFinite(n) ? n : 0); + let qc = 0; + let ready = 0; + let stocked = 0; + rows.forEach((r) => { + qc += fin(r.fgInQcQty); + ready += fin(r.fgReadyToStockInQty); + stocked += fin(r.fgStockedQty); + }); + return { qc, ready, stocked }; + }, [rows]); + + const isPlanToday = targetDate === dayjs().format("YYYY-MM-DD"); + const planWeekdayZh = ["日", "一", "二", "三", "四", "五", "六"][dayjs(targetDate).day()] ?? ""; + + return ( + + + 工單即時看板 + + + 圖示化階段與進度條;入庫欄僅統計成品/半成品(系統 FG/WIP)工單之入庫明細(依驗收數量)。「已驗待入」= 狀態為 receiving / + received(QC 完成待上架)。 + {autoRefreshOn + ? ` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)` + : " 預設不自動更新,可開啟「自動重新整理」並選擇間隔。"} + {" 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。"} + {lastUpdated ? ` · 最後更新 ${lastUpdated}` : ""} + 快捷鍵(不在輸入框內時): + + T + {" "} + 今日、 + + Y + {" "} + 昨日(僅更新計劃開始日)。 + + {error && ( + setError(null)}> + {error} + + )} + + + + + 查詢與列表 + + + + 計劃開始日 {targetDate}(週{planWeekdayZh}) + {!isPlanToday && } + + + + + + + + + + + + + setTargetDate(e.target.value)} + disabled={allIncompleteOpen} + InputLabelProps={{ shrink: true }} + sx={{ minWidth: 178 }} + /> + + + + + 載入全部未完成工單(階段≠已完成),不篩「計劃開始日」。筆數多時仍可能較慢。 + + + 再按一次可切回依「計劃開始日」載入。 + + + } + arrow + placement="top-start" + > + + + + + + + + + + 其他看板 + + + + + + + + + + + 自動重新整理 + + + setAutoRefreshOn(v)} + inputProps={{ "aria-label": "自動重新整理" }} + /> + } + label="開啟" + sx={{ ml: 0, mr: 0 }} + /> + + 間隔(秒) + + + + + + + {!loading && rows.length > 0 && ( + + + + 工單狀態分佈 + + + 點擊圓環或下方圖例可依階段篩選列表;再點同一項或表格上方「清除列表篩選」可還原。 + + {statusDonut.series.length > 0 ? ( + + ) : ( + 無資料 + )} + + + + 本頁摘要 + + + 領料:待領 {materialSummary.pending} / 已揀 {materialSummary.picked} + + + + 成品/半成品入庫(驗收數量)— QC 中/已驗待入/已入庫 + + + 點擊長條色塊或上方圖例,篩選該入庫區塊有資料的工單;再點一次可還原。 + + + {fgTotals.qc + fgTotals.ready + fgTotals.stocked > 0 ? ( + <> + + + + ) : ( + <> + + 驗收數量合計為 0,不顯示長條圖 + + + + )} + + + )} + + {loading && rows.length === 0 ? ( + + + + ) : rows.length === 0 ? ( + + 此條件下沒有工單 + + ) : ( + + {(tableStatusKey || tableStockBucket) && ( + + + + 列表篩選中: + + {tableStockBucket && ( + setTableStockBucket(null)} + /> + )} + {tableStatusKey && ( + setTableStatusKey(null)} + /> + )} + + + + )} + + + + + 工單號 + 階段 + 領料進度 + 工序進度 + 當前製程 + 成品/半成品入庫 + 開啟 + + + + {displayRows.length === 0 ? ( + + + + {tableStockBucket + ? "目前沒有符合此入庫區塊的工單(可點「清除列表篩選」)。" + : tableStatusKey + ? "此階段下目前沒有工單(可點「清除列表篩選」)。" + : "沒有資料"} + + + + ) : ( + displayRows.map((r) => ( + + + + setOpenId((id) => (id === r.jobOrderId ? null : r.jobOrderId))} + > + {openId === r.jobOrderId ? : } + + + {r.code} + + + + + + {statusLabelZh(r.status)} + + + + + + + + + + + + + + {formatCurrentProcessLabel(r.currentProcessCode, r.currentProcessName)} + + + + + + 已驗待入 {r.fgReadyToStockInCount} 行,驗收數量 {formatQty(r.fgReadyToStockInQty)};全單驗收合計{" "} + {formatQty(r.stockInAcceptedQtyTotal)} + + + + + + + + + + + + 明細 + + + + 生產流程摘要(與工單·工藝流程一致,來自產線彙總) + + + + + {r.itemCode || r.itemName + ? `${r.itemCode || ""}${r.itemCode && r.itemName ? "-" : ""}${r.itemName || ""}` + : "—"} + + {r.jobTypeName?.trim() ? r.jobTypeName : "—"} + + {formatQty(r.reqQty)} + {r.outputQtyUom ? `(${r.outputQtyUom})` : ""} + + {r.productionDate || "—"} + {formatDurationMins(r.actualLineMinsTotal)} + + + + {r.planProcessingMinsTotal > 0 ? `${Math.round(r.planProcessingMinsTotal)} 分鐘` : "—"} + {r.planSetupChangeoverMinsTotal > 0 + ? ` · 備料/轉換合計 ${Math.round(r.planSetupChangeoverMinsTotal)} 分鐘` + : ""} + + {r.productProcessStart || "—"} + + {formatAssumeEndMmDdHhMm(r.productProcessStart, r.planProcessingMinsTotal)} + + + + + 工單計劃/實際 + + {r.planStart || "—"} + {r.actualStart || "—"} + {r.planEnd || "—"} + {r.actualEnd || "—"} + {r.currentProcessStartTime || "—"} + + + + {statusLabelZh(r.status)} + + + + + + 成品/半成品入庫狀況 + + + + {formatQty(r.fgInQcQty)}({r.fgInQcLineCount} 行) + + + {formatQty(r.fgReadyToStockInQty)}({r.fgReadyToStockInCount} 行) + + {formatQty(r.fgStockedQty)} + {formatQty(r.stockInAcceptedQtyTotal)} + + + + + + + + + )) + )} + +
+
+ )} +
+ ); +} diff --git a/src/app/(main)/chart/joborder/page.tsx b/src/app/(main)/chart/joborder/page.tsx index 1a61f4e..15ecc36 100644 --- a/src/app/(main)/chart/joborder/page.tsx +++ b/src/app/(main)/chart/joborder/page.tsx @@ -1,10 +1,11 @@ "use client"; import React, { useCallback, useState } from "react"; -import { Box, Typography, Skeleton, Alert, TextField } from "@mui/material"; -import dynamic from "next/dynamic"; +import { Box, Typography, Skeleton, Alert, TextField, Button, Stack } from "@mui/material"; +import Link from "next/link"; import dayjs from "dayjs"; import Assignment from "@mui/icons-material/Assignment"; +import Microwave from "@mui/icons-material/Microwave"; import { fetchJobOrderByStatus, fetchJobOrderCountByDate, @@ -16,8 +17,7 @@ import { import ChartCard from "../_components/ChartCard"; import DateRangeSelect from "../_components/DateRangeSelect"; import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants"; - -const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); +import SafeApexCharts from "@/components/charts/SafeApexCharts"; const PAGE_TITLE = "工單"; @@ -153,9 +153,28 @@ export default function JobOrderChartPage() { return ( - - {PAGE_TITLE} - + + + {PAGE_TITLE} + + + + + + + {error && ( setError(null)}> {error} @@ -181,7 +200,7 @@ export default function JobOrderChartPage() { {loadingCharts.joStatus ? ( ) : ( - p.status), @@ -209,7 +228,7 @@ export default function JobOrderChartPage() { {loadingCharts.joCountByDate ? ( ) : ( - d.date) }, @@ -239,7 +258,7 @@ export default function JobOrderChartPage() { {loadingCharts.joCreatedCompleted ? ( ) : ( - d.date) }, @@ -275,7 +294,7 @@ export default function JobOrderChartPage() { {loadingCharts.joMaterial ? ( ) : ( - d.date) }, @@ -309,7 +328,7 @@ export default function JobOrderChartPage() { {loadingCharts.joProcess ? ( ) : ( - d.date) }, @@ -343,7 +362,7 @@ export default function JobOrderChartPage() { {loadingCharts.joEquipment ? ( ) : ( - d.date) }, diff --git a/src/app/(main)/chart/process/board/page.tsx b/src/app/(main)/chart/process/board/page.tsx new file mode 100644 index 0000000..ab8adb5 --- /dev/null +++ b/src/app/(main)/chart/process/board/page.tsx @@ -0,0 +1,1309 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + alpha, + Avatar, + Box, + Typography, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Paper, + TextField, + Alert, + CircularProgress, + Stack, + Button, + Chip, + ToggleButton, + ToggleButtonGroup, + FormControl, + FormControlLabel, + InputLabel, + MenuItem, + Select, + Switch, + Tooltip, +} from "@mui/material"; +import Link from "next/link"; +import dayjs from "dayjs"; +import AccountTree from "@mui/icons-material/AccountTree"; +import CheckCircleOutline from "@mui/icons-material/CheckCircleOutline"; +import FilterAltOutlined from "@mui/icons-material/FilterAltOutlined"; +import HourglassEmpty from "@mui/icons-material/HourglassEmpty"; +import PlayCircleOutline from "@mui/icons-material/PlayCircleOutline"; +import Schedule from "@mui/icons-material/Schedule"; +import TableChart from "@mui/icons-material/TableChart"; +import { useTheme, type Theme } from "@mui/material/styles"; +import { fetchProcessBoard, type ProcessBoardRow } from "@/app/api/chart/client"; +import SafeApexCharts from "@/components/charts/SafeApexCharts"; +import { CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS } from "@/app/(main)/chart/chartBoardRefreshPrefs"; +import { useChartBoardRefreshPrefs } from "@/app/(main)/chart/useChartBoardRefreshPrefs"; + +const BOARD_STATUS_ZH: Record = { + pending: "未開工", + in_progress: "進行中", + completed: "已完工", +}; + +const BOARD_STATUS_ORDER = ["in_progress", "pending", "completed"] as const; + +/** Max processes shown in the horizontal bar chart (typical BOM has ~20–30 steps). */ +const PROCESS_BAR_MAX = 35; + +/** + * HSL (degrees, 0–100, 0–100) → #rrggbb for chart-safe saturation/lightness. + */ +function hslToHex(h: number, s: number, l: number): string { + const sh = ((h % 360) + 360) % 360; + const ss = Math.min(100, Math.max(0, s)) / 100; + const sl = Math.min(100, Math.max(0, l)) / 100; + const c = (1 - Math.abs(2 * sl - 1)) * ss; + const x = c * (1 - Math.abs(((sh / 60) % 2) - 1)); + const m = sl - c / 2; + let rp = 0; + let gp = 0; + let bp = 0; + if (sh < 60) { + rp = c; + gp = x; + } else if (sh < 120) { + rp = x; + gp = c; + } else if (sh < 180) { + gp = c; + bp = x; + } else if (sh < 240) { + gp = x; + bp = c; + } else if (sh < 300) { + rp = x; + bp = c; + } else { + rp = c; + bp = x; + } + const r = Math.round((rp + m) * 255); + const g = Math.round((gp + m) * 255); + const b = Math.round((bp + m) * 255); + return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`; +} + +/** + * Golden-angle hue spacing + staggered S/L so 20–40 adjacent bars rarely look like duplicates. + */ +function generateProcessBarColors(count: number): string[] { + const n = Math.max(0, count); + const GOLDEN = 137.508; + const out: string[] = []; + for (let i = 0; i < n; i++) { + const h = (i * GOLDEN) % 360; + const s = 56 + (i % 5) * 5.5; + const l = 40 + (i % 7) * 2.4; + out.push(hslToHex(h, s, l)); + } + return out; +} + +/** Stable color for a process row that is not in the current top-N bar chart. */ +function fallbackProcessColor(processId: number): string { + const h = (Math.abs(processId) * 137.508) % 360; + return hslToHex(h, 64, 46); +} + +function norm(s: string | undefined): string { + return (s ?? "").trim().toLowerCase(); +} + +function formatCodeNameLine(code: string, name: string): string { + const c = (code ?? "").trim(); + const n = (name ?? "").trim(); + if (!c && !n) return "—"; + if (!c) return n; + if (!n) return c; + if (c.toLowerCase() === n.toLowerCase()) return n; + if (n.toLowerCase().startsWith(`${c.toLowerCase()} `) || n.toLowerCase().startsWith(`${c.toLowerCase()} `)) return n; + if (n.length > c.length && n.toLowerCase().startsWith(c.toLowerCase())) { + const after = n.slice(c.length, c.length + 1); + if (/[\s\-–—::·.|//]/.test(after)) return n; + } + return `${c} ${n}`; +} + +function boardStatusAccent( + theme: Theme, + status: string, +): { stripe: string; tint: string } { + const k = norm(status); + if (k === "completed") { + return { + stripe: theme.palette.success.main, + tint: alpha(theme.palette.success.main, 0.06), + }; + } + if (k === "in_progress") { + return { + stripe: theme.palette.primary.main, + tint: alpha(theme.palette.primary.main, 0.07), + }; + } + return { + stripe: theme.palette.grey[400], + tint: alpha(theme.palette.grey[500], 0.06), + }; +} + +function processAccentColor(processId: number, barColorMap: Map): string { + return barColorMap.get(processId) ?? fallbackProcessColor(processId); +} + +function BoardStatusVisual({ status }: { status: string }) { + const k = norm(status); + const label = BOARD_STATUS_ZH[k] ?? status; + if (k === "completed") { + return ( + } + label={label} + color="success" + variant="filled" + sx={{ fontWeight: 600 }} + /> + ); + } + if (k === "in_progress") { + return ( + } + label={label} + color="primary" + variant="filled" + sx={{ fontWeight: 600 }} + /> + ); + } + return ( + } + label={label} + color="default" + variant="outlined" + sx={{ fontWeight: 600, borderWidth: 2 }} + /> + ); +} + +function timeCell(text: string) { + const empty = !text || text === "—"; + return ( + + + + {empty ? "—" : text} + + + ); +} + +function formatQtyBoard(n: number): string { + if (!Number.isFinite(n) || n === 0) return "0"; + const t = Number(n); + return Math.abs(t - Math.round(t)) < 1e-6 ? String(Math.round(t)) : t.toFixed(2); +} + +/** API: decimal minutes (Σ seconds ÷ 60). Shows seconds when under 1 min. */ +function formatDurationMins(mins: number): string { + if (!Number.isFinite(mins) || mins <= 0) return "—"; + const secs = Math.round(mins * 60); + if (secs < 60) return `${secs} 秒`; + if (mins < 10) return `${mins.toFixed(1)} 分`; + return `${Math.round(mins)} 分`; +} + +function formatAssumeEndMmDdHhMm(processStart: string, planProcessingMins: number): string { + if (!processStart?.trim() || !planProcessingMins) return "—"; + const d = dayjs(processStart.replace(" ", "T")); + if (!d.isValid()) return "—"; + return d.add(planProcessingMins, "minute").format("MM-DD HH:mm"); +} + +function ProcessBoardJoSummary({ r }: { r: ProcessBoardRow }) { + const mat = + [r.itemCode, r.itemName].filter((x) => (x ?? "").trim()).join("-") || "—"; + const assumeEnd = formatAssumeEndMmDdHhMm(r.productProcessStart, r.planProcessingMinsTotal); + const startMmDd = r.productProcessStart ? dayjs(r.productProcessStart.replace(" ", "T")).format("MM-DD HH:mm") : "—"; + return ( + + + + {mat} + + + + 工單類型{" "} + + {r.jobTypeName?.trim() || "—"} + + + + 數量{" "} + + {formatQtyBoard(r.reqQty)} + {r.outputQtyUom ? `(${r.outputQtyUom})` : ""} + + + + 生產日期{" "} + + {r.productionDate || "—"} + + + + 實際耗時(全單線段加總){" "} + + {formatDurationMins(r.actualLineMinsTotal)} + + + + + + 預計生產{" "} + + {r.planProcessingMinsTotal > 0 ? `${Math.round(r.planProcessingMinsTotal)} 分` : "—"} + {r.planSetupChangeoverMinsTotal > 0 + ? ` · 備轉 ${Math.round(r.planSetupChangeoverMinsTotal)} 分` + : ""} + + + + 開始{" "} + + {startMmDd} + + + + 預計完成{" "} + + {assumeEnd} + + + + ); +} + +function StepPlanActualMins({ plan, actual }: { plan: number; actual: number }) { + return ( + + + 預計 {plan > 0 ? Math.round(plan) : "—"} 分 + + + 實際 {formatDurationMins(actual)} + + + ); +} + +/** 頂部橫向捲軸與下方表格同步,避免使用者只捲到底部才發現可左右捲。 */ +function HorizontalScrollSync({ minWidth, children }: { minWidth: number; children: React.ReactNode }) { + const topRef = useRef(null); + const bottomRef = useRef(null); + const lock = useRef(false); + + const syncTopFromBottom = () => { + const top = topRef.current; + const bottom = bottomRef.current; + if (!top || !bottom || lock.current) return; + if (top.scrollLeft === bottom.scrollLeft) return; + lock.current = true; + top.scrollLeft = bottom.scrollLeft; + requestAnimationFrame(() => { + lock.current = false; + }); + }; + const syncBottomFromTop = () => { + const top = topRef.current; + const bottom = bottomRef.current; + if (!top || !bottom || lock.current) return; + if (top.scrollLeft === bottom.scrollLeft) return; + lock.current = true; + bottom.scrollLeft = top.scrollLeft; + requestAnimationFrame(() => { + lock.current = false; + }); + }; + + return ( + + alpha(t.palette.primary.main, 0.06), + "&::-webkit-scrollbar": { height: 10 }, + }} + aria-hidden + > + + + + {children} + + + ); +} + +/** 生產流程摘要欄加寬(原 300 + 併入之名稱+描述欄 128+220)後,表格最小總寬。 */ +const PROCESS_BOARD_TABLE_MIN_WIDTH = 1861; + +/** 與工單工藝流程欄位一致:長文換行、空值顯示 — */ +function processFlowTextCell(text: string, maxWidth: number) { + const t = (text ?? "").trim(); + const empty = !t; + return ( + + {empty ? "—" : t} + + ); +} + +/** 名稱 + 描述併單欄(欄標題已為「名稱/描述」,不重複小標);描述在窄欄內強制換行多列顯示 */ +function nameDescriptionCell(name: string, description: string, maxWidth: number) { + const d = (description ?? "").trim(); + return ( + + {processFlowTextCell(name, maxWidth)} + + {d || "—"} + + + ); +} + +type GroupMode = "status" | "process"; + +function groupSectionStripe(theme: Theme, mode: GroupMode, groupKey: string): string { + if (mode === "status") { + const k = norm(groupKey); + if (k === "in_progress") return theme.palette.primary.main; + if (k === "pending") return theme.palette.grey[500]; + if (k === "completed") return theme.palette.success.main; + } + return theme.palette.secondary.main; +} + +type ProcessGroupBlock = { key: string; title: string; rows: ProcessBoardRow[] }; + +export default function ProcessBoardPage() { + const theme = useTheme(); + const [targetDate, setTargetDate] = useState(() => dayjs().format("YYYY-MM-DD")); + const [allIncompleteOpen, setAllIncompleteOpen] = useState(false); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(""); + const [groupMode, setGroupMode] = useState("status"); + const [filterBoardStatus, setFilterBoardStatus] = useState(null); + const [filterProcessId, setFilterProcessId] = useState(null); + const { autoRefreshOn, setAutoRefreshOn, refreshIntervalSec, setRefreshIntervalSec } = + useChartBoardRefreshPrefs("process"); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = allIncompleteOpen + ? await fetchProcessBoard(undefined, { incompleteOnly: true }) + : await fetchProcessBoard(targetDate); + setRows(data); + setLastUpdated(dayjs().format("YYYY-MM-DD HH:mm:ss")); + } catch (e) { + setError(e instanceof Error ? e.message : "Request failed"); + } finally { + setLoading(false); + } + }, [allIncompleteOpen, targetDate]); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (!autoRefreshOn || refreshIntervalSec < 10) return; + const t = setInterval(() => void load(), refreshIntervalSec * 1000); + return () => clearInterval(t); + }, [load, autoRefreshOn, refreshIntervalSec]); + + useEffect(() => { + setFilterBoardStatus(null); + setFilterProcessId(null); + }, [targetDate, allIncompleteOpen]); + + useEffect(() => { + const onKey = (ev: KeyboardEvent) => { + const t = ev.target as HTMLElement | null; + if ( + t && + (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable) + ) { + return; + } + if (ev.ctrlKey || ev.metaKey || ev.altKey) return; + if (ev.key === "t" || ev.key === "T") { + ev.preventDefault(); + setTargetDate(dayjs().format("YYYY-MM-DD")); + } + if (ev.key === "y" || ev.key === "Y") { + ev.preventDefault(); + setTargetDate(dayjs().subtract(1, "day").format("YYYY-MM-DD")); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + const displayRows = useMemo(() => { + return rows.filter((r) => { + if (filterBoardStatus && norm(r.boardStatus) !== norm(filterBoardStatus)) return false; + if (filterProcessId != null && r.processId !== filterProcessId) return false; + return true; + }); + }, [rows, filterBoardStatus, filterProcessId]); + + const statusDonut = useMemo(() => { + const map = new Map(); + rows.forEach((r) => { + const k = norm(r.boardStatus) || "pending"; + map.set(k, (map.get(k) ?? 0) + 1); + }); + const order = ["in_progress", "pending", "completed"]; + const keys = order.filter((k) => (map.get(k) ?? 0) > 0); + const extra = Array.from(map.keys()).filter((k) => !order.includes(k)); + extra.sort(); + const allKeys = [...keys, ...extra]; + return { + keys: allKeys, + labels: allKeys.map((k) => BOARD_STATUS_ZH[k] ?? k), + series: allKeys.map((k) => map.get(k) ?? 0), + }; + }, [rows]); + + const processBar = useMemo(() => { + const m = new Map(); + rows.forEach((r) => { + const id = r.processId; + if (!id) return; + const cur = m.get(id) ?? { + processId: id, + label: formatCodeNameLine(r.processCode, r.processName), + count: 0, + }; + cur.count += 1; + m.set(id, cur); + }); + const list = Array.from(m.values()) + .sort((a, b) => b.count - a.count) + .slice(0, PROCESS_BAR_MAX); + return { + processIds: list.map((x) => x.processId), + categories: list.map((x) => (x.label.length > 28 ? `${x.label.slice(0, 26)}…` : x.label)), + data: list.map((x) => x.count), + }; + }, [rows]); + + const processBarPalette = useMemo( + () => generateProcessBarColors(processBar.processIds.length), + [processBar.processIds], + ); + + const processBarColorById = useMemo(() => { + const m = new Map(); + processBar.processIds.forEach((id, i) => { + const c = processBarPalette[i]; + if (c) m.set(id, c); + }); + return m; + }, [processBar.processIds, processBarPalette]); + + const donutClickRef = useRef<(i: number) => void>(() => {}); + donutClickRef.current = (index: number) => { + const key = statusDonut.keys[index]; + if (key == null) return; + setFilterProcessId(null); + setFilterBoardStatus((prev) => (prev === key ? null : key)); + }; + + const barClickRef = useRef<(i: number) => void>(() => {}); + barClickRef.current = (index: number) => { + const id = processBar.processIds[index]; + if (id == null) return; + setFilterBoardStatus(null); + setFilterProcessId((prev) => (prev === id ? null : id)); + }; + + const donutColors = useMemo(() => { + const fallback = [ + theme.palette.primary.main, + theme.palette.warning.main, + theme.palette.success.main, + theme.palette.secondary.main, + theme.palette.error.main, + ]; + return statusDonut.keys.map((k, i) => { + if (k === "in_progress") return theme.palette.primary.main; + if (k === "pending") return theme.palette.grey[500]; + if (k === "completed") return theme.palette.success.main; + return fallback[(i + 3) % fallback.length]; + }); + }, [statusDonut.keys, theme]); + + const donutOptions = useMemo( + () => ({ + chart: { + type: "donut" as const, + toolbar: { show: false }, + events: { + dataPointSelection: (_e: unknown, _ctx: unknown, cfg: { dataPointIndex?: number }) => { + const i = cfg?.dataPointIndex; + if (typeof i !== "number" || i < 0) return; + donutClickRef.current(i); + }, + legendClick: (_chartContext: unknown, seriesIndex: number) => { + if (typeof seriesIndex !== "number" || seriesIndex < 0) return false; + donutClickRef.current(seriesIndex); + return false; + }, + }, + }, + labels: statusDonut.labels, + colors: donutColors, + legend: { position: "bottom" as const, onItemClick: { toggleDataSeries: false } }, + plotOptions: { pie: { donut: { size: "62%" } } }, + dataLabels: { enabled: true }, + }), + [donutColors, statusDonut.labels], + ); + + const barColors = useMemo(() => processBarPalette.slice(0, processBar.data.length), [processBar.data.length, processBarPalette]); + + const barOptions = useMemo( + () => ({ + chart: { + type: "bar" as const, + toolbar: { show: false }, + events: { + dataPointSelection: (_e: unknown, _ctx: unknown, cfg: { dataPointIndex?: number }) => { + const i = cfg?.dataPointIndex; + if (typeof i !== "number" || i < 0) return; + barClickRef.current(i); + }, + }, + }, + colors: barColors, + plotOptions: { + bar: { + borderRadius: 6, + horizontal: true, + barHeight: "72%", + distributed: true, + }, + }, + dataLabels: { enabled: true }, + xaxis: { categories: processBar.categories }, + yaxis: { labels: { maxWidth: 220 } }, + legend: { show: false }, + }), + [barColors, processBar.categories], + ); + + const filterProcessLabel = useMemo(() => { + if (filterProcessId == null) return ""; + const hit = rows.find((r) => r.processId === filterProcessId); + return hit ? formatCodeNameLine(hit.processCode, hit.processName) : `工序 #${filterProcessId}`; + }, [filterProcessId, rows]); + + const grouped = useMemo((): ProcessGroupBlock[] => { + if (groupMode === "status") { + const buckets: Record = {}; + BOARD_STATUS_ORDER.forEach((k) => { + buckets[k] = []; + }); + displayRows.forEach((r) => { + const k = norm(r.boardStatus) || "pending"; + if (!buckets[k]) buckets[k] = []; + buckets[k].push(r); + }); + return BOARD_STATUS_ORDER.filter((k) => (buckets[k]?.length ?? 0) > 0).map((k) => ({ + key: k, + title: BOARD_STATUS_ZH[k] ?? k, + rows: buckets[k] ?? [], + })); + } + const byProc = new Map(); + displayRows.forEach((r) => { + const id = r.processId; + const title = formatCodeNameLine(r.processCode, r.processName); + const cur = byProc.get(id) ?? { title, rows: [] }; + cur.rows.push(r); + byProc.set(id, cur); + }); + return Array.from(byProc.entries()) + .sort((a, b) => a[1].title.localeCompare(b[1].title, "zh-Hant")) + .map(([id, g]) => ({ + key: `proc-${id}`, + title: g.title, + rows: g.rows.sort((a: ProcessBoardRow, b: ProcessBoardRow) => a.seqNo - b.seqNo), + })); + }, [displayRows, groupMode]); + + const isTargetToday = targetDate === dayjs().format("YYYY-MM-DD"); + const targetWeekdayZh = ["日", "一", "二", "三", "四", "五", "六"][dayjs(targetDate).day()] ?? ""; + + return ( + + + 工序即時看板 + + + 每列為一筆工單工序(job_order_process)。狀態與工單「工藝流程」一致:優先依產線明細(productprocessline)狀態與起訖時間;若無對應明細則沿用工序上的開工/完工時間。 + {autoRefreshOn + ? ` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)` + : " 預設不自動更新,可開啟「自動重新整理」並選擇間隔。"} + {" 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。"} + {" · 最後更新 "} + {lastUpdated || "—"} + 快捷鍵(不在輸入框內時): + + T + {" "} + 今日、 + + Y + {" "} + 昨日(僅更新工單計劃開始日)。 + + + {error && ( + setError(null)}> + {error} + + )} + + + + + 查詢與列表 + + + + 工單計劃開始日 {targetDate}(週{targetWeekdayZh}) + {!isTargetToday && } + + + + + + + + + + + + + setTargetDate(e.target.value)} + disabled={allIncompleteOpen} + InputLabelProps={{ shrink: true }} + sx={{ minWidth: 178 }} + /> + + + + v && setGroupMode(v)} + aria-label="group mode" + sx={{ alignSelf: "flex-start" }} + > + 依工序狀態 + 依工序 + + + + + + + 其他看板 + + + + + + + + + + + 自動重新整理 + + + setAutoRefreshOn(v)} + inputProps={{ "aria-label": "自動重新整理" }} + /> + } + label="開啟" + sx={{ ml: 0, mr: 0 }} + /> + + 間隔(秒) + + + + + + + {(filterBoardStatus || filterProcessId != null) && ( + + + 篩選中: + + {filterBoardStatus ? ( + setFilterBoardStatus(null)} + size="small" + /> + ) : null} + {filterProcessId != null ? ( + setFilterProcessId(null)} size="small" /> + ) : null} + + + )} + + {!loading && ( + + + + + + + + + 工序列總數 + + + {rows.length} + + + + + + + + + + + + 篩選後 + + + {displayRows.length} + + + + + + )} + + {rows.length > 0 && ( + <> + } sx={{ mb: 2 }}> + + 圓環圖與長條圖是查詢篩選條件(不是另一份獨立統計):點某一扇區或長條後,下方表格只顯示符合該條件的工序列,與上方「篩選後」筆數一致。再點同一項可取消篩選;亦可用「清除篩選」。 + + + + + + 依工序狀態(點扇區 → 篩選下方列表) + + + 與「篩選後」聯動:選「進行中」僅列出進行中的工序列。 + + {statusDonut.series.some((n) => n > 0) ? ( + + ) : ( + + 無資料 + + )} + + + + 依工序名稱 · 筆數(點長條 → 篩選下方列表) + + + 每色代表不同工序;點長條後表格僅保留該工序。 + + {processBar.data.length > 0 ? ( + + ) : ( + + 無資料 + + )} + + + + )} + + {loading && rows.length === 0 ? ( + + + + ) : rows.length === 0 ? ( + + 此條件下沒有工序資料。 + + ) : ( + + {grouped.map((g) => { + const sectionStripe = groupSectionStripe(theme, groupMode, g.key); + return ( + + + + + {g.rows.length} + + + {g.title} + + · {g.rows.length} 筆 + + + + + + 表格較寬時可左右捲動;上方同步捲軸與表格對齊,無需捲到頁面底部。點工單號於新分頁開啟編輯;序顯示於單號下方。 + + + + + + 工序狀態 + + 工單 + + 點選開啟 · 序在下 + + + + 生產流程摘要 + + + 本工序時間 + + 預計/實際 + + + + 名稱/描述 + + + 設備 + + 類型-名稱-編號 + + + 員工 + + 開工/完工 + + 本工序 + + + + + + {g.rows.map((r) => { + const rowVis = boardStatusAccent(theme, r.boardStatus); + const procColor = processAccentColor(r.processId, processBarColorById); + return ( + + + + + + + + + + + + 序 + + + {r.seqNo} + + + + + + + + + + + + + + {nameDescriptionCell(r.lineStepName, r.lineDescription, 128)} + + {processFlowTextCell(r.lineEquipmentLabel, 280)} + {processFlowTextCell(r.lineOperatorInfo, 260)} + + + + + 開工 + + {timeCell(r.startTime)} + + + + 完工 + + {timeCell(r.endTime)} + + + + + ); + })} + +
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/app/(main)/chart/purchase/page.tsx b/src/app/(main)/chart/purchase/page.tsx index 753a03b..075ae65 100644 --- a/src/app/(main)/chart/purchase/page.tsx +++ b/src/app/(main)/chart/purchase/page.tsx @@ -21,7 +21,6 @@ import { TableHead, TableRow, } from "@mui/material"; -import dynamic from "next/dynamic"; import ShoppingCart from "@mui/icons-material/ShoppingCart"; import TableChart from "@mui/icons-material/TableChart"; import { @@ -43,8 +42,7 @@ import { import ChartCard from "../_components/ChartCard"; import { exportPurchaseChartMasterToFile } from "./exportPurchaseChartMaster"; import dayjs from "dayjs"; - -const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); +import SafeApexCharts from "@/components/charts/SafeApexCharts"; const PAGE_TITLE = "採購"; const DEFAULT_DRILL_STATUS = "completed"; @@ -709,7 +707,7 @@ export default function PurchaseChartPage() { {estimatedLoading ? ( ) : ( - ) : ( - ) : ( - 無供應商資料(請先確認上方貨品篩選或日期) ) : ( - ) : ( -
) : ( - `${i.itemCode} ${i.itemName}`.trim()) }, diff --git a/src/app/(main)/chart/useChartBoardRefreshPrefs.ts b/src/app/(main)/chart/useChartBoardRefreshPrefs.ts new file mode 100644 index 0000000..4893d12 --- /dev/null +++ b/src/app/(main)/chart/useChartBoardRefreshPrefs.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useSession } from "next-auth/react"; +import { useEffect, useState } from "react"; +import type { SessionWithTokens } from "@/config/authConfig"; +import { + type ChartBoardId, + CHART_BOARD_DEFAULT_REFRESH_INTERVAL_SEC, + loadChartBoardRefreshPrefs, + saveChartBoardRefreshPrefs, +} from "./chartBoardRefreshPrefs"; + +/** Session id may be string or number from JWT / callbacks — never assume .trim exists. */ +function normalizeKeyPart(v: unknown): string | undefined { + if (v == null) return undefined; + const s = typeof v === "string" ? v : String(v); + const t = s.trim(); + return t.length > 0 ? t : undefined; +} + +function resolveUserKey(session: SessionWithTokens | null): string | undefined { + const id = normalizeKeyPart(session?.id); + if (id) return `id:${id}`; + const email = normalizeKeyPart(session?.user?.email); + if (email) return `email:${email.toLowerCase()}`; + return undefined; +} + +export function useChartBoardRefreshPrefs(boardId: ChartBoardId) { + const { data: session } = useSession() as { data: SessionWithTokens | null }; + const userKeyPart = resolveUserKey(session); + + const [autoRefreshOn, setAutoRefreshOn] = useState(false); + const [refreshIntervalSec, setRefreshIntervalSec] = useState(CHART_BOARD_DEFAULT_REFRESH_INTERVAL_SEC); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + const prefs = loadChartBoardRefreshPrefs(boardId, userKeyPart); + setAutoRefreshOn(prefs.autoRefreshOn); + setRefreshIntervalSec(prefs.refreshIntervalSec); + setHydrated(true); + }, [boardId, userKeyPart]); + + useEffect(() => { + if (!hydrated) return; + saveChartBoardRefreshPrefs(boardId, userKeyPart, { + autoRefreshOn, + refreshIntervalSec, + }); + }, [hydrated, boardId, userKeyPart, autoRefreshOn, refreshIntervalSec]); + + return { + autoRefreshOn, + setAutoRefreshOn, + refreshIntervalSec, + setRefreshIntervalSec, + /** Logged-in user key for storage; undefined if not available */ + userKeyPart, + hydrated, + }; +} diff --git a/src/app/(main)/chart/warehouse/page.tsx b/src/app/(main)/chart/warehouse/page.tsx index 8e893a5..0609384 100644 --- a/src/app/(main)/chart/warehouse/page.tsx +++ b/src/app/(main)/chart/warehouse/page.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useState } from "react"; import { Box, Typography, Skeleton, Alert, TextField, Button, Chip, Stack } from "@mui/material"; -import dynamic from "next/dynamic"; import dayjs from "dayjs"; import WarehouseIcon from "@mui/icons-material/Warehouse"; import { @@ -14,8 +13,7 @@ import { import ChartCard from "../_components/ChartCard"; import DateRangeSelect from "../_components/DateRangeSelect"; import { toDateRange, DEFAULT_RANGE_DAYS, ITEM_CODE_DEBOUNCE_MS } from "../_components/constants"; - -const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); +import SafeApexCharts from "@/components/charts/SafeApexCharts"; const PAGE_TITLE = "庫存與倉儲"; @@ -184,7 +182,7 @@ export default function WarehouseChartPage() { {loadingCharts.stockTxn ? ( ) : ( - s.date) }, @@ -218,7 +216,7 @@ export default function WarehouseChartPage() { {loadingCharts.stockInOut ? ( ) : ( - s.date) }, @@ -261,7 +259,7 @@ export default function WarehouseChartPage() { {loadingCharts.balance ? ( ) : ( - b.date) }, @@ -327,7 +325,7 @@ export default function WarehouseChartPage() { {loadingCharts.consumption ? ( ) : chartData.consumptionByItems ? ( - ) : ( - c.month) }, diff --git a/src/app/api/chart/client.ts b/src/app/api/chart/client.ts index ca0c245..39287bd 100644 --- a/src/app/api/chart/client.ts +++ b/src/app/api/chart/client.ts @@ -284,6 +284,238 @@ export async function fetchJobEquipmentWorkingWorkedByDate( })); } +export interface JobOrderBoardRow { + jobOrderId: number; + code: string; + status: string; + planStart: string; + actualStart: string; + planEnd: string; + actualEnd: string; + materialPendingCount: number; + materialPickedCount: number; + processTotalCount: number; + processCompletedCount: number; + currentProcessCode: string; + currentProcessName: string; + currentProcessStartTime: string; + /** FG/WIP job stock-in: sum acceptedQty on all linked lines */ + stockInAcceptedQtyTotal: number; + /** Lines QC-passed, waiting putaway (receiving / received) */ + fgReadyToStockInCount: number; + fgReadyToStockInQty: number; + fgInQcLineCount: number; + fgInQcQty: number; + fgStockedQty: number; + /** Same sources as /jo/edit 工藝流程 summary (product process + lines) */ + itemCode: string; + itemName: string; + jobTypeName: string; + reqQty: number; + outputQtyUom: string; + productionDate: string; + /** Sum of line processingTime (matches ProcessSummaryHeader 預計所需時間) */ + planProcessingMinsTotal: number; + /** Sum of setup + changeover minutes on all lines */ + planSetupChangeoverMinsTotal: number; + productProcessStart: string; + /** Σ line durations in decimal minutes (seconds÷60); sub-minute shown; Pass w/o endTime uses planned processing min */ + actualLineMinsTotal: number; +} + +function numField(v: unknown): number { + if (v == null || v === "") return 0; + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} + +function mapJobOrderBoardRow(r: Record): JobOrderBoardRow { + const id = r.jobOrderId ?? r.joborderid; + return { + jobOrderId: Number(id ?? 0), + code: String(r.code ?? ""), + status: String(r.status ?? ""), + planStart: String(r.planStart ?? r.planstart ?? ""), + actualStart: String(r.actualStart ?? r.actualstart ?? ""), + planEnd: String(r.planEnd ?? r.planend ?? ""), + actualEnd: String(r.actualEnd ?? r.actualend ?? ""), + materialPendingCount: Number(r.materialPendingCount ?? r.materialpendingcount ?? 0), + materialPickedCount: Number(r.materialPickedCount ?? r.materialpickedcount ?? 0), + processTotalCount: Number(r.processTotalCount ?? r.processtotalcount ?? 0), + processCompletedCount: Number(r.processCompletedCount ?? r.processcompletedcount ?? 0), + currentProcessCode: String(r.currentProcessCode ?? r.currentprocesscode ?? ""), + currentProcessName: String(r.currentProcessName ?? r.currentprocessname ?? ""), + currentProcessStartTime: String(r.currentProcessStartTime ?? r.currentprocessstarttime ?? ""), + stockInAcceptedQtyTotal: Number(r.stockInAcceptedQtyTotal ?? r.stockinacceptedqtytotal ?? 0), + fgReadyToStockInCount: Number(r.fgReadyToStockInCount ?? r.fgreadytostockincount ?? 0), + fgReadyToStockInQty: Number(r.fgReadyToStockInQty ?? r.fgreadytostockinqty ?? 0), + fgInQcLineCount: Number(r.fgInQcLineCount ?? r.fginqclinecount ?? 0), + fgInQcQty: Number(r.fgInQcQty ?? r.fginqcqty ?? 0), + fgStockedQty: Number(r.fgStockedQty ?? r.fgstockedqty ?? 0), + itemCode: String(r.itemCode ?? r.itemcode ?? ""), + itemName: String(r.itemName ?? r.itemname ?? ""), + jobTypeName: String(r.jobTypeName ?? r.jobtypename ?? ""), + reqQty: numField(r.reqQty ?? r.reqqty), + outputQtyUom: String(r.outputQtyUom ?? r.outputqtyuom ?? ""), + productionDate: String(r.productionDate ?? r.productiondate ?? ""), + planProcessingMinsTotal: numField(r.planProcessingMinsTotal ?? r.planprocessingminstotal), + planSetupChangeoverMinsTotal: numField(r.planSetupChangeoverMinsTotal ?? r.plansetupchangeoverminstotal), + productProcessStart: String(r.productProcessStart ?? r.productprocessstart ?? ""), + actualLineMinsTotal: numField(r.actualLineMinsTotal ?? r.actuallineminstotal), + }; +} + +/** Per-job board rows. With [incompleteOnly], excludes status completed (backend LOWER(status) <> 'completed'). */ +export async function fetchJobOrderBoard( + targetDate?: string, + opts?: { incompleteOnly?: boolean }, +): Promise { + const params: Record = {}; + if (targetDate) params.targetDate = targetDate; + if (opts?.incompleteOnly) params.incompleteOnly = "true"; + const q = buildParams(params); + const res = await clientAuthFetch(q ? `${BASE}/job-order-board?${q}` : `${BASE}/job-order-board`); + if (!res.ok) throw new Error("Failed to fetch job order board"); + const data = await res.json(); + return ((Array.isArray(data) ? data : []) as Record[]).map(mapJobOrderBoardRow); +} + +export interface ProcessBoardRow { + jopId: number; + jobOrderId: number; + jobOrderCode: string; + jobOrderStatus: string; + processId: number; + processCode: string; + processName: string; + seqNo: number; + rowStatus: string; + jobPlanStart: string; + startTime: string; + endTime: string; + /** Derived: pending | in_progress | completed */ + boardStatus: string; + /** 工藝流程步驟名稱(productprocessline.name;多筆以 | 分隔);無明細時為主檔工序名。 */ + lineStepName: string; + /** 描述 */ + lineDescription: string; + /** 設備類型-設備名稱-編號(與工單工藝流程一致) */ + lineEquipmentLabel: string; + /** 操作員/員工顯示名 */ + lineOperatorInfo: string; + itemCode: string; + itemName: string; + jobTypeName: string; + reqQty: number; + outputQtyUom: string; + productionDate: string; + planProcessingMinsTotal: number; + planSetupChangeoverMinsTotal: number; + productProcessStart: string; + actualLineMinsTotal: number; + /** This BOM step: sum(processing+setup+changeover) on matching lines */ + stepPlanMins: number; + /** This BOM step: Σ line durations in decimal minutes (seconds÷60); Pass/Completed without endTime uses planned processing min as fallback */ + stepActualMins: number; +} + +function mapProcessBoardRow(r: Record): ProcessBoardRow { + return { + jopId: Number(r.jopId ?? r.jopid ?? 0), + jobOrderId: Number(r.jobOrderId ?? r.joborderid ?? 0), + jobOrderCode: String(r.jobOrderCode ?? r.jobordercode ?? ""), + jobOrderStatus: String(r.jobOrderStatus ?? r.joborderstatus ?? ""), + processId: Number(r.processId ?? r.processid ?? 0), + processCode: String(r.processCode ?? r.processcode ?? ""), + processName: String(r.processName ?? r.processname ?? ""), + seqNo: Number(r.seqNo ?? r.seqno ?? 0), + rowStatus: String(r.rowStatus ?? r.rowstatus ?? ""), + jobPlanStart: String(r.jobPlanStart ?? r.jobplanstart ?? ""), + startTime: String(r.startTime ?? r.starttime ?? ""), + endTime: String(r.endTime ?? r.endtime ?? ""), + boardStatus: String(r.boardStatus ?? r.boardstatus ?? "pending").toLowerCase(), + lineStepName: String(r.lineStepName ?? r.linestepname ?? r.line_step_name ?? ""), + lineDescription: String(r.lineDescription ?? r.linedescription ?? r.line_description ?? ""), + lineEquipmentLabel: String(r.lineEquipmentLabel ?? r.lineequipmentlabel ?? r.line_equipment_label ?? ""), + lineOperatorInfo: String(r.lineOperatorInfo ?? r.lineoperatorinfo ?? r.line_operator_info ?? ""), + itemCode: String(r.itemCode ?? r.itemcode ?? ""), + itemName: String(r.itemName ?? r.itemname ?? ""), + jobTypeName: String(r.jobTypeName ?? r.jobtypename ?? ""), + reqQty: numField(r.reqQty ?? r.reqqty), + outputQtyUom: String(r.outputQtyUom ?? r.outputqtyuom ?? ""), + productionDate: String(r.productionDate ?? r.productiondate ?? ""), + planProcessingMinsTotal: numField(r.planProcessingMinsTotal ?? r.planprocessingminstotal), + planSetupChangeoverMinsTotal: numField(r.planSetupChangeoverMinsTotal ?? r.plansetupchangeoverminstotal), + productProcessStart: String(r.productProcessStart ?? r.productprocessstart ?? ""), + actualLineMinsTotal: numField(r.actualLineMinsTotal ?? r.actuallineminstotal), + stepPlanMins: numField(r.stepPlanMins ?? r.stepplanmins), + stepActualMins: numField(r.stepActualMins ?? r.stepactualmins), + }; +} + +/** Per job_order_process line; same filters as job-order board. */ +export async function fetchProcessBoard( + targetDate?: string, + opts?: { incompleteOnly?: boolean }, +): Promise { + const params: Record = {}; + if (targetDate) params.targetDate = targetDate; + if (opts?.incompleteOnly) params.incompleteOnly = "true"; + const q = buildParams(params); + const res = await clientAuthFetch(q ? `${BASE}/process-board?${q}` : `${BASE}/process-board`); + if (!res.ok) throw new Error("Failed to fetch process board"); + const data = await res.json(); + return ((Array.isArray(data) ? data : []) as Record[]).map(mapProcessBoardRow); +} + +export interface EquipmentUsageBoardRow { + jopdId: number; + equipmentId: number; + equipmentCode: string; + equipmentName: string; + jobOrderId: number; + jobOrderCode: string; + jobPlanStart: string; + processCode: string; + processName: string; + operatingStart: string; + operatingEnd: string; + /** Estimated usage minutes (start–end diff, or 產線 processingTime when Pass/Completed without end). */ + usageMinutes: number; + workingNow: number; + operatorUsername: string; + operatorName: string; +} + +function mapEquipmentUsageBoardRow(r: Record): EquipmentUsageBoardRow { + return { + jopdId: Number(r.jopdId ?? r.jopdid ?? 0), + equipmentId: Number(r.equipmentId ?? r.equipmentid ?? 0), + equipmentCode: String(r.equipmentCode ?? r.equipmentcode ?? ""), + equipmentName: String(r.equipmentName ?? r.equipmentname ?? ""), + jobOrderId: Number(r.jobOrderId ?? r.joborderid ?? 0), + jobOrderCode: String(r.jobOrderCode ?? r.jobordercode ?? ""), + jobPlanStart: String(r.jobPlanStart ?? r.jobplanstart ?? ""), + processCode: String(r.processCode ?? r.processcode ?? ""), + processName: String(r.processName ?? r.processname ?? ""), + operatingStart: String(r.operatingStart ?? r.operatingstart ?? ""), + operatingEnd: String(r.operatingEnd ?? r.operatingend ?? ""), + usageMinutes: Number(r.usageMinutes ?? r.usageminutes ?? 0), + workingNow: Number(r.workingNow ?? r.workingnow ?? 0), + operatorUsername: String(r.operatorUsername ?? r.operatorusername ?? ""), + operatorName: String(r.operatorName ?? r.operatorname ?? ""), + }; +} + +/** Day = COALESCE(line/jopd times, jop.endTime, planStart). Includes productprocessline (工藝流程) and job_order_process_detail. Omit targetDate = server today. */ +export async function fetchEquipmentUsageBoard(targetDate?: string): Promise { + const q = buildParams({ targetDate: targetDate ?? "" }); + const res = await clientAuthFetch(q ? `${BASE}/equipment-usage-board?${q}` : `${BASE}/equipment-usage-board`); + if (!res.ok) throw new Error("Failed to fetch equipment usage board"); + const data = await res.json(); + return ((Array.isArray(data) ? data : []) as Record[]).map(mapEquipmentUsageBoardRow); +} + export async function fetchProductionScheduleByDate( startDate?: string, endDate?: string diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 76b2600..23fbf64 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -14,6 +14,7 @@ const pathToLabelMap: { [path: string]: string } = { "/chart/purchase": "採購", "/chart/delivery": "發貨與配送", "/chart/joborder": "工單", + "/chart/joborder/board": "工單即時看板", "/chart/forecast": "預測與計劃", "/projects": "Projects", "/projects/create": "Create Project", diff --git a/src/components/DashboardPage/chart/ApplicationCompletionChart.tsx b/src/components/DashboardPage/chart/ApplicationCompletionChart.tsx index f00e3b6..767d789 100644 --- a/src/components/DashboardPage/chart/ApplicationCompletionChart.tsx +++ b/src/components/DashboardPage/chart/ApplicationCompletionChart.tsx @@ -1,7 +1,6 @@ "use client"; import React, { useState } from "react"; -import dynamic from "next/dynamic"; -const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); +import SafeApexCharts from "@/components/charts/SafeApexCharts"; import { useTranslation } from "react-i18next"; const ApplicationCompletionChart: React.FC = () => { const { t } = useTranslation(); @@ -110,7 +109,7 @@ const ApplicationCompletionChart: React.FC = () => { margin: "0 auto", }} > - import("react-apexcharts"), { ssr: false }); +import SafeApexCharts from "@/components/charts/SafeApexCharts"; import { useTranslation } from "react-i18next"; interface Props { // params type @@ -70,7 +69,7 @@ const DashboardLineChart: React.FC = () => { - import("react-apexcharts"), { ssr: false }); - const DashboardProgressChart: React.FC = () => { const { t } = useTranslation("dashboard"); const [series, setSeries] = useState([]); @@ -45,7 +43,7 @@ const DashboardProgressChart: React.FC = () => { }, labels: [t("pending"), t("receiving")], dataLabels: { - formatter: (val: number) => `${val.toFixed(1)}%`, + formatter: (val: number) => `${(Number(val) || 0).toFixed(1)}%`, dropShadow: { enabled: false, }, @@ -120,7 +118,7 @@ const DashboardProgressChart: React.FC = () => { }} > {series.length > 0 ? ( - import("react-apexcharts"), { ssr: false }); +import SafeApexCharts from "@/components/charts/SafeApexCharts"; import { useTranslation } from "react-i18next"; const OrderCompletionChart: React.FC = () => { const { t } = useTranslation(); @@ -122,7 +121,7 @@ const OrderCompletionChart: React.FC = () => { margin: "0 auto", }} > - import("react-apexcharts"), { ssr: false }); +import SafeApexCharts from "@/components/charts/SafeApexCharts"; import { useTranslation } from "react-i18next"; const PendingInspectionChart: React.FC = () => { @@ -58,7 +57,7 @@ const PendingInspectionChart: React.FC = () => { margin: "0 auto", }} > - import("react-apexcharts"), { ssr: false }); +import SafeApexCharts from "@/components/charts/SafeApexCharts"; const PendingStorageChart: React.FC = () => { const { t } = useTranslation(); @@ -58,7 +57,7 @@ const PendingStorageChart: React.FC = () => { margin: "0 auto", }} > - { - + {t("Now")}: {now.format('HH:mm')} diff --git a/src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx b/src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx index dd0dd5b..ee7fba7 100644 --- a/src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx +++ b/src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx @@ -491,7 +491,7 @@ const TruckScheduleDashboard: React.FC = () => { - + {t("Now")}: {now.format('HH:mm')} diff --git a/src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx b/src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx index ffd6110..28fa484 100644 --- a/src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx +++ b/src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx @@ -283,7 +283,7 @@ const FGPickOrderTicketReleaseTable: React.FC = () => { - + {t("Now")}: {now.format("HH:mm")} diff --git a/src/components/Jodetail/MaterialPickStatusTable.tsx b/src/components/Jodetail/MaterialPickStatusTable.tsx index 1db9537..ed25b9b 100644 --- a/src/components/Jodetail/MaterialPickStatusTable.tsx +++ b/src/components/Jodetail/MaterialPickStatusTable.tsx @@ -257,7 +257,7 @@ const MaterialPickStatusTable: React.FC = () => { - + {t("Now")}: {now.format('HH:mm')} diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 06852c7..15d7604 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -223,6 +223,12 @@ const NavigationContent: React.FC = () => { path: "/chart/joborder", requiredAbility: [AUTH.TESTING, AUTH.ADMIN], }, + { + icon: , + label: "工單即時看板", + path: "/chart/joborder/board", + requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + }, { icon: , label: "發貨與配送", diff --git a/src/components/ProductionProcess/EquipmentStatusDashboard.tsx b/src/components/ProductionProcess/EquipmentStatusDashboard.tsx index d6127db..58c48d8 100644 --- a/src/components/ProductionProcess/EquipmentStatusDashboard.tsx +++ b/src/components/ProductionProcess/EquipmentStatusDashboard.tsx @@ -207,7 +207,7 @@ const EquipmentStatusDashboard: React.FC = () => { - + {t("Now")}: {now.format('HH:mm')} diff --git a/src/components/ProductionProcess/JobProcessStatus.tsx b/src/components/ProductionProcess/JobProcessStatus.tsx index f93a9f0..b411d03 100644 --- a/src/components/ProductionProcess/JobProcessStatus.tsx +++ b/src/components/ProductionProcess/JobProcessStatus.tsx @@ -199,7 +199,7 @@ const JobProcessStatus: React.FC = () => { - + {t("Now")}: {currentTime.format('HH:mm')} diff --git a/src/components/ProductionProcess/OperatorKpiDashboard.tsx b/src/components/ProductionProcess/OperatorKpiDashboard.tsx index 02f59c8..204bbef 100644 --- a/src/components/ProductionProcess/OperatorKpiDashboard.tsx +++ b/src/components/ProductionProcess/OperatorKpiDashboard.tsx @@ -181,7 +181,7 @@ const OperatorKpiDashboard: React.FC = () => { - + {t("Now")}: {now.format('HH:mm')} diff --git a/src/components/charts/SafeApexCharts.tsx b/src/components/charts/SafeApexCharts.tsx new file mode 100644 index 0000000..cacf369 --- /dev/null +++ b/src/components/charts/SafeApexCharts.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useEffect, useRef, type CSSProperties } from "react"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import type { ApexOptions } from "apexcharts"; +import type { Props as ApexChartProps } from "react-apexcharts"; + +export type SafeApexChartsProps = ApexChartProps & { + /** Bumps internal remount when set — JSON.stringify(options) drops `chart.events`, so use this when handlers/data must stay in sync. */ + chartRevision?: string | number; +}; + +/** + * Donut/pie series is number[] (possibly string numbers from JSON). + * Do not use `typeof series[0] === "number"` only — first slice can be 0 or a string. + */ +function tryCoerceOneDimensionalNumericSeries(raw: unknown[]): number[] | null { + if (raw.length === 0) return null; + const out: number[] = []; + for (const x of raw) { + if (typeof x === "number" && Number.isFinite(x)) { + out.push(x); + continue; + } + if (typeof x === "string" && x.trim() !== "") { + const n = Number(x); + if (Number.isFinite(n)) { + out.push(n); + continue; + } + } + return null; + } + return out; +} + +function sanitizeSeries(series: ApexChartProps["series"]): ApexChartProps["series"] { + if (series == null) return []; + if (Array.isArray(series)) { + const asNums = tryCoerceOneDimensionalNumericSeries(series as unknown[]); + if (asNums != null) { + return asNums.map((v) => (Number.isFinite(v) ? v : 0)); + } + return (series as { name?: string; data?: unknown[] }[]).map((s) => ({ + name: s?.name ?? "", + data: Array.isArray(s?.data) + ? s.data.map((v) => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + }) + : [], + })); + } + return []; +} + +function isAxisChart(type: ApexChartProps["type"]): boolean { + return type === "line" || type === "area" || type === "bar"; +} + +function isRadialChart(type: ApexChartProps["type"]): boolean { + return type === "donut" || type === "pie" || type === "radialBar"; +} + +function isNumberArray(sanitized: ApexChartProps["series"]): sanitized is number[] { + return Array.isArray(sanitized) && sanitized.length > 0 && sanitized.every((v) => typeof v === "number"); +} + +/** Stacked/category charts: every series row must have data.length === categories.length or Apex can throw. */ +function alignSeriesToCategoryCount( + series: ApexChartProps["series"], + categoryCount: number, +): ApexChartProps["series"] { + if (!Array.isArray(series) || categoryCount <= 0 || isNumberArray(series)) return series; + return (series as { name?: string; data?: unknown[] }[]).map((row) => { + const raw = Array.isArray(row.data) ? row.data : []; + const data = raw.map((v) => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + }); + const next = data.slice(0, categoryCount); + while (next.length < categoryCount) next.push(0); + return { ...row, data: next }; + }); +} + +/** Sum numeric series (donut) or all y values in multi-series bar/line. */ +function seriesNumericTotal(sanitized: ApexChartProps["series"]): number { + if (!Array.isArray(sanitized) || sanitized.length === 0) return 0; + if (isNumberArray(sanitized)) { + return sanitized.reduce((a, v) => a + (Number.isFinite(v) ? v : 0), 0); + } + return (sanitized as { data?: number[] }[]).reduce((sum, row) => { + const d = row.data ?? []; + return sum + d.reduce((a, v) => a + (Number.isFinite(v) ? v : 0), 0); + }, 0); +} + +/** ApexCharts throws (e.g. .toString of undefined) on empty categories, empty multi-series, donut sum 0, etc. */ +function shouldShowPlaceholder( + type: ApexChartProps["type"], + options: ApexChartProps["options"], + sanitized: ApexChartProps["series"], +): boolean { + if (!Array.isArray(sanitized) || sanitized.length === 0) return true; + + if (isRadialChart(type)) { + if (!isNumberArray(sanitized)) return true; + const sum = sanitized.reduce((a, v) => a + (Number.isFinite(v) && v > 0 ? v : 0), 0); + if (sum <= 0) return true; + return false; + } + + if (!isAxisChart(type)) return false; + + const cats = options?.xaxis?.categories; + if (!Array.isArray(cats) || cats.length === 0) return true; + if (isNumberArray(sanitized)) return false; + const rows = sanitized as { data?: number[] }[]; + const anyPoint = rows.some((r) => (r.data?.length ?? 0) > 0); + if (!anyPoint) return true; + if (type === "bar" && seriesNumericTotal(sanitized) <= 0) return true; + return false; +} + +function buildApexConfig( + base: ApexOptions | undefined, + chartType: string, + h: ApexChartProps["height"], + w: ApexChartProps["width"], + s: ApexChartProps["series"], +): ApexOptions { + const o = base ?? {}; + const prev = o.chart && typeof o.chart === "object" ? o.chart : {}; + return { + ...o, + chart: { + ...prev, + type: chartType, + height: h ?? (prev as { height?: ApexChartProps["height"] }).height ?? "auto", + width: w ?? (prev as { width?: string | number }).width ?? "100%", + } as ApexOptions["chart"], + series: s as ApexOptions["series"], + }; +} + +const EMPTY_MESSAGE = "暫無圖表資料(後端無法連線或此區間無資料)。"; + +export default function SafeApexCharts(props: SafeApexChartsProps) { + const { type, series, options, height, width, chartRevision, ...rest } = props; + const containerRef = useRef(null); + const chartRef = useRef<{ destroy: () => void } | null>(null); + + let sanitized = sanitizeSeries(series); + + const cats = options?.xaxis?.categories; + if (isAxisChart(type) && Array.isArray(cats) && cats.length > 0) { + sanitized = alignSeriesToCategoryCount(sanitized, cats.length); + } + + if (shouldShowPlaceholder(type, options, sanitized)) { + return ( + + {EMPTY_MESSAGE} + + ); + } + + let chartOptions = options; + let renderSeries: ApexChartProps["series"] = sanitized; + + if (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; + const raw = prev[i]; + const label = raw != null && String(raw).trim() !== "" ? String(raw) : `項目 ${i + 1}`; + return { v: Math.max(0, n), label }; + }); + const nonzero = pairs.filter((p) => p.v > 0); + if (nonzero.length === 0) { + return ( + + {EMPTY_MESSAGE} + + ); + } + 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 ?? "")}`; + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + let disposed = false; + + void import("apexcharts").then((mod) => { + if (disposed || containerRef.current !== el) return; + + const ApexChartsCtor = mod.default; + const config = buildApexConfig(chartOptions as ApexOptions, chartType, height, width, renderSeries); + + let instance: { destroy: () => void; render: () => Promise }; + try { + instance = new ApexChartsCtor(el, config); + } catch { + return; + } + + chartRef.current = instance; + + if (disposed || containerRef.current !== el) { + if (chartRef.current === instance) { + try { + instance.destroy(); + } catch { + /* ignore */ + } + chartRef.current = null; + } + return; + } + + void instance.render().catch(() => { + /* ignore */ + }); + }); + + return () => { + disposed = true; + const c = chartRef.current; + chartRef.current = null; + if (c) { + try { + c.destroy(); + } catch { + /* ignore */ + } + } + }; + }, [configSnapshot]); + + const minH = typeof height === "number" ? height : typeof height === "string" ? height : 240; + const dom = rest as { className?: string; id?: string; style?: CSSProperties; sx?: object }; + + return ( + + ); +} diff --git a/src/config/authConfig.ts b/src/config/authConfig.ts index f08a163..cb8b2e3 100644 --- a/src/config/authConfig.ts +++ b/src/config/authConfig.ts @@ -105,6 +105,7 @@ export type SessionWithTokens = Session & { accessToken: string | null; refreshToken?: string; abilities: string[]; - id?: string; + /** Backend / JWT subject — often numeric string or number */ + id?: string | number; }; export default authOptions; \ No newline at end of file