Просмотр исходного кода

added jo process and job order board as chart

MergeProblem1
PC-20260115JRSN\Administrator 20 часов назад
Родитель
Сommit
65329be227
28 измененных файлов: 3618 добавлений и 69 удалений
  1. +88
    -0
      src/app/(main)/chart/chartBoardRefreshPrefs.ts
  2. +4
    -6
      src/app/(main)/chart/delivery/page.tsx
  3. +535
    -0
      src/app/(main)/chart/equipment/board/page.tsx
  4. +3
    -5
      src/app/(main)/chart/forecast/page.tsx
  5. +1047
    -0
      src/app/(main)/chart/joborder/board/page.tsx
  6. +32
    -13
      src/app/(main)/chart/joborder/page.tsx
  7. +1309
    -0
      src/app/(main)/chart/process/board/page.tsx
  8. +7
    -9
      src/app/(main)/chart/purchase/page.tsx
  9. +61
    -0
      src/app/(main)/chart/useChartBoardRefreshPrefs.ts
  10. +6
    -8
      src/app/(main)/chart/warehouse/page.tsx
  11. +232
    -0
      src/app/api/chart/client.ts
  12. +1
    -0
      src/components/Breadcrumb/Breadcrumb.tsx
  13. +2
    -3
      src/components/DashboardPage/chart/ApplicationCompletionChart.tsx
  14. +2
    -3
      src/components/DashboardPage/chart/DashboardLineChart.tsx
  15. +3
    -5
      src/components/DashboardPage/chart/DashboardProgressChart.tsx
  16. +2
    -3
      src/components/DashboardPage/chart/OrderCompletionChart.tsx
  17. +2
    -3
      src/components/DashboardPage/chart/PendingInspectionChart.tsx
  18. +2
    -3
      src/components/DashboardPage/chart/PendingStorageChart.tsx
  19. +1
    -1
      src/components/DashboardPage/goodsReceiptStatus/GoodsReceiptStatusNew.tsx
  20. +1
    -1
      src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx
  21. +1
    -1
      src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx
  22. +1
    -1
      src/components/Jodetail/MaterialPickStatusTable.tsx
  23. +6
    -0
      src/components/NavigationContent/NavigationContent.tsx
  24. +1
    -1
      src/components/ProductionProcess/EquipmentStatusDashboard.tsx
  25. +1
    -1
      src/components/ProductionProcess/JobProcessStatus.tsx
  26. +1
    -1
      src/components/ProductionProcess/OperatorKpiDashboard.tsx
  27. +265
    -0
      src/components/charts/SafeApexCharts.tsx
  28. +2
    -1
      src/config/authConfig.ts

+ 88
- 0
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<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 */
}
}

+ 4
- 6
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 ? (
<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: {


+ 535
- 0
src/app/(main)/chart/equipment/board/page.tsx Просмотреть файл

@@ -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>
);
}

+ 3
- 5
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() {
此日期範圍內尚無排程資料。
</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) },


+ 1047
- 0
src/app/(main)/chart/joborder/board/page.tsx
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 32
- 13
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 (
<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) },


+ 1309
- 0
src/app/(main)/chart/process/board/page.tsx
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 7
- 9
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 ? (
<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()) },


+ 61
- 0
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,
};
}

+ 6
- 8
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 ? (
<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) },


+ 232
- 0
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<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


+ 1
- 0
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",


+ 2
- 3
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",
}}
>
<ApexCharts
<SafeApexCharts
options={options}
series={[0, 100]}
type="donut"


+ 2
- 3
src/components/DashboardPage/chart/DashboardLineChart.tsx Просмотреть файл

@@ -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"


+ 3
- 5
src/components/DashboardPage/chart/DashboardProgressChart.tsx Просмотреть файл

@@ -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"


+ 2
- 3
src/components/DashboardPage/chart/OrderCompletionChart.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 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"


+ 2
- 3
src/components/DashboardPage/chart/PendingInspectionChart.tsx Просмотреть файл

@@ -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"


+ 2
- 3
src/components/DashboardPage/chart/PendingStorageChart.tsx Просмотреть файл

@@ -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"


+ 1
- 1
src/components/DashboardPage/goodsReceiptStatus/GoodsReceiptStatusNew.tsx Просмотреть файл

@@ -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' }}>


+ 1
- 1
src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx Просмотреть файл

@@ -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' }}>


+ 1
- 1
src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx Просмотреть файл

@@ -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" }}>


+ 1
- 1
src/components/Jodetail/MaterialPickStatusTable.tsx Просмотреть файл

@@ -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' }}>


+ 6
- 0
src/components/NavigationContent/NavigationContent.tsx Просмотреть файл

@@ -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: "發貨與配送",


+ 1
- 1
src/components/ProductionProcess/EquipmentStatusDashboard.tsx Просмотреть файл

@@ -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' }}>


+ 1
- 1
src/components/ProductionProcess/JobProcessStatus.tsx Просмотреть файл

@@ -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' }}>


+ 1
- 1
src/components/ProductionProcess/OperatorKpiDashboard.tsx Просмотреть файл

@@ -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' }}>


+ 265
- 0
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<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,
}}
/>
);
}

+ 2
- 1
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;

Загрузка…
Отмена
Сохранить