"use client"; import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { ComposedChart } from "recharts"; import { Cell } from "recharts"; import { ResponsiveContainer, ScatterChart, Scatter, LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip, ReferenceLine, BarChart, Bar, } from "recharts"; 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; // ISO t: number; // epoch ms cycleCount: number | null; actual: number; // seconds 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; }; export default function MachineDetailClient() { 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: { label: "Ciclo Normal", dot: "#12D18E", glow: "rgba(18,209,142,.35)", chip: "bg-emerald-500/15 text-emerald-300 border-emerald-500/20" }, slow: { label: "Ciclo Lento", dot: "#F7B500", glow: "rgba(247,181,0,.35)", chip: "bg-yellow-500/15 text-yellow-300 border-yellow-500/20" }, microstop:{ label: "Microparo", dot: "#FF7A00", glow: "rgba(255,122,0,.35)", chip: "bg-orange-500/15 text-orange-300 border-orange-500/20" }, macrostop:{ label: "Macroparo", dot: "#FF3B5C", glow: "rgba(255,59,92,.35)", chip: "bg-rose-500/15 text-rose-300 border-rose-500/20" }, unknown: { label: "Desconocido", dot: "#A1A1AA", glow: "rgba(161,161,170,.25)", chip: "bg-white/10 text-zinc-200 border-white/10" }, } as const; useEffect(() => { if (!machineId) return; // <-- IMPORTANT guard 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(); if (!alive) return; if (!res.ok || json?.ok === false) { setError(json?.error ?? "Failed to load machine"); setLoading(false); return; } setMachine(json.machine ?? null); setEvents(json.events ?? []); setCycles(json.cycles ?? []); setError(null); setLoading(false); } catch { if (!alive) return; setError("Network error"); setLoading(false); } } load(); const t = setInterval(load, 5000); return () => { alive = false; clearInterval(t); }; }, [machineId]); function fmtPct(v?: number | null) { if (v === null || v === undefined || Number.isNaN(v)) return "—"; return `${v.toFixed(1)}%`; } function fmtNum(v?: number | null) { if (v === null || v === undefined || Number.isNaN(v)) return "—"; return `${v}`; } function timeAgo(ts?: string) { if (!ts) return "never"; const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); if (diff < 60) return `${diff}s ago`; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; return `${Math.floor(diff / 3600)}h ago`; } function isOffline(ts?: string) { if (!ts) return true; return Date.now() - new Date(ts).getTime() > 15000; } 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"; } const hb = machine?.latestHeartbeat ?? null; const kpi = machine?.latestKpi ?? null; const offline = useMemo(() => isOffline(hb?.ts), [hb?.ts]); const statusLabel = offline ? "OFFLINE" : (hb?.status ?? "UNKNOWN"); const cycleTarget = (machine as any)?.effectiveCycleTime ?? kpi?.cycleTime ?? null; 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 (
Machine Activity Timeline
Análisis en tiempo real de ciclos de producción
{windowSec}s
{(["normal","slow","microstop","macrostop"] as const).map((k) => (
{BUCKET[k].label}
))}
{/* time marks */}
0s 3h
{/* strip */}
{segments.length === 0 ? (
No timeline data yet.
) : ( segments.map((seg, idx) => { const wPct = Math.max(0.25, (seg.durationSec / windowSec) * 100); // min width for visibility 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 (
{/* overlay */}
{/* panel */}
{/* gradient wash (Step 2) */}
{/* content */}
{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 (
Ciclo: {label}
Duración: {actual?.toFixed(2)}s
Ideal: {ideal != null ? `${ideal.toFixed(2)}s` : "—"}
Desviación: {deltaPct != null ? `${deltaPct.toFixed(1)}%` : "—"}
); } const TOL = 0.10; function hasIdealAndActual(r: CycleDerivedRow): r is CycleDerivedRow & { ideal: number; actual: number } { return r.ideal != null && r.actual != null && r.ideal > 0; } const cycleDerived = useMemo(() => { const rows = cycles ?? []; const mapped: CycleDerivedRow[] = rows.map((c) => { const ideal = c.ideal ?? null; const actual = c.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 { ...c, ideal, actual, extra, bucket }; }); const counts = mapped.reduce( (acc, r) => { acc.total += 1; acc[r.bucket] += 1; if (r.extra != null && r.extra > 0) acc.extraTotal += r.extra; return acc; }, { total: 0, normal: 0, slow: 0, microstop: 0, macrostop: 0, unknown: 0, extraTotal: 0 } ); const deltas = mapped .filter(hasIdealAndActual) .map((r) => ((r.actual - r.ideal) / r.ideal) * 100); const avgDeltaPct = deltas.length ? deltas.reduce((a, b) => a + b, 0) / deltas.length : null; return { mapped, counts, avgDeltaPct }; }, [cycles]); const deviationSeries = useMemo(() => { // use last N cycles to keep chart readable const last = cycleDerived.mapped.slice(-100); return last .map((r, idx) => { const ideal = r.ideal; const actual = r.actual; if (ideal == null || actual == null || ideal <= 0) return null; const deltaPct = ((actual - ideal) / ideal) * 100; return { i: idx + 1, // x-axis index (cycle order) actual, ideal, deltaPct, bucket: r.bucket, }; }) .filter(Boolean) as Array<{ i: number; actual: number; ideal: number; deltaPct: number; bucket: string; }>; }, [cycleDerived.mapped]); const impactAgg = useMemo(() => { // sum extra seconds by bucket const buckets = { slow: 0, microstop: 0, macrostop: 0 } as Record; for (const r of cycleDerived.mapped) { if (!r.extra || r.extra <= 0) continue; if (r.bucket === "slow" || r.bucket === "microstop" || r.bucket === "macrostop") { buckets[r.bucket] += r.extra; } } const rows = [ { name: "Slow", seconds: Math.round(buckets.slow * 10) / 10 }, { name: "Microstop", seconds: Math.round(buckets.microstop * 10) / 10 }, { name: "Macrostop", seconds: Math.round(buckets.macrostop * 10) / 10 }, ]; const total = rows.reduce((a, b) => a + b.seconds, 0); return { rows, total }; }, [cycleDerived.mapped]); type TimelineState = "normal" | "slow" | "microstop" | "macrostop"; type TimelineSeg = { start: number; // ms end: number; // ms durationSec: number; state: TimelineState; }; 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]; // merge if same state and touching 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; } 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 }; } // window: last 180s (like your screenshot) const windowSec = 10800; const end = rows[rows.length - 1].t; const start = end - windowSec * 1000; // keep cycles that overlap window (need one cycle before start to build first interval) const idxFirst = Math.max( 0, rows.findIndex(r => r.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 s = Math.max(prev.t, start); const e = Math.min(cur.t, end); if (e <= s) 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: s, end: e, durationSec: (e - s) / 1000, state, }); } const segments = mergeAdjacent(segs); return { windowSec, segments, start, end }; }, [cycles, cycleTarget]); return (

{machine?.name ?? "Machine"}

{statusLabel}
{machine?.code ? machine.code : "—"} • {machine?.location ? machine.location : "—"} • Last seen{" "} {hb?.ts ? timeAgo(hb.ts) : "never"}
Back
{loading &&
Loading…
} {error && !loading && (
{error}
)} {!loading && !error && ( <> {/* KPI cards */}
OEE
{fmtPct(kpi?.oee)}
Updated {kpi?.ts ? timeAgo(kpi.ts) : "never"}
Availability
{fmtPct(kpi?.availability)}
Performance
{fmtPct(kpi?.performance)}
Quality
{fmtPct(kpi?.quality)}
{/* Work order + recent events */}
Current Work Order
{kpi?.workOrderId ?? "—"}
SKU
{kpi?.sku ?? "—"}
Target
{fmtNum(kpi?.target)}
Good
{fmtNum(kpi?.good)}
Scrap
{fmtNum(kpi?.scrap)}
Cycle target: {cycleTarget ? `${cycleTarget}s` : "—"}
Recent Events
{events.length} shown
{events.length === 0 ? (
No events yet.
) : (
{events.map((e) => (
{e.severity.toUpperCase()} {e.eventType} {e.requiresAck && ( ACK )}
{e.title}
{e.description && (
{e.description}
)}
{timeAgo(e.ts)}
))}
)}
{/* Mini analysis cards */}
setOpen("events")} /> setOpen("deviation")} /> setOpen("impact")} />
setOpen(null)} title="Eventos Detectados" >
{cycleDerived.mapped .filter((r) => r.bucket !== "normal" && r.bucket !== "unknown") .slice() .reverse() .map((r, idx) => { const meta = BUCKET[r.bucket as keyof typeof BUCKET]; return (
{/* left accent dot */}
{/* colored chip */} {meta.label} {r.actual?.toFixed(2)}s {r.ideal != null ? ` (ideal ${r.ideal.toFixed(2)}s)` : ""}
{timeAgo(r.ts)}
); })}
setOpen(null)} title="Ciclo Real vs Estándar" >
{/* Summary cards */}
Ciclo estándar (ideal)
{cycleTarget ? `${Number(cycleTarget).toFixed(1)}s` : "—"}
Desviación promedio
{cycleDerived.avgDeltaPct == null ? "—" : `${cycleDerived.avgDeltaPct.toFixed(1)}%`}
Muestra
{deviationSeries.length} ciclos
{/* Chart */}
} cursor={{ stroke: "rgba(255,255,255,0.15)" }} /> {/* Ideal center line */} {kpi?.cycleTime ? ( <> {/* ±10% tolerance band lines */} ) : null} {/* Optional: ideal line from series */} {/* ONE scatter so hover always matches */} } shape={(props: any) => { const { cx, cy, payload } = props; const meta = BUCKET[payload.bucket as keyof typeof BUCKET] ?? BUCKET.unknown; return ( ); }} />
Tip: la línea tenue es el ideal. Cada punto es un ciclo real.
setOpen(null)} title="Impacto en Producción" >
Tiempo extra total
{Math.round(impactAgg.total)}s
Microstops
{Math.round((impactAgg.rows.find(r => r.name === "Microstop")?.seconds ?? 0))}s
Macroparos
{Math.round((impactAgg.rows.find(r => r.name === "Macrostop")?.seconds ?? 0))}s
[`${Number(val).toFixed(1)}s`, "Tiempo extra"]} /> {impactAgg.rows.map((row, idx) => { const key = row.name === "Slow" ? "slow" : row.name === "Microstop" ? "microstop" : "macrostop"; return ; })}
Esto es “tiempo perdido” vs ideal, distribuido por tipo de evento.
)}
); }