FPSMS-frontend
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 

1133 líneas
48 KiB

  1. "use client";
  2. import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
  3. import {
  4. Box,
  5. Typography,
  6. Table,
  7. TableBody,
  8. TableCell,
  9. TableContainer,
  10. TableHead,
  11. TableRow,
  12. Paper,
  13. TextField,
  14. Alert,
  15. CircularProgress,
  16. IconButton,
  17. Collapse,
  18. Stack,
  19. Tooltip,
  20. Button,
  21. LinearProgress,
  22. Chip,
  23. FormControl,
  24. FormControlLabel,
  25. InputLabel,
  26. MenuItem,
  27. Select,
  28. Switch,
  29. } from "@mui/material";
  30. import Link from "next/link";
  31. import dayjs from "dayjs";
  32. import { alpha, useTheme } from "@mui/material/styles";
  33. import ViewKanban from "@mui/icons-material/ViewKanban";
  34. import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown";
  35. import KeyboardArrowUp from "@mui/icons-material/KeyboardArrowUp";
  36. import EditCalendar from "@mui/icons-material/EditCalendar";
  37. import Schedule from "@mui/icons-material/Schedule";
  38. import Inventory2 from "@mui/icons-material/Inventory2";
  39. import PrecisionManufacturing from "@mui/icons-material/PrecisionManufacturing";
  40. import Microwave from "@mui/icons-material/Microwave";
  41. import VerifiedUser from "@mui/icons-material/VerifiedUser";
  42. import Warehouse from "@mui/icons-material/Warehouse";
  43. import CheckCircle from "@mui/icons-material/CheckCircle";
  44. import HelpOutline from "@mui/icons-material/HelpOutline";
  45. import { fetchJobOrderBoard, type JobOrderBoardRow } from "@/app/api/chart/client";
  46. import SafeApexCharts from "@/components/charts/SafeApexCharts";
  47. import { CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS } from "@/app/(main)/chart/chartBoardRefreshPrefs";
  48. import { useChartBoardRefreshPrefs } from "@/app/(main)/chart/useChartBoardRefreshPrefs";
  49. import { useTranslation } from "react-i18next";
  50. import { useUiText } from "@/i18n/useUiText";
  51. type UiText = (zh: string, en: string) => string;
  52. const PIPELINE = [
  53. { status: "planning", zh: "計劃", en: "Planning" },
  54. { status: "pending", zh: "待開", en: "Pending" },
  55. { status: "packaging", zh: "包裝", en: "Packaging" },
  56. { status: "processing", zh: "製程", en: "In Process" },
  57. { status: "pendingqc", zh: "QC", en: "QC" },
  58. { status: "storing", zh: "上架", en: "Put-away" },
  59. { status: "completed", zh: "完成", en: "Done" },
  60. ] as const;
  61. const JOB_STATUS: Record<string, { zh: string; en: string }> = {
  62. planning: { zh: "計劃中", en: "Planning" },
  63. pending: { zh: "待開工", en: "Not Started" },
  64. packaging: { zh: "包裝", en: "Packaging" },
  65. processing: { zh: "製程中", en: "In Process" },
  66. pendingqc: { zh: "待質檢", en: "Pending QC" },
  67. storing: { zh: "上架中", en: "Put-away" },
  68. completed: { zh: "已完成", en: "Completed" },
  69. unknown: { zh: "未知", en: "Unknown" },
  70. };
  71. /** 與摘要堆疊長條圖 series 順序一致:QC 中 → 已驗待入 → 已入庫 */
  72. const FG_SUMMARY_SERIES_BUCKETS = ["qc", "ready", "stocked"] as const;
  73. type FgSummaryStockBucket = (typeof FG_SUMMARY_SERIES_BUCKETS)[number];
  74. const FG_SUMMARY_BUCKET_LABEL: Record<FgSummaryStockBucket, { zh: string; en: string }> = {
  75. qc: { zh: "入庫 QC 中(有量)", en: "In QC (has qty)" },
  76. ready: { zh: "已驗待入(有量)", en: "Checked, awaiting put-away (has qty)" },
  77. stocked: { zh: "已入庫(有量)", en: "Stocked (has qty)" },
  78. };
  79. function normStatus(s: string | undefined): string {
  80. return (s ?? "").trim().toLowerCase();
  81. }
  82. function statusLabel(status: string, L: UiText): string {
  83. const row = JOB_STATUS[normStatus(status)];
  84. return row ? L(row.zh, row.en) : status;
  85. }
  86. function isKnownJobStatus(status: string): boolean {
  87. return normStatus(status) in JOB_STATUS;
  88. }
  89. function StatusIcon({ status }: { status: string }) {
  90. const s = normStatus(status);
  91. const sx = { fontSize: 28, opacity: 0.95 };
  92. switch (s) {
  93. case "planning":
  94. return <EditCalendar sx={{ ...sx, color: "text.secondary" }} />;
  95. case "pending":
  96. return <Schedule sx={{ ...sx, color: "secondary.main" }} />;
  97. case "packaging":
  98. return <Inventory2 sx={{ ...sx, color: "info.main" }} />;
  99. case "processing":
  100. return <PrecisionManufacturing sx={{ ...sx, color: "info.dark" }} />;
  101. case "pendingqc":
  102. return <VerifiedUser sx={{ ...sx, color: "warning.main" }} />;
  103. case "storing":
  104. return <Warehouse sx={{ ...sx, color: "warning.dark" }} />;
  105. case "completed":
  106. return <CheckCircle sx={{ ...sx, color: "success.main" }} />;
  107. default:
  108. return <HelpOutline sx={{ ...sx, color: "text.disabled" }} />;
  109. }
  110. }
  111. function statusStepIndex(status: string): number {
  112. const s = normStatus(status);
  113. const i = PIPELINE.findIndex((p) => p.status === s);
  114. return i >= 0 ? i : 0;
  115. }
  116. /** Dot chain — current step ring highlight */
  117. function PipelineDots({ status }: { status: string }) {
  118. const L = useUiText();
  119. const idx = statusStepIndex(status);
  120. const allDone = normStatus(status) === "completed";
  121. return (
  122. <Stack direction="row" alignItems="center" sx={{ flexWrap: "wrap", gap: 0.25 }}>
  123. {PIPELINE.map((step, i) => {
  124. const done = allDone || i < idx;
  125. const current = !allDone && i === idx;
  126. return (
  127. <Tooltip key={step.status} title={L(step.zh, step.en)}>
  128. <Box
  129. sx={{
  130. width: current ? 12 : 9,
  131. height: current ? 12 : 9,
  132. borderRadius: "50%",
  133. bgcolor: done ? "success.main" : current ? "primary.main" : "grey.300",
  134. boxShadow: current ? 2 : 0,
  135. border: current ? "2px solid" : "none",
  136. borderColor: "warning.light",
  137. flexShrink: 0,
  138. }}
  139. />
  140. </Tooltip>
  141. );
  142. })}
  143. </Stack>
  144. );
  145. }
  146. function formatQty(n: number): string {
  147. if (!Number.isFinite(n) || n === 0) return "0";
  148. const t = Number(n);
  149. return Math.abs(t - Math.round(t)) < 1e-6 ? String(Math.round(t)) : t.toFixed(2);
  150. }
  151. /** 與工藝流程摘要:開始時間 + 僅生產分鐘(不含備料/轉換) */
  152. function formatAssumeEndMmDdHhMm(processStart: string, planProcessingMins: number): string {
  153. if (!processStart?.trim() || !planProcessingMins) return "—";
  154. const d = dayjs(processStart.replace(" ", "T"));
  155. if (!d.isValid()) return "—";
  156. return d.add(planProcessingMins, "minute").format("MM-DD HH:mm");
  157. }
  158. /** Backend sends decimal minutes (from summed seconds ÷ 60). */
  159. function formatDurationMins(mins: number, L: UiText): string {
  160. if (!Number.isFinite(mins) || mins <= 0) return "—";
  161. const secs = Math.round(mins * 60);
  162. if (secs < 60) return L(`${secs} 秒`, `${secs} sec`);
  163. if (mins < 10) return L(`${mins.toFixed(1)} 分`, `${mins.toFixed(1)} min`);
  164. return L(`${Math.round(mins)} 分`, `${Math.round(mins)} min`);
  165. }
  166. /** Avoid "CODE CODE" or "X X" when DB code/name duplicate or name already includes code. */
  167. function formatCurrentProcessLabel(code: string, name: string): string {
  168. const c = (code ?? "").trim();
  169. const n = (name ?? "").trim();
  170. if (!c && !n) return "—";
  171. if (!c) return n;
  172. if (!n) return c;
  173. const lc = c.toLowerCase();
  174. const ln = n.toLowerCase();
  175. if (lc === ln) return n;
  176. if (ln.startsWith(`${lc} `) || ln.startsWith(`${lc} `)) return n;
  177. if (n.length > c.length && ln.startsWith(lc)) {
  178. const after = n.slice(c.length, c.length + 1);
  179. if (/[\s\-–—::·.|//]/.test(after)) return n;
  180. }
  181. return `${c} ${n}`;
  182. }
  183. function MaterialBar({ pending, picked }: { pending: number; picked: number }) {
  184. const L = useUiText();
  185. const total = pending + picked;
  186. if (total <= 0) {
  187. return (
  188. <Typography variant="caption" color="text.disabled">
  189. {L("無領料行", "No pick lines")}
  190. </Typography>
  191. );
  192. }
  193. const pct = (100 * picked) / total;
  194. return (
  195. <Box sx={{ minWidth: 100, maxWidth: 160 }}>
  196. <LinearProgress
  197. variant="determinate"
  198. value={pct}
  199. sx={{ height: 8, borderRadius: 1, bgcolor: "grey.200" }}
  200. color={pct >= 100 ? "success" : "primary"}
  201. />
  202. <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.25 }}>
  203. {L(`已揀 ${picked} / 共 ${total}`, `Picked ${picked} / ${total}`)}
  204. </Typography>
  205. </Box>
  206. );
  207. }
  208. function ProcessBar({ done, total }: { done: number; total: number }) {
  209. const L = useUiText();
  210. if (total <= 0) {
  211. return (
  212. <Typography variant="caption" color="text.disabled">
  213. {L("無工序", "No processes")}
  214. </Typography>
  215. );
  216. }
  217. const pct = (100 * done) / total;
  218. return (
  219. <Box sx={{ minWidth: 100, maxWidth: 160 }}>
  220. <LinearProgress
  221. variant="determinate"
  222. value={pct}
  223. sx={{ height: 8, borderRadius: 1, bgcolor: "grey.200" }}
  224. color={done >= total ? "success" : "info"}
  225. />
  226. <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.25 }}>
  227. {done}/{total}
  228. </Typography>
  229. </Box>
  230. );
  231. }
  232. /** 成品/半成品入庫:QC 中 | 已驗待入 | 已入庫 — by qty */
  233. function FgStockBar({ qc, ready, stocked }: { qc: number; ready: number; stocked: number }) {
  234. const L = useUiText();
  235. const sum = qc + ready + stocked;
  236. if (sum <= 0) {
  237. return (
  238. <Typography variant="caption" color="text.disabled">
  239. {L("無入庫數量資料", "No stock-in qty")}
  240. </Typography>
  241. );
  242. }
  243. const pQc = (100 * qc) / sum;
  244. const pReady = (100 * ready) / sum;
  245. const pStocked = 100 - pQc - pReady;
  246. return (
  247. <Box sx={{ minWidth: 120, maxWidth: 200 }}>
  248. <Stack direction="row" sx={{ height: 10, borderRadius: 1, overflow: "hidden", bgcolor: "grey.200" }}>
  249. <Tooltip title={L(`QC 中 ${formatQty(qc)}`, `In QC ${formatQty(qc)}`)}>
  250. <Box sx={{ width: `${pQc}%`, bgcolor: "warning.main" }} />
  251. </Tooltip>
  252. <Tooltip title={L(`已驗待入 ${formatQty(ready)}`, `Awaiting put-away ${formatQty(ready)}`)}>
  253. <Box sx={{ width: `${pReady}%`, bgcolor: "info.main" }} />
  254. </Tooltip>
  255. <Tooltip title={L(`已入庫 ${formatQty(stocked)}`, `Stocked ${formatQty(stocked)}`)}>
  256. <Box sx={{ width: `${pStocked}%`, bgcolor: "success.main" }} />
  257. </Tooltip>
  258. </Stack>
  259. <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.35 }} noWrap>
  260. {L(
  261. `QC ${formatQty(qc)} · 待入 ${formatQty(ready)} · 已入 ${formatQty(stocked)}`,
  262. `QC ${formatQty(qc)} · Awaiting ${formatQty(ready)} · Stocked ${formatQty(stocked)}`
  263. )}
  264. </Typography>
  265. </Box>
  266. );
  267. }
  268. const COLSPAN = 8;
  269. function DetailLine({ label, children }: { label: string; children: React.ReactNode }) {
  270. return (
  271. <Stack direction="row" spacing={1.5} alignItems="baseline" flexWrap="wrap" useFlexGap sx={{ columnGap: 1.5, rowGap: 0.25 }}>
  272. <Typography component="span" variant="body2" color="text.secondary" sx={{ minWidth: 108, flexShrink: 0 }}>
  273. {label}
  274. </Typography>
  275. <Typography component="span" variant="body2" color="text.primary">
  276. {children}
  277. </Typography>
  278. </Stack>
  279. );
  280. }
  281. export default function JobOrderBoardPage() {
  282. const theme = useTheme();
  283. const { t } = useTranslation("chart");
  284. const L = useUiText();
  285. const [targetDate, setTargetDate] = useState(() => dayjs().format("YYYY-MM-DD"));
  286. /** true: load all non-completed JOs (no plan-start date); false: filter by targetDate */
  287. const [allIncompleteOpen, setAllIncompleteOpen] = useState(false);
  288. const [rows, setRows] = useState<JobOrderBoardRow[]>([]);
  289. const [loading, setLoading] = useState(true);
  290. const [error, setError] = useState<string | null>(null);
  291. const [lastUpdated, setLastUpdated] = useState("");
  292. const [openId, setOpenId] = useState<number | null>(null);
  293. /** Normalized status key from donut (e.g. pending); null = not filtering by stage */
  294. const [tableStatusKey, setTableStatusKey] = useState<string | null>(null);
  295. /** Summary bar: filter rows that have FG/WIP stock-in in that bucket; mutually exclusive with tableStatusKey in UI */
  296. const [tableStockBucket, setTableStockBucket] = useState<FgSummaryStockBucket | null>(null);
  297. const { autoRefreshOn, setAutoRefreshOn, refreshIntervalSec, setRefreshIntervalSec } =
  298. useChartBoardRefreshPrefs("joborder");
  299. const load = useCallback(async () => {
  300. setLoading(true);
  301. setError(null);
  302. try {
  303. const data = allIncompleteOpen
  304. ? await fetchJobOrderBoard(undefined, { incompleteOnly: true })
  305. : await fetchJobOrderBoard(targetDate);
  306. setRows(data);
  307. setLastUpdated(dayjs().format("YYYY-MM-DD HH:mm:ss"));
  308. } catch (e) {
  309. setError(e instanceof Error ? e.message : "Request failed");
  310. } finally {
  311. setLoading(false);
  312. }
  313. }, [allIncompleteOpen, targetDate]);
  314. useEffect(() => {
  315. void load();
  316. }, [load]);
  317. useEffect(() => {
  318. if (!autoRefreshOn || refreshIntervalSec < 10) return;
  319. const t = setInterval(() => void load(), refreshIntervalSec * 1000);
  320. return () => clearInterval(t);
  321. }, [load, autoRefreshOn, refreshIntervalSec]);
  322. useEffect(() => {
  323. setTableStatusKey(null);
  324. setTableStockBucket(null);
  325. }, [targetDate, allIncompleteOpen]);
  326. useEffect(() => {
  327. setOpenId(null);
  328. }, [tableStatusKey, tableStockBucket]);
  329. useEffect(() => {
  330. const onKey = (ev: KeyboardEvent) => {
  331. const t = ev.target as HTMLElement | null;
  332. if (
  333. t &&
  334. (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)
  335. ) {
  336. return;
  337. }
  338. if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
  339. if (ev.key === "t" || ev.key === "T") {
  340. ev.preventDefault();
  341. setTargetDate(dayjs().format("YYYY-MM-DD"));
  342. }
  343. if (ev.key === "y" || ev.key === "Y") {
  344. ev.preventDefault();
  345. setTargetDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"));
  346. }
  347. };
  348. window.addEventListener("keydown", onKey);
  349. return () => window.removeEventListener("keydown", onKey);
  350. }, []);
  351. const statusDonut = useMemo(() => {
  352. const map = new Map<string, number>();
  353. rows.forEach((r) => {
  354. const k = normStatus(r.status) || "unknown";
  355. map.set(k, (map.get(k) ?? 0) + 1);
  356. });
  357. const keysAll = Array.from(map.keys());
  358. const pairs = keysAll
  359. .map((k) => {
  360. const v = map.get(k) ?? 0;
  361. const n = Number.isFinite(v) && v > 0 ? v : 0;
  362. return { key: k, label: statusLabel(k, L), v: n };
  363. })
  364. .filter((p) => p.v > 0);
  365. return {
  366. keys: pairs.map((p) => p.key),
  367. labels: pairs.map((p) => p.label),
  368. series: pairs.map((p) => p.v),
  369. };
  370. }, [rows, L]);
  371. const displayRows = useMemo(() => {
  372. const fin = (n: number) => (Number.isFinite(n) ? n : 0);
  373. if (tableStockBucket) {
  374. return rows.filter((r) => {
  375. if (tableStockBucket === "qc") return fin(r.fgInQcQty) > 0 || (r.fgInQcLineCount ?? 0) > 0;
  376. if (tableStockBucket === "ready") return fin(r.fgReadyToStockInQty) > 0 || (r.fgReadyToStockInCount ?? 0) > 0;
  377. if (tableStockBucket === "stocked") return fin(r.fgStockedQty) > 0;
  378. return true;
  379. });
  380. }
  381. if (tableStatusKey) {
  382. return rows.filter((r) => (normStatus(r.status) || "unknown") === tableStatusKey);
  383. }
  384. return rows;
  385. }, [rows, tableStatusKey, tableStockBucket]);
  386. const donutSliceClickRef = useRef<(index: number) => void>(() => {});
  387. donutSliceClickRef.current = (index: number) => {
  388. setTableStockBucket(null);
  389. const key = statusDonut.keys[index];
  390. if (key == null) return;
  391. setTableStatusKey((prev) => (prev === key ? null : key));
  392. };
  393. const fgBarSeriesClickRef = useRef<(seriesIndex: number) => void>(() => {});
  394. fgBarSeriesClickRef.current = (seriesIndex: number) => {
  395. if (typeof seriesIndex !== "number" || seriesIndex < 0 || seriesIndex >= FG_SUMMARY_SERIES_BUCKETS.length) return;
  396. setTableStatusKey(null);
  397. const bucket = FG_SUMMARY_SERIES_BUCKETS[seriesIndex];
  398. setTableStockBucket((prev) => (prev === bucket ? null : bucket));
  399. };
  400. const statusDonutChartOptions = useMemo(
  401. () => ({
  402. chart: {
  403. type: "donut" as const,
  404. toolbar: { show: false },
  405. events: {
  406. dataPointSelection: (_e: unknown, _ctx: unknown, cfg: { dataPointIndex?: number }) => {
  407. const i = cfg?.dataPointIndex;
  408. if (typeof i !== "number" || i < 0) return;
  409. donutSliceClickRef.current(i);
  410. },
  411. /** Donut/pie: legend index matches slice index */
  412. legendClick: (_chartContext: unknown, seriesIndex: number) => {
  413. if (typeof seriesIndex !== "number" || seriesIndex < 0) return false;
  414. donutSliceClickRef.current(seriesIndex);
  415. return false;
  416. },
  417. },
  418. },
  419. labels: statusDonut.labels,
  420. legend: {
  421. position: "bottom" as const,
  422. onItemClick: { toggleDataSeries: false },
  423. },
  424. plotOptions: { pie: { donut: { size: "62%" } } },
  425. dataLabels: { enabled: true },
  426. }),
  427. [statusDonut.labels],
  428. );
  429. const fgSummaryChartOptions = useMemo(
  430. () => ({
  431. chart: {
  432. type: "bar" as const,
  433. stacked: true,
  434. toolbar: { show: false },
  435. events: {
  436. dataPointSelection: (_e: unknown, _ctx: unknown, cfg: { seriesIndex?: number; dataPointIndex?: number }) => {
  437. const si = cfg?.seriesIndex;
  438. if (typeof si !== "number") return;
  439. fgBarSeriesClickRef.current(si);
  440. },
  441. legendClick: (_chartContext: unknown, seriesIndex: number) => {
  442. if (typeof seriesIndex !== "number" || seriesIndex < 0) return false;
  443. fgBarSeriesClickRef.current(seriesIndex);
  444. return false;
  445. },
  446. },
  447. },
  448. plotOptions: { bar: { horizontal: true, barHeight: "48%" } },
  449. xaxis: { categories: [t("series_total")] },
  450. colors: ["#ed6c02", "#0288d1", "#2e7d32"],
  451. legend: {
  452. position: "top" as const,
  453. onItemClick: { toggleDataSeries: false },
  454. },
  455. dataLabels: { enabled: false },
  456. }),
  457. [t],
  458. );
  459. const materialSummary = useMemo(() => {
  460. let p = 0;
  461. let k = 0;
  462. rows.forEach((r) => {
  463. p += r.materialPendingCount;
  464. k += r.materialPickedCount;
  465. });
  466. return { pending: p, picked: k };
  467. }, [rows]);
  468. const fgTotals = useMemo(() => {
  469. const fin = (n: number) => (Number.isFinite(n) ? n : 0);
  470. let qc = 0;
  471. let ready = 0;
  472. let stocked = 0;
  473. rows.forEach((r) => {
  474. qc += fin(r.fgInQcQty);
  475. ready += fin(r.fgReadyToStockInQty);
  476. stocked += fin(r.fgStockedQty);
  477. });
  478. return { qc, ready, stocked };
  479. }, [rows]);
  480. const isPlanToday = targetDate === dayjs().format("YYYY-MM-DD");
  481. const weekdayIndex = dayjs(targetDate).day();
  482. const planWeekdayZh = ["日", "一", "二", "三", "四", "五", "六"][weekdayIndex] ?? "";
  483. const planWeekdayEn = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][weekdayIndex] ?? "";
  484. return (
  485. <Box
  486. sx={{
  487. width: "100%",
  488. maxWidth: "100%",
  489. mx: "auto",
  490. p: { xs: 0.5, sm: 1 },
  491. boxSizing: "border-box",
  492. }}
  493. >
  494. <Typography variant="h5" sx={{ mb: 1, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
  495. <ViewKanban /> {t("pageTitle_jobOrderBoard")}
  496. </Typography>
  497. <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
  498. {L(
  499. "圖示化階段與進度條;入庫欄僅統計",
  500. "Stages and progress are shown as icons. The stock-in column counts only"
  501. )}
  502. <strong>{L("成品/半成品", "finished goods / WIP")}</strong>
  503. {L(
  504. "(系統 FG/WIP)工單之入庫明細(依驗收數量)。「已驗待入」= 狀態為 receiving / received(QC 完成待上架)。",
  505. " (system FG/WIP) job orders, using accepted qty. “Awaiting put-away” means status receiving or received (QC done, waiting for put-away)."
  506. )}
  507. {autoRefreshOn
  508. ? L(
  509. ` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)`,
  510. ` Auto refresh is on (every ${refreshIntervalSec} sec).`
  511. )
  512. : L(
  513. " 預設不自動更新,可開啟「自動重新整理」並選擇間隔。",
  514. " Auto refresh is off by default. Turn on Auto Refresh and choose an interval."
  515. )}
  516. {L(
  517. " 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。",
  518. " Settings are saved on this device (per account when signed in, or for this browser session when signed out)."
  519. )}
  520. {lastUpdated ? L(` · 最後更新 ${lastUpdated}`, ` · Last updated ${lastUpdated}`) : ""}
  521. <strong> {L("快捷鍵", "Shortcuts")}</strong>
  522. {L("(不在輸入框內時):", " (when the cursor is not in a text field): ")}
  523. <Box
  524. component="kbd"
  525. sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }}
  526. >
  527. T
  528. </Box>{" "}
  529. {t("today")}
  530. {L("、", ", ")}
  531. <Box
  532. component="kbd"
  533. sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }}
  534. >
  535. Y
  536. </Box>{" "}
  537. {L("昨日(僅更新計劃開始日)。", "yesterday (updates the plan start date only).")}
  538. </Typography>
  539. {error && (
  540. <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
  541. {error}
  542. </Alert>
  543. )}
  544. <Stack
  545. direction={{ xs: "column", lg: "row" }}
  546. spacing={2}
  547. alignItems={{ xs: "stretch", lg: "stretch" }}
  548. justifyContent={{ lg: "space-between" }}
  549. sx={{ mb: 2 }}
  550. >
  551. <Paper
  552. variant="outlined"
  553. sx={{
  554. p: 1.5,
  555. flex: { lg: "1 1 0" },
  556. minWidth: { xs: "100%", lg: 280 },
  557. borderColor: "divider",
  558. bgcolor: alpha(theme.palette.primary.main, 0.03),
  559. }}
  560. >
  561. <Typography
  562. variant="overline"
  563. color="text.secondary"
  564. sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
  565. >
  566. {t("equipment_searchAndList")}
  567. </Typography>
  568. <Stack spacing={1.25}>
  569. <Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}>
  570. {L(`計劃開始日 ${targetDate}(週${planWeekdayZh})`, `Plan start date ${targetDate} (${planWeekdayEn})`)}
  571. {!isPlanToday && <Chip size="small" label={t("process_nonToday")} sx={{ ml: 1 }} variant="outlined" />}
  572. </Typography>
  573. <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}>
  574. <Tooltip title={L("快捷鍵 T", "Shortcut T")}>
  575. <span>
  576. <Button
  577. size="small"
  578. variant={isPlanToday && !allIncompleteOpen ? "contained" : "outlined"}
  579. onClick={() => setTargetDate(dayjs().format("YYYY-MM-DD"))}
  580. disabled={allIncompleteOpen}
  581. >
  582. {t("today")}
  583. </Button>
  584. </span>
  585. </Tooltip>
  586. <Tooltip title={L("快捷鍵 Y", "Shortcut Y")}>
  587. <span>
  588. <Button
  589. size="small"
  590. variant="outlined"
  591. onClick={() => setTargetDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"))}
  592. disabled={allIncompleteOpen}
  593. >
  594. {t("yesterday")}
  595. </Button>
  596. </span>
  597. </Tooltip>
  598. <TextField
  599. size="small"
  600. label={L("計劃開始日", "Plan start date")}
  601. type="date"
  602. value={targetDate}
  603. onChange={(e) => setTargetDate(e.target.value)}
  604. disabled={allIncompleteOpen}
  605. InputLabelProps={{ shrink: true }}
  606. sx={{ minWidth: 178 }}
  607. />
  608. <Button variant="outlined" size="small" onClick={() => void load()} disabled={loading}>
  609. {t("refresh")}
  610. </Button>
  611. </Stack>
  612. <Tooltip
  613. title={
  614. <>
  615. <Typography variant="caption" component="span" display="block" sx={{ mb: 0.5 }}>
  616. {L("載入", "Load")}{" "}
  617. <strong>{L("全部未完成", "all incomplete")}</strong>
  618. {L(
  619. "工單(階段≠已完成),不篩「計劃開始日」。筆數多時仍可能較慢。",
  620. " job orders (stage is not completed), without filtering by plan start date. A large result can still be slow."
  621. )}
  622. </Typography>
  623. <Typography variant="caption" component="span" display="block">
  624. {L("再按一次可切回依「計劃開始日」載入。", "Click again to load by plan start date.")}
  625. </Typography>
  626. </>
  627. }
  628. arrow
  629. placement="top-start"
  630. >
  631. <span>
  632. <Button
  633. variant={allIncompleteOpen ? "contained" : "outlined"}
  634. color={allIncompleteOpen ? "warning" : "primary"}
  635. size="small"
  636. onClick={() => setAllIncompleteOpen((v) => !v)}
  637. disabled={loading}
  638. aria-pressed={allIncompleteOpen}
  639. sx={{ alignSelf: "flex-start" }}
  640. >
  641. {allIncompleteOpen
  642. ? L("改依計劃開始日", "Use plan start date")
  643. : L("顯示所有未完成工單", "Show all incomplete job orders")}
  644. </Button>
  645. </span>
  646. </Tooltip>
  647. </Stack>
  648. </Paper>
  649. <Paper
  650. variant="outlined"
  651. sx={{
  652. p: 1.5,
  653. flex: { lg: "0 0 auto" },
  654. width: { xs: "100%", lg: "auto" },
  655. minWidth: { lg: 200 },
  656. borderColor: "divider",
  657. bgcolor: alpha(theme.palette.grey[500], 0.06),
  658. }}
  659. >
  660. <Typography
  661. variant="overline"
  662. color="text.secondary"
  663. sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
  664. >
  665. {t("otherBoards")}
  666. </Typography>
  667. <Stack direction="column" spacing={1} sx={{ maxWidth: 220 }}>
  668. <Button component={Link} href="/chart/equipment/board" size="small" variant="outlined" fullWidth startIcon={<Microwave />}>
  669. {t("board_equipmentUsage")}
  670. </Button>
  671. <Button component={Link} href="/chart/process/board" size="small" variant="outlined" fullWidth>
  672. {t("board_processLive")}
  673. </Button>
  674. <Button component={Link} href="/chart/joborder" size="small" variant="outlined" fullWidth>
  675. {t("board_jobOrderChart")}
  676. </Button>
  677. </Stack>
  678. </Paper>
  679. <Paper
  680. variant="outlined"
  681. sx={{
  682. p: 1.5,
  683. flex: { lg: "0 0 auto" },
  684. width: { xs: "100%", lg: "auto" },
  685. minWidth: { xs: "100%", lg: 300 },
  686. borderColor: "divider",
  687. bgcolor: alpha(theme.palette.info.main, 0.04),
  688. }}
  689. >
  690. <Typography
  691. variant="overline"
  692. color="text.secondary"
  693. sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
  694. >
  695. {t("autoRefresh")}
  696. </Typography>
  697. <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}>
  698. <FormControlLabel
  699. control={
  700. <Switch
  701. size="small"
  702. checked={autoRefreshOn}
  703. onChange={(_, v) => setAutoRefreshOn(v)}
  704. inputProps={{ "aria-label": t("autoRefresh") }}
  705. />
  706. }
  707. label={t("on")}
  708. sx={{ ml: 0, mr: 0 }}
  709. />
  710. <FormControl size="small" sx={{ minWidth: 124 }} disabled={!autoRefreshOn}>
  711. <InputLabel id="jo-board-refresh-interval-label">{t("intervalSeconds")}</InputLabel>
  712. <Select
  713. labelId="jo-board-refresh-interval-label"
  714. label={t("intervalSeconds")}
  715. value={refreshIntervalSec}
  716. onChange={(e) => setRefreshIntervalSec(Number(e.target.value))}
  717. >
  718. {CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS.map((sec) => (
  719. <MenuItem key={sec} value={sec}>
  720. {L(`${sec} 秒`, `${sec} sec`)}
  721. </MenuItem>
  722. ))}
  723. </Select>
  724. </FormControl>
  725. </Stack>
  726. </Paper>
  727. </Stack>
  728. {!loading && rows.length > 0 && (
  729. <Stack direction={{ xs: "column", md: "row" }} spacing={2} sx={{ mb: 3 }}>
  730. <Paper variant="outlined" sx={{ p: 2, flex: 1, minWidth: 280 }}>
  731. <Typography variant="subtitle2" fontWeight={600} gutterBottom>
  732. {L("工單狀態分佈", "Job Order Status")}
  733. </Typography>
  734. <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
  735. {L("點擊圓環或下方圖例可依", "Click a slice or the legend to filter the list by")}{" "}
  736. <strong>{L("階段", "stage")}</strong>
  737. {L(
  738. "篩選列表;再點同一項或表格上方「清除列表篩選」可還原。",
  739. ". Click the same item again, or Clear list filter above the table, to reset."
  740. )}
  741. </Typography>
  742. {statusDonut.series.length > 0 ? (
  743. <SafeApexCharts
  744. chartRevision={JSON.stringify(statusDonut.keys)}
  745. options={statusDonutChartOptions}
  746. series={statusDonut.series}
  747. type="donut"
  748. height={280}
  749. />
  750. ) : (
  751. <Typography color="text.secondary">{L("無資料", "No data")}</Typography>
  752. )}
  753. </Paper>
  754. <Paper variant="outlined" sx={{ p: 2, flex: 1, minWidth: 280 }}>
  755. <Typography variant="subtitle2" fontWeight={600} gutterBottom>
  756. {L("本頁摘要", "Summary on this page")}
  757. </Typography>
  758. <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
  759. {L(
  760. `領料:待領 ${materialSummary.pending} / 已揀 ${materialSummary.picked}`,
  761. `Material: pending ${materialSummary.pending} / picked ${materialSummary.picked}`
  762. )}
  763. </Typography>
  764. <MaterialBar pending={materialSummary.pending} picked={materialSummary.picked} />
  765. <Typography variant="body2" color="text.secondary" sx={{ mt: 2, mb: 0.5 }}>
  766. {L(
  767. "成品/半成品入庫(驗收數量)— QC 中/已驗待入/已入庫",
  768. "Finished goods / WIP stock-in (accepted qty) — in QC / awaiting put-away / stocked"
  769. )}
  770. </Typography>
  771. <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>
  772. {L(
  773. "點擊長條色塊或上方圖例,篩選該入庫區塊有資料的工單;再點一次可還原。",
  774. "Click a bar segment or the legend to show job orders with qty in that stock-in bucket. Click again to reset."
  775. )}
  776. </Typography>
  777. <FgStockBar qc={fgTotals.qc} ready={fgTotals.ready} stocked={fgTotals.stocked} />
  778. {fgTotals.qc + fgTotals.ready + fgTotals.stocked > 0 ? (
  779. <>
  780. <SafeApexCharts
  781. chartRevision={`fg-bar-${fgTotals.qc}-${fgTotals.ready}-${fgTotals.stocked}`}
  782. options={fgSummaryChartOptions}
  783. series={[
  784. { name: L("QC 中", "In QC"), data: [Math.max(0, fgTotals.qc)] },
  785. { name: L("已驗待入", "Awaiting put-away"), data: [Math.max(0, fgTotals.ready)] },
  786. { name: L("已入庫", "Stocked"), data: [Math.max(0, fgTotals.stocked)] },
  787. ]}
  788. type="bar"
  789. height={140}
  790. />
  791. <Button
  792. size="small"
  793. variant="text"
  794. sx={{ mt: 0.5, p: 0, minHeight: "auto" }}
  795. onClick={() => {
  796. setTableStockBucket(null);
  797. setTableStatusKey((p) => (p === "processing" ? null : "processing"));
  798. }}
  799. >
  800. {tableStatusKey === "processing"
  801. ? L("取消「製程中」篩選", "Clear “In Process” filter")
  802. : L("篩選製程中工單(階段)", "Filter job orders in process (stage)")}
  803. </Button>
  804. </>
  805. ) : (
  806. <>
  807. <Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
  808. {L("驗收數量合計為 0,不顯示長條圖", "Accepted qty totals 0, so the bar chart is hidden.")}
  809. </Typography>
  810. <Button
  811. size="small"
  812. variant="text"
  813. sx={{ mt: 0.5, p: 0, minHeight: "auto" }}
  814. onClick={() => {
  815. setTableStockBucket(null);
  816. setTableStatusKey((p) => (p === "processing" ? null : "processing"));
  817. }}
  818. >
  819. {tableStatusKey === "processing"
  820. ? L("取消「製程中」篩選", "Clear “In Process” filter")
  821. : L("篩選製程中工單(階段)", "Filter job orders in process (stage)")}
  822. </Button>
  823. </>
  824. )}
  825. </Paper>
  826. </Stack>
  827. )}
  828. {loading && rows.length === 0 ? (
  829. <Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
  830. <CircularProgress />
  831. </Box>
  832. ) : rows.length === 0 ? (
  833. <Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}>
  834. <Typography color="text.secondary">{L("此條件下沒有工單", "No job orders for this filter.")}</Typography>
  835. </Paper>
  836. ) : (
  837. <TableContainer component={Paper} variant="outlined">
  838. {(tableStatusKey || tableStockBucket) && (
  839. <Box sx={{ px: 2, py: 1.5, borderBottom: 1, borderColor: "divider", bgcolor: "action.hover" }}>
  840. <Stack direction="row" alignItems="center" spacing={1} flexWrap="wrap" useFlexGap>
  841. <Typography variant="body2" color="text.secondary">
  842. {L("列表篩選中:", "List filter:")}
  843. </Typography>
  844. {tableStockBucket && (
  845. <Chip
  846. label={L(
  847. FG_SUMMARY_BUCKET_LABEL[tableStockBucket].zh,
  848. FG_SUMMARY_BUCKET_LABEL[tableStockBucket].en
  849. )}
  850. size="small"
  851. color="secondary"
  852. variant="outlined"
  853. onDelete={() => setTableStockBucket(null)}
  854. />
  855. )}
  856. {tableStatusKey && (
  857. <Chip
  858. label={L(`階段:${statusLabel(tableStatusKey, L)}`, `Stage: ${statusLabel(tableStatusKey, L)}`)}
  859. size="small"
  860. color="primary"
  861. variant="outlined"
  862. onDelete={() => setTableStatusKey(null)}
  863. />
  864. )}
  865. <Button
  866. size="small"
  867. variant="text"
  868. onClick={() => {
  869. setTableStatusKey(null);
  870. setTableStockBucket(null);
  871. }}
  872. >
  873. {L("清除列表篩選", "Clear list filter")}
  874. </Button>
  875. </Stack>
  876. </Box>
  877. )}
  878. <Table size="small" stickyHeader>
  879. <TableHead>
  880. <TableRow>
  881. <TableCell padding="checkbox" />
  882. <TableCell>{L("工單號", "Job Order No.")}</TableCell>
  883. <TableCell sx={{ minWidth: 200 }}>{L("階段", "Stage")}</TableCell>
  884. <TableCell>{L("領料進度", "Pick progress")}</TableCell>
  885. <TableCell>{L("工序進度", "Process progress")}</TableCell>
  886. <TableCell>{L("當前製程", "Current process")}</TableCell>
  887. <TableCell sx={{ minWidth: 200 }}>{L("成品/半成品入庫", "FG / WIP stock-in")}</TableCell>
  888. <TableCell align="center">{L("開啟", "Open")}</TableCell>
  889. </TableRow>
  890. </TableHead>
  891. <TableBody>
  892. {displayRows.length === 0 ? (
  893. <TableRow>
  894. <TableCell colSpan={COLSPAN} align="center" sx={{ py: 4 }}>
  895. <Typography color="text.secondary">
  896. {tableStockBucket
  897. ? L(
  898. "目前沒有符合此入庫區塊的工單(可點「清除列表篩選」)。",
  899. "No job orders match this stock-in bucket. Click Clear list filter."
  900. )
  901. : tableStatusKey
  902. ? L(
  903. "此階段下目前沒有工單(可點「清除列表篩選」)。",
  904. "No job orders in this stage. Click Clear list filter."
  905. )
  906. : L("沒有資料", "No data")}
  907. </Typography>
  908. </TableCell>
  909. </TableRow>
  910. ) : (
  911. displayRows.map((r) => (
  912. <React.Fragment key={r.jobOrderId}>
  913. <TableRow hover>
  914. <TableCell>
  915. <IconButton
  916. size="small"
  917. aria-label="expand row"
  918. onClick={() => setOpenId((id) => (id === r.jobOrderId ? null : r.jobOrderId))}
  919. >
  920. {openId === r.jobOrderId ? <KeyboardArrowUp /> : <KeyboardArrowDown />}
  921. </IconButton>
  922. </TableCell>
  923. <TableCell sx={{ fontWeight: 600 }}>{r.code}</TableCell>
  924. <TableCell>
  925. <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
  926. <StatusIcon status={r.status} />
  927. <Box>
  928. <Typography variant="body2" fontWeight={700}>
  929. {statusLabel(r.status, L)}
  930. </Typography>
  931. <PipelineDots status={r.status} />
  932. </Box>
  933. </Stack>
  934. </TableCell>
  935. <TableCell>
  936. <MaterialBar pending={r.materialPendingCount} picked={r.materialPickedCount} />
  937. </TableCell>
  938. <TableCell>
  939. <ProcessBar done={r.processCompletedCount} total={r.processTotalCount} />
  940. </TableCell>
  941. <TableCell sx={{ maxWidth: 200 }}>
  942. <Typography variant="body2" noWrap title={formatCurrentProcessLabel(r.currentProcessCode, r.currentProcessName)}>
  943. {formatCurrentProcessLabel(r.currentProcessCode, r.currentProcessName)}
  944. </Typography>
  945. </TableCell>
  946. <TableCell>
  947. <FgStockBar qc={r.fgInQcQty} ready={r.fgReadyToStockInQty} stocked={r.fgStockedQty} />
  948. <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.5 }}>
  949. {L("已驗待入", "Awaiting put-away")} <strong>{r.fgReadyToStockInCount}</strong>{" "}
  950. {L("行,驗收數量", "lines, accepted qty")} {formatQty(r.fgReadyToStockInQty)}
  951. {L(";全單驗收合計", "; order accepted total")}{" "}
  952. {formatQty(r.stockInAcceptedQtyTotal)}
  953. </Typography>
  954. </TableCell>
  955. <TableCell align="center">
  956. <Button
  957. component={Link}
  958. href={`/jo/edit?id=${r.jobOrderId}`}
  959. target="_blank"
  960. rel="noopener noreferrer"
  961. size="small"
  962. >
  963. {L("開啟", "Open")}
  964. </Button>
  965. </TableCell>
  966. </TableRow>
  967. <TableRow>
  968. <TableCell sx={{ py: 0, borderBottom: openId === r.jobOrderId ? undefined : 0 }} colSpan={COLSPAN}>
  969. <Collapse in={openId === r.jobOrderId} timeout="auto" unmountOnExit>
  970. <Box sx={{ py: 2, px: 1 }}>
  971. <Typography variant="subtitle2" gutterBottom>
  972. {L("明細", "Details")}
  973. </Typography>
  974. <Stack spacing={1} sx={{ pl: 0.5 }}>
  975. <Typography variant="caption" color="primary" fontWeight={700} display="block" sx={{ mb: 0.5 }}>
  976. {L(
  977. "生產流程摘要(與工單·工藝流程一致,來自產線彙總)",
  978. "Production summary (same as the job order process flow, from the line rollup)"
  979. )}
  980. </Typography>
  981. <Stack
  982. direction={{ xs: "column", sm: "row" }}
  983. spacing={2}
  984. alignItems="flex-start"
  985. sx={{ pl: 0.5 }}
  986. >
  987. <Stack spacing={1} sx={{ flex: 1, minWidth: 0 }}>
  988. <Typography variant="body2" fontWeight={600} sx={{ lineHeight: 1.35 }}>
  989. {r.itemCode || r.itemName
  990. ? `${r.itemCode || ""}${r.itemCode && r.itemName ? "-" : ""}${r.itemName || ""}`
  991. : "—"}
  992. </Typography>
  993. <DetailLine label={L("工單類型", "Job Order Type")}>{r.jobTypeName?.trim() ? r.jobTypeName : "—"}</DetailLine>
  994. <DetailLine label={t("warehouse_qty")}>
  995. {formatQty(r.reqQty)}
  996. {r.outputQtyUom ? L(`(${r.outputQtyUom})`, ` (${r.outputQtyUom})`) : ""}
  997. </DetailLine>
  998. <DetailLine label={L("生產日期", "Production date")}>{r.productionDate || "—"}</DetailLine>
  999. <DetailLine label={L("實際耗時(各線起訖加總)", "Actual time (sum of line start to end)")}>
  1000. {formatDurationMins(r.actualLineMinsTotal, L)}
  1001. </DetailLine>
  1002. </Stack>
  1003. <Stack
  1004. spacing={1}
  1005. sx={{
  1006. flex: 1,
  1007. minWidth: 0,
  1008. pl: { sm: 2 },
  1009. borderLeft: { sm: 1 },
  1010. borderTop: { xs: 1, sm: 0 },
  1011. borderColor: "divider",
  1012. pt: { xs: 1.5, sm: 0 },
  1013. }}
  1014. >
  1015. <DetailLine label={L("預計生產時間", "Planned production time")}>
  1016. {r.planProcessingMinsTotal > 0
  1017. ? L(
  1018. `${Math.round(r.planProcessingMinsTotal)} 分鐘`,
  1019. `${Math.round(r.planProcessingMinsTotal)} min`
  1020. )
  1021. : "—"}
  1022. {r.planSetupChangeoverMinsTotal > 0
  1023. ? L(
  1024. ` · 備料/轉換合計 ${Math.round(r.planSetupChangeoverMinsTotal)} 分鐘`,
  1025. ` · Setup and changeover ${Math.round(r.planSetupChangeoverMinsTotal)} min`
  1026. )
  1027. : ""}
  1028. </DetailLine>
  1029. <DetailLine label={L("開始時間(產程)", "Start time (run)")}>{r.productProcessStart || "—"}</DetailLine>
  1030. <DetailLine label={L("預計完成時間", "Estimated finish")}>
  1031. {formatAssumeEndMmDdHhMm(r.productProcessStart, r.planProcessingMinsTotal)}
  1032. </DetailLine>
  1033. </Stack>
  1034. </Stack>
  1035. <Typography variant="caption" color="text.secondary" fontWeight={700} display="block" sx={{ mt: 1.5, mb: 0.25 }}>
  1036. {L("工單計劃/實際", "Job Order Plan / Actual")}
  1037. </Typography>
  1038. <DetailLine label={L("計劃開始", "Plan start")}>{r.planStart || "—"}</DetailLine>
  1039. <DetailLine label={L("實際開始", "Actual start")}>{r.actualStart || "—"}</DetailLine>
  1040. <DetailLine label={L("計劃結束", "Plan end")}>{r.planEnd || "—"}</DetailLine>
  1041. <DetailLine label={L("實際結束", "Actual end")}>{r.actualEnd || "—"}</DetailLine>
  1042. <DetailLine label={L("當前製程開始", "Current process start")}>{r.currentProcessStartTime || "—"}</DetailLine>
  1043. <DetailLine label={L("階段", "Stage")}>
  1044. <Tooltip
  1045. title={
  1046. isKnownJobStatus(r.status)
  1047. ? L(`系統狀態碼:${r.status}`, `Status code: ${r.status}`)
  1048. : L(`未定義對應中文,原始值:${r.status}`, `No label for this status. Raw value: ${r.status}`)
  1049. }
  1050. arrow
  1051. enterDelay={400}
  1052. >
  1053. <Box component="span" sx={{ cursor: "default" }}>
  1054. {statusLabel(r.status, L)}
  1055. </Box>
  1056. </Tooltip>
  1057. </DetailLine>
  1058. <Box sx={{ pt: 0.5 }}>
  1059. <Typography variant="caption" color="text.secondary" fontWeight={600} display="block" sx={{ mb: 0.75 }}>
  1060. {L("成品/半成品入庫狀況", "FG / WIP stock-in")}
  1061. </Typography>
  1062. <Stack spacing={0.75} sx={{ pl: 0.5 }}>
  1063. <DetailLine label={L("QC 中", "In QC")}>
  1064. {formatQty(r.fgInQcQty)}
  1065. {L(`(${r.fgInQcLineCount} 行)`, ` (${r.fgInQcLineCount} lines)`)}
  1066. </DetailLine>
  1067. <DetailLine label={L("已驗待入", "Awaiting put-away")}>
  1068. {formatQty(r.fgReadyToStockInQty)}
  1069. {L(`(${r.fgReadyToStockInCount} 行)`, ` (${r.fgReadyToStockInCount} lines)`)}
  1070. </DetailLine>
  1071. <DetailLine label={L("已入庫", "Stocked")}>{formatQty(r.fgStockedQty)}</DetailLine>
  1072. <DetailLine label={L("全單驗收合計", "Order accepted total")}>{formatQty(r.stockInAcceptedQtyTotal)}</DetailLine>
  1073. </Stack>
  1074. </Box>
  1075. </Stack>
  1076. </Box>
  1077. </Collapse>
  1078. </TableCell>
  1079. </TableRow>
  1080. </React.Fragment>
  1081. ))
  1082. )}
  1083. </TableBody>
  1084. </Table>
  1085. </TableContainer>
  1086. )}
  1087. </Box>
  1088. );
  1089. }