"use client"; import { useEffect, useMemo, 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 TimelineState = "normal" | "slow" | "microstop" | "macrostop"; type TimelineSeg = { start: number; end: number; durationSec: number; state: TimelineState; }; const TOL = 0.10; function classifyGap(dtSec: number, idealSec: number): TimelineState { const SLOW_X = 1.5; const STOP_X = 3.0; const MACRO_X = 10.0; if (dtSec <= idealSec * SLOW_X) return "normal"; if (dtSec <= idealSec * STOP_X) return "slow"; if (dtSec <= idealSec * MACRO_X) return "microstop"; return "macrostop"; } function mergeAdjacent(segs: TimelineSeg[]): TimelineSeg[] { if (!segs.length) return []; const out: TimelineSeg[] = [segs[0]]; for (let i = 1; i < segs.length; i++) { const prev = out[out.length - 1]; const cur = segs[i]; if (cur.state === prev.state && cur.start <= prev.end + 1) { prev.end = Math.max(prev.end, cur.end); prev.durationSec = (prev.end - prev.start) / 1000; } else { out.push(cur); } } 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 [open, setOpen] = useState(null); 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 ?? []); setError(null); setLoading(false); } catch { if (!alive) return; setError(t("machine.detail.error.network")); setLoading(false); } } load(); const timer = setInterval(load, 5000); return () => { alive = false; clearInterval(timer); }; }, [machineId, t]); 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 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 3h
{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 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) { if (actual <= ideal * (1 + TOL)) bucket = "normal"; else if (extra != null && extra <= 1) bucket = "slow"; else if (extra != null && extra <= 10) bucket = "microstop"; else bucket = "macrostop"; } 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]); 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 < 2) { 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 idxFirst = Math.max( 0, rows.findIndex((row) => row.t >= start) - 1 ); const sliced = rows.slice(idxFirst); const segs: TimelineSeg[] = []; for (let i = 1; i < sliced.length; i++) { const prev = sliced[i - 1]; const cur = sliced[i]; const segStart = Math.max(prev.t, start); const segEnd = Math.min(cur.t, end); if (segEnd <= segStart) continue; const dtSec = (cur.t - prev.t) / 1000; const ideal = (cur.ideal ?? prev.ideal ?? cycleTarget ?? 0) as number; if (!ideal || ideal <= 0) continue; const state = classifyGap(dtSec, ideal); segs.push({ start: segStart, end: segEnd, durationSec: (segEnd - segStart) / 1000, state, }); } const segments = mergeAdjacent(segs); return { windowSec, segments, start, end }; }, [cycles, cycleTarget]); 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")}
{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")}
)}
); }