|
|
|
@@ -0,0 +1,572 @@ |
|
|
|
"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"; |
|
|
|
import { useTranslation } from "react-i18next"; |
|
|
|
import { useUiText } from "@/i18n/useUiText"; |
|
|
|
|
|
|
|
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, L: (zh: string, en: string) => string): string { |
|
|
|
if (!Number.isFinite(m) || m <= 0) return "—"; |
|
|
|
const rounded = Math.round(m * 10) / 10; |
|
|
|
if (rounded < 60) return L(`${rounded} 分`, `${rounded} min`); |
|
|
|
const h = Math.floor(rounded / 60); |
|
|
|
const mm = Math.round((rounded % 60) * 10) / 10; |
|
|
|
return mm > 0 ? L(`${h} 小時 ${mm} 分`, `${h} hr ${mm} min`) : L(`${h} 小時`, `${h} hr`); |
|
|
|
} |
|
|
|
|
|
|
|
export default function EquipmentUsageBoardPage() { |
|
|
|
const theme = useTheme(); |
|
|
|
const { t } = useTranslation("chart"); |
|
|
|
const L = useUiText(); |
|
|
|
/** 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) ? L(`${val} 分`, `${val} min`) : "") }, |
|
|
|
xaxis: { categories: equipmentUsageChart.categories, title: { text: t("minutes") } }, |
|
|
|
yaxis: { labels: { maxWidth: 260 } }, |
|
|
|
legend: { show: false }, |
|
|
|
tooltip: { y: { formatter: (val: number) => t("minutesWithVal", { val }) } }, |
|
|
|
}), |
|
|
|
[equipmentUsageChart.categories, L, t], |
|
|
|
); |
|
|
|
|
|
|
|
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 weekdayIndex = dayjs(viewDate).day(); |
|
|
|
const weekdayZh = ["日", "一", "二", "三", "四", "五", "六"][weekdayIndex] ?? ""; |
|
|
|
const weekdayEn = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][weekdayIndex] ?? ""; |
|
|
|
|
|
|
|
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 /> {t("pageTitle_equipmentBoard")} |
|
|
|
</Typography> |
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> |
|
|
|
{L("資料來源與", "Data matches")}{" "} |
|
|
|
<strong>{L("工單編輯/工藝流程", "Job Order Edit / Process Flow")}</strong> |
|
|
|
{L("一致。上方", ". ")} |
|
|
|
<strong>{L("使用時間(分鐘)", "Usage time (minutes)")}</strong> |
|
|
|
{L( |
|
|
|
"為各設備當日明細加總(有起訖則相減;產線 Pass/無完工時間時用預設生產分鐘)。點", |
|
|
|
" above is the sum of each equipment row for the day (subtract end from start; a line Pass or a missing end time uses the default production minutes). Click a" |
|
|
|
)}{" "} |
|
|
|
<strong>{L("長條圖", "bar")}</strong> |
|
|
|
{L("可篩選下方列表,再點同一項取消。", " to filter the list below. Click the same bar again to clear.")} |
|
|
|
<strong> {L("快捷鍵", "Shortcuts")}</strong> |
|
|
|
{L("(不在輸入框內時):", " (when the cursor is not in a text field): ")} |
|
|
|
<kbd style={{ padding: "1px 6px", borderRadius: 4, border: "1px solid #ccc" }}>T</kbd>{" "} |
|
|
|
{t("today")} |
|
|
|
{L("、", ", ")} |
|
|
|
<kbd style={{ padding: "1px 6px", borderRadius: 4, border: "1px solid #ccc" }}>Y</kbd> {t("yesterday")} |
|
|
|
{L("。", ".")} |
|
|
|
{autoRefreshOn |
|
|
|
? L( |
|
|
|
` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)`, |
|
|
|
` Auto refresh is on (every ${refreshIntervalSec} sec).` |
|
|
|
) |
|
|
|
: L( |
|
|
|
" 預設不自動更新,可開啟「自動重新整理」並選擇間隔。", |
|
|
|
" Auto refresh is off by default. Turn on Auto Refresh and choose an interval." |
|
|
|
)} |
|
|
|
{L( |
|
|
|
" 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。", |
|
|
|
" Settings are saved on this device (per account when signed in, or for this browser session when signed out)." |
|
|
|
)} |
|
|
|
{lastUpdated ? L(` · 最後更新 ${lastUpdated}`, ` · Last updated ${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 }} |
|
|
|
> |
|
|
|
{t("equipment_searchAndList")} |
|
|
|
</Typography> |
|
|
|
<Stack spacing={1.25}> |
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}> |
|
|
|
{L(`歸屬日 ${viewDate}(週${weekdayZh})`, `Usage date ${viewDate} (${weekdayEn})`)} |
|
|
|
{!isToday && <Chip size="small" label={t("equipment_notToday")} sx={{ ml: 1 }} variant="outlined" />} |
|
|
|
</Typography> |
|
|
|
<Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}> |
|
|
|
<Tooltip title={L("快捷鍵 T", "Shortcut T")}> |
|
|
|
<Button |
|
|
|
size="small" |
|
|
|
variant={isToday ? "contained" : "outlined"} |
|
|
|
onClick={() => setViewDate(dayjs().format("YYYY-MM-DD"))} |
|
|
|
> |
|
|
|
{t("today")} |
|
|
|
</Button> |
|
|
|
</Tooltip> |
|
|
|
<Tooltip title={L("快捷鍵 Y", "Shortcut Y")}> |
|
|
|
<Button size="small" variant="outlined" onClick={() => setViewDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"))}> |
|
|
|
{t("yesterday")} |
|
|
|
</Button> |
|
|
|
</Tooltip> |
|
|
|
<TextField |
|
|
|
size="small" |
|
|
|
label={t("selectDate")} |
|
|
|
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}> |
|
|
|
{t("refresh")} |
|
|
|
</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 }} |
|
|
|
> |
|
|
|
{t("otherBoards")} |
|
|
|
</Typography> |
|
|
|
<Stack direction="column" spacing={1} sx={{ maxWidth: 220 }}> |
|
|
|
<Button component={Link} href="/chart/joborder/board" size="small" variant="outlined" fullWidth> |
|
|
|
{t("board_jobOrderLive")} |
|
|
|
</Button> |
|
|
|
<Button component={Link} href="/chart/process/board" size="small" variant="outlined" fullWidth startIcon={<AccountTree />}> |
|
|
|
{t("board_processLive")} |
|
|
|
</Button> |
|
|
|
<Button component={Link} href="/chart/joborder" size="small" variant="outlined" fullWidth> |
|
|
|
{t("board_jobOrderChart")} |
|
|
|
</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 }} |
|
|
|
> |
|
|
|
{t("autoRefresh")} |
|
|
|
</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": t("autoRefresh") }} |
|
|
|
/> |
|
|
|
} |
|
|
|
label={t("on")} |
|
|
|
sx={{ ml: 0, mr: 0 }} |
|
|
|
/> |
|
|
|
<FormControl size="small" sx={{ minWidth: 124 }} disabled={!autoRefreshOn}> |
|
|
|
<InputLabel id="eq-board-refresh-interval-label">{t("intervalSeconds")}</InputLabel> |
|
|
|
<Select |
|
|
|
labelId="eq-board-refresh-interval-label" |
|
|
|
label={t("intervalSeconds")} |
|
|
|
value={refreshIntervalSec} |
|
|
|
onChange={(e) => setRefreshIntervalSec(Number(e.target.value))} |
|
|
|
> |
|
|
|
{CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS.map((sec) => ( |
|
|
|
<MenuItem key={sec} value={sec}> |
|
|
|
{L(`${sec} 秒`, `${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={L(`篩選設備:${selectedLabel}`, `Equipment filter: ${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> |
|
|
|
{L(`使用時間(分鐘)— ${viewDate}`, `Usage time (minutes) — ${viewDate}`)} |
|
|
|
</Typography> |
|
|
|
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}> |
|
|
|
{L( |
|
|
|
`點擊長條篩選下方明細(最多顯示 ${EQUIPMENT_CHART_MAX} 台,依分鐘數由高到低)。`, |
|
|
|
`Click a bar to filter the rows below (up to ${EQUIPMENT_CHART_MAX} equipment, highest minutes first).` |
|
|
|
)} |
|
|
|
</Typography> |
|
|
|
<SafeApexCharts |
|
|
|
chartRevision={JSON.stringify(equipmentUsageChart.keys)} |
|
|
|
options={barOptions} |
|
|
|
series={[{ name: t("minutes"), 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 }}> |
|
|
|
{L( |
|
|
|
"當日有明細但無法加總使用分鐘(多數為缺開/完工時間且無預設生產分鐘)。仍可在下方表格檢視。", |
|
|
|
"This date has rows, but usage minutes could not be totaled (usually a missing start or end time and no default production minutes). You can still review them in the table below." |
|
|
|
)} |
|
|
|
</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 ? L("篩選後筆數", "Filtered rows") : L("該日總筆數", "Rows for this date")} |
|
|
|
</Typography> |
|
|
|
<Typography variant="h6">{stats.sessions}</Typography> |
|
|
|
</Paper> |
|
|
|
<Paper variant="outlined" sx={{ px: 2, py: 1.5 }}> |
|
|
|
<Typography variant="caption" color="text.secondary"> |
|
|
|
{L("使用分鐘合計(篩選範圍)", "Total usage minutes (current filter)")} |
|
|
|
</Typography> |
|
|
|
<Typography variant="h6">{formatUsageMinutes(stats.totalMins, L)}</Typography> |
|
|
|
</Paper> |
|
|
|
<Paper variant="outlined" sx={{ px: 2, py: 1.5 }}> |
|
|
|
<Typography variant="caption" color="text.secondary"> |
|
|
|
{L("涉及設備數(篩選範圍)", "Equipment count (current filter)")} |
|
|
|
</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"> |
|
|
|
{t("equipment_unclosedHours")} |
|
|
|
</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"> |
|
|
|
{L( |
|
|
|
"此日期沒有符合歸屬日的設備使用紀錄(含工藝流程明細),或該日尚無已完工且已填設備的步驟。", |
|
|
|
"No equipment usage for this date (including process-flow rows), or no completed step with equipment filled in." |
|
|
|
)} |
|
|
|
</Typography> |
|
|
|
</Paper> |
|
|
|
) : displayRows.length === 0 ? ( |
|
|
|
<Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}> |
|
|
|
<Typography color="text.secondary" sx={{ mb: 2 }}> |
|
|
|
{L("此篩選下沒有明細。", "No rows match this filter.")} |
|
|
|
</Typography> |
|
|
|
<Button size="small" onClick={() => setSelectedEquipmentKey(null)}> |
|
|
|
{L("清除設備篩選", "Clear equipment filter")} |
|
|
|
</Button> |
|
|
|
</Paper> |
|
|
|
) : ( |
|
|
|
<TableContainer component={Paper} variant="outlined"> |
|
|
|
<Table size="small" stickyHeader> |
|
|
|
<TableHead> |
|
|
|
<TableRow> |
|
|
|
<TableCell>{t("equipment_status")}</TableCell> |
|
|
|
<TableCell>{t("equipment_equipment")}</TableCell> |
|
|
|
<TableCell align="right">{L("使用(分)", "Usage (min)")}</TableCell> |
|
|
|
<TableCell>{t("equipment_jobOrder")}</TableCell> |
|
|
|
<TableCell>{t("equipment_process")}</TableCell> |
|
|
|
<TableCell>{t("equipment_planStart")}</TableCell> |
|
|
|
<TableCell>{t("equipment_startTime")}</TableCell> |
|
|
|
<TableCell>{t("equipment_endTime")}</TableCell> |
|
|
|
<TableCell>{t("equipment_operator")}</TableCell> |
|
|
|
<TableCell align="center">{L("開啟", "Open")}</TableCell> |
|
|
|
</TableRow> |
|
|
|
</TableHead> |
|
|
|
<TableBody> |
|
|
|
{displayRows.map((r) => ( |
|
|
|
<TableRow key={`${r.jobOrderId}-${r.jopdId}-${r.operatingEnd}-${r.operatingStart}`} hover> |
|
|
|
<TableCell> |
|
|
|
{r.workingNow === 1 ? ( |
|
|
|
<Chip label={t("equipment_unclosedHours")} size="small" color="warning" variant="outlined" /> |
|
|
|
) : !r.operatingStart?.trim() && !r.operatingEnd?.trim() ? ( |
|
|
|
<Chip label={t("equipment_missingHours")} size="small" color="default" variant="outlined" /> |
|
|
|
) : ( |
|
|
|
<Chip label={t("equipment_completed")} size="small" variant="outlined" /> |
|
|
|
)} |
|
|
|
</TableCell> |
|
|
|
<TableCell sx={{ fontWeight: 600 }}>{formatCodeNameLine(r.equipmentCode, r.equipmentName)}</TableCell> |
|
|
|
<TableCell align="right">{formatUsageMinutes(r.usageMinutes, L)}</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" |
|
|
|
> |
|
|
|
{L("開啟", "Open")} |
|
|
|
</Button> |
|
|
|
</TableCell> |
|
|
|
</TableRow> |
|
|
|
))} |
|
|
|
</TableBody> |
|
|
|
</Table> |
|
|
|
</TableContainer> |
|
|
|
)} |
|
|
|
</Box> |
|
|
|
); |
|
|
|
} |