"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"; import { useTranslation } from "react-i18next"; import { useUiText } from "@/i18n/useUiText"; type UiText = (zh: string, en: string) => string; const PIPELINE = [ { status: "planning", zh: "計劃", en: "Planning" }, { status: "pending", zh: "待開", en: "Pending" }, { status: "packaging", zh: "包裝", en: "Packaging" }, { status: "processing", zh: "製程", en: "In Process" }, { status: "pendingqc", zh: "QC", en: "QC" }, { status: "storing", zh: "上架", en: "Put-away" }, { status: "completed", zh: "完成", en: "Done" }, ] as const; const JOB_STATUS: Record = { planning: { zh: "計劃中", en: "Planning" }, pending: { zh: "待開工", en: "Not Started" }, packaging: { zh: "包裝", en: "Packaging" }, processing: { zh: "製程中", en: "In Process" }, pendingqc: { zh: "待質檢", en: "Pending QC" }, storing: { zh: "上架中", en: "Put-away" }, completed: { zh: "已完成", en: "Completed" }, unknown: { zh: "未知", en: "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: { zh: "入庫 QC 中(有量)", en: "In QC (has qty)" }, ready: { zh: "已驗待入(有量)", en: "Checked, awaiting put-away (has qty)" }, stocked: { zh: "已入庫(有量)", en: "Stocked (has qty)" }, }; function normStatus(s: string | undefined): string { return (s ?? "").trim().toLowerCase(); } function statusLabel(status: string, L: UiText): string { const row = JOB_STATUS[normStatus(status)]; return row ? L(row.zh, row.en) : status; } function isKnownJobStatus(status: string): boolean { return normStatus(status) in JOB_STATUS; } 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 L = useUiText(); 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, L: UiText): string { if (!Number.isFinite(mins) || mins <= 0) return "—"; const secs = Math.round(mins * 60); if (secs < 60) return L(`${secs} 秒`, `${secs} sec`); if (mins < 10) return L(`${mins.toFixed(1)} 分`, `${mins.toFixed(1)} min`); return L(`${Math.round(mins)} 分`, `${Math.round(mins)} min`); } /** 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 L = useUiText(); const total = pending + picked; if (total <= 0) { return ( {L("無領料行", "No pick lines")} ); } const pct = (100 * picked) / total; return ( = 100 ? "success" : "primary"} /> {L(`已揀 ${picked} / 共 ${total}`, `Picked ${picked} / ${total}`)} ); } function ProcessBar({ done, total }: { done: number; total: number }) { const L = useUiText(); if (total <= 0) { return ( {L("無工序", "No processes")} ); } 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 L = useUiText(); const sum = qc + ready + stocked; if (sum <= 0) { return ( {L("無入庫數量資料", "No stock-in qty")} ); } const pQc = (100 * qc) / sum; const pReady = (100 * ready) / sum; const pStocked = 100 - pQc - pReady; return ( {L( `QC ${formatQty(qc)} · 待入 ${formatQty(ready)} · 已入 ${formatQty(stocked)}`, `QC ${formatQty(qc)} · Awaiting ${formatQty(ready)} · Stocked ${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 { t } = useTranslation("chart"); const L = useUiText(); 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: statusLabel(k, L), 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, L]); 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: [t("series_total")] }, colors: ["#ed6c02", "#0288d1", "#2e7d32"], legend: { position: "top" as const, onItemClick: { toggleDataSeries: false }, }, dataLabels: { enabled: false }, }), [t], ); 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 weekdayIndex = dayjs(targetDate).day(); const planWeekdayZh = ["日", "一", "二", "三", "四", "五", "六"][weekdayIndex] ?? ""; const planWeekdayEn = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][weekdayIndex] ?? ""; return ( {t("pageTitle_jobOrderBoard")} {L( "圖示化階段與進度條;入庫欄僅統計", "Stages and progress are shown as icons. The stock-in column counts only" )} {L("成品/半成品", "finished goods / WIP")} {L( "(系統 FG/WIP)工單之入庫明細(依驗收數量)。「已驗待入」= 狀態為 receiving / received(QC 完成待上架)。", " (system FG/WIP) job orders, using accepted qty. “Awaiting put-away” means status receiving or received (QC done, waiting for put-away)." )} {autoRefreshOn ? L( ` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)`, ` Auto refresh is on (every ${refreshIntervalSec} sec).` ) : L( " 預設不自動更新,可開啟「自動重新整理」並選擇間隔。", " Auto refresh is off by default. Turn on Auto Refresh and choose an interval." )} {L( " 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。", " Settings are saved on this device (per account when signed in, or for this browser session when signed out)." )} {lastUpdated ? L(` · 最後更新 ${lastUpdated}`, ` · Last updated ${lastUpdated}`) : ""} {L("快捷鍵", "Shortcuts")} {L("(不在輸入框內時):", " (when the cursor is not in a text field): ")} T {" "} {t("today")} {L("、", ", ")} Y {" "} {L("昨日(僅更新計劃開始日)。", "yesterday (updates the plan start date only).")} {error && ( setError(null)}> {error} )} {t("equipment_searchAndList")} {L(`計劃開始日 ${targetDate}(週${planWeekdayZh})`, `Plan start date ${targetDate} (${planWeekdayEn})`)} {!isPlanToday && } setTargetDate(e.target.value)} disabled={allIncompleteOpen} InputLabelProps={{ shrink: true }} sx={{ minWidth: 178 }} /> {L("載入", "Load")}{" "} {L("全部未完成", "all incomplete")} {L( "工單(階段≠已完成),不篩「計劃開始日」。筆數多時仍可能較慢。", " job orders (stage is not completed), without filtering by plan start date. A large result can still be slow." )} {L("再按一次可切回依「計劃開始日」載入。", "Click again to load by plan start date.")} } arrow placement="top-start" > {t("otherBoards")} {t("autoRefresh")} setAutoRefreshOn(v)} inputProps={{ "aria-label": t("autoRefresh") }} /> } label={t("on")} sx={{ ml: 0, mr: 0 }} /> {t("intervalSeconds")} {!loading && rows.length > 0 && ( {L("工單狀態分佈", "Job Order Status")} {L("點擊圓環或下方圖例可依", "Click a slice or the legend to filter the list by")}{" "} {L("階段", "stage")} {L( "篩選列表;再點同一項或表格上方「清除列表篩選」可還原。", ". Click the same item again, or Clear list filter above the table, to reset." )} {statusDonut.series.length > 0 ? ( ) : ( {L("無資料", "No data")} )} {L("本頁摘要", "Summary on this page")} {L( `領料:待領 ${materialSummary.pending} / 已揀 ${materialSummary.picked}`, `Material: pending ${materialSummary.pending} / picked ${materialSummary.picked}` )} {L( "成品/半成品入庫(驗收數量)— QC 中/已驗待入/已入庫", "Finished goods / WIP stock-in (accepted qty) — in QC / awaiting put-away / stocked" )} {L( "點擊長條色塊或上方圖例,篩選該入庫區塊有資料的工單;再點一次可還原。", "Click a bar segment or the legend to show job orders with qty in that stock-in bucket. Click again to reset." )} {fgTotals.qc + fgTotals.ready + fgTotals.stocked > 0 ? ( <> ) : ( <> {L("驗收數量合計為 0,不顯示長條圖", "Accepted qty totals 0, so the bar chart is hidden.")} )} )} {loading && rows.length === 0 ? ( ) : rows.length === 0 ? ( {L("此條件下沒有工單", "No job orders for this filter.")} ) : ( {(tableStatusKey || tableStockBucket) && ( {L("列表篩選中:", "List filter:")} {tableStockBucket && ( setTableStockBucket(null)} /> )} {tableStatusKey && ( setTableStatusKey(null)} /> )} )} {L("工單號", "Job Order No.")} {L("階段", "Stage")} {L("領料進度", "Pick progress")} {L("工序進度", "Process progress")} {L("當前製程", "Current process")} {L("成品/半成品入庫", "FG / WIP stock-in")} {L("開啟", "Open")} {displayRows.length === 0 ? ( {tableStockBucket ? L( "目前沒有符合此入庫區塊的工單(可點「清除列表篩選」)。", "No job orders match this stock-in bucket. Click Clear list filter." ) : tableStatusKey ? L( "此階段下目前沒有工單(可點「清除列表篩選」)。", "No job orders in this stage. Click Clear list filter." ) : L("沒有資料", "No data")} ) : ( displayRows.map((r) => ( setOpenId((id) => (id === r.jobOrderId ? null : r.jobOrderId))} > {openId === r.jobOrderId ? : } {r.code} {statusLabel(r.status, L)} {formatCurrentProcessLabel(r.currentProcessCode, r.currentProcessName)} {L("已驗待入", "Awaiting put-away")} {r.fgReadyToStockInCount}{" "} {L("行,驗收數量", "lines, accepted qty")} {formatQty(r.fgReadyToStockInQty)} {L(";全單驗收合計", "; order accepted total")}{" "} {formatQty(r.stockInAcceptedQtyTotal)} {L("明細", "Details")} {L( "生產流程摘要(與工單·工藝流程一致,來自產線彙總)", "Production summary (same as the job order process flow, from the line rollup)" )} {r.itemCode || r.itemName ? `${r.itemCode || ""}${r.itemCode && r.itemName ? "-" : ""}${r.itemName || ""}` : "—"} {r.jobTypeName?.trim() ? r.jobTypeName : "—"} {formatQty(r.reqQty)} {r.outputQtyUom ? L(`(${r.outputQtyUom})`, ` (${r.outputQtyUom})`) : ""} {r.productionDate || "—"} {formatDurationMins(r.actualLineMinsTotal, L)} {r.planProcessingMinsTotal > 0 ? L( `${Math.round(r.planProcessingMinsTotal)} 分鐘`, `${Math.round(r.planProcessingMinsTotal)} min` ) : "—"} {r.planSetupChangeoverMinsTotal > 0 ? L( ` · 備料/轉換合計 ${Math.round(r.planSetupChangeoverMinsTotal)} 分鐘`, ` · Setup and changeover ${Math.round(r.planSetupChangeoverMinsTotal)} min` ) : ""} {r.productProcessStart || "—"} {formatAssumeEndMmDdHhMm(r.productProcessStart, r.planProcessingMinsTotal)} {L("工單計劃/實際", "Job Order Plan / Actual")} {r.planStart || "—"} {r.actualStart || "—"} {r.planEnd || "—"} {r.actualEnd || "—"} {r.currentProcessStartTime || "—"} {statusLabel(r.status, L)} {L("成品/半成品入庫狀況", "FG / WIP stock-in")} {formatQty(r.fgInQcQty)} {L(`(${r.fgInQcLineCount} 行)`, ` (${r.fgInQcLineCount} lines)`)} {formatQty(r.fgReadyToStockInQty)} {L(`(${r.fgReadyToStockInCount} 行)`, ` (${r.fgReadyToStockInCount} lines)`)} {formatQty(r.fgStockedQty)} {formatQty(r.stockInAcceptedQtyTotal)} )) )}
)}
); }