FPSMS-frontend
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 

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