"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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [lastUpdated, setLastUpdated] = useState(""); /** null = all equipment; otherwise rowEquipmentKey */ const [selectedEquipmentKey, setSelectedEquipmentKey] = useState(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(); 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 ( 設備使用看板 資料來源與工單編輯/工藝流程一致。上方使用時間(分鐘)為各設備當日明細加總(有起訖則相減;產線 Pass/無完工時間時用預設生產分鐘)。 點長條圖可篩選下方列表,再點同一項取消。 快捷鍵(不在輸入框內時):T{" "} 今日、Y 昨日。 {autoRefreshOn ? ` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)` : " 預設不自動更新,可開啟「自動重新整理」並選擇間隔。"} {" 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。"} {lastUpdated ? ` · 最後更新 ${lastUpdated}` : ""} {error && ( setError(null)}> {error} )} 查詢與列表 歸屬日 {viewDate}(週{weekdayZh}) {!isToday && } setViewDate(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 178 }} /> 其他看板 自動重新整理 setAutoRefreshOn(v)} inputProps={{ "aria-label": "自動重新整理" }} /> } label="開啟" sx={{ ml: 0, mr: 0 }} /> 間隔(秒) {selectedEquipmentKey && ( setSelectedEquipmentKey(null)} color="primary" variant="outlined" /> )} {!loading && rows.length > 0 && equipmentUsageChart.data.length > 0 && ( 使用時間(分鐘)— {viewDate} 點擊長條篩選下方明細(最多顯示 {EQUIPMENT_CHART_MAX} 台,依分鐘數由高到低)。 )} {!loading && rows.length > 0 && equipmentUsageChart.data.length === 0 && ( 當日有明細但無法加總使用分鐘(多數為缺開/完工時間且無預設生產分鐘)。仍可在下方表格檢視。 )} {!loading && ( {selectedEquipmentKey ? "篩選後筆數" : "該日總筆數"} {stats.sessions} 使用分鐘合計(篩選範圍) {formatUsageMinutes(stats.totalMins)} 涉及設備數(篩選範圍) {stats.equipmentTouched} {stats.working > 0 && ( 設備工時未結案 {stats.working} )} )} {loading && rows.length === 0 ? ( ) : rows.length === 0 ? ( 此日期沒有符合歸屬日的設備使用紀錄(含工藝流程明細),或該日尚無已完工且已填設備的步驟。 ) : displayRows.length === 0 ? ( 此篩選下沒有明細。 ) : ( 狀態 設備 使用(分) 工單 工序 工單計劃開始 開工時間 完工時間 操作員 開啟 {displayRows.map((r) => ( {r.workingNow === 1 ? ( ) : !r.operatingStart?.trim() && !r.operatingEnd?.trim() ? ( ) : ( )} {formatCodeNameLine(r.equipmentCode, r.equipmentName)} {formatUsageMinutes(r.usageMinutes)} {r.jobOrderCode || "—"} {formatCodeNameLine(r.processCode, r.processName)} {r.jobPlanStart || "—"} {r.operatingStart || "—"} {r.operatingEnd || "—"} {r.operatorName || r.operatorUsername || "—"} ))}
)}
); }