|
- "use client";
-
- import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
- import {
- Box,
- Typography,
- Table,
- TableBody,
- TableCell,
- TableContainer,
- TableHead,
- TableRow,
- Paper,
- TextField,
- Alert,
- CircularProgress,
- IconButton,
- Collapse,
- Stack,
- Tooltip,
- Button,
- LinearProgress,
- Chip,
- FormControl,
- FormControlLabel,
- InputLabel,
- MenuItem,
- Select,
- Switch,
- } from "@mui/material";
- import Link from "next/link";
- import dayjs from "dayjs";
- import { alpha, useTheme } from "@mui/material/styles";
- import ViewKanban from "@mui/icons-material/ViewKanban";
- import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown";
- import KeyboardArrowUp from "@mui/icons-material/KeyboardArrowUp";
- import EditCalendar from "@mui/icons-material/EditCalendar";
- import Schedule from "@mui/icons-material/Schedule";
- import Inventory2 from "@mui/icons-material/Inventory2";
- import PrecisionManufacturing from "@mui/icons-material/PrecisionManufacturing";
- import Microwave from "@mui/icons-material/Microwave";
- import VerifiedUser from "@mui/icons-material/VerifiedUser";
- import Warehouse from "@mui/icons-material/Warehouse";
- import CheckCircle from "@mui/icons-material/CheckCircle";
- import HelpOutline from "@mui/icons-material/HelpOutline";
- import { fetchJobOrderBoard, type JobOrderBoardRow } from "@/app/api/chart/client";
- import SafeApexCharts from "@/components/charts/SafeApexCharts";
- import { CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS } from "@/app/(main)/chart/chartBoardRefreshPrefs";
- import { useChartBoardRefreshPrefs } from "@/app/(main)/chart/useChartBoardRefreshPrefs";
-
- const PIPELINE = [
- { status: "planning", label: "計劃" },
- { status: "pending", label: "待開" },
- { status: "packaging", label: "包裝" },
- { status: "processing", label: "製程" },
- { status: "pendingqc", label: "QC" },
- { status: "storing", label: "上架" },
- { status: "completed", label: "完成" },
- ] as const;
-
- const JOB_STATUS_ZH: Record<string, string> = {
- planning: "計劃中",
- pending: "待開工",
- packaging: "包裝",
- processing: "製程中",
- pendingqc: "待質檢",
- storing: "上架中",
- completed: "已完成",
- unknown: "未知",
- };
-
- /** 與摘要堆疊長條圖 series 順序一致:QC 中 → 已驗待入 → 已入庫 */
- const FG_SUMMARY_SERIES_BUCKETS = ["qc", "ready", "stocked"] as const;
- type FgSummaryStockBucket = (typeof FG_SUMMARY_SERIES_BUCKETS)[number];
-
- const FG_SUMMARY_BUCKET_LABEL: Record<FgSummaryStockBucket, string> = {
- qc: "入庫 QC 中(有量)",
- ready: "已驗待入(有量)",
- stocked: "已入庫(有量)",
- };
-
- function normStatus(s: string | undefined): string {
- return (s ?? "").trim().toLowerCase();
- }
-
- function statusLabelZh(status: string): string {
- return JOB_STATUS_ZH[normStatus(status)] ?? status;
- }
-
- function isKnownJobStatus(status: string): boolean {
- return normStatus(status) in JOB_STATUS_ZH;
- }
-
- function StatusIcon({ status }: { status: string }) {
- const s = normStatus(status);
- const sx = { fontSize: 28, opacity: 0.95 };
- switch (s) {
- case "planning":
- return <EditCalendar sx={{ ...sx, color: "text.secondary" }} />;
- case "pending":
- return <Schedule sx={{ ...sx, color: "secondary.main" }} />;
- case "packaging":
- return <Inventory2 sx={{ ...sx, color: "info.main" }} />;
- case "processing":
- return <PrecisionManufacturing sx={{ ...sx, color: "info.dark" }} />;
- case "pendingqc":
- return <VerifiedUser sx={{ ...sx, color: "warning.main" }} />;
- case "storing":
- return <Warehouse sx={{ ...sx, color: "warning.dark" }} />;
- case "completed":
- return <CheckCircle sx={{ ...sx, color: "success.main" }} />;
- default:
- return <HelpOutline sx={{ ...sx, color: "text.disabled" }} />;
- }
- }
-
- function statusStepIndex(status: string): number {
- const s = normStatus(status);
- const i = PIPELINE.findIndex((p) => p.status === s);
- return i >= 0 ? i : 0;
- }
-
- /** Dot chain — current step ring highlight */
- function PipelineDots({ status }: { status: string }) {
- const idx = statusStepIndex(status);
- const allDone = normStatus(status) === "completed";
- return (
- <Stack direction="row" alignItems="center" sx={{ flexWrap: "wrap", gap: 0.25 }}>
- {PIPELINE.map((step, i) => {
- const done = allDone || i < idx;
- const current = !allDone && i === idx;
- return (
- <Tooltip key={step.status} title={step.label}>
- <Box
- sx={{
- width: current ? 12 : 9,
- height: current ? 12 : 9,
- borderRadius: "50%",
- bgcolor: done ? "success.main" : current ? "primary.main" : "grey.300",
- boxShadow: current ? 2 : 0,
- border: current ? "2px solid" : "none",
- borderColor: "warning.light",
- flexShrink: 0,
- }}
- />
- </Tooltip>
- );
- })}
- </Stack>
- );
- }
-
- function formatQty(n: number): string {
- if (!Number.isFinite(n) || n === 0) return "0";
- const t = Number(n);
- return Math.abs(t - Math.round(t)) < 1e-6 ? String(Math.round(t)) : t.toFixed(2);
- }
-
- /** 與工藝流程摘要:開始時間 + 僅生產分鐘(不含備料/轉換) */
- function formatAssumeEndMmDdHhMm(processStart: string, planProcessingMins: number): string {
- if (!processStart?.trim() || !planProcessingMins) return "—";
- const d = dayjs(processStart.replace(" ", "T"));
- if (!d.isValid()) return "—";
- return d.add(planProcessingMins, "minute").format("MM-DD HH:mm");
- }
-
- /** Backend sends decimal minutes (from summed seconds ÷ 60). */
- function formatDurationMins(mins: number): string {
- if (!Number.isFinite(mins) || mins <= 0) return "—";
- const secs = Math.round(mins * 60);
- if (secs < 60) return `${secs} 秒`;
- if (mins < 10) return `${mins.toFixed(1)} 分`;
- return `${Math.round(mins)} 分`;
- }
-
- /** Avoid "CODE CODE" or "X X" when DB code/name duplicate or name already includes code. */
- function formatCurrentProcessLabel(code: string, name: string): string {
- const c = (code ?? "").trim();
- const n = (name ?? "").trim();
- if (!c && !n) return "—";
- if (!c) return n;
- if (!n) return c;
- const lc = c.toLowerCase();
- const ln = n.toLowerCase();
- if (lc === ln) return n;
- if (ln.startsWith(`${lc} `) || ln.startsWith(`${lc} `)) return n;
- if (n.length > c.length && ln.startsWith(lc)) {
- const after = n.slice(c.length, c.length + 1);
- if (/[\s\-–—::·.|//]/.test(after)) return n;
- }
- return `${c} ${n}`;
- }
-
- function MaterialBar({ pending, picked }: { pending: number; picked: number }) {
- const total = pending + picked;
- if (total <= 0) {
- return (
- <Typography variant="caption" color="text.disabled">
- 無領料行
- </Typography>
- );
- }
- const pct = (100 * picked) / total;
- return (
- <Box sx={{ minWidth: 100, maxWidth: 160 }}>
- <LinearProgress
- variant="determinate"
- value={pct}
- sx={{ height: 8, borderRadius: 1, bgcolor: "grey.200" }}
- color={pct >= 100 ? "success" : "primary"}
- />
- <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.25 }}>
- 已揀 {picked} / 共 {total}
- </Typography>
- </Box>
- );
- }
-
- function ProcessBar({ done, total }: { done: number; total: number }) {
- if (total <= 0) {
- return (
- <Typography variant="caption" color="text.disabled">
- 無工序
- </Typography>
- );
- }
- const pct = (100 * done) / total;
- return (
- <Box sx={{ minWidth: 100, maxWidth: 160 }}>
- <LinearProgress
- variant="determinate"
- value={pct}
- sx={{ height: 8, borderRadius: 1, bgcolor: "grey.200" }}
- color={done >= total ? "success" : "info"}
- />
- <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.25 }}>
- {done}/{total}
- </Typography>
- </Box>
- );
- }
-
- /** 成品/半成品入庫:QC 中 | 已驗待入 | 已入庫 — by qty */
- function FgStockBar({ qc, ready, stocked }: { qc: number; ready: number; stocked: number }) {
- const sum = qc + ready + stocked;
- if (sum <= 0) {
- return (
- <Typography variant="caption" color="text.disabled">
- 無入庫數量資料
- </Typography>
- );
- }
- const pQc = (100 * qc) / sum;
- const pReady = (100 * ready) / sum;
- const pStocked = 100 - pQc - pReady;
- return (
- <Box sx={{ minWidth: 120, maxWidth: 200 }}>
- <Stack direction="row" sx={{ height: 10, borderRadius: 1, overflow: "hidden", bgcolor: "grey.200" }}>
- <Tooltip title={`QC 中 ${formatQty(qc)}`}>
- <Box sx={{ width: `${pQc}%`, bgcolor: "warning.main" }} />
- </Tooltip>
- <Tooltip title={`已驗待入 ${formatQty(ready)}`}>
- <Box sx={{ width: `${pReady}%`, bgcolor: "info.main" }} />
- </Tooltip>
- <Tooltip title={`已入庫 ${formatQty(stocked)}`}>
- <Box sx={{ width: `${pStocked}%`, bgcolor: "success.main" }} />
- </Tooltip>
- </Stack>
- <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.35 }} noWrap>
- QC {formatQty(qc)} · 待入 {formatQty(ready)} · 已入 {formatQty(stocked)}
- </Typography>
- </Box>
- );
- }
-
- const COLSPAN = 8;
-
- function DetailLine({ label, children }: { label: string; children: React.ReactNode }) {
- return (
- <Stack direction="row" spacing={1.5} alignItems="baseline" flexWrap="wrap" useFlexGap sx={{ columnGap: 1.5, rowGap: 0.25 }}>
- <Typography component="span" variant="body2" color="text.secondary" sx={{ minWidth: 108, flexShrink: 0 }}>
- {label}
- </Typography>
- <Typography component="span" variant="body2" color="text.primary">
- {children}
- </Typography>
- </Stack>
- );
- }
-
- export default function JobOrderBoardPage() {
- const theme = useTheme();
- const [targetDate, setTargetDate] = useState(() => dayjs().format("YYYY-MM-DD"));
- /** true: load all non-completed JOs (no plan-start date); false: filter by targetDate */
- const [allIncompleteOpen, setAllIncompleteOpen] = useState(false);
- const [rows, setRows] = useState<JobOrderBoardRow[]>([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState<string | null>(null);
- const [lastUpdated, setLastUpdated] = useState("");
- const [openId, setOpenId] = useState<number | null>(null);
- /** Normalized status key from donut (e.g. pending); null = not filtering by stage */
- const [tableStatusKey, setTableStatusKey] = useState<string | null>(null);
- /** Summary bar: filter rows that have FG/WIP stock-in in that bucket; mutually exclusive with tableStatusKey in UI */
- const [tableStockBucket, setTableStockBucket] = useState<FgSummaryStockBucket | null>(null);
- const { autoRefreshOn, setAutoRefreshOn, refreshIntervalSec, setRefreshIntervalSec } =
- useChartBoardRefreshPrefs("joborder");
-
- const load = useCallback(async () => {
- setLoading(true);
- setError(null);
- try {
- const data = allIncompleteOpen
- ? await fetchJobOrderBoard(undefined, { incompleteOnly: true })
- : await fetchJobOrderBoard(targetDate);
- setRows(data);
- setLastUpdated(dayjs().format("YYYY-MM-DD HH:mm:ss"));
- } catch (e) {
- setError(e instanceof Error ? e.message : "Request failed");
- } finally {
- setLoading(false);
- }
- }, [allIncompleteOpen, targetDate]);
-
- useEffect(() => {
- void load();
- }, [load]);
-
- useEffect(() => {
- if (!autoRefreshOn || refreshIntervalSec < 10) return;
- const t = setInterval(() => void load(), refreshIntervalSec * 1000);
- return () => clearInterval(t);
- }, [load, autoRefreshOn, refreshIntervalSec]);
-
- useEffect(() => {
- setTableStatusKey(null);
- setTableStockBucket(null);
- }, [targetDate, allIncompleteOpen]);
-
- useEffect(() => {
- setOpenId(null);
- }, [tableStatusKey, tableStockBucket]);
-
- useEffect(() => {
- const onKey = (ev: KeyboardEvent) => {
- const t = ev.target as HTMLElement | null;
- if (
- t &&
- (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)
- ) {
- return;
- }
- if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
- if (ev.key === "t" || ev.key === "T") {
- ev.preventDefault();
- setTargetDate(dayjs().format("YYYY-MM-DD"));
- }
- if (ev.key === "y" || ev.key === "Y") {
- ev.preventDefault();
- setTargetDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"));
- }
- };
- window.addEventListener("keydown", onKey);
- return () => window.removeEventListener("keydown", onKey);
- }, []);
-
- const statusDonut = useMemo(() => {
- const map = new Map<string, number>();
- rows.forEach((r) => {
- const k = normStatus(r.status) || "unknown";
- map.set(k, (map.get(k) ?? 0) + 1);
- });
- const keysAll = Array.from(map.keys());
- const pairs = keysAll
- .map((k) => {
- const v = map.get(k) ?? 0;
- const n = Number.isFinite(v) && v > 0 ? v : 0;
- return { key: k, label: JOB_STATUS_ZH[k] ?? k, v: n };
- })
- .filter((p) => p.v > 0);
- return {
- keys: pairs.map((p) => p.key),
- labels: pairs.map((p) => p.label),
- series: pairs.map((p) => p.v),
- };
- }, [rows]);
-
- const displayRows = useMemo(() => {
- const fin = (n: number) => (Number.isFinite(n) ? n : 0);
- if (tableStockBucket) {
- return rows.filter((r) => {
- if (tableStockBucket === "qc") return fin(r.fgInQcQty) > 0 || (r.fgInQcLineCount ?? 0) > 0;
- if (tableStockBucket === "ready") return fin(r.fgReadyToStockInQty) > 0 || (r.fgReadyToStockInCount ?? 0) > 0;
- if (tableStockBucket === "stocked") return fin(r.fgStockedQty) > 0;
- return true;
- });
- }
- if (tableStatusKey) {
- return rows.filter((r) => (normStatus(r.status) || "unknown") === tableStatusKey);
- }
- return rows;
- }, [rows, tableStatusKey, tableStockBucket]);
-
- const donutSliceClickRef = useRef<(index: number) => void>(() => {});
- donutSliceClickRef.current = (index: number) => {
- setTableStockBucket(null);
- const key = statusDonut.keys[index];
- if (key == null) return;
- setTableStatusKey((prev) => (prev === key ? null : key));
- };
-
- const fgBarSeriesClickRef = useRef<(seriesIndex: number) => void>(() => {});
- fgBarSeriesClickRef.current = (seriesIndex: number) => {
- if (typeof seriesIndex !== "number" || seriesIndex < 0 || seriesIndex >= FG_SUMMARY_SERIES_BUCKETS.length) return;
- setTableStatusKey(null);
- const bucket = FG_SUMMARY_SERIES_BUCKETS[seriesIndex];
- setTableStockBucket((prev) => (prev === bucket ? null : bucket));
- };
-
- const statusDonutChartOptions = useMemo(
- () => ({
- chart: {
- type: "donut" as const,
- toolbar: { show: false },
- events: {
- dataPointSelection: (_e: unknown, _ctx: unknown, cfg: { dataPointIndex?: number }) => {
- const i = cfg?.dataPointIndex;
- if (typeof i !== "number" || i < 0) return;
- donutSliceClickRef.current(i);
- },
- /** Donut/pie: legend index matches slice index */
- legendClick: (_chartContext: unknown, seriesIndex: number) => {
- if (typeof seriesIndex !== "number" || seriesIndex < 0) return false;
- donutSliceClickRef.current(seriesIndex);
- return false;
- },
- },
- },
- labels: statusDonut.labels,
- legend: {
- position: "bottom" as const,
- onItemClick: { toggleDataSeries: false },
- },
- plotOptions: { pie: { donut: { size: "62%" } } },
- dataLabels: { enabled: true },
- }),
- [statusDonut.labels],
- );
-
- const fgSummaryChartOptions = useMemo(
- () => ({
- chart: {
- type: "bar" as const,
- stacked: true,
- toolbar: { show: false },
- events: {
- dataPointSelection: (_e: unknown, _ctx: unknown, cfg: { seriesIndex?: number; dataPointIndex?: number }) => {
- const si = cfg?.seriesIndex;
- if (typeof si !== "number") return;
- fgBarSeriesClickRef.current(si);
- },
- legendClick: (_chartContext: unknown, seriesIndex: number) => {
- if (typeof seriesIndex !== "number" || seriesIndex < 0) return false;
- fgBarSeriesClickRef.current(seriesIndex);
- return false;
- },
- },
- },
- plotOptions: { bar: { horizontal: true, barHeight: "48%" } },
- xaxis: { categories: ["合計"] },
- colors: ["#ed6c02", "#0288d1", "#2e7d32"],
- legend: {
- position: "top" as const,
- onItemClick: { toggleDataSeries: false },
- },
- dataLabels: { enabled: false },
- }),
- [],
- );
-
- const materialSummary = useMemo(() => {
- let p = 0;
- let k = 0;
- rows.forEach((r) => {
- p += r.materialPendingCount;
- k += r.materialPickedCount;
- });
- return { pending: p, picked: k };
- }, [rows]);
-
- const fgTotals = useMemo(() => {
- const fin = (n: number) => (Number.isFinite(n) ? n : 0);
- let qc = 0;
- let ready = 0;
- let stocked = 0;
- rows.forEach((r) => {
- qc += fin(r.fgInQcQty);
- ready += fin(r.fgReadyToStockInQty);
- stocked += fin(r.fgStockedQty);
- });
- return { qc, ready, stocked };
- }, [rows]);
-
- const isPlanToday = targetDate === dayjs().format("YYYY-MM-DD");
- const planWeekdayZh = ["日", "一", "二", "三", "四", "五", "六"][dayjs(targetDate).day()] ?? "";
-
- return (
- <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 }}>
- <ViewKanban /> 工單即時看板
- </Typography>
- <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
- 圖示化階段與進度條;入庫欄僅統計<strong>成品/半成品</strong>(系統 FG/WIP)工單之入庫明細(依驗收數量)。「已驗待入」= 狀態為 receiving /
- received(QC 完成待上架)。
- {autoRefreshOn
- ? ` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)`
- : " 預設不自動更新,可開啟「自動重新整理」並選擇間隔。"}
- {" 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。"}
- {lastUpdated ? ` · 最後更新 ${lastUpdated}` : ""}
- <strong> 快捷鍵</strong>(不在輸入框內時):
- <Box
- component="kbd"
- sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }}
- >
- T
- </Box>{" "}
- 今日、
- <Box
- component="kbd"
- sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }}
- >
- Y
- </Box>{" "}
- 昨日(僅更新計劃開始日)。
- </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 }}>
- 計劃開始日 {targetDate}(週{planWeekdayZh})
- {!isPlanToday && <Chip size="small" label="非今日" sx={{ ml: 1 }} variant="outlined" />}
- </Typography>
- <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}>
- <Tooltip title="快捷鍵 T">
- <span>
- <Button
- size="small"
- variant={isPlanToday && !allIncompleteOpen ? "contained" : "outlined"}
- onClick={() => setTargetDate(dayjs().format("YYYY-MM-DD"))}
- disabled={allIncompleteOpen}
- >
- 今日
- </Button>
- </span>
- </Tooltip>
- <Tooltip title="快捷鍵 Y">
- <span>
- <Button
- size="small"
- variant="outlined"
- onClick={() => setTargetDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"))}
- disabled={allIncompleteOpen}
- >
- 昨日
- </Button>
- </span>
- </Tooltip>
- <TextField
- size="small"
- label="計劃開始日"
- type="date"
- value={targetDate}
- onChange={(e) => setTargetDate(e.target.value)}
- disabled={allIncompleteOpen}
- InputLabelProps={{ shrink: true }}
- sx={{ minWidth: 178 }}
- />
- <Button variant="outlined" size="small" onClick={() => void load()} disabled={loading}>
- 重新整理
- </Button>
- </Stack>
- <Tooltip
- title={
- <>
- <Typography variant="caption" component="span" display="block" sx={{ mb: 0.5 }}>
- 載入<strong>全部未完成</strong>工單(階段≠已完成),不篩「計劃開始日」。筆數多時仍可能較慢。
- </Typography>
- <Typography variant="caption" component="span" display="block">
- 再按一次可切回依「計劃開始日」載入。
- </Typography>
- </>
- }
- arrow
- placement="top-start"
- >
- <span>
- <Button
- variant={allIncompleteOpen ? "contained" : "outlined"}
- color={allIncompleteOpen ? "warning" : "primary"}
- size="small"
- onClick={() => setAllIncompleteOpen((v) => !v)}
- disabled={loading}
- aria-pressed={allIncompleteOpen}
- sx={{ alignSelf: "flex-start" }}
- >
- {allIncompleteOpen ? "改依計劃開始日" : "顯示所有未完成工單"}
- </Button>
- </span>
- </Tooltip>
- </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/equipment/board" size="small" variant="outlined" fullWidth startIcon={<Microwave />}>
- 設備使用看板
- </Button>
- <Button component={Link} href="/chart/process/board" size="small" variant="outlined" fullWidth>
- 工序即時看板
- </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="jo-board-refresh-interval-label">間隔(秒)</InputLabel>
- <Select
- labelId="jo-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>
-
- {!loading && rows.length > 0 && (
- <Stack direction={{ xs: "column", md: "row" }} spacing={2} sx={{ mb: 3 }}>
- <Paper variant="outlined" sx={{ p: 2, flex: 1, minWidth: 280 }}>
- <Typography variant="subtitle2" fontWeight={600} gutterBottom>
- 工單狀態分佈
- </Typography>
- <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
- 點擊圓環或下方圖例可依<strong>階段</strong>篩選列表;再點同一項或表格上方「清除列表篩選」可還原。
- </Typography>
- {statusDonut.series.length > 0 ? (
- <SafeApexCharts
- chartRevision={JSON.stringify(statusDonut.keys)}
- options={statusDonutChartOptions}
- series={statusDonut.series}
- type="donut"
- height={280}
- />
- ) : (
- <Typography color="text.secondary">無資料</Typography>
- )}
- </Paper>
- <Paper variant="outlined" sx={{ p: 2, flex: 1, minWidth: 280 }}>
- <Typography variant="subtitle2" fontWeight={600} gutterBottom>
- 本頁摘要
- </Typography>
- <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
- 領料:待領 {materialSummary.pending} / 已揀 {materialSummary.picked}
- </Typography>
- <MaterialBar pending={materialSummary.pending} picked={materialSummary.picked} />
- <Typography variant="body2" color="text.secondary" sx={{ mt: 2, mb: 0.5 }}>
- 成品/半成品入庫(驗收數量)— QC 中/已驗待入/已入庫
- </Typography>
- <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>
- 點擊長條色塊或上方圖例,篩選該入庫區塊有資料的工單;再點一次可還原。
- </Typography>
- <FgStockBar qc={fgTotals.qc} ready={fgTotals.ready} stocked={fgTotals.stocked} />
- {fgTotals.qc + fgTotals.ready + fgTotals.stocked > 0 ? (
- <>
- <SafeApexCharts
- chartRevision={`fg-bar-${fgTotals.qc}-${fgTotals.ready}-${fgTotals.stocked}`}
- options={fgSummaryChartOptions}
- series={[
- { name: "QC 中", data: [Math.max(0, fgTotals.qc)] },
- { name: "已驗待入", data: [Math.max(0, fgTotals.ready)] },
- { name: "已入庫", data: [Math.max(0, fgTotals.stocked)] },
- ]}
- type="bar"
- height={140}
- />
- <Button
- size="small"
- variant="text"
- sx={{ mt: 0.5, p: 0, minHeight: "auto" }}
- onClick={() => {
- setTableStockBucket(null);
- setTableStatusKey((p) => (p === "processing" ? null : "processing"));
- }}
- >
- {tableStatusKey === "processing" ? "取消「製程中」篩選" : "篩選製程中工單(階段)"}
- </Button>
- </>
- ) : (
- <>
- <Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
- 驗收數量合計為 0,不顯示長條圖
- </Typography>
- <Button
- size="small"
- variant="text"
- sx={{ mt: 0.5, p: 0, minHeight: "auto" }}
- onClick={() => {
- setTableStockBucket(null);
- setTableStatusKey((p) => (p === "processing" ? null : "processing"));
- }}
- >
- {tableStatusKey === "processing" ? "取消「製程中」篩選" : "篩選製程中工單(階段)"}
- </Button>
- </>
- )}
- </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>
- ) : (
- <TableContainer component={Paper} variant="outlined">
- {(tableStatusKey || tableStockBucket) && (
- <Box sx={{ px: 2, py: 1.5, borderBottom: 1, borderColor: "divider", bgcolor: "action.hover" }}>
- <Stack direction="row" alignItems="center" spacing={1} flexWrap="wrap" useFlexGap>
- <Typography variant="body2" color="text.secondary">
- 列表篩選中:
- </Typography>
- {tableStockBucket && (
- <Chip
- label={FG_SUMMARY_BUCKET_LABEL[tableStockBucket]}
- size="small"
- color="secondary"
- variant="outlined"
- onDelete={() => setTableStockBucket(null)}
- />
- )}
- {tableStatusKey && (
- <Chip
- label={`階段:${statusLabelZh(tableStatusKey)}`}
- size="small"
- color="primary"
- variant="outlined"
- onDelete={() => setTableStatusKey(null)}
- />
- )}
- <Button
- size="small"
- variant="text"
- onClick={() => {
- setTableStatusKey(null);
- setTableStockBucket(null);
- }}
- >
- 清除列表篩選
- </Button>
- </Stack>
- </Box>
- )}
- <Table size="small" stickyHeader>
- <TableHead>
- <TableRow>
- <TableCell padding="checkbox" />
- <TableCell>工單號</TableCell>
- <TableCell sx={{ minWidth: 200 }}>階段</TableCell>
- <TableCell>領料進度</TableCell>
- <TableCell>工序進度</TableCell>
- <TableCell>當前製程</TableCell>
- <TableCell sx={{ minWidth: 200 }}>成品/半成品入庫</TableCell>
- <TableCell align="center">開啟</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {displayRows.length === 0 ? (
- <TableRow>
- <TableCell colSpan={COLSPAN} align="center" sx={{ py: 4 }}>
- <Typography color="text.secondary">
- {tableStockBucket
- ? "目前沒有符合此入庫區塊的工單(可點「清除列表篩選」)。"
- : tableStatusKey
- ? "此階段下目前沒有工單(可點「清除列表篩選」)。"
- : "沒有資料"}
- </Typography>
- </TableCell>
- </TableRow>
- ) : (
- displayRows.map((r) => (
- <React.Fragment key={r.jobOrderId}>
- <TableRow hover>
- <TableCell>
- <IconButton
- size="small"
- aria-label="expand row"
- onClick={() => setOpenId((id) => (id === r.jobOrderId ? null : r.jobOrderId))}
- >
- {openId === r.jobOrderId ? <KeyboardArrowUp /> : <KeyboardArrowDown />}
- </IconButton>
- </TableCell>
- <TableCell sx={{ fontWeight: 600 }}>{r.code}</TableCell>
- <TableCell>
- <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
- <StatusIcon status={r.status} />
- <Box>
- <Typography variant="body2" fontWeight={700}>
- {statusLabelZh(r.status)}
- </Typography>
- <PipelineDots status={r.status} />
- </Box>
- </Stack>
- </TableCell>
- <TableCell>
- <MaterialBar pending={r.materialPendingCount} picked={r.materialPickedCount} />
- </TableCell>
- <TableCell>
- <ProcessBar done={r.processCompletedCount} total={r.processTotalCount} />
- </TableCell>
- <TableCell sx={{ maxWidth: 200 }}>
- <Typography variant="body2" noWrap title={formatCurrentProcessLabel(r.currentProcessCode, r.currentProcessName)}>
- {formatCurrentProcessLabel(r.currentProcessCode, r.currentProcessName)}
- </Typography>
- </TableCell>
- <TableCell>
- <FgStockBar qc={r.fgInQcQty} ready={r.fgReadyToStockInQty} stocked={r.fgStockedQty} />
- <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.5 }}>
- 已驗待入 <strong>{r.fgReadyToStockInCount}</strong> 行,驗收數量 {formatQty(r.fgReadyToStockInQty)};全單驗收合計{" "}
- {formatQty(r.stockInAcceptedQtyTotal)}
- </Typography>
- </TableCell>
- <TableCell align="center">
- <Button
- component={Link}
- href={`/jo/edit?id=${r.jobOrderId}`}
- target="_blank"
- rel="noopener noreferrer"
- size="small"
- >
- 開啟
- </Button>
- </TableCell>
- </TableRow>
- <TableRow>
- <TableCell sx={{ py: 0, borderBottom: openId === r.jobOrderId ? undefined : 0 }} colSpan={COLSPAN}>
- <Collapse in={openId === r.jobOrderId} timeout="auto" unmountOnExit>
- <Box sx={{ py: 2, px: 1 }}>
- <Typography variant="subtitle2" gutterBottom>
- 明細
- </Typography>
- <Stack spacing={1} sx={{ pl: 0.5 }}>
- <Typography variant="caption" color="primary" fontWeight={700} display="block" sx={{ mb: 0.5 }}>
- 生產流程摘要(與工單·工藝流程一致,來自產線彙總)
- </Typography>
- <Stack
- direction={{ xs: "column", sm: "row" }}
- spacing={2}
- alignItems="flex-start"
- sx={{ pl: 0.5 }}
- >
- <Stack spacing={1} sx={{ flex: 1, minWidth: 0 }}>
- <Typography variant="body2" fontWeight={600} sx={{ lineHeight: 1.35 }}>
- {r.itemCode || r.itemName
- ? `${r.itemCode || ""}${r.itemCode && r.itemName ? "-" : ""}${r.itemName || ""}`
- : "—"}
- </Typography>
- <DetailLine label="工單類型">{r.jobTypeName?.trim() ? r.jobTypeName : "—"}</DetailLine>
- <DetailLine label="數量">
- {formatQty(r.reqQty)}
- {r.outputQtyUom ? `(${r.outputQtyUom})` : ""}
- </DetailLine>
- <DetailLine label="生產日期">{r.productionDate || "—"}</DetailLine>
- <DetailLine label="實際耗時(各線起訖加總)">{formatDurationMins(r.actualLineMinsTotal)}</DetailLine>
- </Stack>
- <Stack
- spacing={1}
- sx={{
- flex: 1,
- minWidth: 0,
- pl: { sm: 2 },
- borderLeft: { sm: 1 },
- borderTop: { xs: 1, sm: 0 },
- borderColor: "divider",
- pt: { xs: 1.5, sm: 0 },
- }}
- >
- <DetailLine label="預計生產時間">
- {r.planProcessingMinsTotal > 0 ? `${Math.round(r.planProcessingMinsTotal)} 分鐘` : "—"}
- {r.planSetupChangeoverMinsTotal > 0
- ? ` · 備料/轉換合計 ${Math.round(r.planSetupChangeoverMinsTotal)} 分鐘`
- : ""}
- </DetailLine>
- <DetailLine label="開始時間(產程)">{r.productProcessStart || "—"}</DetailLine>
- <DetailLine label="預計完成時間">
- {formatAssumeEndMmDdHhMm(r.productProcessStart, r.planProcessingMinsTotal)}
- </DetailLine>
- </Stack>
- </Stack>
- <Typography variant="caption" color="text.secondary" fontWeight={700} display="block" sx={{ mt: 1.5, mb: 0.25 }}>
- 工單計劃/實際
- </Typography>
- <DetailLine label="計劃開始">{r.planStart || "—"}</DetailLine>
- <DetailLine label="實際開始">{r.actualStart || "—"}</DetailLine>
- <DetailLine label="計劃結束">{r.planEnd || "—"}</DetailLine>
- <DetailLine label="實際結束">{r.actualEnd || "—"}</DetailLine>
- <DetailLine label="當前製程開始">{r.currentProcessStartTime || "—"}</DetailLine>
- <DetailLine label="階段">
- <Tooltip
- title={
- isKnownJobStatus(r.status)
- ? `系統狀態碼:${r.status}`
- : `未定義對應中文,原始值:${r.status}`
- }
- arrow
- enterDelay={400}
- >
- <Box component="span" sx={{ cursor: "default" }}>
- {statusLabelZh(r.status)}
- </Box>
- </Tooltip>
- </DetailLine>
- <Box sx={{ pt: 0.5 }}>
- <Typography variant="caption" color="text.secondary" fontWeight={600} display="block" sx={{ mb: 0.75 }}>
- 成品/半成品入庫狀況
- </Typography>
- <Stack spacing={0.75} sx={{ pl: 0.5 }}>
- <DetailLine label="QC 中">
- {formatQty(r.fgInQcQty)}({r.fgInQcLineCount} 行)
- </DetailLine>
- <DetailLine label="已驗待入">
- {formatQty(r.fgReadyToStockInQty)}({r.fgReadyToStockInCount} 行)
- </DetailLine>
- <DetailLine label="已入庫">{formatQty(r.fgStockedQty)}</DetailLine>
- <DetailLine label="全單驗收合計">{formatQty(r.stockInAcceptedQtyTotal)}</DetailLine>
- </Stack>
- </Box>
- </Stack>
- </Box>
- </Collapse>
- </TableCell>
- </TableRow>
- </React.Fragment>
- ))
- )}
- </TableBody>
- </Table>
- </TableContainer>
- )}
- </Box>
- );
- }
|