FPSMS-frontend
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 

536 satır
21 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. Stack,
  17. Button,
  18. Chip,
  19. Tooltip,
  20. FormControl,
  21. FormControlLabel,
  22. InputLabel,
  23. MenuItem,
  24. Select,
  25. Switch,
  26. } from "@mui/material";
  27. import { alpha, useTheme } from "@mui/material/styles";
  28. import Link from "next/link";
  29. import dayjs from "dayjs";
  30. import Microwave from "@mui/icons-material/Microwave";
  31. import AccountTree from "@mui/icons-material/AccountTree";
  32. import FilterAltOutlined from "@mui/icons-material/FilterAltOutlined";
  33. import { fetchEquipmentUsageBoard, type EquipmentUsageBoardRow } from "@/app/api/chart/client";
  34. import SafeApexCharts from "@/components/charts/SafeApexCharts";
  35. import { CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS } from "@/app/(main)/chart/chartBoardRefreshPrefs";
  36. import { useChartBoardRefreshPrefs } from "@/app/(main)/chart/useChartBoardRefreshPrefs";
  37. const EQUIPMENT_CHART_MAX = 35;
  38. /** Stable key for grouping / filter (master equipment id or free-text label). */
  39. function rowEquipmentKey(r: EquipmentUsageBoardRow): string {
  40. if (r.equipmentId > 0) return `id:${r.equipmentId}`;
  41. const c = (r.equipmentCode ?? "").trim();
  42. const n = (r.equipmentName ?? "").trim();
  43. return `txt:${c}\u0001${n}`;
  44. }
  45. /** Single display line when code/name may duplicate (equipment or process). */
  46. function formatCodeNameLine(code: string, name: string): string {
  47. const c = (code ?? "").trim();
  48. const n = (name ?? "").trim();
  49. if (!c && !n) return "—";
  50. if (!c) return n;
  51. if (!n) return c;
  52. if (c.toLowerCase() === n.toLowerCase()) return n;
  53. if (n.toLowerCase().startsWith(`${c.toLowerCase()} `) || n.toLowerCase().startsWith(`${c.toLowerCase()} `)) return n;
  54. if (n.length > c.length && n.toLowerCase().startsWith(c.toLowerCase())) {
  55. const after = n.slice(c.length, c.length + 1);
  56. if (/[\s\-–—::·.|//]/.test(after)) return n;
  57. }
  58. return `${c} ${n}`;
  59. }
  60. function formatUsageMinutes(m: number): string {
  61. if (!Number.isFinite(m) || m <= 0) return "—";
  62. const rounded = Math.round(m * 10) / 10;
  63. if (rounded < 60) return `${rounded} 分`;
  64. const h = Math.floor(rounded / 60);
  65. const mm = Math.round((rounded % 60) * 10) / 10;
  66. return mm > 0 ? `${h} 小時 ${mm} 分` : `${h} 小時`;
  67. }
  68. export default function EquipmentUsageBoardPage() {
  69. const theme = useTheme();
  70. /** Calendar day for API (local). Default: today. */
  71. const [viewDate, setViewDate] = useState(() => dayjs().format("YYYY-MM-DD"));
  72. const [rows, setRows] = useState<EquipmentUsageBoardRow[]>([]);
  73. const [loading, setLoading] = useState(true);
  74. const [error, setError] = useState<string | null>(null);
  75. const [lastUpdated, setLastUpdated] = useState("");
  76. /** null = all equipment; otherwise rowEquipmentKey */
  77. const [selectedEquipmentKey, setSelectedEquipmentKey] = useState<string | null>(null);
  78. const { autoRefreshOn, setAutoRefreshOn, refreshIntervalSec, setRefreshIntervalSec } =
  79. useChartBoardRefreshPrefs("equipment");
  80. const load = useCallback(async () => {
  81. setLoading(true);
  82. setError(null);
  83. try {
  84. const data = await fetchEquipmentUsageBoard(viewDate);
  85. setRows(data);
  86. setLastUpdated(dayjs().format("YYYY-MM-DD HH:mm:ss"));
  87. } catch (e) {
  88. setError(e instanceof Error ? e.message : "Request failed");
  89. } finally {
  90. setLoading(false);
  91. }
  92. }, [viewDate]);
  93. useEffect(() => {
  94. void load();
  95. }, [load]);
  96. useEffect(() => {
  97. if (!autoRefreshOn || refreshIntervalSec < 10) return;
  98. const t = setInterval(() => void load(), refreshIntervalSec * 1000);
  99. return () => clearInterval(t);
  100. }, [load, autoRefreshOn, refreshIntervalSec]);
  101. useEffect(() => {
  102. setSelectedEquipmentKey(null);
  103. }, [viewDate]);
  104. useEffect(() => {
  105. const onKey = (ev: KeyboardEvent) => {
  106. const t = ev.target as HTMLElement | null;
  107. if (
  108. t &&
  109. (t.tagName === "INPUT" ||
  110. t.tagName === "TEXTAREA" ||
  111. t.tagName === "SELECT" ||
  112. t.isContentEditable)
  113. ) {
  114. return;
  115. }
  116. if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
  117. if (ev.key === "t" || ev.key === "T") {
  118. ev.preventDefault();
  119. setViewDate(dayjs().format("YYYY-MM-DD"));
  120. }
  121. if (ev.key === "y" || ev.key === "Y") {
  122. ev.preventDefault();
  123. setViewDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"));
  124. }
  125. };
  126. window.addEventListener("keydown", onKey);
  127. return () => window.removeEventListener("keydown", onKey);
  128. }, []);
  129. const equipmentUsageChart = useMemo(() => {
  130. const map = new Map<string, { key: string; label: string; minutes: number }>();
  131. rows.forEach((r) => {
  132. const k = rowEquipmentKey(r);
  133. const label = formatCodeNameLine(r.equipmentCode, r.equipmentName);
  134. const add = Number.isFinite(r.usageMinutes) ? r.usageMinutes : 0;
  135. const cur = map.get(k) ?? { key: k, label, minutes: 0 };
  136. cur.minutes += add;
  137. map.set(k, cur);
  138. });
  139. const list = Array.from(map.values())
  140. .filter((x) => x.minutes > 0)
  141. .sort((a, b) => b.minutes - a.minutes)
  142. .slice(0, EQUIPMENT_CHART_MAX);
  143. return {
  144. keys: list.map((x) => x.key),
  145. categories: list.map((x) => (x.label.length > 34 ? `${x.label.slice(0, 32)}…` : x.label)),
  146. data: list.map((x) => Math.round(x.minutes * 10) / 10),
  147. };
  148. }, [rows]);
  149. const barClickRef = useRef<(index: number) => void>(() => {});
  150. barClickRef.current = (index: number) => {
  151. const key = equipmentUsageChart.keys[index];
  152. if (key == null) return;
  153. setSelectedEquipmentKey((prev) => (prev === key ? null : key));
  154. };
  155. const barOptions = useMemo(
  156. () => ({
  157. chart: {
  158. type: "bar" as const,
  159. toolbar: { show: false },
  160. events: {
  161. dataPointSelection: (_e: unknown, _ctx: unknown, cfg: { dataPointIndex?: number }) => {
  162. const i = cfg?.dataPointIndex;
  163. if (typeof i !== "number" || i < 0) return;
  164. barClickRef.current(i);
  165. },
  166. },
  167. },
  168. plotOptions: {
  169. bar: {
  170. horizontal: true,
  171. barHeight: "72%",
  172. borderRadius: 4,
  173. distributed: true,
  174. },
  175. },
  176. colors: ["#1976d2", "#0288d1", "#0097a7", "#00838f", "#00695c", "#2e7d32", "#558b2f", "#827717"],
  177. dataLabels: { enabled: true, formatter: (val: number) => (Number.isFinite(val) ? `${val} 分` : "") },
  178. xaxis: { categories: equipmentUsageChart.categories, title: { text: "分鐘" } },
  179. yaxis: { labels: { maxWidth: 260 } },
  180. legend: { show: false },
  181. tooltip: { y: { formatter: (val: number) => `${val} 分鐘` } },
  182. }),
  183. [equipmentUsageChart.categories],
  184. );
  185. const displayRows = useMemo(() => {
  186. if (!selectedEquipmentKey) return rows;
  187. return rows.filter((r) => rowEquipmentKey(r) === selectedEquipmentKey);
  188. }, [rows, selectedEquipmentKey]);
  189. const selectedLabel = useMemo(() => {
  190. if (!selectedEquipmentKey) return "";
  191. const hit = rows.find((r) => rowEquipmentKey(r) === selectedEquipmentKey);
  192. return hit ? formatCodeNameLine(hit.equipmentCode, hit.equipmentName) : selectedEquipmentKey;
  193. }, [rows, selectedEquipmentKey]);
  194. const stats = useMemo(() => {
  195. const working = displayRows.filter((r) => r.workingNow === 1).length;
  196. const eqKeys = new Set(displayRows.map((r) => rowEquipmentKey(r)));
  197. const totalMins = displayRows.reduce((s, r) => s + (Number.isFinite(r.usageMinutes) ? r.usageMinutes : 0), 0);
  198. return { working, sessions: displayRows.length, equipmentTouched: eqKeys.size, totalMins };
  199. }, [displayRows]);
  200. const isToday = viewDate === dayjs().format("YYYY-MM-DD");
  201. const weekdayZh = ["日", "一", "二", "三", "四", "五", "六"][dayjs(viewDate).day()] ?? "";
  202. return (
  203. <Box
  204. sx={{
  205. width: "100%",
  206. maxWidth: "100%",
  207. mx: "auto",
  208. p: { xs: 0.5, sm: 1 },
  209. boxSizing: "border-box",
  210. }}
  211. >
  212. <Typography variant="h5" sx={{ mb: 1, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
  213. <Microwave /> 設備使用看板
  214. </Typography>
  215. <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
  216. 資料來源與<strong>工單編輯/工藝流程</strong>一致。上方<strong>使用時間(分鐘)</strong>為各設備當日明細加總(有起訖則相減;產線 Pass/無完工時間時用預設生產分鐘)。
  217. 點<strong>長條圖</strong>可篩選下方列表,再點同一項取消。
  218. <strong> 快捷鍵</strong>(不在輸入框內時):<kbd style={{ padding: "1px 6px", borderRadius: 4, border: "1px solid #ccc" }}>T</kbd>{" "}
  219. 今日、<kbd style={{ padding: "1px 6px", borderRadius: 4, border: "1px solid #ccc" }}>Y</kbd> 昨日。
  220. {autoRefreshOn
  221. ? ` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)`
  222. : " 預設不自動更新,可開啟「自動重新整理」並選擇間隔。"}
  223. {" 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。"}
  224. {lastUpdated ? ` · 最後更新 ${lastUpdated}` : ""}
  225. </Typography>
  226. {error && (
  227. <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
  228. {error}
  229. </Alert>
  230. )}
  231. <Stack
  232. direction={{ xs: "column", lg: "row" }}
  233. spacing={2}
  234. alignItems={{ xs: "stretch", lg: "stretch" }}
  235. justifyContent={{ lg: "space-between" }}
  236. sx={{ mb: 2 }}
  237. >
  238. <Paper
  239. variant="outlined"
  240. sx={{
  241. p: 1.5,
  242. flex: { lg: "1 1 0" },
  243. minWidth: { xs: "100%", lg: 280 },
  244. borderColor: "divider",
  245. bgcolor: alpha(theme.palette.primary.main, 0.03),
  246. }}
  247. >
  248. <Typography
  249. variant="overline"
  250. color="text.secondary"
  251. sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
  252. >
  253. 查詢與列表
  254. </Typography>
  255. <Stack spacing={1.25}>
  256. <Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}>
  257. 歸屬日 {viewDate}(週{weekdayZh})
  258. {!isToday && <Chip size="small" label="非今日" sx={{ ml: 1 }} variant="outlined" />}
  259. </Typography>
  260. <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}>
  261. <Tooltip title="快捷鍵 T">
  262. <Button
  263. size="small"
  264. variant={isToday ? "contained" : "outlined"}
  265. onClick={() => setViewDate(dayjs().format("YYYY-MM-DD"))}
  266. >
  267. 今日
  268. </Button>
  269. </Tooltip>
  270. <Tooltip title="快捷鍵 Y">
  271. <Button size="small" variant="outlined" onClick={() => setViewDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"))}>
  272. 昨日
  273. </Button>
  274. </Tooltip>
  275. <TextField
  276. size="small"
  277. label="選擇日期"
  278. type="date"
  279. value={viewDate}
  280. onChange={(e) => setViewDate(e.target.value)}
  281. InputLabelProps={{ shrink: true }}
  282. sx={{ minWidth: 178 }}
  283. />
  284. <Button variant="outlined" size="small" onClick={() => void load()} disabled={loading}>
  285. 重新整理
  286. </Button>
  287. </Stack>
  288. </Stack>
  289. </Paper>
  290. <Paper
  291. variant="outlined"
  292. sx={{
  293. p: 1.5,
  294. flex: { lg: "0 0 auto" },
  295. width: { xs: "100%", lg: "auto" },
  296. minWidth: { lg: 200 },
  297. borderColor: "divider",
  298. bgcolor: alpha(theme.palette.grey[500], 0.06),
  299. }}
  300. >
  301. <Typography
  302. variant="overline"
  303. color="text.secondary"
  304. sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
  305. >
  306. 其他看板
  307. </Typography>
  308. <Stack direction="column" spacing={1} sx={{ maxWidth: 220 }}>
  309. <Button component={Link} href="/chart/joborder/board" size="small" variant="outlined" fullWidth>
  310. 工單即時看板
  311. </Button>
  312. <Button component={Link} href="/chart/process/board" size="small" variant="outlined" fullWidth startIcon={<AccountTree />}>
  313. 工序即時看板
  314. </Button>
  315. <Button component={Link} href="/chart/joborder" size="small" variant="outlined" fullWidth>
  316. 工單圖表
  317. </Button>
  318. </Stack>
  319. </Paper>
  320. <Paper
  321. variant="outlined"
  322. sx={{
  323. p: 1.5,
  324. flex: { lg: "0 0 auto" },
  325. width: { xs: "100%", lg: "auto" },
  326. minWidth: { xs: "100%", lg: 300 },
  327. borderColor: "divider",
  328. bgcolor: alpha(theme.palette.info.main, 0.04),
  329. }}
  330. >
  331. <Typography
  332. variant="overline"
  333. color="text.secondary"
  334. sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
  335. >
  336. 自動重新整理
  337. </Typography>
  338. <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}>
  339. <FormControlLabel
  340. control={
  341. <Switch
  342. size="small"
  343. checked={autoRefreshOn}
  344. onChange={(_, v) => setAutoRefreshOn(v)}
  345. inputProps={{ "aria-label": "自動重新整理" }}
  346. />
  347. }
  348. label="開啟"
  349. sx={{ ml: 0, mr: 0 }}
  350. />
  351. <FormControl size="small" sx={{ minWidth: 124 }} disabled={!autoRefreshOn}>
  352. <InputLabel id="eq-board-refresh-interval-label">間隔(秒)</InputLabel>
  353. <Select
  354. labelId="eq-board-refresh-interval-label"
  355. label="間隔(秒)"
  356. value={refreshIntervalSec}
  357. onChange={(e) => setRefreshIntervalSec(Number(e.target.value))}
  358. >
  359. {CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS.map((sec) => (
  360. <MenuItem key={sec} value={sec}>
  361. {sec} 秒
  362. </MenuItem>
  363. ))}
  364. </Select>
  365. </FormControl>
  366. </Stack>
  367. </Paper>
  368. </Stack>
  369. {selectedEquipmentKey && (
  370. <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 2 }}>
  371. <FilterAltOutlined fontSize="small" color="action" />
  372. <Chip
  373. label={`篩選設備:${selectedLabel}`}
  374. onDelete={() => setSelectedEquipmentKey(null)}
  375. color="primary"
  376. variant="outlined"
  377. />
  378. </Stack>
  379. )}
  380. {!loading && rows.length > 0 && equipmentUsageChart.data.length > 0 && (
  381. <Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
  382. <Typography variant="subtitle2" fontWeight={600} gutterBottom>
  383. 使用時間(分鐘)— {viewDate}
  384. </Typography>
  385. <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
  386. 點擊長條篩選下方明細(最多顯示 {EQUIPMENT_CHART_MAX} 台,依分鐘數由高到低)。
  387. </Typography>
  388. <SafeApexCharts
  389. chartRevision={JSON.stringify(equipmentUsageChart.keys)}
  390. options={barOptions}
  391. series={[{ name: "分鐘", data: equipmentUsageChart.data }]}
  392. type="bar"
  393. height={Math.min(520, 120 + equipmentUsageChart.data.length * 28)}
  394. />
  395. </Paper>
  396. )}
  397. {!loading && rows.length > 0 && equipmentUsageChart.data.length === 0 && (
  398. <Alert severity="info" sx={{ mb: 3 }}>
  399. 當日有明細但無法加總使用分鐘(多數為缺開/完工時間且無預設生產分鐘)。仍可在下方表格檢視。
  400. </Alert>
  401. )}
  402. {!loading && (
  403. <Stack direction="row" spacing={2} useFlexGap flexWrap="wrap" sx={{ mb: 2 }}>
  404. <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}>
  405. <Typography variant="caption" color="text.secondary">
  406. {selectedEquipmentKey ? "篩選後筆數" : "該日總筆數"}
  407. </Typography>
  408. <Typography variant="h6">{stats.sessions}</Typography>
  409. </Paper>
  410. <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}>
  411. <Typography variant="caption" color="text.secondary">
  412. 使用分鐘合計(篩選範圍)
  413. </Typography>
  414. <Typography variant="h6">{formatUsageMinutes(stats.totalMins)}</Typography>
  415. </Paper>
  416. <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}>
  417. <Typography variant="caption" color="text.secondary">
  418. 涉及設備數(篩選範圍)
  419. </Typography>
  420. <Typography variant="h6">{stats.equipmentTouched}</Typography>
  421. </Paper>
  422. {stats.working > 0 && (
  423. <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}>
  424. <Typography variant="caption" color="text.secondary">
  425. 設備工時未結案
  426. </Typography>
  427. <Typography variant="h6">{stats.working}</Typography>
  428. </Paper>
  429. )}
  430. </Stack>
  431. )}
  432. {loading && rows.length === 0 ? (
  433. <Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
  434. <CircularProgress />
  435. </Box>
  436. ) : rows.length === 0 ? (
  437. <Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}>
  438. <Typography color="text.secondary">
  439. 此日期沒有符合歸屬日的設備使用紀錄(含工藝流程明細),或該日尚無已完工且已填設備的步驟。
  440. </Typography>
  441. </Paper>
  442. ) : displayRows.length === 0 ? (
  443. <Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}>
  444. <Typography color="text.secondary" sx={{ mb: 2 }}>
  445. 此篩選下沒有明細。
  446. </Typography>
  447. <Button size="small" onClick={() => setSelectedEquipmentKey(null)}>
  448. 清除設備篩選
  449. </Button>
  450. </Paper>
  451. ) : (
  452. <TableContainer component={Paper} variant="outlined">
  453. <Table size="small" stickyHeader>
  454. <TableHead>
  455. <TableRow>
  456. <TableCell>狀態</TableCell>
  457. <TableCell>設備</TableCell>
  458. <TableCell align="right">使用(分)</TableCell>
  459. <TableCell>工單</TableCell>
  460. <TableCell>工序</TableCell>
  461. <TableCell>工單計劃開始</TableCell>
  462. <TableCell>開工時間</TableCell>
  463. <TableCell>完工時間</TableCell>
  464. <TableCell>操作員</TableCell>
  465. <TableCell align="center">開啟</TableCell>
  466. </TableRow>
  467. </TableHead>
  468. <TableBody>
  469. {displayRows.map((r) => (
  470. <TableRow key={`${r.jobOrderId}-${r.jopdId}-${r.operatingEnd}-${r.operatingStart}`} hover>
  471. <TableCell>
  472. {r.workingNow === 1 ? (
  473. <Chip label="設備工時未結案" size="small" color="warning" variant="outlined" />
  474. ) : !r.operatingStart?.trim() && !r.operatingEnd?.trim() ? (
  475. <Chip label="未填設備工時" size="small" color="default" variant="outlined" />
  476. ) : (
  477. <Chip label="已完工" size="small" variant="outlined" />
  478. )}
  479. </TableCell>
  480. <TableCell sx={{ fontWeight: 600 }}>{formatCodeNameLine(r.equipmentCode, r.equipmentName)}</TableCell>
  481. <TableCell align="right">{formatUsageMinutes(r.usageMinutes)}</TableCell>
  482. <TableCell>{r.jobOrderCode || "—"}</TableCell>
  483. <TableCell sx={{ maxWidth: 220 }}>{formatCodeNameLine(r.processCode, r.processName)}</TableCell>
  484. <TableCell>{r.jobPlanStart || "—"}</TableCell>
  485. <TableCell>{r.operatingStart || "—"}</TableCell>
  486. <TableCell>{r.operatingEnd || "—"}</TableCell>
  487. <TableCell>{r.operatorName || r.operatorUsername || "—"}</TableCell>
  488. <TableCell align="center">
  489. <Button
  490. component={Link}
  491. href={`/jo/edit?id=${r.jobOrderId}`}
  492. target="_blank"
  493. rel="noopener noreferrer"
  494. size="small"
  495. >
  496. 開啟
  497. </Button>
  498. </TableCell>
  499. </TableRow>
  500. ))}
  501. </TableBody>
  502. </Table>
  503. </TableContainer>
  504. )}
  505. </Box>
  506. );
  507. }