"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { Bar, BarChart, CartesianGrid, Cell, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Scatter, Tooltip, XAxis, YAxis, } from "recharts"; import { useI18n } from "@/lib/i18n/useI18n"; type Heartbeat = { ts: string; status: string; message?: string | null; ip?: string | null; fwVersion?: string | null; }; type Kpi = { ts: string; oee?: number | null; availability?: number | null; performance?: number | null; quality?: number | null; workOrderId?: string | null; sku?: string | null; good?: number | null; scrap?: number | null; target?: number | null; cycleTime?: number | null; }; type EventRow = { id: string; ts: string; topic: string; eventType: string; severity: string; title: string; description?: string | null; requiresAck: boolean; }; type CycleRow = { ts: string; t: number; cycleCount: number | null; actual: number; ideal: number | null; workOrderId: string | null; sku: string | null; }; type CycleDerivedRow = CycleRow & { extra: number | null; bucket: "normal" | "slow" | "microstop" | "macrostop" | "unknown"; }; type MachineDetail = { id: string; name: string; code?: string | null; location?: string | null; latestHeartbeat: Heartbeat | null; latestKpi: Kpi | null; }; type Thresholds = { stoppageMultiplier: number; macroStoppageMultiplier: number; }; type TimelineState = "normal" | "slow" | "microstop" | "macrostop"; type TimelineSeg = { start: number; end: number; durationSec: number; state: TimelineState; }; type ActiveStoppage = { state: "microstop" | "macrostop"; startedAt: string; durationSec: number; theoreticalCycleTime: number; }; type UploadState = { status: "idle" | "parsing" | "uploading" | "success" | "error"; message?: string; count?: number; }; type WorkOrderUpload = { workOrderId: string; sku?: string; targetQty?: number; cycleTime?: number; }; const TOL = 0.10; const DEFAULT_MICRO_MULT = 1.5; const DEFAULT_MACRO_MULT = 5; const NORMAL_TOL_SEC = 0.1; function resolveMultipliers(thresholds?: Thresholds | null) { const micro = Number(thresholds?.stoppageMultiplier ?? DEFAULT_MICRO_MULT); const macro = Math.max( micro, Number(thresholds?.macroStoppageMultiplier ?? DEFAULT_MACRO_MULT) ); return { micro, macro }; } function classifyCycleDuration( actualSec: number, idealSec: number, thresholds?: Thresholds | null ): TimelineState { const { micro, macro } = resolveMultipliers(thresholds); if (actualSec < idealSec + NORMAL_TOL_SEC) return "normal"; if (actualSec < idealSec * micro) return "slow"; if (actualSec < idealSec * macro) return "microstop"; return "macrostop"; } const WORK_ORDER_KEYS = { id: new Set(["workorderid", "workorder", "orderid", "woid", "work_order_id", "otid"]), sku: new Set(["sku"]), cycle: new Set([ "theoreticalcycletimeseconds", "theoreticalcycletime", "cycletime", "cycle_time", "theoretical_cycle_time", ]), target: new Set(["targetquantity", "targetqty", "target", "target_qty"]), }; function normalizeKey(value: string) { return value.toLowerCase().replace(/[^a-z0-9]/g, ""); } function parseCsvText(text: string) { const rows: string[][] = []; let row: string[] = []; let field = ""; let inQuotes = false; for (let i = 0; i < text.length; i += 1) { const ch = text[i]; if (ch === "\"") { if (inQuotes && text[i + 1] === "\"") { field += "\""; i += 1; } else { inQuotes = !inQuotes; } continue; } if (ch === "," && !inQuotes) { row.push(field); field = ""; continue; } if ((ch === "\n" || ch === "\r") && !inQuotes) { if (ch === "\r" && text[i + 1] === "\n") i += 1; row.push(field); field = ""; if (row.some((cell) => cell.trim().length > 0)) { rows.push(row); } row = []; continue; } field += ch; } row.push(field); if (row.some((cell) => cell.trim().length > 0)) { rows.push(row); } if (!rows.length) return []; const headers = rows.shift()!.map((h) => h.trim()); return rows.map((cols) => { const obj: Record = {}; headers.forEach((header, idx) => { obj[header] = (cols[idx] ?? "").trim(); }); return obj; }); } function pickRowValue(row: Record, keys: Set) { for (const [key, value] of Object.entries(row)) { if (keys.has(normalizeKey(key))) return value; } return undefined; } function rowsToWorkOrders(rows: Array>): WorkOrderUpload[] { const seen = new Set(); const out: WorkOrderUpload[] = []; rows.forEach((row) => { const rawId = pickRowValue(row, WORK_ORDER_KEYS.id); const workOrderId = String(rawId ?? "").trim(); if (!workOrderId || seen.has(workOrderId)) return; seen.add(workOrderId); const sku = String(pickRowValue(row, WORK_ORDER_KEYS.sku) ?? "").trim(); const targetRaw = pickRowValue(row, WORK_ORDER_KEYS.target); const cycleRaw = pickRowValue(row, WORK_ORDER_KEYS.cycle); const targetQty = Number.isFinite(Number(targetRaw)) ? Math.trunc(Number(targetRaw)) : undefined; const cycleTime = Number.isFinite(Number(cycleRaw)) ? Number(cycleRaw) : undefined; out.push({ workOrderId, sku: sku || undefined, targetQty, cycleTime }); }); return out; } export default function MachineDetailClient() { const { t, locale } = useI18n(); const params = useParams<{ machineId: string }>(); const machineId = params?.machineId; const [loading, setLoading] = useState(true); const [machine, setMachine] = useState(null); const [events, setEvents] = useState([]); const [error, setError] = useState(null); const [cycles, setCycles] = useState([]); const [thresholds, setThresholds] = useState(null); const [activeStoppage, setActiveStoppage] = useState(null); const [open, setOpen] = useState(null); const fileInputRef = useRef(null); const [uploadState, setUploadState] = useState({ status: "idle" }); const [nowMs, setNowMs] = useState(() => Date.now()); const BUCKET = { normal: { labelKey: "machine.detail.bucket.normal", dot: "#12D18E", glow: "rgba(18,209,142,.35)", chip: "bg-emerald-500/15 text-emerald-300 border-emerald-500/20", }, slow: { labelKey: "machine.detail.bucket.slow", dot: "#F7B500", glow: "rgba(247,181,0,.35)", chip: "bg-yellow-500/15 text-yellow-300 border-yellow-500/20", }, microstop: { labelKey: "machine.detail.bucket.microstop", dot: "#FF7A00", glow: "rgba(255,122,0,.35)", chip: "bg-orange-500/15 text-orange-300 border-orange-500/20", }, macrostop: { labelKey: "machine.detail.bucket.macrostop", dot: "#FF3B5C", glow: "rgba(255,59,92,.35)", chip: "bg-rose-500/15 text-rose-300 border-rose-500/20", }, unknown: { labelKey: "machine.detail.bucket.unknown", dot: "#A1A1AA", glow: "rgba(161,161,170,.25)", chip: "bg-white/10 text-zinc-200 border-white/10", }, } as const; useEffect(() => { if (!machineId) return; let alive = true; async function load() { try { const res = await fetch(`/api/machines/${machineId}?windowSec=10800`, { cache: "no-store", credentials: "include", }); const json = await res.json().catch(() => ({})); if (!alive) return; if (!res.ok || json?.ok === false) { setError(json?.error ?? t("machine.detail.error.failed")); setLoading(false); return; } setMachine(json.machine ?? null); setEvents(json.events ?? []); setCycles(json.cycles ?? []); setThresholds(json.thresholds ?? null); setActiveStoppage(json.activeStoppage ?? null); setError(null); setLoading(false); } catch { if (!alive) return; setError(t("machine.detail.error.network")); setLoading(false); } } load(); const timer = setInterval(load, 15000); return () => { alive = false; clearInterval(timer); }; }, [machineId, t]); useEffect(() => { const timer = setInterval(() => setNowMs(Date.now()), 1000); return () => clearInterval(timer); }, []); async function parseWorkOrdersFile(file: File) { const name = file.name.toLowerCase(); if (name.endsWith(".csv")) { const text = await file.text(); return rowsToWorkOrders(parseCsvText(text)); } if (name.endsWith(".xls") || name.endsWith(".xlsx")) { const buffer = await file.arrayBuffer(); const xlsx = await import("xlsx"); const workbook = xlsx.read(buffer, { type: "array" }); const sheet = workbook.Sheets[workbook.SheetNames[0]]; if (!sheet) return []; const rows = xlsx.utils.sheet_to_json(sheet, { defval: "" }); return rowsToWorkOrders(rows as Array>); } return null; } async function handleWorkOrderUpload(event: any) { const file = event?.target?.files?.[0] as File | undefined; if (!file) return; if (!machineId) { setUploadState({ status: "error", message: t("machine.detail.workOrders.uploadError") }); event.target.value = ""; return; } setUploadState({ status: "parsing", message: t("machine.detail.workOrders.uploadParsing") }); try { const workOrders = await parseWorkOrdersFile(file); if (!workOrders) { setUploadState({ status: "error", message: t("machine.detail.workOrders.uploadInvalid") }); event.target.value = ""; return; } if (!workOrders.length) { setUploadState({ status: "error", message: t("machine.detail.workOrders.uploadInvalid") }); event.target.value = ""; return; } setUploadState({ status: "uploading", message: t("machine.detail.workOrders.uploading") }); const res = await fetch("/api/work-orders", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ machineId, workOrders }), }); const json = await res.json().catch(() => ({})); if (!res.ok || json?.ok === false) { if (res.status === 401 || res.status === 403) { setUploadState({ status: "error", message: t("machine.detail.workOrders.uploadUnauthorized") }); } else { setUploadState({ status: "error", message: json?.error ?? t("machine.detail.workOrders.uploadError"), }); } event.target.value = ""; return; } setUploadState({ status: "success", message: t("machine.detail.workOrders.uploadSuccess", { count: workOrders.length }), count: workOrders.length, }); event.target.value = ""; } catch { setUploadState({ status: "error", message: t("machine.detail.workOrders.uploadError") }); event.target.value = ""; } } const uploadButtonLabel = uploadState.status === "parsing" ? t("machine.detail.workOrders.uploadParsing") : uploadState.status === "uploading" ? t("machine.detail.workOrders.uploading") : t("machine.detail.workOrders.upload"); const uploadStatusClass = uploadState.status === "success" ? "bg-emerald-500/15 text-emerald-300 border-emerald-500/20" : uploadState.status === "error" ? "bg-red-500/15 text-red-300 border-red-500/20" : "bg-white/10 text-zinc-200 border-white/10"; const isUploading = uploadState.status === "parsing" || uploadState.status === "uploading"; function fmtPct(v?: number | null) { if (v === null || v === undefined || Number.isNaN(v)) return t("common.na"); return `${v.toFixed(1)}%`; } function fmtNum(v?: number | null) { if (v === null || v === undefined || Number.isNaN(v)) return t("common.na"); return `${v}`; } function formatDurationShort(totalSec?: number | null) { if (totalSec === null || totalSec === undefined || Number.isNaN(totalSec)) { return t("common.na"); } const sec = Math.max(0, Math.floor(totalSec)); const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m ${s}s`; return `${s}s`; } function timeAgo(ts?: string) { if (!ts) return t("common.never"); const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); if (diff < 60) return rtf.format(-diff, "second"); if (diff < 3600) return rtf.format(-Math.floor(diff / 60), "minute"); return rtf.format(-Math.floor(diff / 3600), "hour"); } function isOffline(ts?: string) { if (!ts) return true; return Date.now() - new Date(ts).getTime() > 30000; } function normalizeStatus(status?: string) { const s = (status ?? "").toUpperCase(); if (s === "ONLINE") return "RUN"; return s; } function statusBadgeClass(status?: string, offline?: boolean) { if (offline) return "bg-white/10 text-zinc-300"; const s = (status ?? "").toUpperCase(); if (s === "RUN") return "bg-emerald-500/15 text-emerald-300"; if (s === "IDLE") return "bg-yellow-500/15 text-yellow-300"; if (s === "STOP" || s === "DOWN") return "bg-red-500/15 text-red-300"; return "bg-white/10 text-white"; } function severityBadgeClass(sev?: string) { const s = (sev ?? "").toLowerCase(); if (s === "critical") return "bg-red-500/15 text-red-300"; if (s === "warning") return "bg-yellow-500/15 text-yellow-300"; return "bg-white/10 text-zinc-200"; } function formatSeverity(severity?: string) { if (!severity) return ""; const key = `overview.severity.${severity.toLowerCase()}`; const label = t(key); return label === key ? severity.toUpperCase() : label; } function formatEventType(eventType?: string) { if (!eventType) return ""; const key = `overview.event.${eventType}`; const label = t(key); return label === key ? eventType : label; } const hb = machine?.latestHeartbeat ?? null; const kpi = machine?.latestKpi ?? null; const offline = useMemo(() => isOffline(hb?.ts), [hb?.ts]); const normalizedStatus = normalizeStatus(hb?.status); const statusLabel = offline ? t("machine.detail.status.offline") : (() => { if (!normalizedStatus) return t("machine.detail.status.unknown"); const key = `machine.detail.status.${normalizedStatus.toLowerCase()}`; const label = t(key); return label === key ? normalizedStatus : label; })(); const cycleTarget = (machine as any)?.effectiveCycleTime ?? kpi?.cycleTime ?? null; const machineCode = machine?.code ?? t("common.na"); const machineLocation = machine?.location ?? t("common.na"); const lastSeenLabel = t("machine.detail.lastSeen", { time: hb?.ts ? timeAgo(hb.ts) : t("common.never"), }); const ActiveRing = (props: any) => { const { cx, cy, fill } = props; if (cx == null || cy == null) return null; return ( ); }; function MiniCard({ title, subtitle, value, onClick, }: { title: string; subtitle: string; value: string; onClick?: () => void; }) { const clickable = typeof onClick === "function"; if (clickable) { return ( ); } return (
{title}
{subtitle}
{value}
); } function MachineActivityTimeline({ segments, windowSec, }: { segments: TimelineSeg[]; windowSec: number; }) { return (
{t("machine.detail.activity.title")}
{t("machine.detail.activity.subtitle")}
{windowSec}s
{(["normal", "slow", "microstop", "macrostop"] as const).map((key) => (
{t(BUCKET[key].labelKey)}
))}
0s 1h
{segments.length === 0 ? (
{t("machine.detail.activity.noData")}
) : ( segments.map((seg, idx) => { const wPct = Math.max(0.25, (seg.durationSec / windowSec) * 100); const meta = BUCKET[seg.state]; const glow = seg.state === "microstop" || seg.state === "macrostop" ? `0 0 22px ${meta.glow}` : `0 0 12px ${meta.glow}`; return (
); }) )}
); } function Modal({ open, onClose, title, children, }: { open: boolean; onClose: () => void; title: string; children: React.ReactNode; }) { if (!open) return null; return (
{title}
{children}
); } function CycleTooltip({ active, payload, label }: any) { if (!active || !payload?.length) return null; const p = payload[0]?.payload; if (!p) return null; const ideal = p.ideal ?? null; const actual = p.actual ?? null; const deltaPct = p.deltaPct ?? null; return (
{t("machine.detail.tooltip.cycle", { label })}
{t("machine.detail.tooltip.duration")}: {actual?.toFixed(2)}s
{t("machine.detail.tooltip.ideal")}: {ideal != null ? `${ideal.toFixed(2)}s` : t("common.na")}
{t("machine.detail.tooltip.deviation")}: {deltaPct != null ? `${deltaPct.toFixed(1)}%` : t("common.na")}
); } function hasIdealAndActual( row: CycleDerivedRow ): row is CycleDerivedRow & { ideal: number; actual: number } { return row.ideal != null && row.actual != null && row.ideal > 0; } const cycleDerived = useMemo(() => { const rows = cycles ?? []; const { micro, macro } = resolveMultipliers(thresholds); const mapped: CycleDerivedRow[] = rows.map((cycle) => { const ideal = cycle.ideal ?? null; const actual = cycle.actual ?? null; const extra = ideal != null && actual != null ? actual - ideal : null; let bucket: CycleDerivedRow["bucket"] = "unknown"; if (ideal != null && actual != null) { bucket = classifyCycleDuration(actual, ideal, thresholds); } return { ...cycle, ideal, actual, extra, bucket }; }); const counts = mapped.reduce( (acc, row) => { acc.total += 1; acc[row.bucket] += 1; if (row.extra != null && row.extra > 0) acc.extraTotal += row.extra; return acc; }, { total: 0, normal: 0, slow: 0, microstop: 0, macrostop: 0, unknown: 0, extraTotal: 0 } ); const deltas = mapped.filter(hasIdealAndActual).map((row) => ((row.actual - row.ideal) / row.ideal) * 100); const avgDeltaPct = deltas.length ? deltas.reduce((a, b) => a + b, 0) / deltas.length : null; return { mapped, counts, avgDeltaPct }; }, [cycles, thresholds]); const deviationSeries = useMemo(() => { const last = cycleDerived.mapped.slice(-100); return last .map((row, idx) => { const ideal = row.ideal; const actual = row.actual; if (ideal == null || actual == null || ideal <= 0) return null; const deltaPct = ((actual - ideal) / ideal) * 100; return { i: idx + 1, actual, ideal, deltaPct, bucket: row.bucket, }; }) .filter(Boolean) as Array<{ i: number; actual: number; ideal: number; deltaPct: number; bucket: string; }>; }, [cycleDerived.mapped]); const impactAgg = useMemo(() => { const buckets = { slow: 0, microstop: 0, macrostop: 0 } as Record; for (const row of cycleDerived.mapped) { if (!row.extra || row.extra <= 0) continue; if (row.bucket === "slow" || row.bucket === "microstop" || row.bucket === "macrostop") { buckets[row.bucket] += row.extra; } } const rows = (["slow", "microstop", "macrostop"] as const).map((bucket) => ({ bucket, label: t(BUCKET[bucket].labelKey), seconds: Math.round(buckets[bucket] * 10) / 10, })); const total = rows.reduce((sum, row) => sum + row.seconds, 0); return { rows, total }; }, [BUCKET, cycleDerived.mapped, t]); const timeline = useMemo(() => { const rows = cycles ?? []; if (rows.length < 1) { return { windowSec: 10800, segments: [] as TimelineSeg[], start: null as number | null, end: null as number | null, }; } const windowSec = 10800; const end = rows[rows.length - 1].t; const start = end - windowSec * 1000; const segs: TimelineSeg[] = []; for (const cycle of rows) { const ideal = (cycle.ideal ?? cycleTarget ?? 0) as number; const actual = cycle.actual ?? 0; if (!ideal || ideal <= 0 || !actual || actual <= 0) continue; const cycleEnd = cycle.t; const cycleStart = cycleEnd - actual * 1000; if (cycleEnd <= start || cycleStart >= end) continue; const segStart = Math.max(cycleStart, start); const segEnd = Math.min(cycleEnd, end); if (segEnd <= segStart) continue; const state = classifyCycleDuration(actual, ideal, thresholds); segs.push({ start: segStart, end: segEnd, durationSec: (segEnd - segStart) / 1000, state, }); } return { windowSec, segments: segs, start, end }; }, [cycles, cycleTarget, thresholds]); const cycleTargetLabel = cycleTarget ? `${cycleTarget}s` : t("common.na"); const workOrderLabel = kpi?.workOrderId ?? t("common.na"); const skuLabel = kpi?.sku ?? t("common.na"); return (

{machine?.name ?? t("machine.detail.titleFallback")}

{statusLabel}
{machineCode} - {machineLocation} - {lastSeenLabel}
{t("machine.detail.back")}
{t("machine.detail.workOrders.uploadHint")}
{uploadState.status !== "idle" && uploadState.message && (
{uploadState.message}
)}
{loading &&
{t("machine.detail.loading")}
} {error && !loading && (
{error}
)} {!loading && !error && ( <>
OEE
{fmtPct(kpi?.oee)}
{t("machine.detail.kpi.updated", { time: kpi?.ts ? timeAgo(kpi.ts) : t("common.never"), })}
Availability
{fmtPct(kpi?.availability)}
Performance
{fmtPct(kpi?.performance)}
Quality
{fmtPct(kpi?.quality)}
{t("machine.detail.currentWorkOrder")}
{workOrderLabel}
SKU
{skuLabel}
{t("overview.target")}
{fmtNum(kpi?.target)}
{t("overview.good")}
{fmtNum(kpi?.good)}
{t("overview.scrap")}
{fmtNum(kpi?.scrap)}
{t("machine.detail.cycleTarget")}: {cycleTargetLabel}
{t("machine.detail.recentEvents")}
{events.length} {t("overview.shown")}
{events.length === 0 ? (
{t("machine.detail.noEvents")}
) : (
{events.map((event) => (
{formatSeverity(event.severity)} {formatEventType(event.eventType)} {event.requiresAck ? ( {t("overview.ack")} ) : null}
{event.title}
{event.description ? (
{event.description}
) : null}
{timeAgo(event.ts)}
))}
)}
setOpen("events")} /> setOpen("deviation")} /> setOpen("impact")} />
setOpen(null)} title={t("machine.detail.modal.events")}>
{cycleDerived.mapped .filter((row) => row.bucket !== "normal" && row.bucket !== "unknown") .slice() .reverse() .map((row, idx) => { const meta = BUCKET[row.bucket as keyof typeof BUCKET]; return (
{t(meta.labelKey)} {row.actual?.toFixed(2)}s {row.ideal != null ? ` (${t("machine.detail.modal.standardCycle")} ${row.ideal.toFixed(2)}s)` : ""}
{timeAgo(row.ts)}
); })}
setOpen(null)} title={t("machine.detail.modal.deviation")}>
{t("machine.detail.modal.standardCycle")}
{cycleTarget ? `${Number(cycleTarget).toFixed(1)}s` : t("common.na")}
{t("machine.detail.modal.avgDeviation")}
{cycleDerived.avgDeltaPct == null ? t("common.na") : `${cycleDerived.avgDeltaPct.toFixed(1)}%`}
{t("machine.detail.modal.sample")}
{deviationSeries.length} {t("machine.detail.modal.cycles")}
} cursor={{ stroke: "var(--app-chart-grid)" }} /> {kpi?.cycleTime ? ( <> ) : null} } shape={(props: any) => { const { cx, cy, payload } = props; const meta = BUCKET[payload.bucket as keyof typeof BUCKET] ?? BUCKET.unknown; return ( ); }} />
{t("machine.detail.modal.tip")}
setOpen(null)} title={t("machine.detail.modal.impact")}>
{t("machine.detail.modal.totalExtra")}
{Math.round(impactAgg.total)}s
{t("machine.detail.modal.microstops")}
{Math.round(impactAgg.rows.find((row) => row.bucket === "microstop")?.seconds ?? 0)}s
{t("machine.detail.modal.macroStops")}
{Math.round(impactAgg.rows.find((row) => row.bucket === "macrostop")?.seconds ?? 0)}s
[`${Number(val).toFixed(1)}s`, t("machine.detail.modal.extraTimeLabel")]} /> {impactAgg.rows.map((row, idx) => { const key = row.bucket as keyof typeof BUCKET; return ; })}
{t("machine.detail.modal.extraTimeNote")}
)}
); }