"use client";
import Link from "next/link";
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
import type { EventRow, Heartbeat, MachineRow } from "./types";
const OFFLINE_MS = 10 * 60 * 1000; // 10 min (sincronizado con RECAP_HEARTBEAT_STALE_MS)
const MAX_EVENT_MACHINES = 6;
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
if (!ts) return fallback;
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() > OFFLINE_MS;
}
function normalizeStatus(status?: string) {
const s = (status ?? "").toUpperCase();
if (s === "ONLINE") return "RUN";
return s;
}
function heartbeatTime(hb?: Heartbeat | null) {
return hb?.tsServer ?? hb?.ts;
}
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 `${Math.round(v)}`;
}
function OverviewTimelineSkeleton() {
return (
{Array.from({ length: 4 }).map((_, idx) => (
))}
);
}
export default function OverviewClient({
initialMachines = [],
initialEvents = [],
}: {
initialMachines?: MachineRow[];
initialEvents?: EventRow[];
}) {
const { t, locale } = useI18n();
const [machines, setMachines] = useState(() => initialMachines);
const [events, setEvents] = useState(() => initialEvents);
const [loading, setLoading] = useState(false);
const [eventsLoading, setEventsLoading] = useState(() => initialEvents.length === 0);
useEffect(() => {
let alive = true;
async function load() {
try {
setEventsLoading(true);
const res = await fetch(
`/api/overview?detail=1&events=critical&eventMachines=${MAX_EVENT_MACHINES}`,
{
cache: "no-cache",
}
);
if (res.status === 304) {
if (alive) setLoading(false);
return;
}
const json = await res.json().catch(() => ({}));
if (!alive) return;
setMachines(json.machines ?? []);
setEvents(json.events ?? []);
setLoading(false);
} catch {
if (!alive) return;
setMachines([]);
setEvents([]);
setLoading(false);
} finally {
if (alive) setEventsLoading(false);
}
}
load();
const t = setInterval(load, 30000);
return () => {
alive = false;
clearInterval(t);
};
}, []);
const stats = useMemo(() => {
const total = machines.length;
let online = 0;
let running = 0;
let idle = 0;
let stopped = 0;
let oeeSum = 0;
let oeeCount = 0;
let availSum = 0;
let availCount = 0;
let perfSum = 0;
let perfCount = 0;
let qualSum = 0;
let qualCount = 0;
let goodSum = 0;
let scrapSum = 0;
let targetSum = 0;
let hasKpi = false;
for (const m of machines) {
const hb = m.latestHeartbeat;
const offline = isOffline(heartbeatTime(hb));
if (!offline) online += 1;
const status = normalizeStatus(hb?.status);
if (!offline) {
if (status === "RUN") running += 1;
else if (status === "IDLE") idle += 1;
else if (status === "STOP" || status === "DOWN") stopped += 1;
}
const k = m.latestKpi;
if (k?.oee != null) {
oeeSum += Number(k.oee);
oeeCount += 1;
hasKpi = true;
}
if (k?.availability != null) {
availSum += Number(k.availability);
availCount += 1;
hasKpi = true;
}
if (k?.performance != null) {
perfSum += Number(k.performance);
perfCount += 1;
hasKpi = true;
}
if (k?.quality != null) {
qualSum += Number(k.quality);
qualCount += 1;
hasKpi = true;
}
if (k?.good != null) {
goodSum += Number(k.good);
hasKpi = true;
}
if (k?.scrap != null) {
scrapSum += Number(k.scrap);
hasKpi = true;
}
if (k?.target != null) {
targetSum += Number(k.target);
hasKpi = true;
}
}
return {
total,
online,
offline: total - online,
running,
idle,
stopped,
oee: oeeCount ? oeeSum / oeeCount : null,
availability: availCount ? availSum / availCount : null,
performance: perfCount ? perfSum / perfCount : null,
quality: qualCount ? qualSum / qualCount : null,
goodSum: hasKpi ? goodSum : null,
scrapSum: hasKpi ? scrapSum : null,
targetSum: hasKpi ? targetSum : null,
};
}, [machines]);
const attention = useMemo(() => {
const list = machines
.map((m) => {
const hb = m.latestHeartbeat;
const offline = isOffline(heartbeatTime(hb));
const k = m.latestKpi;
const oee = k?.oee ?? null;
let score = 0;
if (offline) score += 100;
if (oee != null && oee < 75) score += 50;
if (oee != null && oee < 85) score += 25;
return { machine: m, offline, oee, score };
})
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 6);
return list;
}, [machines]);
return (
{t("overview.title")}
{t("overview.subtitle")}
{t("overview.viewMachines")}
{loading &&
{t("overview.loading")}
}
{t("overview.recap.title")}
{t("overview.recap.subtitle")}
{t("overview.recap.cta")}
{t("overview.fleetHealth")}
{stats.total}
{t("overview.machinesTotal")}
{t("overview.online")} {stats.online}
{t("overview.offline")} {stats.offline}
{t("overview.run")} {stats.running}
{t("overview.idle")} {stats.idle}
{t("overview.stop")} {stats.stopped}
{t("overview.productionTotals")}
{t("overview.good")}
{fmtNum(stats.goodSum)}
{t("overview.scrap")}
{fmtNum(stats.scrapSum)}
{t("overview.target")}
{fmtNum(stats.targetSum)}
{t("overview.kpiSumNote")}
{t("overview.activityFeed")}
{events.length}
{eventsLoading ? t("overview.eventsRefreshing") : t("overview.eventsLast30")}
{events.slice(0, 3).map((e) => (
{e.machineName ? `${e.machineName}: ` : ""}
{e.title}
{secondsAgo(e.ts, locale, t("common.never"))}
))}
{events.length === 0 && !eventsLoading ? (
{t("overview.eventsNone")}
) : null}
{t("overview.oeeAvg")}
{fmtPct(stats.oee)}
{t("overview.availabilityAvg")}
{fmtPct(stats.availability)}
{t("overview.performanceAvg")}
{fmtPct(stats.performance)}
{t("overview.qualityAvg")}
{fmtPct(stats.quality)}
{t("overview.attentionList")}
{attention.length} {t("overview.shown")}
{attention.length === 0 ? (
{t("overview.noUrgent")}
) : (
{attention.map(({ machine, offline, oee }) => (
{machine.name}
{machine.code ?? ""} {machine.location ? `- ${machine.location}` : ""}
{secondsAgo(heartbeatTime(machine.latestHeartbeat), locale, t("common.never"))}
{offline ? t("overview.status.offline") : t("overview.status.online")}
{oee != null && (
OEE {fmtPct(oee)}
)}
))}
)}
}>
);
}