Files
MIS-Contro-Tower/app/(app)/machines/[machineId]/MachineDetailClient.tsx
2025-12-17 20:24:06 +00:00

282 lines
10 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
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 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<MachineDetail | null>(null);
const [events, setEvents] = useState<EventRow[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!machineId) return; // <-- IMPORTANT guard
let alive = true;
async function load() {
try {
const res = await fetch(`/api/machines/${machineId}`, {
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 ?? []);
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");
return (
<div className="p-6">
<div className="mb-6 flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3">
<h1 className="truncate text-2xl font-semibold text-white">
{machine?.name ?? "Machine"}
</h1>
<span className={`rounded-full px-3 py-1 text-xs ${statusBadgeClass(hb?.status, offline)}`}>
{statusLabel}
</span>
</div>
<div className="mt-1 text-sm text-zinc-400">
{machine?.code ? machine.code : "—"} {machine?.location ? machine.location : "—"} Last seen{" "}
{hb?.ts ? timeAgo(hb.ts) : "never"}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Link
href="/machines"
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
>
Back
</Link>
</div>
</div>
{loading && <div className="text-sm text-zinc-400">Loading</div>}
{error && !loading && (
<div className="rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-200">
{error}
</div>
)}
{!loading && !error && (
<>
{/* KPI cards */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">OEE</div>
<div className="mt-2 text-3xl font-bold text-emerald-300">{fmtPct(kpi?.oee)}</div>
<div className="mt-1 text-xs text-zinc-400">Updated {kpi?.ts ? timeAgo(kpi.ts) : "never"}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">Availability</div>
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(kpi?.availability)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">Performance</div>
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(kpi?.performance)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">Quality</div>
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(kpi?.quality)}</div>
</div>
</div>
{/* Work order + recent events */}
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-1">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold text-white">Current Work Order</div>
<div className="text-xs text-zinc-400">{kpi?.workOrderId ?? "—"}</div>
</div>
<div className="text-xs text-zinc-400">SKU</div>
<div className="mt-1 text-base font-semibold text-white">{kpi?.sku ?? "—"}</div>
<div className="mt-4 grid grid-cols-3 gap-3">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Target</div>
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(kpi?.target)}</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Good</div>
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(kpi?.good)}</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Scrap</div>
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(kpi?.scrap)}</div>
</div>
</div>
<div className="mt-4 text-xs text-zinc-400">
Cycle target: <span className="text-white">{kpi?.cycleTime ? `${kpi.cycleTime}s` : "—"}</span>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold text-white">Recent Events</div>
<div className="text-xs text-zinc-400">{events.length} shown</div>
</div>
{events.length === 0 ? (
<div className="text-sm text-zinc-400">No events yet.</div>
) : (
<div className="space-y-3">
{events.map((e) => (
<div key={e.id} className="rounded-xl border border-white/10 bg-black/20 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs ${severityBadgeClass(e.severity)}`}>
{e.severity.toUpperCase()}
</span>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-zinc-200">
{e.eventType}
</span>
{e.requiresAck && (
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
ACK
</span>
)}
</div>
<div className="mt-2 truncate text-sm font-semibold text-white">{e.title}</div>
{e.description && (
<div className="mt-1 text-sm text-zinc-300">{e.description}</div>
)}
</div>
<div className="shrink-0 text-xs text-zinc-400">{timeAgo(e.ts)}</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</>
)}
</div>
);
}