| @@ -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<number>(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<ChartBoardRefreshPrefs>; | |||
| 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 */ | |||
| } | |||
| } | |||
| @@ -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 ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.delivery.map((d) => d.date) }, | |||
| @@ -247,7 +245,7 @@ export default function DeliveryChartPage() { | |||
| {loadingCharts.topItems ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { | |||
| @@ -359,7 +357,7 @@ export default function DeliveryChartPage() { | |||
| <Typography variant="subtitle2" color="text.secondary" gutterBottom> | |||
| 每日按員工單數 | |||
| </Typography> | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { type: "bar", stacked: true }, | |||
| xaxis: { | |||
| @@ -0,0 +1,535 @@ | |||
| "use client"; | |||
| import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |||
| import { | |||
| Box, | |||
| Typography, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| Paper, | |||
| TextField, | |||
| Alert, | |||
| CircularProgress, | |||
| Stack, | |||
| Button, | |||
| Chip, | |||
| Tooltip, | |||
| FormControl, | |||
| FormControlLabel, | |||
| InputLabel, | |||
| MenuItem, | |||
| Select, | |||
| Switch, | |||
| } from "@mui/material"; | |||
| import { alpha, useTheme } from "@mui/material/styles"; | |||
| import Link from "next/link"; | |||
| import dayjs from "dayjs"; | |||
| import Microwave from "@mui/icons-material/Microwave"; | |||
| import AccountTree from "@mui/icons-material/AccountTree"; | |||
| import FilterAltOutlined from "@mui/icons-material/FilterAltOutlined"; | |||
| import { fetchEquipmentUsageBoard, type EquipmentUsageBoardRow } 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 EQUIPMENT_CHART_MAX = 35; | |||
| /** Stable key for grouping / filter (master equipment id or free-text label). */ | |||
| function rowEquipmentKey(r: EquipmentUsageBoardRow): string { | |||
| if (r.equipmentId > 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<EquipmentUsageBoardRow[]>([]); | |||
| const [loading, setLoading] = useState(true); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [lastUpdated, setLastUpdated] = useState(""); | |||
| /** null = all equipment; otherwise rowEquipmentKey */ | |||
| const [selectedEquipmentKey, setSelectedEquipmentKey] = useState<string | null>(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<string, { key: string; label: string; minutes: number }>(); | |||
| 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 ( | |||
| <Box | |||
| sx={{ | |||
| width: "100%", | |||
| maxWidth: "100%", | |||
| mx: "auto", | |||
| p: { xs: 0.5, sm: 1 }, | |||
| boxSizing: "border-box", | |||
| }} | |||
| > | |||
| <Typography variant="h5" sx={{ mb: 1, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | |||
| <Microwave /> 設備使用看板 | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | |||
| 資料來源與<strong>工單編輯/工藝流程</strong>一致。上方<strong>使用時間(分鐘)</strong>為各設備當日明細加總(有起訖則相減;產線 Pass/無完工時間時用預設生產分鐘)。 | |||
| 點<strong>長條圖</strong>可篩選下方列表,再點同一項取消。 | |||
| <strong> 快捷鍵</strong>(不在輸入框內時):<kbd style={{ padding: "1px 6px", borderRadius: 4, border: "1px solid #ccc" }}>T</kbd>{" "} | |||
| 今日、<kbd style={{ padding: "1px 6px", borderRadius: 4, border: "1px solid #ccc" }}>Y</kbd> 昨日。 | |||
| {autoRefreshOn | |||
| ? ` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)` | |||
| : " 預設不自動更新,可開啟「自動重新整理」並選擇間隔。"} | |||
| {" 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。"} | |||
| {lastUpdated ? ` · 最後更新 ${lastUpdated}` : ""} | |||
| </Typography> | |||
| {error && ( | |||
| <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | |||
| {error} | |||
| </Alert> | |||
| )} | |||
| <Stack | |||
| direction={{ xs: "column", lg: "row" }} | |||
| spacing={2} | |||
| alignItems={{ xs: "stretch", lg: "stretch" }} | |||
| justifyContent={{ lg: "space-between" }} | |||
| sx={{ mb: 2 }} | |||
| > | |||
| <Paper | |||
| variant="outlined" | |||
| sx={{ | |||
| p: 1.5, | |||
| flex: { lg: "1 1 0" }, | |||
| minWidth: { xs: "100%", lg: 280 }, | |||
| borderColor: "divider", | |||
| bgcolor: alpha(theme.palette.primary.main, 0.03), | |||
| }} | |||
| > | |||
| <Typography | |||
| variant="overline" | |||
| color="text.secondary" | |||
| sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | |||
| > | |||
| 查詢與列表 | |||
| </Typography> | |||
| <Stack spacing={1.25}> | |||
| <Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}> | |||
| 歸屬日 {viewDate}(週{weekdayZh}) | |||
| {!isToday && <Chip size="small" label="非今日" sx={{ ml: 1 }} variant="outlined" />} | |||
| </Typography> | |||
| <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}> | |||
| <Tooltip title="快捷鍵 T"> | |||
| <Button | |||
| size="small" | |||
| variant={isToday ? "contained" : "outlined"} | |||
| onClick={() => setViewDate(dayjs().format("YYYY-MM-DD"))} | |||
| > | |||
| 今日 | |||
| </Button> | |||
| </Tooltip> | |||
| <Tooltip title="快捷鍵 Y"> | |||
| <Button size="small" variant="outlined" onClick={() => setViewDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"))}> | |||
| 昨日 | |||
| </Button> | |||
| </Tooltip> | |||
| <TextField | |||
| size="small" | |||
| label="選擇日期" | |||
| type="date" | |||
| value={viewDate} | |||
| onChange={(e) => setViewDate(e.target.value)} | |||
| InputLabelProps={{ shrink: true }} | |||
| sx={{ minWidth: 178 }} | |||
| /> | |||
| <Button variant="outlined" size="small" onClick={() => void load()} disabled={loading}> | |||
| 重新整理 | |||
| </Button> | |||
| </Stack> | |||
| </Stack> | |||
| </Paper> | |||
| <Paper | |||
| variant="outlined" | |||
| sx={{ | |||
| p: 1.5, | |||
| flex: { lg: "0 0 auto" }, | |||
| width: { xs: "100%", lg: "auto" }, | |||
| minWidth: { lg: 200 }, | |||
| borderColor: "divider", | |||
| bgcolor: alpha(theme.palette.grey[500], 0.06), | |||
| }} | |||
| > | |||
| <Typography | |||
| variant="overline" | |||
| color="text.secondary" | |||
| sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | |||
| > | |||
| 其他看板 | |||
| </Typography> | |||
| <Stack direction="column" spacing={1} sx={{ maxWidth: 220 }}> | |||
| <Button component={Link} href="/chart/joborder/board" size="small" variant="outlined" fullWidth> | |||
| 工單即時看板 | |||
| </Button> | |||
| <Button component={Link} href="/chart/process/board" size="small" variant="outlined" fullWidth startIcon={<AccountTree />}> | |||
| 工序即時看板 | |||
| </Button> | |||
| <Button component={Link} href="/chart/joborder" size="small" variant="outlined" fullWidth> | |||
| 工單圖表 | |||
| </Button> | |||
| </Stack> | |||
| </Paper> | |||
| <Paper | |||
| variant="outlined" | |||
| sx={{ | |||
| p: 1.5, | |||
| flex: { lg: "0 0 auto" }, | |||
| width: { xs: "100%", lg: "auto" }, | |||
| minWidth: { xs: "100%", lg: 300 }, | |||
| borderColor: "divider", | |||
| bgcolor: alpha(theme.palette.info.main, 0.04), | |||
| }} | |||
| > | |||
| <Typography | |||
| variant="overline" | |||
| color="text.secondary" | |||
| sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | |||
| > | |||
| 自動重新整理 | |||
| </Typography> | |||
| <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}> | |||
| <FormControlLabel | |||
| control={ | |||
| <Switch | |||
| size="small" | |||
| checked={autoRefreshOn} | |||
| onChange={(_, v) => setAutoRefreshOn(v)} | |||
| inputProps={{ "aria-label": "自動重新整理" }} | |||
| /> | |||
| } | |||
| label="開啟" | |||
| sx={{ ml: 0, mr: 0 }} | |||
| /> | |||
| <FormControl size="small" sx={{ minWidth: 124 }} disabled={!autoRefreshOn}> | |||
| <InputLabel id="eq-board-refresh-interval-label">間隔(秒)</InputLabel> | |||
| <Select | |||
| labelId="eq-board-refresh-interval-label" | |||
| label="間隔(秒)" | |||
| value={refreshIntervalSec} | |||
| onChange={(e) => setRefreshIntervalSec(Number(e.target.value))} | |||
| > | |||
| {CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS.map((sec) => ( | |||
| <MenuItem key={sec} value={sec}> | |||
| {sec} 秒 | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| </Stack> | |||
| </Paper> | |||
| </Stack> | |||
| {selectedEquipmentKey && ( | |||
| <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 2 }}> | |||
| <FilterAltOutlined fontSize="small" color="action" /> | |||
| <Chip | |||
| label={`篩選設備:${selectedLabel}`} | |||
| onDelete={() => setSelectedEquipmentKey(null)} | |||
| color="primary" | |||
| variant="outlined" | |||
| /> | |||
| </Stack> | |||
| )} | |||
| {!loading && rows.length > 0 && equipmentUsageChart.data.length > 0 && ( | |||
| <Paper variant="outlined" sx={{ p: 2, mb: 3 }}> | |||
| <Typography variant="subtitle2" fontWeight={600} gutterBottom> | |||
| 使用時間(分鐘)— {viewDate} | |||
| </Typography> | |||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}> | |||
| 點擊長條篩選下方明細(最多顯示 {EQUIPMENT_CHART_MAX} 台,依分鐘數由高到低)。 | |||
| </Typography> | |||
| <SafeApexCharts | |||
| chartRevision={JSON.stringify(equipmentUsageChart.keys)} | |||
| options={barOptions} | |||
| series={[{ name: "分鐘", data: equipmentUsageChart.data }]} | |||
| type="bar" | |||
| height={Math.min(520, 120 + equipmentUsageChart.data.length * 28)} | |||
| /> | |||
| </Paper> | |||
| )} | |||
| {!loading && rows.length > 0 && equipmentUsageChart.data.length === 0 && ( | |||
| <Alert severity="info" sx={{ mb: 3 }}> | |||
| 當日有明細但無法加總使用分鐘(多數為缺開/完工時間且無預設生產分鐘)。仍可在下方表格檢視。 | |||
| </Alert> | |||
| )} | |||
| {!loading && ( | |||
| <Stack direction="row" spacing={2} useFlexGap flexWrap="wrap" sx={{ mb: 2 }}> | |||
| <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {selectedEquipmentKey ? "篩選後筆數" : "該日總筆數"} | |||
| </Typography> | |||
| <Typography variant="h6">{stats.sessions}</Typography> | |||
| </Paper> | |||
| <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| 使用分鐘合計(篩選範圍) | |||
| </Typography> | |||
| <Typography variant="h6">{formatUsageMinutes(stats.totalMins)}</Typography> | |||
| </Paper> | |||
| <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| 涉及設備數(篩選範圍) | |||
| </Typography> | |||
| <Typography variant="h6">{stats.equipmentTouched}</Typography> | |||
| </Paper> | |||
| {stats.working > 0 && ( | |||
| <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| 設備工時未結案 | |||
| </Typography> | |||
| <Typography variant="h6">{stats.working}</Typography> | |||
| </Paper> | |||
| )} | |||
| </Stack> | |||
| )} | |||
| {loading && rows.length === 0 ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", py: 6 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : rows.length === 0 ? ( | |||
| <Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}> | |||
| <Typography color="text.secondary"> | |||
| 此日期沒有符合歸屬日的設備使用紀錄(含工藝流程明細),或該日尚無已完工且已填設備的步驟。 | |||
| </Typography> | |||
| </Paper> | |||
| ) : displayRows.length === 0 ? ( | |||
| <Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}> | |||
| <Typography color="text.secondary" sx={{ mb: 2 }}> | |||
| 此篩選下沒有明細。 | |||
| </Typography> | |||
| <Button size="small" onClick={() => setSelectedEquipmentKey(null)}> | |||
| 清除設備篩選 | |||
| </Button> | |||
| </Paper> | |||
| ) : ( | |||
| <TableContainer component={Paper} variant="outlined"> | |||
| <Table size="small" stickyHeader> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>狀態</TableCell> | |||
| <TableCell>設備</TableCell> | |||
| <TableCell align="right">使用(分)</TableCell> | |||
| <TableCell>工單</TableCell> | |||
| <TableCell>工序</TableCell> | |||
| <TableCell>工單計劃開始</TableCell> | |||
| <TableCell>開工時間</TableCell> | |||
| <TableCell>完工時間</TableCell> | |||
| <TableCell>操作員</TableCell> | |||
| <TableCell align="center">開啟</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {displayRows.map((r) => ( | |||
| <TableRow key={`${r.jobOrderId}-${r.jopdId}-${r.operatingEnd}-${r.operatingStart}`} hover> | |||
| <TableCell> | |||
| {r.workingNow === 1 ? ( | |||
| <Chip label="設備工時未結案" size="small" color="warning" variant="outlined" /> | |||
| ) : !r.operatingStart?.trim() && !r.operatingEnd?.trim() ? ( | |||
| <Chip label="未填設備工時" size="small" color="default" variant="outlined" /> | |||
| ) : ( | |||
| <Chip label="已完工" size="small" variant="outlined" /> | |||
| )} | |||
| </TableCell> | |||
| <TableCell sx={{ fontWeight: 600 }}>{formatCodeNameLine(r.equipmentCode, r.equipmentName)}</TableCell> | |||
| <TableCell align="right">{formatUsageMinutes(r.usageMinutes)}</TableCell> | |||
| <TableCell>{r.jobOrderCode || "—"}</TableCell> | |||
| <TableCell sx={{ maxWidth: 220 }}>{formatCodeNameLine(r.processCode, r.processName)}</TableCell> | |||
| <TableCell>{r.jobPlanStart || "—"}</TableCell> | |||
| <TableCell>{r.operatingStart || "—"}</TableCell> | |||
| <TableCell>{r.operatingEnd || "—"}</TableCell> | |||
| <TableCell>{r.operatorName || r.operatorUsername || "—"}</TableCell> | |||
| <TableCell align="center"> | |||
| <Button | |||
| component={Link} | |||
| href={`/jo/edit?id=${r.jobOrderId}`} | |||
| target="_blank" | |||
| rel="noopener noreferrer" | |||
| size="small" | |||
| > | |||
| 開啟 | |||
| </Button> | |||
| </TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| )} | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -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() { | |||
| 此日期範圍內尚無排程資料。 | |||
| </Typography> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| key={plannedOutputChart.chartKey} | |||
| options={{ | |||
| chart: { type: "bar", animations: { enabled: false } }, | |||
| @@ -288,7 +286,7 @@ export default function ForecastChartPage() { | |||
| {loadingCharts.prodSchedule ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.prodSchedule.map((d) => d.date) }, | |||
| @@ -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 ( | |||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | |||
| <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | |||
| <Assignment /> {PAGE_TITLE} | |||
| </Typography> | |||
| <Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1} sx={{ mb: 2 }}> | |||
| <Typography variant="h5" sx={{ fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | |||
| <Assignment /> {PAGE_TITLE} | |||
| </Typography> | |||
| <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap> | |||
| <Button component={Link} href="/chart/joborder/board" variant="outlined" size="small"> | |||
| 工單即時看板 | |||
| </Button> | |||
| <Button | |||
| component={Link} | |||
| href="/chart/equipment/board" | |||
| variant="outlined" | |||
| size="small" | |||
| startIcon={<Microwave />} | |||
| > | |||
| 設備使用看板 | |||
| </Button> | |||
| <Button component={Link} href="/chart/process/board" variant="outlined" size="small"> | |||
| 工序即時看板 | |||
| </Button> | |||
| </Stack> | |||
| </Stack> | |||
| {error && ( | |||
| <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | |||
| {error} | |||
| @@ -181,7 +200,7 @@ export default function JobOrderChartPage() { | |||
| {loadingCharts.joStatus ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { type: "donut" }, | |||
| labels: chartData.joStatus.map((p) => p.status), | |||
| @@ -209,7 +228,7 @@ export default function JobOrderChartPage() { | |||
| {loadingCharts.joCountByDate ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.joCountByDate.map((d) => d.date) }, | |||
| @@ -239,7 +258,7 @@ export default function JobOrderChartPage() { | |||
| {loadingCharts.joCreatedCompleted ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { type: "line" }, | |||
| xaxis: { categories: chartData.joCreatedCompleted.map((d) => d.date) }, | |||
| @@ -275,7 +294,7 @@ export default function JobOrderChartPage() { | |||
| {loadingCharts.joMaterial ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.joMaterial.map((d) => d.date) }, | |||
| @@ -309,7 +328,7 @@ export default function JobOrderChartPage() { | |||
| {loadingCharts.joProcess ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.joProcess.map((d) => d.date) }, | |||
| @@ -343,7 +362,7 @@ export default function JobOrderChartPage() { | |||
| {loadingCharts.joEquipment ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.joEquipment.map((d) => d.date) }, | |||
| @@ -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 ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { | |||
| type: "donut", | |||
| @@ -756,7 +754,7 @@ export default function PurchaseChartPage() { | |||
| {loading ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { | |||
| type: "donut", | |||
| @@ -954,7 +952,7 @@ export default function PurchaseChartPage() { | |||
| 無資料(請確認訂單日期{selectedEstimatedBucket ? "與篩選" : "與狀態"}) | |||
| </Typography> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| key={itemChartKey} | |||
| options={{ | |||
| chart: { | |||
| @@ -1010,7 +1008,7 @@ export default function PurchaseChartPage() { | |||
| ) : supplierChartData.length === 0 ? ( | |||
| <Typography color="text.secondary">無供應商資料(請先確認上方貨品篩選或日期)</Typography> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| key={supplierChartKey} | |||
| options={{ | |||
| chart: { | |||
| @@ -1059,7 +1057,7 @@ export default function PurchaseChartPage() { | |||
| 無採購單。請確認該「訂單日期」是否有此狀態的採購單。 | |||
| </Typography> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| key={poChartKey} | |||
| options={{ | |||
| chart: { | |||
| @@ -1108,7 +1106,7 @@ export default function PurchaseChartPage() { | |||
| <CircularProgress size={28} /> | |||
| </Box> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: poLineItems.map((i) => `${i.itemCode} ${i.itemName}`.trim()) }, | |||
| @@ -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, | |||
| }; | |||
| } | |||
| @@ -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 ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { type: "line" }, | |||
| xaxis: { categories: chartData.stockTxn.map((s) => s.date) }, | |||
| @@ -218,7 +216,7 @@ export default function WarehouseChartPage() { | |||
| {loadingCharts.stockInOut ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { type: "area", stacked: false }, | |||
| xaxis: { categories: chartData.stockInOut.map((s) => s.date) }, | |||
| @@ -261,7 +259,7 @@ export default function WarehouseChartPage() { | |||
| {loadingCharts.balance ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { type: "line" }, | |||
| xaxis: { categories: chartData.balance.map((b) => b.date) }, | |||
| @@ -327,7 +325,7 @@ export default function WarehouseChartPage() { | |||
| {loadingCharts.consumption ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : chartData.consumptionByItems ? ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { type: "bar", stacked: false }, | |||
| xaxis: { categories: chartData.consumptionByItems.months }, | |||
| @@ -342,7 +340,7 @@ export default function WarehouseChartPage() { | |||
| height={320} | |||
| /> | |||
| ) : ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.consumption.map((c) => c.month) }, | |||
| @@ -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<string, unknown>): 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<JobOrderBoardRow[]> { | |||
| const params: Record<string, string | number | undefined> = {}; | |||
| 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<string, unknown>[]).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<string, unknown>): 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<ProcessBoardRow[]> { | |||
| const params: Record<string, string | number | undefined> = {}; | |||
| 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<string, unknown>[]).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<string, unknown>): 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<EquipmentUsageBoardRow[]> { | |||
| 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<string, unknown>[]).map(mapEquipmentUsageBoardRow); | |||
| } | |||
| export async function fetchProductionScheduleByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| @@ -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", | |||
| @@ -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", | |||
| }} | |||
| > | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={options} | |||
| series={[0, 100]} | |||
| type="donut" | |||
| @@ -2,8 +2,7 @@ | |||
| // npm install | |||
| import { Select, MenuItem, FormControl, InputLabel } from "@mui/material"; | |||
| 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"; | |||
| interface Props { | |||
| // params type | |||
| @@ -70,7 +69,7 @@ const DashboardLineChart: React.FC = () => { | |||
| </Select> | |||
| </FormControl> | |||
| </div> | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={options} | |||
| series={series} | |||
| type="line" | |||
| @@ -1,17 +1,15 @@ | |||
| "use client"; | |||
| import React, { useEffect, useState } from "react"; | |||
| import dynamic from "next/dynamic"; | |||
| import { PoResult } from "@/app/api/po"; | |||
| import { fetchPoListClient } from "@/app/api/po/actions"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SafeApexCharts from "@/components/charts/SafeApexCharts"; | |||
| interface Props { | |||
| // params type | |||
| po: PoResult[]; | |||
| } | |||
| const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); | |||
| const DashboardProgressChart: React.FC = () => { | |||
| const { t } = useTranslation("dashboard"); | |||
| const [series, setSeries] = useState<number[]>([]); | |||
| @@ -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 ? ( | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={options} | |||
| series={series} | |||
| type="donut" | |||
| @@ -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 OrderCompletionChart: React.FC = () => { | |||
| const { t } = useTranslation(); | |||
| @@ -122,7 +121,7 @@ const OrderCompletionChart: React.FC = () => { | |||
| margin: "0 auto", | |||
| }} | |||
| > | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={options} | |||
| series={[0, 100]} | |||
| type="donut" | |||
| @@ -1,7 +1,6 @@ | |||
| "use client"; | |||
| import React 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 PendingInspectionChart: React.FC = () => { | |||
| @@ -58,7 +57,7 @@ const PendingInspectionChart: React.FC = () => { | |||
| margin: "0 auto", | |||
| }} | |||
| > | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={options} | |||
| series={[1, 15]} | |||
| type="donut" | |||
| @@ -1,8 +1,7 @@ | |||
| "use client"; | |||
| import React from "react"; | |||
| import dynamic from "next/dynamic"; | |||
| import { useTranslation } from "react-i18next"; | |||
| const ApexCharts = dynamic(() => 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", | |||
| }} | |||
| > | |||
| <ApexCharts | |||
| <SafeApexCharts | |||
| options={options} | |||
| series={[15, 1]} | |||
| type="donut" | |||
| @@ -194,7 +194,7 @@ const GoodsReceiptStatusNew: React.FC = () => { | |||
| <Box sx={{ flexGrow: 1 }} /> | |||
| <Stack direction="row" spacing={2} sx={{ alignSelf: 'center' }}> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }}> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }} suppressHydrationWarning> | |||
| {t("Now")}: {now.format('HH:mm')} | |||
| </Typography> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }}> | |||
| @@ -491,7 +491,7 @@ const TruckScheduleDashboard: React.FC = () => { | |||
| <Box sx={{ flexGrow: 1 }} /> | |||
| <Stack direction="row" spacing={2} sx={{ alignSelf: 'center' }}> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }}> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }} suppressHydrationWarning> | |||
| {t("Now")}: {now.format('HH:mm')} | |||
| </Typography> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }}> | |||
| @@ -283,7 +283,7 @@ const FGPickOrderTicketReleaseTable: React.FC = () => { | |||
| <Box sx={{ flexGrow: 1 }} /> | |||
| <Stack direction="row" spacing={2} sx={{ flexShrink: 0, alignSelf: "center" }}> | |||
| <Typography variant="body2" sx={{ color: "text.secondary" }}> | |||
| <Typography variant="body2" sx={{ color: "text.secondary" }} suppressHydrationWarning> | |||
| {t("Now")}: {now.format("HH:mm")} | |||
| </Typography> | |||
| <Typography variant="body2" sx={{ color: "text.secondary" }}> | |||
| @@ -257,7 +257,7 @@ const MaterialPickStatusTable: React.FC = () => { | |||
| <Box sx={{ flexGrow: 1 }} /> | |||
| <Stack direction="row" spacing={2} sx={{ alignSelf: 'center' }}> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }}> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }} suppressHydrationWarning> | |||
| {t("Now")}: {now.format('HH:mm')} | |||
| </Typography> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }}> | |||
| @@ -223,6 +223,12 @@ const NavigationContent: React.FC = () => { | |||
| path: "/chart/joborder", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| icon: <ViewModule />, | |||
| label: "工單即時看板", | |||
| path: "/chart/joborder/board", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| icon: <LocalShipping />, | |||
| label: "發貨與配送", | |||
| @@ -207,7 +207,7 @@ const EquipmentStatusDashboard: React.FC = () => { | |||
| </Tabs> | |||
| </Box> | |||
| <Stack direction="row" spacing={2} sx={{ flexShrink: 0, alignSelf: 'center' }}> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }}> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }} suppressHydrationWarning> | |||
| {t("Now")}: {now.format('HH:mm')} | |||
| </Typography> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }}> | |||
| @@ -199,7 +199,7 @@ const JobProcessStatus: React.FC = () => { | |||
| <Box sx={{ flexGrow: 1 }} /> | |||
| <Stack direction="row" spacing={2} sx={{ alignSelf: 'center' }}> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }}> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }} suppressHydrationWarning> | |||
| {t("Now")}: {currentTime.format('HH:mm')} | |||
| </Typography> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }}> | |||
| @@ -181,7 +181,7 @@ const OperatorKpiDashboard: React.FC = () => { | |||
| <Box sx={{ flexGrow: 1 }} /> | |||
| <Stack direction="row" spacing={2} sx={{ alignSelf: 'center' }}> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }}> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }} suppressHydrationWarning> | |||
| {t("Now")}: {now.format('HH:mm')} | |||
| </Typography> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }}> | |||
| @@ -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<HTMLDivElement>(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 ( | |||
| <Typography color="text.secondary" sx={{ py: 3 }}> | |||
| {EMPTY_MESSAGE} | |||
| </Typography> | |||
| ); | |||
| } | |||
| 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 ( | |||
| <Typography color="text.secondary" sx={{ py: 3 }}> | |||
| {EMPTY_MESSAGE} | |||
| </Typography> | |||
| ); | |||
| } | |||
| 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<void> }; | |||
| 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 ( | |||
| <Box | |||
| ref={containerRef} | |||
| className={dom.className} | |||
| id={dom.id} | |||
| style={dom.style} | |||
| sx={{ | |||
| width: "100%", | |||
| minHeight: minH, | |||
| position: "relative", | |||
| ...dom.sx, | |||
| }} | |||
| /> | |||
| ); | |||
| } | |||
| @@ -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; | |||