almost_done
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -219,6 +219,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
|
||||
hasData={timelineHasData}
|
||||
loading={timelineLoading}
|
||||
locale={locale}
|
||||
rangeMode={initialData.range.mode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -298,25 +298,68 @@ export async function GET(req: NextRequest) {
|
||||
scrapRate: [],
|
||||
};
|
||||
|
||||
type TsBucket = {
|
||||
oeeSum: number; oeeCount: number;
|
||||
availSum: number; availCount: number;
|
||||
perfSum: number; perfCount: number;
|
||||
qualSum: number; qualCount: number;
|
||||
goodSum: number; scrapSum: number;
|
||||
anyProduction: boolean;
|
||||
};
|
||||
const tsBuckets = new Map<string, TsBucket>();
|
||||
|
||||
for (const k of kpiRows) {
|
||||
const t = k.ts.toISOString();
|
||||
if (!isProductionSnapshot(k.trackingEnabled, k.productionStarted)) {
|
||||
// Preserve timeline gaps across non-production windows for OEE-family charting.
|
||||
let b = tsBuckets.get(t);
|
||||
if (!b) {
|
||||
b = {
|
||||
oeeSum: 0, oeeCount: 0,
|
||||
availSum: 0, availCount: 0,
|
||||
perfSum: 0, perfCount: 0,
|
||||
qualSum: 0, qualCount: 0,
|
||||
goodSum: 0, scrapSum: 0,
|
||||
anyProduction: false,
|
||||
};
|
||||
tsBuckets.set(t, b);
|
||||
}
|
||||
|
||||
const isProd = isProductionSnapshot(k.trackingEnabled, k.productionStarted);
|
||||
if (isProd) {
|
||||
b.anyProduction = true;
|
||||
const oee = safeNum(k.oee);
|
||||
if (oee != null) { b.oeeSum += Number(oee); b.oeeCount += 1; }
|
||||
const avail = safeNum(k.availability);
|
||||
if (avail != null) { b.availSum += Number(avail); b.availCount += 1; }
|
||||
const perf = safeNum(k.performance);
|
||||
if (perf != null) { b.perfSum += Number(perf); b.perfCount += 1; }
|
||||
const qual = safeNum(k.quality);
|
||||
if (qual != null) { b.qualSum += Number(qual); b.qualCount += 1; }
|
||||
}
|
||||
|
||||
const good = safeNum(k.good);
|
||||
const scrap = safeNum(k.scrap);
|
||||
if (good != null) b.goodSum += Number(good);
|
||||
if (scrap != null) b.scrapSum += Number(scrap);
|
||||
}
|
||||
|
||||
// Iterate sorted ts. kpiRows already orderBy ts asc, but Map insertion
|
||||
// order matches that, so spreading keys preserves order.
|
||||
for (const [t, b] of tsBuckets) {
|
||||
if (!b.anyProduction) {
|
||||
// No machine producing at this ts -> gap, same as before.
|
||||
trend.oee.push({ t, v: null });
|
||||
trend.availability.push({ t, v: null });
|
||||
trend.performance.push({ t, v: null });
|
||||
trend.quality.push({ t, v: null });
|
||||
} else {
|
||||
trend.oee.push({ t, v: safeNum(k.oee) != null ? Number(k.oee) : null });
|
||||
trend.availability.push({ t, v: safeNum(k.availability) != null ? Number(k.availability) : null });
|
||||
trend.performance.push({ t, v: safeNum(k.performance) != null ? Number(k.performance) : null });
|
||||
trend.quality.push({ t, v: safeNum(k.quality) != null ? Number(k.quality) : null });
|
||||
trend.oee.push({ t, v: b.oeeCount ? b.oeeSum / b.oeeCount : null });
|
||||
trend.availability.push({ t, v: b.availCount ? b.availSum / b.availCount : null });
|
||||
trend.performance.push({ t, v: b.perfCount ? b.perfSum / b.perfCount : null });
|
||||
trend.quality.push({ t, v: b.qualCount ? b.qualSum / b.qualCount : null });
|
||||
}
|
||||
|
||||
const good = safeNum(k.good);
|
||||
const scrap = safeNum(k.scrap);
|
||||
if (good != null && scrap != null && good + scrap > 0) {
|
||||
trend.scrapRate.push({ t, v: (scrap / (good + scrap)) * 100 });
|
||||
const total = b.goodSum + b.scrapSum;
|
||||
if (total > 0) {
|
||||
trend.scrapRate.push({ t, v: (b.scrapSum / total) * 100 });
|
||||
}
|
||||
}
|
||||
const cycleRowsStart = nowMs();
|
||||
|
||||
Reference in New Issue
Block a user