almost_done

This commit is contained in:
Marcelo
2026-04-30 16:59:42 +00:00
parent 5e7ddaa0db
commit b2214ec46f
23 changed files with 662 additions and 196 deletions

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState, type KeyboardEvent } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
type MachineRow = {
id: string;
@@ -20,6 +21,7 @@ type MachineRow = {
};
};
const LIVE_REFRESH_MS = 5000;
const OFFLINE_MS = RECAP_HEARTBEAT_STALE_MS;
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
if (!ts) return fallback;
@@ -31,7 +33,7 @@ function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
function isOffline(ts?: string) {
if (!ts) return true;
return Date.now() - new Date(ts).getTime() > 10 * 60 * 1000; // 10 min (sincronizado con RECAP_HEARTBEAT_STALE_MS)
return Date.now() - new Date(ts).getTime() > OFFLINE_MS;
}
function normalizeStatus(status?: string) {

View File

@@ -21,6 +21,7 @@ import {
import { useI18n } from "@/lib/i18n/useI18n";
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
import type { RecapTimelineResponse, RecapTimelineSegment } from "@/lib/recap/types";
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
import {
computeWidths,
formatDuration,
@@ -375,6 +376,7 @@ function getMinuteFlooredOneHourRange(referenceMs = Date.now()) {
function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimelineProps) {
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
const [timelineLoading, setTimelineLoading] = useState(true);
const [showWindowInfo, setShowWindowInfo] = useState(false);
const timelineHashRef = useRef("");
useEffect(() => {
@@ -444,7 +446,13 @@ function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimeli
<div className="text-sm font-semibold text-white">{t("machine.detail.activity.title")}</div>
<div className="mt-1 text-xs text-zinc-400">{t("machine.detail.activity.subtitle")}</div>
</div>
<div className="text-xs text-zinc-400">1h</div>
<button
type="button"
onClick={() => setShowWindowInfo(true)}
className="rounded-md border border-white/20 px-2 py-1 text-xs text-zinc-300 hover:border-white/40 hover:text-white"
>
{t("machine.detail.activity.windowBadge")}
</button>
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-zinc-300">
@@ -500,6 +508,31 @@ function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimeli
)}
</div>
</div>
{showWindowInfo ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="machine-timeline-window-title"
>
<div className="w-full max-w-sm rounded-2xl border border-white/15 bg-zinc-950 p-5">
<h3 id="machine-timeline-window-title" className="text-sm font-semibold text-white">
{t("machine.detail.activity.windowModalTitle")}
</h3>
<p className="mt-2 text-sm text-zinc-300">{t("machine.detail.activity.windowModalBody")}</p>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={() => setShowWindowInfo(false)}
className="rounded-lg border border-white/20 px-3 py-1.5 text-sm text-zinc-200 hover:border-white/40 hover:text-white"
>
{t("common.close")}
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
@@ -800,7 +833,7 @@ export default function MachineDetailClient() {
function isOffline(ts?: string) {
if (!ts) return true;
return Date.now() - new Date(ts).getTime() > 30000;
return Date.now() - new Date(ts).getTime() > RECAP_HEARTBEAT_STALE_MS;
}
function normalizeStatus(status?: string) {

View File

@@ -3,9 +3,10 @@
import Link from "next/link";
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
import type { EventRow, Heartbeat, MachineRow } from "./types";
const OFFLINE_MS = 10 * 60 * 1000; // 10 min (sincronizado con RECAP_HEARTBEAT_STALE_MS)
const OFFLINE_MS = RECAP_HEARTBEAT_STALE_MS;
const MAX_EVENT_MACHINES = 6;
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
@@ -199,20 +200,65 @@ export default function OverviewClient({
.map((m) => {
const hb = m.latestHeartbeat;
const offline = isOffline(heartbeatTime(hb));
const status = normalizeStatus(hb?.status);
const k = m.latestKpi;
const oee = k?.oee ?? null;
const good = k?.good ?? null;
const scrap = k?.scrap ?? null;
const availability = k?.availability ?? null;
const reasons: string[] = [];
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 };
// Trigger 1: offline (highest priority — can't tell what's wrong)
if (offline) {
score += 100;
reasons.push(t("overview.attention.offline"));
}
// Trigger 2: stopped right now (and online — operator should act)
if (!offline && (status === "STOP" || status === "DOWN")) {
score += 60;
reasons.push(t("overview.attention.stopped"));
}
// Trigger 3: low OEE
if (!offline && oee != null) {
if (oee < 50) {
score += 50;
reasons.push(t("overview.attention.oeeCritical", { value: oee.toFixed(0) }));
} else if (oee < 75) {
score += 30;
reasons.push(t("overview.attention.oeeLow", { value: oee.toFixed(0) }));
}
}
// Trigger 4: scrap rate >5% on active WO
if (!offline && good != null && scrap != null && good + scrap > 0) {
const scrapPct = (scrap / (good + scrap)) * 100;
if (scrapPct > 10) {
score += 40;
reasons.push(t("overview.attention.scrapHigh", { value: scrapPct.toFixed(1) }));
} else if (scrapPct > 5) {
score += 20;
reasons.push(t("overview.attention.scrapMod", { value: scrapPct.toFixed(1) }));
}
}
// Trigger 5: availability collapse (often means undeclared stops)
if (!offline && availability != null && availability < 60) {
score += 25;
reasons.push(t("overview.attention.availLow", { value: availability.toFixed(0) }));
}
return { machine: m, offline, oee, score, reasons };
})
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 6);
return list;
}, [machines]);
}, [machines, t]);
return (
<div className="p-4 sm:p-6">
@@ -346,8 +392,12 @@ export default function OverviewClient({
<div className="text-sm text-zinc-400">{t("overview.noUrgent")}</div>
) : (
<div className="space-y-3">
{attention.map(({ machine, offline, oee }) => (
<div key={machine.id} className="rounded-xl border border-white/10 bg-black/20 p-3">
{attention.map(({ machine, offline, oee, reasons }) => (
<Link
key={machine.id}
href={`/recap/${machine.id}`}
className="block rounded-xl border border-white/10 bg-black/20 p-3 hover:border-white/20 hover:bg-black/30 transition"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{machine.name}</div>
@@ -359,7 +409,7 @@ export default function OverviewClient({
{secondsAgo(heartbeatTime(machine.latestHeartbeat), locale, t("common.never"))}
</div>
</div>
<div className="mt-2 flex items-center gap-2 text-xs">
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
<span
className={`rounded-full px-2 py-0.5 ${
offline ? "bg-white/10 text-zinc-300" : "bg-emerald-500/15 text-emerald-300"
@@ -367,13 +417,20 @@ export default function OverviewClient({
>
{offline ? t("overview.status.offline") : t("overview.status.online")}
</span>
{oee != null && (
{oee != null && !offline && (
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
OEE {fmtPct(oee)}
</span>
)}
</div>
</div>
{reasons.length > 0 && (
<ul className="mt-2 space-y-0.5 text-[11px] text-zinc-400">
{reasons.map((r, i) => (
<li key={i}>· {r}</li>
))}
</ul>
)}
</Link>
))}
</div>
)}

View File

@@ -219,6 +219,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
hasData={timelineHasData}
loading={timelineLoading}
locale={locale}
rangeMode={initialData.range.mode}
/>
</div>