"use client"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, TextField, Alert, CircularProgress, IconButton, Collapse, Stack, Tooltip, Button, LinearProgress, Chip, FormControl, FormControlLabel, InputLabel, MenuItem, Select, Switch, } from "@mui/material"; import Link from "next/link"; import dayjs from "dayjs"; import { alpha, useTheme } from "@mui/material/styles"; import ViewKanban from "@mui/icons-material/ViewKanban"; import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown"; import KeyboardArrowUp from "@mui/icons-material/KeyboardArrowUp"; import EditCalendar from "@mui/icons-material/EditCalendar"; import Schedule from "@mui/icons-material/Schedule"; import Inventory2 from "@mui/icons-material/Inventory2"; import PrecisionManufacturing from "@mui/icons-material/PrecisionManufacturing"; import Microwave from "@mui/icons-material/Microwave"; import VerifiedUser from "@mui/icons-material/VerifiedUser"; import Warehouse from "@mui/icons-material/Warehouse"; import CheckCircle from "@mui/icons-material/CheckCircle"; import HelpOutline from "@mui/icons-material/HelpOutline"; import { fetchJobOrderBoard, type JobOrderBoardRow } from "@/app/api/chart/client"; import SafeApexCharts from "@/components/charts/SafeApexCharts"; import { CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS } from "@/app/(main)/chart/chartBoardRefreshPrefs"; import { useChartBoardRefreshPrefs } from "@/app/(main)/chart/useChartBoardRefreshPrefs"; const PIPELINE = [ { status: "planning", label: "計劃" }, { status: "pending", label: "待開" }, { status: "packaging", label: "包裝" }, { status: "processing", label: "製程" }, { status: "pendingqc", label: "QC" }, { status: "storing", label: "上架" }, { status: "completed", label: "完成" }, ] as const; const JOB_STATUS_ZH: Record = { planning: "計劃中", pending: "待開工", packaging: "包裝", processing: "製程中", pendingqc: "待質檢", storing: "上架中", completed: "已完成", unknown: "未知", }; /** 與摘要堆疊長條圖 series 順序一致:QC 中 → 已驗待入 → 已入庫 */ const FG_SUMMARY_SERIES_BUCKETS = ["qc", "ready", "stocked"] as const; type FgSummaryStockBucket = (typeof FG_SUMMARY_SERIES_BUCKETS)[number]; const FG_SUMMARY_BUCKET_LABEL: Record = { qc: "入庫 QC 中(有量)", ready: "已驗待入(有量)", stocked: "已入庫(有量)", }; function normStatus(s: string | undefined): string { return (s ?? "").trim().toLowerCase(); } function statusLabelZh(status: string): string { return JOB_STATUS_ZH[normStatus(status)] ?? status; } function isKnownJobStatus(status: string): boolean { return normStatus(status) in JOB_STATUS_ZH; } function StatusIcon({ status }: { status: string }) { const s = normStatus(status); const sx = { fontSize: 28, opacity: 0.95 }; switch (s) { case "planning": return ; case "pending": return ; case "packaging": return ; case "processing": return ; case "pendingqc": return ; case "storing": return ; case "completed": return ; default: return ; } } function statusStepIndex(status: string): number { const s = normStatus(status); const i = PIPELINE.findIndex((p) => p.status === s); return i >= 0 ? i : 0; } /** Dot chain — current step ring highlight */ function PipelineDots({ status }: { status: string }) { const idx = statusStepIndex(status); const allDone = normStatus(status) === "completed"; return ( {PIPELINE.map((step, i) => { const done = allDone || i < idx; const current = !allDone && i === idx; return ( ); })} ); } function formatQty(n: number): string { if (!Number.isFinite(n) || n === 0) return "0"; const t = Number(n); return Math.abs(t - Math.round(t)) < 1e-6 ? String(Math.round(t)) : t.toFixed(2); } /** 與工藝流程摘要:開始時間 + 僅生產分鐘(不含備料/轉換) */ function formatAssumeEndMmDdHhMm(processStart: string, planProcessingMins: number): string { if (!processStart?.trim() || !planProcessingMins) return "—"; const d = dayjs(processStart.replace(" ", "T")); if (!d.isValid()) return "—"; return d.add(planProcessingMins, "minute").format("MM-DD HH:mm"); } /** Backend sends decimal minutes (from summed seconds ÷ 60). */ function formatDurationMins(mins: number): string { if (!Number.isFinite(mins) || mins <= 0) return "—"; const secs = Math.round(mins * 60); if (secs < 60) return `${secs} 秒`; if (mins < 10) return `${mins.toFixed(1)} 分`; return `${Math.round(mins)} 分`; } /** Avoid "CODE CODE" or "X X" when DB code/name duplicate or name already includes code. */ function formatCurrentProcessLabel(code: string, name: string): string { const c = (code ?? "").trim(); const n = (name ?? "").trim(); if (!c && !n) return "—"; if (!c) return n; if (!n) return c; const lc = c.toLowerCase(); const ln = n.toLowerCase(); if (lc === ln) return n; if (ln.startsWith(`${lc} `) || ln.startsWith(`${lc} `)) return n; if (n.length > c.length && ln.startsWith(lc)) { const after = n.slice(c.length, c.length + 1); if (/[\s\-–—::·.|//]/.test(after)) return n; } return `${c} ${n}`; } function MaterialBar({ pending, picked }: { pending: number; picked: number }) { const total = pending + picked; if (total <= 0) { return ( 無領料行 ); } const pct = (100 * picked) / total; return ( = 100 ? "success" : "primary"} /> 已揀 {picked} / 共 {total} ); } function ProcessBar({ done, total }: { done: number; total: number }) { if (total <= 0) { return ( 無工序 ); } const pct = (100 * done) / total; return ( = total ? "success" : "info"} /> {done}/{total} ); } /** 成品/半成品入庫:QC 中 | 已驗待入 | 已入庫 — by qty */ function FgStockBar({ qc, ready, stocked }: { qc: number; ready: number; stocked: number }) { const sum = qc + ready + stocked; if (sum <= 0) { return ( 無入庫數量資料 ); } const pQc = (100 * qc) / sum; const pReady = (100 * ready) / sum; const pStocked = 100 - pQc - pReady; return ( QC {formatQty(qc)} · 待入 {formatQty(ready)} · 已入 {formatQty(stocked)} ); } const COLSPAN = 8; function DetailLine({ label, children }: { label: string; children: React.ReactNode }) { return ( {label} {children} ); } export default function JobOrderBoardPage() { const theme = useTheme(); const [targetDate, setTargetDate] = useState(() => dayjs().format("YYYY-MM-DD")); /** true: load all non-completed JOs (no plan-start date); false: filter by targetDate */ const [allIncompleteOpen, setAllIncompleteOpen] = useState(false); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [lastUpdated, setLastUpdated] = useState(""); const [openId, setOpenId] = useState(null); /** Normalized status key from donut (e.g. pending); null = not filtering by stage */ const [tableStatusKey, setTableStatusKey] = useState(null); /** Summary bar: filter rows that have FG/WIP stock-in in that bucket; mutually exclusive with tableStatusKey in UI */ const [tableStockBucket, setTableStockBucket] = useState(null); const { autoRefreshOn, setAutoRefreshOn, refreshIntervalSec, setRefreshIntervalSec } = useChartBoardRefreshPrefs("joborder"); const load = useCallback(async () => { setLoading(true); setError(null); try { const data = allIncompleteOpen ? await fetchJobOrderBoard(undefined, { incompleteOnly: true }) : await fetchJobOrderBoard(targetDate); setRows(data); setLastUpdated(dayjs().format("YYYY-MM-DD HH:mm:ss")); } catch (e) { setError(e instanceof Error ? e.message : "Request failed"); } finally { setLoading(false); } }, [allIncompleteOpen, targetDate]); useEffect(() => { void load(); }, [load]); useEffect(() => { if (!autoRefreshOn || refreshIntervalSec < 10) return; const t = setInterval(() => void load(), refreshIntervalSec * 1000); return () => clearInterval(t); }, [load, autoRefreshOn, refreshIntervalSec]); useEffect(() => { setTableStatusKey(null); setTableStockBucket(null); }, [targetDate, allIncompleteOpen]); useEffect(() => { setOpenId(null); }, [tableStatusKey, tableStockBucket]); useEffect(() => { const onKey = (ev: KeyboardEvent) => { const t = ev.target as HTMLElement | null; if ( t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable) ) { return; } if (ev.ctrlKey || ev.metaKey || ev.altKey) return; if (ev.key === "t" || ev.key === "T") { ev.preventDefault(); setTargetDate(dayjs().format("YYYY-MM-DD")); } if (ev.key === "y" || ev.key === "Y") { ev.preventDefault(); setTargetDate(dayjs().subtract(1, "day").format("YYYY-MM-DD")); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); const statusDonut = useMemo(() => { const map = new Map(); rows.forEach((r) => { const k = normStatus(r.status) || "unknown"; map.set(k, (map.get(k) ?? 0) + 1); }); const keysAll = Array.from(map.keys()); const pairs = keysAll .map((k) => { const v = map.get(k) ?? 0; const n = Number.isFinite(v) && v > 0 ? v : 0; return { key: k, label: JOB_STATUS_ZH[k] ?? k, v: n }; }) .filter((p) => p.v > 0); return { keys: pairs.map((p) => p.key), labels: pairs.map((p) => p.label), series: pairs.map((p) => p.v), }; }, [rows]); const displayRows = useMemo(() => { const fin = (n: number) => (Number.isFinite(n) ? n : 0); if (tableStockBucket) { return rows.filter((r) => { if (tableStockBucket === "qc") return fin(r.fgInQcQty) > 0 || (r.fgInQcLineCount ?? 0) > 0; if (tableStockBucket === "ready") return fin(r.fgReadyToStockInQty) > 0 || (r.fgReadyToStockInCount ?? 0) > 0; if (tableStockBucket === "stocked") return fin(r.fgStockedQty) > 0; return true; }); } if (tableStatusKey) { return rows.filter((r) => (normStatus(r.status) || "unknown") === tableStatusKey); } return rows; }, [rows, tableStatusKey, tableStockBucket]); const donutSliceClickRef = useRef<(index: number) => void>(() => {}); donutSliceClickRef.current = (index: number) => { setTableStockBucket(null); const key = statusDonut.keys[index]; if (key == null) return; setTableStatusKey((prev) => (prev === key ? null : key)); }; const fgBarSeriesClickRef = useRef<(seriesIndex: number) => void>(() => {}); fgBarSeriesClickRef.current = (seriesIndex: number) => { if (typeof seriesIndex !== "number" || seriesIndex < 0 || seriesIndex >= FG_SUMMARY_SERIES_BUCKETS.length) return; setTableStatusKey(null); const bucket = FG_SUMMARY_SERIES_BUCKETS[seriesIndex]; setTableStockBucket((prev) => (prev === bucket ? null : bucket)); }; const statusDonutChartOptions = useMemo( () => ({ chart: { type: "donut" as const, toolbar: { show: false }, events: { dataPointSelection: (_e: unknown, _ctx: unknown, cfg: { dataPointIndex?: number }) => { const i = cfg?.dataPointIndex; if (typeof i !== "number" || i < 0) return; donutSliceClickRef.current(i); }, /** Donut/pie: legend index matches slice index */ legendClick: (_chartContext: unknown, seriesIndex: number) => { if (typeof seriesIndex !== "number" || seriesIndex < 0) return false; donutSliceClickRef.current(seriesIndex); return false; }, }, }, labels: statusDonut.labels, legend: { position: "bottom" as const, onItemClick: { toggleDataSeries: false }, }, plotOptions: { pie: { donut: { size: "62%" } } }, dataLabels: { enabled: true }, }), [statusDonut.labels], ); const fgSummaryChartOptions = useMemo( () => ({ chart: { type: "bar" as const, stacked: true, toolbar: { show: false }, events: { dataPointSelection: (_e: unknown, _ctx: unknown, cfg: { seriesIndex?: number; dataPointIndex?: number }) => { const si = cfg?.seriesIndex; if (typeof si !== "number") return; fgBarSeriesClickRef.current(si); }, legendClick: (_chartContext: unknown, seriesIndex: number) => { if (typeof seriesIndex !== "number" || seriesIndex < 0) return false; fgBarSeriesClickRef.current(seriesIndex); return false; }, }, }, plotOptions: { bar: { horizontal: true, barHeight: "48%" } }, xaxis: { categories: ["合計"] }, colors: ["#ed6c02", "#0288d1", "#2e7d32"], legend: { position: "top" as const, onItemClick: { toggleDataSeries: false }, }, dataLabels: { enabled: false }, }), [], ); const materialSummary = useMemo(() => { let p = 0; let k = 0; rows.forEach((r) => { p += r.materialPendingCount; k += r.materialPickedCount; }); return { pending: p, picked: k }; }, [rows]); const fgTotals = useMemo(() => { const fin = (n: number) => (Number.isFinite(n) ? n : 0); let qc = 0; let ready = 0; let stocked = 0; rows.forEach((r) => { qc += fin(r.fgInQcQty); ready += fin(r.fgReadyToStockInQty); stocked += fin(r.fgStockedQty); }); return { qc, ready, stocked }; }, [rows]); const isPlanToday = targetDate === dayjs().format("YYYY-MM-DD"); const planWeekdayZh = ["日", "一", "二", "三", "四", "五", "六"][dayjs(targetDate).day()] ?? ""; return ( 工單即時看板 圖示化階段與進度條;入庫欄僅統計成品/半成品(系統 FG/WIP)工單之入庫明細(依驗收數量)。「已驗待入」= 狀態為 receiving / received(QC 完成待上架)。 {autoRefreshOn ? ` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)` : " 預設不自動更新,可開啟「自動重新整理」並選擇間隔。"} {" 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。"} {lastUpdated ? ` · 最後更新 ${lastUpdated}` : ""} 快捷鍵(不在輸入框內時): T {" "} 今日、 Y {" "} 昨日(僅更新計劃開始日)。 {error && ( setError(null)}> {error} )} 查詢與列表 計劃開始日 {targetDate}(週{planWeekdayZh}) {!isPlanToday && } setTargetDate(e.target.value)} disabled={allIncompleteOpen} InputLabelProps={{ shrink: true }} sx={{ minWidth: 178 }} /> 載入全部未完成工單(階段≠已完成),不篩「計劃開始日」。筆數多時仍可能較慢。 再按一次可切回依「計劃開始日」載入。 } arrow placement="top-start" > 其他看板 自動重新整理 setAutoRefreshOn(v)} inputProps={{ "aria-label": "自動重新整理" }} /> } label="開啟" sx={{ ml: 0, mr: 0 }} /> 間隔(秒) {!loading && rows.length > 0 && ( 工單狀態分佈 點擊圓環或下方圖例可依階段篩選列表;再點同一項或表格上方「清除列表篩選」可還原。 {statusDonut.series.length > 0 ? ( ) : ( 無資料 )} 本頁摘要 領料:待領 {materialSummary.pending} / 已揀 {materialSummary.picked} 成品/半成品入庫(驗收數量)— QC 中/已驗待入/已入庫 點擊長條色塊或上方圖例,篩選該入庫區塊有資料的工單;再點一次可還原。 {fgTotals.qc + fgTotals.ready + fgTotals.stocked > 0 ? ( <> ) : ( <> 驗收數量合計為 0,不顯示長條圖 )} )} {loading && rows.length === 0 ? ( ) : rows.length === 0 ? ( 此條件下沒有工單 ) : ( {(tableStatusKey || tableStockBucket) && ( 列表篩選中: {tableStockBucket && ( setTableStockBucket(null)} /> )} {tableStatusKey && ( setTableStatusKey(null)} /> )} )} 工單號 階段 領料進度 工序進度 當前製程 成品/半成品入庫 開啟 {displayRows.length === 0 ? ( {tableStockBucket ? "目前沒有符合此入庫區塊的工單(可點「清除列表篩選」)。" : tableStatusKey ? "此階段下目前沒有工單(可點「清除列表篩選」)。" : "沒有資料"} ) : ( displayRows.map((r) => ( setOpenId((id) => (id === r.jobOrderId ? null : r.jobOrderId))} > {openId === r.jobOrderId ? : } {r.code} {statusLabelZh(r.status)} {formatCurrentProcessLabel(r.currentProcessCode, r.currentProcessName)} 已驗待入 {r.fgReadyToStockInCount} 行,驗收數量 {formatQty(r.fgReadyToStockInQty)};全單驗收合計{" "} {formatQty(r.stockInAcceptedQtyTotal)} 明細 生產流程摘要(與工單·工藝流程一致,來自產線彙總) {r.itemCode || r.itemName ? `${r.itemCode || ""}${r.itemCode && r.itemName ? "-" : ""}${r.itemName || ""}` : "—"} {r.jobTypeName?.trim() ? r.jobTypeName : "—"} {formatQty(r.reqQty)} {r.outputQtyUom ? `(${r.outputQtyUom})` : ""} {r.productionDate || "—"} {formatDurationMins(r.actualLineMinsTotal)} {r.planProcessingMinsTotal > 0 ? `${Math.round(r.planProcessingMinsTotal)} 分鐘` : "—"} {r.planSetupChangeoverMinsTotal > 0 ? ` · 備料/轉換合計 ${Math.round(r.planSetupChangeoverMinsTotal)} 分鐘` : ""} {r.productProcessStart || "—"} {formatAssumeEndMmDdHhMm(r.productProcessStart, r.planProcessingMinsTotal)} 工單計劃/實際 {r.planStart || "—"} {r.actualStart || "—"} {r.planEnd || "—"} {r.actualEnd || "—"} {r.currentProcessStartTime || "—"} {statusLabelZh(r.status)} 成品/半成品入庫狀況 {formatQty(r.fgInQcQty)}({r.fgInQcLineCount} 行) {formatQty(r.fgReadyToStockInQty)}({r.fgReadyToStockInCount} 行) {formatQty(r.fgStockedQty)} {formatQty(r.stockInAcceptedQtyTotal)} )) )}
)}
); }