|
- "use client";
-
- import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
- import {
- Box,
- Typography,
- Table,
- TableBody,
- TableCell,
- TableContainer,
- TableHead,
- TableRow,
- Paper,
- TextField,
- Alert,
- CircularProgress,
- Stack,
- Button,
- Chip,
- Tooltip,
- FormControl,
- FormControlLabel,
- InputLabel,
- MenuItem,
- Select,
- Switch,
- } from "@mui/material";
- import { alpha, useTheme } from "@mui/material/styles";
- import Link from "next/link";
- import dayjs from "dayjs";
- import Microwave from "@mui/icons-material/Microwave";
- import AccountTree from "@mui/icons-material/AccountTree";
- import FilterAltOutlined from "@mui/icons-material/FilterAltOutlined";
- import { fetchEquipmentUsageBoard, type EquipmentUsageBoardRow } from "@/app/api/chart/client";
- import SafeApexCharts from "@/components/charts/SafeApexCharts";
- import { CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS } from "@/app/(main)/chart/chartBoardRefreshPrefs";
- import { useChartBoardRefreshPrefs } from "@/app/(main)/chart/useChartBoardRefreshPrefs";
-
- const EQUIPMENT_CHART_MAX = 35;
-
- /** Stable key for grouping / filter (master equipment id or free-text label). */
- function rowEquipmentKey(r: EquipmentUsageBoardRow): string {
- if (r.equipmentId > 0) return `id:${r.equipmentId}`;
- const c = (r.equipmentCode ?? "").trim();
- const n = (r.equipmentName ?? "").trim();
- return `txt:${c}\u0001${n}`;
- }
-
- /** Single display line when code/name may duplicate (equipment or process). */
- function formatCodeNameLine(code: string, name: string): string {
- const c = (code ?? "").trim();
- const n = (name ?? "").trim();
- if (!c && !n) return "—";
- if (!c) return n;
- if (!n) return c;
- if (c.toLowerCase() === n.toLowerCase()) return n;
- if (n.toLowerCase().startsWith(`${c.toLowerCase()} `) || n.toLowerCase().startsWith(`${c.toLowerCase()} `)) return n;
- if (n.length > c.length && n.toLowerCase().startsWith(c.toLowerCase())) {
- const after = n.slice(c.length, c.length + 1);
- if (/[\s\-–—::·.|//]/.test(after)) return n;
- }
- return `${c} ${n}`;
- }
-
- function formatUsageMinutes(m: number): string {
- if (!Number.isFinite(m) || m <= 0) return "—";
- const rounded = Math.round(m * 10) / 10;
- if (rounded < 60) return `${rounded} 分`;
- const h = Math.floor(rounded / 60);
- const mm = Math.round((rounded % 60) * 10) / 10;
- return mm > 0 ? `${h} 小時 ${mm} 分` : `${h} 小時`;
- }
-
- export default function EquipmentUsageBoardPage() {
- const theme = useTheme();
- /** Calendar day for API (local). Default: today. */
- const [viewDate, setViewDate] = useState(() => dayjs().format("YYYY-MM-DD"));
- const [rows, setRows] = useState<EquipmentUsageBoardRow[]>([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState<string | null>(null);
- const [lastUpdated, setLastUpdated] = useState("");
- /** null = all equipment; otherwise rowEquipmentKey */
- const [selectedEquipmentKey, setSelectedEquipmentKey] = useState<string | null>(null);
- const { autoRefreshOn, setAutoRefreshOn, refreshIntervalSec, setRefreshIntervalSec } =
- useChartBoardRefreshPrefs("equipment");
-
- const load = useCallback(async () => {
- setLoading(true);
- setError(null);
- try {
- const data = await fetchEquipmentUsageBoard(viewDate);
- setRows(data);
- setLastUpdated(dayjs().format("YYYY-MM-DD HH:mm:ss"));
- } catch (e) {
- setError(e instanceof Error ? e.message : "Request failed");
- } finally {
- setLoading(false);
- }
- }, [viewDate]);
-
- useEffect(() => {
- void load();
- }, [load]);
-
- useEffect(() => {
- if (!autoRefreshOn || refreshIntervalSec < 10) return;
- const t = setInterval(() => void load(), refreshIntervalSec * 1000);
- return () => clearInterval(t);
- }, [load, autoRefreshOn, refreshIntervalSec]);
-
- useEffect(() => {
- setSelectedEquipmentKey(null);
- }, [viewDate]);
-
- useEffect(() => {
- const onKey = (ev: KeyboardEvent) => {
- const t = ev.target as HTMLElement | null;
- if (
- t &&
- (t.tagName === "INPUT" ||
- t.tagName === "TEXTAREA" ||
- t.tagName === "SELECT" ||
- t.isContentEditable)
- ) {
- return;
- }
- if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
- if (ev.key === "t" || ev.key === "T") {
- ev.preventDefault();
- setViewDate(dayjs().format("YYYY-MM-DD"));
- }
- if (ev.key === "y" || ev.key === "Y") {
- ev.preventDefault();
- setViewDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"));
- }
- };
- window.addEventListener("keydown", onKey);
- return () => window.removeEventListener("keydown", onKey);
- }, []);
-
- const equipmentUsageChart = useMemo(() => {
- const map = new Map<string, { key: string; label: string; minutes: number }>();
- rows.forEach((r) => {
- const k = rowEquipmentKey(r);
- const label = formatCodeNameLine(r.equipmentCode, r.equipmentName);
- const add = Number.isFinite(r.usageMinutes) ? r.usageMinutes : 0;
- const cur = map.get(k) ?? { key: k, label, minutes: 0 };
- cur.minutes += add;
- map.set(k, cur);
- });
- const list = Array.from(map.values())
- .filter((x) => x.minutes > 0)
- .sort((a, b) => b.minutes - a.minutes)
- .slice(0, EQUIPMENT_CHART_MAX);
- return {
- keys: list.map((x) => x.key),
- categories: list.map((x) => (x.label.length > 34 ? `${x.label.slice(0, 32)}…` : x.label)),
- data: list.map((x) => Math.round(x.minutes * 10) / 10),
- };
- }, [rows]);
-
- const barClickRef = useRef<(index: number) => void>(() => {});
- barClickRef.current = (index: number) => {
- const key = equipmentUsageChart.keys[index];
- if (key == null) return;
- setSelectedEquipmentKey((prev) => (prev === key ? null : key));
- };
-
- const barOptions = useMemo(
- () => ({
- chart: {
- type: "bar" as const,
- toolbar: { show: false },
- events: {
- dataPointSelection: (_e: unknown, _ctx: unknown, cfg: { dataPointIndex?: number }) => {
- const i = cfg?.dataPointIndex;
- if (typeof i !== "number" || i < 0) return;
- barClickRef.current(i);
- },
- },
- },
- plotOptions: {
- bar: {
- horizontal: true,
- barHeight: "72%",
- borderRadius: 4,
- distributed: true,
- },
- },
- colors: ["#1976d2", "#0288d1", "#0097a7", "#00838f", "#00695c", "#2e7d32", "#558b2f", "#827717"],
- dataLabels: { enabled: true, formatter: (val: number) => (Number.isFinite(val) ? `${val} 分` : "") },
- xaxis: { categories: equipmentUsageChart.categories, title: { text: "分鐘" } },
- yaxis: { labels: { maxWidth: 260 } },
- legend: { show: false },
- tooltip: { y: { formatter: (val: number) => `${val} 分鐘` } },
- }),
- [equipmentUsageChart.categories],
- );
-
- const displayRows = useMemo(() => {
- if (!selectedEquipmentKey) return rows;
- return rows.filter((r) => rowEquipmentKey(r) === selectedEquipmentKey);
- }, [rows, selectedEquipmentKey]);
-
- const selectedLabel = useMemo(() => {
- if (!selectedEquipmentKey) return "";
- const hit = rows.find((r) => rowEquipmentKey(r) === selectedEquipmentKey);
- return hit ? formatCodeNameLine(hit.equipmentCode, hit.equipmentName) : selectedEquipmentKey;
- }, [rows, selectedEquipmentKey]);
-
- const stats = useMemo(() => {
- const working = displayRows.filter((r) => r.workingNow === 1).length;
- const eqKeys = new Set(displayRows.map((r) => rowEquipmentKey(r)));
- const totalMins = displayRows.reduce((s, r) => s + (Number.isFinite(r.usageMinutes) ? r.usageMinutes : 0), 0);
- return { working, sessions: displayRows.length, equipmentTouched: eqKeys.size, totalMins };
- }, [displayRows]);
-
- const isToday = viewDate === dayjs().format("YYYY-MM-DD");
- const weekdayZh = ["日", "一", "二", "三", "四", "五", "六"][dayjs(viewDate).day()] ?? "";
-
- return (
- <Box
- sx={{
- width: "100%",
- maxWidth: "100%",
- mx: "auto",
- p: { xs: 0.5, sm: 1 },
- boxSizing: "border-box",
- }}
- >
- <Typography variant="h5" sx={{ mb: 1, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
- <Microwave /> 設備使用看板
- </Typography>
- <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
- 資料來源與<strong>工單編輯/工藝流程</strong>一致。上方<strong>使用時間(分鐘)</strong>為各設備當日明細加總(有起訖則相減;產線 Pass/無完工時間時用預設生產分鐘)。
- 點<strong>長條圖</strong>可篩選下方列表,再點同一項取消。
- <strong> 快捷鍵</strong>(不在輸入框內時):<kbd style={{ padding: "1px 6px", borderRadius: 4, border: "1px solid #ccc" }}>T</kbd>{" "}
- 今日、<kbd style={{ padding: "1px 6px", borderRadius: 4, border: "1px solid #ccc" }}>Y</kbd> 昨日。
- {autoRefreshOn
- ? ` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)`
- : " 預設不自動更新,可開啟「自動重新整理」並選擇間隔。"}
- {" 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。"}
- {lastUpdated ? ` · 最後更新 ${lastUpdated}` : ""}
- </Typography>
-
- {error && (
- <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
- {error}
- </Alert>
- )}
-
- <Stack
- direction={{ xs: "column", lg: "row" }}
- spacing={2}
- alignItems={{ xs: "stretch", lg: "stretch" }}
- justifyContent={{ lg: "space-between" }}
- sx={{ mb: 2 }}
- >
- <Paper
- variant="outlined"
- sx={{
- p: 1.5,
- flex: { lg: "1 1 0" },
- minWidth: { xs: "100%", lg: 280 },
- borderColor: "divider",
- bgcolor: alpha(theme.palette.primary.main, 0.03),
- }}
- >
- <Typography
- variant="overline"
- color="text.secondary"
- sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
- >
- 查詢與列表
- </Typography>
- <Stack spacing={1.25}>
- <Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}>
- 歸屬日 {viewDate}(週{weekdayZh})
- {!isToday && <Chip size="small" label="非今日" sx={{ ml: 1 }} variant="outlined" />}
- </Typography>
- <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}>
- <Tooltip title="快捷鍵 T">
- <Button
- size="small"
- variant={isToday ? "contained" : "outlined"}
- onClick={() => setViewDate(dayjs().format("YYYY-MM-DD"))}
- >
- 今日
- </Button>
- </Tooltip>
- <Tooltip title="快捷鍵 Y">
- <Button size="small" variant="outlined" onClick={() => setViewDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"))}>
- 昨日
- </Button>
- </Tooltip>
- <TextField
- size="small"
- label="選擇日期"
- type="date"
- value={viewDate}
- onChange={(e) => setViewDate(e.target.value)}
- InputLabelProps={{ shrink: true }}
- sx={{ minWidth: 178 }}
- />
- <Button variant="outlined" size="small" onClick={() => void load()} disabled={loading}>
- 重新整理
- </Button>
- </Stack>
- </Stack>
- </Paper>
-
- <Paper
- variant="outlined"
- sx={{
- p: 1.5,
- flex: { lg: "0 0 auto" },
- width: { xs: "100%", lg: "auto" },
- minWidth: { lg: 200 },
- borderColor: "divider",
- bgcolor: alpha(theme.palette.grey[500], 0.06),
- }}
- >
- <Typography
- variant="overline"
- color="text.secondary"
- sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
- >
- 其他看板
- </Typography>
- <Stack direction="column" spacing={1} sx={{ maxWidth: 220 }}>
- <Button component={Link} href="/chart/joborder/board" size="small" variant="outlined" fullWidth>
- 工單即時看板
- </Button>
- <Button component={Link} href="/chart/process/board" size="small" variant="outlined" fullWidth startIcon={<AccountTree />}>
- 工序即時看板
- </Button>
- <Button component={Link} href="/chart/joborder" size="small" variant="outlined" fullWidth>
- 工單圖表
- </Button>
- </Stack>
- </Paper>
-
- <Paper
- variant="outlined"
- sx={{
- p: 1.5,
- flex: { lg: "0 0 auto" },
- width: { xs: "100%", lg: "auto" },
- minWidth: { xs: "100%", lg: 300 },
- borderColor: "divider",
- bgcolor: alpha(theme.palette.info.main, 0.04),
- }}
- >
- <Typography
- variant="overline"
- color="text.secondary"
- sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
- >
- 自動重新整理
- </Typography>
- <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}>
- <FormControlLabel
- control={
- <Switch
- size="small"
- checked={autoRefreshOn}
- onChange={(_, v) => setAutoRefreshOn(v)}
- inputProps={{ "aria-label": "自動重新整理" }}
- />
- }
- label="開啟"
- sx={{ ml: 0, mr: 0 }}
- />
- <FormControl size="small" sx={{ minWidth: 124 }} disabled={!autoRefreshOn}>
- <InputLabel id="eq-board-refresh-interval-label">間隔(秒)</InputLabel>
- <Select
- labelId="eq-board-refresh-interval-label"
- label="間隔(秒)"
- value={refreshIntervalSec}
- onChange={(e) => setRefreshIntervalSec(Number(e.target.value))}
- >
- {CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS.map((sec) => (
- <MenuItem key={sec} value={sec}>
- {sec} 秒
- </MenuItem>
- ))}
- </Select>
- </FormControl>
- </Stack>
- </Paper>
- </Stack>
-
- {selectedEquipmentKey && (
- <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 2 }}>
- <FilterAltOutlined fontSize="small" color="action" />
- <Chip
- label={`篩選設備:${selectedLabel}`}
- onDelete={() => setSelectedEquipmentKey(null)}
- color="primary"
- variant="outlined"
- />
- </Stack>
- )}
-
- {!loading && rows.length > 0 && equipmentUsageChart.data.length > 0 && (
- <Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
- <Typography variant="subtitle2" fontWeight={600} gutterBottom>
- 使用時間(分鐘)— {viewDate}
- </Typography>
- <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
- 點擊長條篩選下方明細(最多顯示 {EQUIPMENT_CHART_MAX} 台,依分鐘數由高到低)。
- </Typography>
- <SafeApexCharts
- chartRevision={JSON.stringify(equipmentUsageChart.keys)}
- options={barOptions}
- series={[{ name: "分鐘", data: equipmentUsageChart.data }]}
- type="bar"
- height={Math.min(520, 120 + equipmentUsageChart.data.length * 28)}
- />
- </Paper>
- )}
-
- {!loading && rows.length > 0 && equipmentUsageChart.data.length === 0 && (
- <Alert severity="info" sx={{ mb: 3 }}>
- 當日有明細但無法加總使用分鐘(多數為缺開/完工時間且無預設生產分鐘)。仍可在下方表格檢視。
- </Alert>
- )}
-
- {!loading && (
- <Stack direction="row" spacing={2} useFlexGap flexWrap="wrap" sx={{ mb: 2 }}>
- <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}>
- <Typography variant="caption" color="text.secondary">
- {selectedEquipmentKey ? "篩選後筆數" : "該日總筆數"}
- </Typography>
- <Typography variant="h6">{stats.sessions}</Typography>
- </Paper>
- <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}>
- <Typography variant="caption" color="text.secondary">
- 使用分鐘合計(篩選範圍)
- </Typography>
- <Typography variant="h6">{formatUsageMinutes(stats.totalMins)}</Typography>
- </Paper>
- <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}>
- <Typography variant="caption" color="text.secondary">
- 涉及設備數(篩選範圍)
- </Typography>
- <Typography variant="h6">{stats.equipmentTouched}</Typography>
- </Paper>
- {stats.working > 0 && (
- <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}>
- <Typography variant="caption" color="text.secondary">
- 設備工時未結案
- </Typography>
- <Typography variant="h6">{stats.working}</Typography>
- </Paper>
- )}
- </Stack>
- )}
-
- {loading && rows.length === 0 ? (
- <Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
- <CircularProgress />
- </Box>
- ) : rows.length === 0 ? (
- <Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}>
- <Typography color="text.secondary">
- 此日期沒有符合歸屬日的設備使用紀錄(含工藝流程明細),或該日尚無已完工且已填設備的步驟。
- </Typography>
- </Paper>
- ) : displayRows.length === 0 ? (
- <Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}>
- <Typography color="text.secondary" sx={{ mb: 2 }}>
- 此篩選下沒有明細。
- </Typography>
- <Button size="small" onClick={() => setSelectedEquipmentKey(null)}>
- 清除設備篩選
- </Button>
- </Paper>
- ) : (
- <TableContainer component={Paper} variant="outlined">
- <Table size="small" stickyHeader>
- <TableHead>
- <TableRow>
- <TableCell>狀態</TableCell>
- <TableCell>設備</TableCell>
- <TableCell align="right">使用(分)</TableCell>
- <TableCell>工單</TableCell>
- <TableCell>工序</TableCell>
- <TableCell>工單計劃開始</TableCell>
- <TableCell>開工時間</TableCell>
- <TableCell>完工時間</TableCell>
- <TableCell>操作員</TableCell>
- <TableCell align="center">開啟</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {displayRows.map((r) => (
- <TableRow key={`${r.jobOrderId}-${r.jopdId}-${r.operatingEnd}-${r.operatingStart}`} hover>
- <TableCell>
- {r.workingNow === 1 ? (
- <Chip label="設備工時未結案" size="small" color="warning" variant="outlined" />
- ) : !r.operatingStart?.trim() && !r.operatingEnd?.trim() ? (
- <Chip label="未填設備工時" size="small" color="default" variant="outlined" />
- ) : (
- <Chip label="已完工" size="small" variant="outlined" />
- )}
- </TableCell>
- <TableCell sx={{ fontWeight: 600 }}>{formatCodeNameLine(r.equipmentCode, r.equipmentName)}</TableCell>
- <TableCell align="right">{formatUsageMinutes(r.usageMinutes)}</TableCell>
- <TableCell>{r.jobOrderCode || "—"}</TableCell>
- <TableCell sx={{ maxWidth: 220 }}>{formatCodeNameLine(r.processCode, r.processName)}</TableCell>
- <TableCell>{r.jobPlanStart || "—"}</TableCell>
- <TableCell>{r.operatingStart || "—"}</TableCell>
- <TableCell>{r.operatingEnd || "—"}</TableCell>
- <TableCell>{r.operatorName || r.operatorUsername || "—"}</TableCell>
- <TableCell align="center">
- <Button
- component={Link}
- href={`/jo/edit?id=${r.jobOrderId}`}
- target="_blank"
- rel="noopener noreferrer"
- size="small"
- >
- 開啟
- </Button>
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </TableContainer>
- )}
- </Box>
- );
- }
|