|
- "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<string, { zh: string; en: string }> = {
- 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<FgSummaryStockBucket, { zh: string; en: string }> = {
- 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 <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 L = useUiText();
- 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={L(step.zh, step.en)}>
- <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, 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 (
- <Typography variant="caption" color="text.disabled">
- {L("無領料行", "No pick lines")}
- </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 }}>
- {L(`已揀 ${picked} / 共 ${total}`, `Picked ${picked} / ${total}`)}
- </Typography>
- </Box>
- );
- }
-
- function ProcessBar({ done, total }: { done: number; total: number }) {
- const L = useUiText();
- if (total <= 0) {
- return (
- <Typography variant="caption" color="text.disabled">
- {L("無工序", "No processes")}
- </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 L = useUiText();
- const sum = qc + ready + stocked;
- if (sum <= 0) {
- return (
- <Typography variant="caption" color="text.disabled">
- {L("無入庫數量資料", "No stock-in qty")}
- </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={L(`QC 中 ${formatQty(qc)}`, `In QC ${formatQty(qc)}`)}>
- <Box sx={{ width: `${pQc}%`, bgcolor: "warning.main" }} />
- </Tooltip>
- <Tooltip title={L(`已驗待入 ${formatQty(ready)}`, `Awaiting put-away ${formatQty(ready)}`)}>
- <Box sx={{ width: `${pReady}%`, bgcolor: "info.main" }} />
- </Tooltip>
- <Tooltip title={L(`已入庫 ${formatQty(stocked)}`, `Stocked ${formatQty(stocked)}`)}>
- <Box sx={{ width: `${pStocked}%`, bgcolor: "success.main" }} />
- </Tooltip>
- </Stack>
- <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.35 }} noWrap>
- {L(
- `QC ${formatQty(qc)} · 待入 ${formatQty(ready)} · 已入 ${formatQty(stocked)}`,
- `QC ${formatQty(qc)} · Awaiting ${formatQty(ready)} · Stocked ${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 { 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<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: 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 (
- <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 /> {t("pageTitle_jobOrderBoard")}
- </Typography>
- <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
- {L(
- "圖示化階段與進度條;入庫欄僅統計",
- "Stages and progress are shown as icons. The stock-in column counts only"
- )}
- <strong>{L("成品/半成品", "finished goods / WIP")}</strong>
- {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}`) : ""}
- <strong> {L("快捷鍵", "Shortcuts")}</strong>
- {L("(不在輸入框內時):", " (when the cursor is not in a text field): ")}
- <Box
- component="kbd"
- sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }}
- >
- T
- </Box>{" "}
- {t("today")}
- {L("、", ", ")}
- <Box
- component="kbd"
- sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }}
- >
- Y
- </Box>{" "}
- {L("昨日(僅更新計劃開始日)。", "yesterday (updates the plan start date only).")}
- </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 }}
- >
- {t("equipment_searchAndList")}
- </Typography>
- <Stack spacing={1.25}>
- <Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}>
- {L(`計劃開始日 ${targetDate}(週${planWeekdayZh})`, `Plan start date ${targetDate} (${planWeekdayEn})`)}
- {!isPlanToday && <Chip size="small" label={t("process_nonToday")} sx={{ ml: 1 }} variant="outlined" />}
- </Typography>
- <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}>
- <Tooltip title={L("快捷鍵 T", "Shortcut T")}>
- <span>
- <Button
- size="small"
- variant={isPlanToday && !allIncompleteOpen ? "contained" : "outlined"}
- onClick={() => setTargetDate(dayjs().format("YYYY-MM-DD"))}
- disabled={allIncompleteOpen}
- >
- {t("today")}
- </Button>
- </span>
- </Tooltip>
- <Tooltip title={L("快捷鍵 Y", "Shortcut Y")}>
- <span>
- <Button
- size="small"
- variant="outlined"
- onClick={() => setTargetDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"))}
- disabled={allIncompleteOpen}
- >
- {t("yesterday")}
- </Button>
- </span>
- </Tooltip>
- <TextField
- size="small"
- label={L("計劃開始日", "Plan start date")}
- 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}>
- {t("refresh")}
- </Button>
- </Stack>
- <Tooltip
- title={
- <>
- <Typography variant="caption" component="span" display="block" sx={{ mb: 0.5 }}>
- {L("載入", "Load")}{" "}
- <strong>{L("全部未完成", "all incomplete")}</strong>
- {L(
- "工單(階段≠已完成),不篩「計劃開始日」。筆數多時仍可能較慢。",
- " job orders (stage is not completed), without filtering by plan start date. A large result can still be slow."
- )}
- </Typography>
- <Typography variant="caption" component="span" display="block">
- {L("再按一次可切回依「計劃開始日」載入。", "Click again to load by plan start date.")}
- </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
- ? L("改依計劃開始日", "Use plan start date")
- : L("顯示所有未完成工單", "Show all incomplete job orders")}
- </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 }}
- >
- {t("otherBoards")}
- </Typography>
- <Stack direction="column" spacing={1} sx={{ maxWidth: 220 }}>
- <Button component={Link} href="/chart/equipment/board" size="small" variant="outlined" fullWidth startIcon={<Microwave />}>
- {t("board_equipmentUsage")}
- </Button>
- <Button component={Link} href="/chart/process/board" size="small" variant="outlined" fullWidth>
- {t("board_processLive")}
- </Button>
- <Button component={Link} href="/chart/joborder" size="small" variant="outlined" fullWidth>
- {t("board_jobOrderChart")}
- </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 }}
- >
- {t("autoRefresh")}
- </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": t("autoRefresh") }}
- />
- }
- label={t("on")}
- sx={{ ml: 0, mr: 0 }}
- />
- <FormControl size="small" sx={{ minWidth: 124 }} disabled={!autoRefreshOn}>
- <InputLabel id="jo-board-refresh-interval-label">{t("intervalSeconds")}</InputLabel>
- <Select
- labelId="jo-board-refresh-interval-label"
- label={t("intervalSeconds")}
- value={refreshIntervalSec}
- onChange={(e) => setRefreshIntervalSec(Number(e.target.value))}
- >
- {CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS.map((sec) => (
- <MenuItem key={sec} value={sec}>
- {L(`${sec} 秒`, `${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>
- {L("工單狀態分佈", "Job Order Status")}
- </Typography>
- <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
- {L("點擊圓環或下方圖例可依", "Click a slice or the legend to filter the list by")}{" "}
- <strong>{L("階段", "stage")}</strong>
- {L(
- "篩選列表;再點同一項或表格上方「清除列表篩選」可還原。",
- ". Click the same item again, or Clear list filter above the table, to reset."
- )}
- </Typography>
- {statusDonut.series.length > 0 ? (
- <SafeApexCharts
- chartRevision={JSON.stringify(statusDonut.keys)}
- options={statusDonutChartOptions}
- series={statusDonut.series}
- type="donut"
- height={280}
- />
- ) : (
- <Typography color="text.secondary">{L("無資料", "No data")}</Typography>
- )}
- </Paper>
- <Paper variant="outlined" sx={{ p: 2, flex: 1, minWidth: 280 }}>
- <Typography variant="subtitle2" fontWeight={600} gutterBottom>
- {L("本頁摘要", "Summary on this page")}
- </Typography>
- <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
- {L(
- `領料:待領 ${materialSummary.pending} / 已揀 ${materialSummary.picked}`,
- `Material: pending ${materialSummary.pending} / picked ${materialSummary.picked}`
- )}
- </Typography>
- <MaterialBar pending={materialSummary.pending} picked={materialSummary.picked} />
- <Typography variant="body2" color="text.secondary" sx={{ mt: 2, mb: 0.5 }}>
- {L(
- "成品/半成品入庫(驗收數量)— QC 中/已驗待入/已入庫",
- "Finished goods / WIP stock-in (accepted qty) — in QC / awaiting put-away / stocked"
- )}
- </Typography>
- <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>
- {L(
- "點擊長條色塊或上方圖例,篩選該入庫區塊有資料的工單;再點一次可還原。",
- "Click a bar segment or the legend to show job orders with qty in that stock-in bucket. Click again to reset."
- )}
- </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: L("QC 中", "In QC"), data: [Math.max(0, fgTotals.qc)] },
- { name: L("已驗待入", "Awaiting put-away"), data: [Math.max(0, fgTotals.ready)] },
- { name: L("已入庫", "Stocked"), 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"
- ? L("取消「製程中」篩選", "Clear “In Process” filter")
- : L("篩選製程中工單(階段)", "Filter job orders in process (stage)")}
- </Button>
- </>
- ) : (
- <>
- <Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
- {L("驗收數量合計為 0,不顯示長條圖", "Accepted qty totals 0, so the bar chart is hidden.")}
- </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"
- ? L("取消「製程中」篩選", "Clear “In Process” filter")
- : L("篩選製程中工單(階段)", "Filter job orders in process (stage)")}
- </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">{L("此條件下沒有工單", "No job orders for this filter.")}</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">
- {L("列表篩選中:", "List filter:")}
- </Typography>
- {tableStockBucket && (
- <Chip
- label={L(
- FG_SUMMARY_BUCKET_LABEL[tableStockBucket].zh,
- FG_SUMMARY_BUCKET_LABEL[tableStockBucket].en
- )}
- size="small"
- color="secondary"
- variant="outlined"
- onDelete={() => setTableStockBucket(null)}
- />
- )}
- {tableStatusKey && (
- <Chip
- label={L(`階段:${statusLabel(tableStatusKey, L)}`, `Stage: ${statusLabel(tableStatusKey, L)}`)}
- size="small"
- color="primary"
- variant="outlined"
- onDelete={() => setTableStatusKey(null)}
- />
- )}
- <Button
- size="small"
- variant="text"
- onClick={() => {
- setTableStatusKey(null);
- setTableStockBucket(null);
- }}
- >
- {L("清除列表篩選", "Clear list filter")}
- </Button>
- </Stack>
- </Box>
- )}
- <Table size="small" stickyHeader>
- <TableHead>
- <TableRow>
- <TableCell padding="checkbox" />
- <TableCell>{L("工單號", "Job Order No.")}</TableCell>
- <TableCell sx={{ minWidth: 200 }}>{L("階段", "Stage")}</TableCell>
- <TableCell>{L("領料進度", "Pick progress")}</TableCell>
- <TableCell>{L("工序進度", "Process progress")}</TableCell>
- <TableCell>{L("當前製程", "Current process")}</TableCell>
- <TableCell sx={{ minWidth: 200 }}>{L("成品/半成品入庫", "FG / WIP stock-in")}</TableCell>
- <TableCell align="center">{L("開啟", "Open")}</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {displayRows.length === 0 ? (
- <TableRow>
- <TableCell colSpan={COLSPAN} align="center" sx={{ py: 4 }}>
- <Typography color="text.secondary">
- {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")}
- </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}>
- {statusLabel(r.status, L)}
- </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 }}>
- {L("已驗待入", "Awaiting put-away")} <strong>{r.fgReadyToStockInCount}</strong>{" "}
- {L("行,驗收數量", "lines, accepted qty")} {formatQty(r.fgReadyToStockInQty)}
- {L(";全單驗收合計", "; order accepted total")}{" "}
- {formatQty(r.stockInAcceptedQtyTotal)}
- </Typography>
- </TableCell>
- <TableCell align="center">
- <Button
- component={Link}
- href={`/jo/edit?id=${r.jobOrderId}`}
- target="_blank"
- rel="noopener noreferrer"
- size="small"
- >
- {L("開啟", "Open")}
- </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>
- {L("明細", "Details")}
- </Typography>
- <Stack spacing={1} sx={{ pl: 0.5 }}>
- <Typography variant="caption" color="primary" fontWeight={700} display="block" sx={{ mb: 0.5 }}>
- {L(
- "生產流程摘要(與工單·工藝流程一致,來自產線彙總)",
- "Production summary (same as the job order process flow, from the line rollup)"
- )}
- </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={L("工單類型", "Job Order Type")}>{r.jobTypeName?.trim() ? r.jobTypeName : "—"}</DetailLine>
- <DetailLine label={t("warehouse_qty")}>
- {formatQty(r.reqQty)}
- {r.outputQtyUom ? L(`(${r.outputQtyUom})`, ` (${r.outputQtyUom})`) : ""}
- </DetailLine>
- <DetailLine label={L("生產日期", "Production date")}>{r.productionDate || "—"}</DetailLine>
- <DetailLine label={L("實際耗時(各線起訖加總)", "Actual time (sum of line start to end)")}>
- {formatDurationMins(r.actualLineMinsTotal, L)}
- </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={L("預計生產時間", "Planned production time")}>
- {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`
- )
- : ""}
- </DetailLine>
- <DetailLine label={L("開始時間(產程)", "Start time (run)")}>{r.productProcessStart || "—"}</DetailLine>
- <DetailLine label={L("預計完成時間", "Estimated finish")}>
- {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 }}>
- {L("工單計劃/實際", "Job Order Plan / Actual")}
- </Typography>
- <DetailLine label={L("計劃開始", "Plan start")}>{r.planStart || "—"}</DetailLine>
- <DetailLine label={L("實際開始", "Actual start")}>{r.actualStart || "—"}</DetailLine>
- <DetailLine label={L("計劃結束", "Plan end")}>{r.planEnd || "—"}</DetailLine>
- <DetailLine label={L("實際結束", "Actual end")}>{r.actualEnd || "—"}</DetailLine>
- <DetailLine label={L("當前製程開始", "Current process start")}>{r.currentProcessStartTime || "—"}</DetailLine>
- <DetailLine label={L("階段", "Stage")}>
- <Tooltip
- title={
- isKnownJobStatus(r.status)
- ? L(`系統狀態碼:${r.status}`, `Status code: ${r.status}`)
- : L(`未定義對應中文,原始值:${r.status}`, `No label for this status. Raw value: ${r.status}`)
- }
- arrow
- enterDelay={400}
- >
- <Box component="span" sx={{ cursor: "default" }}>
- {statusLabel(r.status, L)}
- </Box>
- </Tooltip>
- </DetailLine>
- <Box sx={{ pt: 0.5 }}>
- <Typography variant="caption" color="text.secondary" fontWeight={600} display="block" sx={{ mb: 0.75 }}>
- {L("成品/半成品入庫狀況", "FG / WIP stock-in")}
- </Typography>
- <Stack spacing={0.75} sx={{ pl: 0.5 }}>
- <DetailLine label={L("QC 中", "In QC")}>
- {formatQty(r.fgInQcQty)}
- {L(`(${r.fgInQcLineCount} 行)`, ` (${r.fgInQcLineCount} lines)`)}
- </DetailLine>
- <DetailLine label={L("已驗待入", "Awaiting put-away")}>
- {formatQty(r.fgReadyToStockInQty)}
- {L(`(${r.fgReadyToStockInCount} 行)`, ` (${r.fgReadyToStockInCount} lines)`)}
- </DetailLine>
- <DetailLine label={L("已入庫", "Stocked")}>{formatQty(r.fgStockedQty)}</DetailLine>
- <DetailLine label={L("全單驗收合計", "Order accepted total")}>{formatQty(r.stockInAcceptedQtyTotal)}</DetailLine>
- </Stack>
- </Box>
- </Stack>
- </Box>
- </Collapse>
- </TableCell>
- </TableRow>
- </React.Fragment>
- ))
- )}
- </TableBody>
- </Table>
- </TableContainer>
- )}
- </Box>
- );
- }
|