Compare commits

..

11 Commits

Author SHA1 Message Date
Marcelo
b2214ec46f almost_done 2026-04-30 16:59:42 +00:00
Marcelo
5e7ddaa0db changes 2026-04-29 07:13:42 +00:00
Marcelo
62169b163c recent changes 2026-04-29 05:05:00 +00:00
Marcelo
7e0fe5c2e1 updates 2026-04-26 16:31:04 +00:00
Marcelo
66c89f9bf4 Backup 2026-04-24 20:47:25 +00:00
Marcelo
30513ff73d changes 2026-04-24 15:17:28 +00:00
Marcelo
5d3a2c533f changes: 2026-04-24 14:45:45 +00:00
Marcelo
6aaafb9115 reliability semi-fix 2026-04-24 14:06:15 +00:00
Marcelo
4973c18dc3 recent 2026-04-24 03:17:04 +00:00
Marcelo
e705f5e965 changes 2026-04-24 02:01:40 +00:00
Marcelo
2707fd974a Recent changes 2026-04-23 17:34:21 +00:00
84 changed files with 19698 additions and 4804 deletions

0
426 Normal file
View File

0
476 Normal file
View File

View File

@@ -75,6 +75,36 @@ sudo systemctl daemon-reload
sudo systemctl enable --now mis-control-tower-reminders.timer
```
## Downtime Reason Backfill
Control-Tower now preserves manual downtime reasons from `downtime-acknowledged` events when later default stop events (`PENDIENTE` / `UNCLASSIFIED`) arrive for the same incident.
If historical rows were already overwritten, run the one-time backfill:
1) Dry run (default lookback: 30 days):
```bash
npm run backfill:downtime-reasons -- --dry-run --since 30d
```
2) Apply updates:
```bash
npm run backfill:downtime-reasons -- --since 30d
```
Optional filters:
```bash
npm run backfill:downtime-reasons -- --dry-run --since 14d --org-id <orgId> --machine-id <machineId>
```
Quick verification query (shows recent incidents with reason + source):
```bash
node -e 'const {PrismaClient}=require("@prisma/client");const p=new PrismaClient();(async()=>{const rows=await p.reasonEntry.findMany({where:{kind:"downtime"},orderBy:{capturedAt:"desc"},take:30,select:{id:true,orgId:true,machineId:true,episodeId:true,reasonCode:true,reasonLabel:true,capturedAt:true,meta:true}});console.log(JSON.stringify(rows,(_,v)=>typeof v==="bigint"?v.toString():v,2));})().finally(()=>p.$disconnect());'
```
## Production build and deploy
**Dev uses Turbopack, production build uses Webpack.** Next.js 16 defaults to Turbopack for both, but Turbopack production builds have known issues. This project uses:

161
Reliability.md Normal file
View File

@@ -0,0 +1,161 @@
Data Reliability — Handoff Prompt
Problem
Same machine shows different numbers in 3 places:
Home UI (Node-RED): goodParts=353, OEE=77.9%
Recap: goodParts=185, OEE=56%
Machine detail: OEE=4.3%, "Sin datos" in 1h timeline
Root cause: each view queries a different table with different logic. No single source of truth.
Rule: pick one source per metric, reuse across views
Metric Authoritative source Why
goodParts, scrapParts (per WO) MachineWorkOrder.good_parts / scrap_parts Node-RED writes this via UPDATE work_orders SET .... It's what Home UI shows.
cycleCount MachineWorkOrder.cycle_count Same reason.
oee / availability / performance / quality time-weighted avg of MachineKpiSnapshot rows in window Snapshots are minute-by-minute; Node-RED already sends them. Don't recompute.
Stops (count, duration) MachineEvent filtered by eventType IN (microstop, macrostop, mold-change) + data->>'status' != 'active' + !is_update && !is_auto_ack Deduped at source.
Timeline segments UNION of: MachineWorkOrder status spans, MachineEvent (mold-change/micro/macro), filled with idle Only way to get continuous coverage.
Backend changes
/api/recap/[machineId]/route.ts and /api/recap/summary/route.ts
goodParts aggregation — stop summing MachineCycle.good_delta. Instead:
// For window [start, end], sum good_parts from WOs that had activity in window
const wos = await prisma.machineWorkOrder.findMany({
where: { machineId, orgId, updatedAt: { gte: start } },
select: { workOrderId: true, sku: true, good_parts: true, scrap_parts: true, target_qty: true, status: true, updatedAt: true }
});
const goodParts = wos.reduce((s, w) => s + (w.good_parts ?? 0), 0);
const scrapParts = wos.reduce((s, w) => s + (w.scrap_parts ?? 0), 0);
Optionally scope to WOs that were RUNNING during the window; but for 24h window this rarely matters.
OEE aggregation — time-weighted average:
const snaps = await prisma.machineKpiSnapshot.findMany({
where: { machineId, orgId, ts: { gte: start, lte: end } },
orderBy: { ts: 'asc' },
select: { ts: true, oee: true, availability: true, performance: true, quality: true }
});
function weightedAvg(field: 'oee' | 'availability' | 'performance' | 'quality') {
if (snaps.length === 0) return null;
let totalMs = 0, sum = 0;
for (let i = 0; i < snaps.length; i++) {
const nextTs = (snaps[i+1]?.ts ?? end).getTime();
const dt = Math.max(0, nextTs - snaps[i].ts.getTime());
sum += (snaps[i][field] ?? 0) * dt;
totalMs += dt;
}
return totalMs > 0 ? sum / totalMs : null;
}
Return null (not 0, not 100) when no snapshots. Frontend renders — for null.
Stops aggregation — filter properly:
const stops = await prisma.machineEvent.findMany({
where: {
machineId, orgId,
ts: { gte: start, lte: end },
eventType: { in: ['microstop','macrostop'] },
}
});
const real = stops.filter(e => {
const d = e.data as any;
return d?.status !== 'active' && !d?.is_auto_ack && !d?.is_update;
});
const stopsCount = real.length;
const stopsMin = real.reduce((s, e) => s + (((e.data as any)?.stoppage_duration_seconds ?? 0) / 60), 0);
/api/recap/[machineId]/timeline — MUST include mold-change
Segment builder in priority order (higher priority wins when overlapping):
mold-change segments (pair active→resolved by incidentKey, duration from data.duration_sec)
macrostop segments (same pairing)
microstop segments (merge runs <60s apart into cluster)
production segments — derived from WO status history, use MachineWorkOrder.status transitions + MachineCycle density (no cycles for >threshold → not production)
idle gap-fill
Never return empty array if any event exists in window. "Sin datos" only if literally zero rows in both MachineEvent and MachineCycle for the window.
Merge rules:
Same-type consecutive segments separated by <30s → merge
Any segment <30s duration, absorb into neighbor
Return format:
{
range: { start, end },
segments: Array<{
type: 'production' | 'mold-change' | 'macrostop' | 'microstop' | 'idle',
startMs, endMs, durationSec,
label?: string, // WO id, mold ids, reason
workOrderId?: string,
sku?: string,
reasonLabel?: string,
}>,
hasData: boolean // false only if literally empty
}
Frontend changes
RecapMachineCard.tsx / Machine detail page / OverviewTimeline.tsx
All three MUST consume the same endpoint and render from the same shape. Timeline in Machine detail page (app/(app)/machines/[machineId]/MachineDetailClient.tsx) currently queries its own source — refactor to call /api/recap/[machineId]/timeline with range=1h for the small timeline, range=24h for the recap.
"Sin datos" fallback: render only when hasData === false. If timeline has any mold-change or stop segment, render the bar.
Null handling for OEE
If backend returns oee: null:
<div className="text-2xl font-semibold text-zinc-400">—</div>
<div className="text-xs text-zinc-500">Sin datos de KPI</div>
Not 0.0%. Not 100%. Dash. User knows "no data" vs. "bad performance".
Reconciling with Home UI live numbers
Home UI reads live state.activeWorkOrder.goodParts from Node-RED. Recap reads MachineWorkOrder.good_parts from CT DB.
These WILL briefly differ because of outbox lag (cycle POST → DB insert → next recap query). Mitigate:
Cache recap endpoints 30-60s max (shorter than current 2-5 min).
On recap header, show "Actualizado hace Xs" timestamp so user sees freshness.
Pi cycle outbox should already be fast (<5s normally). If backlog is persistent, flag it in the UI with a "CT desincronizado" warning (compare MachineHeartbeat.ts to now; if >5min, show amber status).
Sanity check queries for debugging
Run on CT to audit one machine:
-- Authoritative WO state (matches Home UI)
SELECT work_order_id, sku, good_parts, scrap_parts, cycle_count, status, "updatedAt"
FROM "MachineWorkOrder"
WHERE "machineId" = '<uuid>'
ORDER BY "updatedAt" DESC LIMIT 5;
-- What KPI snapshots exist in last 24h
SELECT ts, oee, availability, performance, quality
FROM "MachineKpiSnapshot"
WHERE "machineId" = '<uuid>' AND ts > NOW() - INTERVAL '24 hours'
ORDER BY ts DESC LIMIT 20;
-- Events breakdown
SELECT "eventType",
COUNT(*) AS total,
COUNT(*) FILTER (WHERE data->>'status' = 'active') AS active,
COUNT(*) FILTER (WHERE (data->>'is_update')::bool) AS updates,
COUNT(*) FILTER (WHERE (data->>'is_auto_ack')::bool) AS auto_acks
FROM "MachineEvent"
WHERE "machineId" = '<uuid>' AND ts > NOW() - INTERVAL '24 hours'
GROUP BY "eventType";
If MachineWorkOrder.good_parts says 353 and Home UI says 353 but recap says 185 → recap is still using old aggregation.
If MachineKpiSnapshot count is 0 for last hour → Node-RED isn't sending snapshots (check outbox).
Checklist
not done
Recap endpoints use MachineWorkOrder.good_parts not cycle sum
not done
OEE uses time-weighted MachineKpiSnapshot avg, returns null when empty
not done
Timeline includes mold-change events
not done
Machine detail timeline uses same endpoint as recap
not done
"Sin datos" fallback only when hasData: false
not done
Null OEE renders as —, not 0 or 100
not done
Same endpoint feeds recap grid mini timeline + detail full timeline
not done
Cache TTL reduced to 30-60s
not done
Staleness indicator visible in UI header
Non-goals: no schema changes, no Node-RED changes, no new ingest endpoints

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() > 30000; // 30s threshold
return Date.now() - new Date(ts).getTime() > OFFLINE_MS;
}
function normalizeStatus(status?: string) {

View File

@@ -0,0 +1,347 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState, type KeyboardEvent } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
type MachineRow = {
id: string;
name: string;
code?: string | null;
location?: string | null;
latestHeartbeat: null | {
ts: string;
tsServer?: string | null;
status: string;
message?: string | null;
ip?: string | null;
fwVersion?: string | null;
};
};
const LIVE_REFRESH_MS = 5000;
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");
return rtf.format(-Math.floor(diff / 60), "minute");
}
function isOffline(ts?: string) {
if (!ts) return true;
return Date.now() - new Date(ts).getTime() > 30000; // 30s threshold
}
function normalizeStatus(status?: string) {
const s = (status ?? "").toUpperCase();
if (s === "ONLINE") return "RUN";
return s;
}
function badgeClass(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";
}
export default function MachinesClient({ initialMachines = [] }: { initialMachines?: MachineRow[] }) {
const { t, locale } = useI18n();
const router = useRouter();
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
const [loading, setLoading] = useState(() => initialMachines.length === 0);
const [showCreate, setShowCreate] = useState(false);
const [createName, setCreateName] = useState("");
const [createCode, setCreateCode] = useState("");
const [createLocation, setCreateLocation] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [createdMachine, setCreatedMachine] = useState<{
id: string;
name: string;
pairingCode: string;
pairingExpiresAt: string;
} | null>(null);
const [copyStatus, setCopyStatus] = useState<string | null>(null);
useEffect(() => {
let alive = true;
let timer: ReturnType<typeof setTimeout> | null = null;
async function load(initial: boolean) {
try {
if (!initial && typeof document !== "undefined" && document.hidden) {
return;
}
const res = await fetch("/api/machines", { cache: "no-store" });
const json = await res.json();
if (alive) {
setMachines(json.machines ?? []);
if (initial) setLoading(false);
}
} catch {
if (alive && initial) setLoading(false);
} finally {
if (!alive) return;
timer = setTimeout(() => {
void load(false);
}, LIVE_REFRESH_MS);
}
}
void load(initialMachines.length === 0);
return () => {
alive = false;
if (timer) clearTimeout(timer);
};
}, [initialMachines.length]);
async function createMachine() {
if (!createName.trim()) {
setCreateError(t("machines.create.error.nameRequired"));
return;
}
setCreating(true);
setCreateError(null);
try {
const res = await fetch("/api/machines", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: createName,
code: createCode,
location: createLocation,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
throw new Error(data.error || t("machines.create.error.failed"));
}
const nextMachine = {
...data.machine,
latestHeartbeat: null,
};
setMachines((prev) => [nextMachine, ...prev]);
setCreatedMachine({
id: data.machine.id,
name: data.machine.name,
pairingCode: data.machine.pairingCode,
pairingExpiresAt: data.machine.pairingCodeExpiresAt,
});
setCreateName("");
setCreateCode("");
setCreateLocation("");
setShowCreate(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : null;
setCreateError(message || t("machines.create.error.failed"));
} finally {
setCreating(false);
}
}
async function copyText(text: string) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
setCopyStatus(t("machines.pairing.copied"));
} else {
setCopyStatus(t("machines.pairing.copyUnsupported"));
}
} catch {
setCopyStatus(t("machines.pairing.copyFailed"));
}
setTimeout(() => setCopyStatus(null), 2000);
}
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, machineId: string) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
router.push(`/machines/${machineId}`);
}
}
const showCreateCard = showCreate || (!loading && machines.length === 0);
return (
<div className="p-4 sm:p-6">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">{t("machines.title")}</h1>
<p className="text-sm text-zinc-400">{t("machines.subtitle")}</p>
</div>
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
<button
type="button"
onClick={() => setShowCreate((prev) => !prev)}
className="w-full rounded-xl border border-emerald-400/40 bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 hover:bg-emerald-500/30 sm:w-auto"
>
{showCreate ? t("machines.cancel") : t("machines.addMachine")}
</button>
<Link
href="/overview"
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-center text-sm text-white hover:bg-white/10 sm:w-auto"
>
{t("machines.backOverview")}
</Link>
</div>
</div>
{showCreateCard && (
<div className="mb-6 rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm font-semibold text-white">{t("machines.addCardTitle")}</div>
<div className="text-xs text-zinc-400">{t("machines.addCardSubtitle")}</div>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3">
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
{t("machines.field.name")}
<input
value={createName}
onChange={(event) => setCreateName(event.target.value)}
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
/>
</label>
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
{t("machines.field.code")}
<input
value={createCode}
onChange={(event) => setCreateCode(event.target.value)}
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
/>
</label>
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
{t("machines.field.location")}
<input
value={createLocation}
onChange={(event) => setCreateLocation(event.target.value)}
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
/>
</label>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={createMachine}
disabled={creating}
className="rounded-xl border border-emerald-400/40 bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 hover:bg-emerald-500/30 disabled:opacity-60"
>
{creating ? t("machines.create.loading") : t("machines.create.default")}
</button>
{createError && <div className="text-xs text-red-200">{createError}</div>}
</div>
</div>
)}
{createdMachine && (
<div className="mb-6 rounded-2xl border border-emerald-500/20 bg-emerald-500/10 p-5">
<div className="text-sm font-semibold text-white">{t("machines.pairing.title")}</div>
<div className="mt-2 text-xs text-zinc-300">
{t("machines.pairing.machine")} <span className="text-white">{createdMachine.name}</span>
</div>
<div className="mt-3 rounded-xl border border-white/10 bg-black/30 p-4">
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("machines.pairing.codeLabel")}</div>
<div className="mt-2 text-3xl font-semibold text-white">{createdMachine.pairingCode}</div>
<div className="mt-2 text-xs text-zinc-400">
{t("machines.pairing.expires")}{" "}
{createdMachine.pairingExpiresAt
? new Date(createdMachine.pairingExpiresAt).toLocaleString(locale)
: t("machines.pairing.soon")}
</div>
</div>
<div className="mt-3 text-xs text-zinc-300">
{t("machines.pairing.instructions")}
</div>
<div className="mt-3 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => copyText(createdMachine.pairingCode)}
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white hover:bg-white/10"
>
{t("machines.pairing.copy")}
</button>
{copyStatus && <div className="text-xs text-zinc-300">{copyStatus}</div>}
</div>
</div>
)}
{loading && <div className="mb-4 text-sm text-zinc-400">{t("machines.loading")}</div>}
{!loading && machines.length === 0 && (
<div className="mb-4 text-sm text-zinc-400">{t("machines.empty")}</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{(!loading ? machines : []).map((m) => {
const hb = m.latestHeartbeat;
const hbTs = hb?.tsServer ?? hb?.ts;
const offline = isOffline(hbTs);
const normalizedStatus = normalizeStatus(hb?.status);
const statusLabel = offline ? t("machines.status.offline") : (normalizedStatus || t("machines.status.unknown"));
const lastSeen = secondsAgo(hbTs, locale, t("common.never"));
return (
<div
key={m.id}
role="link"
tabIndex={0}
onClick={() => router.push(`/machines/${m.id}`)}
onKeyDown={(event) => handleCardKeyDown(event, m.id)}
className="cursor-pointer rounded-2xl border border-white/10 bg-white/5 p-5 hover:bg-white/10"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-lg font-semibold text-white">{m.name}</div>
<div className="mt-1 text-xs text-zinc-400">
{m.code ? m.code : t("common.na")} - {t("machines.lastSeen", { time: lastSeen })}
</div>
</div>
<span
className={`shrink-0 rounded-full px-3 py-1 text-xs ${badgeClass(
normalizedStatus,
offline
)}`}
>
{statusLabel}
</span>
</div>
<div className="mt-4 text-sm text-zinc-400">{t("machines.status")}</div>
<div className="mt-1 flex items-center gap-2 text-sm font-semibold text-white">
{offline ? (
<>
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-zinc-500" aria-hidden="true" />
<span>{t("machines.status.noHeartbeat")}</span>
</>
) : (
<>
<span className="relative flex h-2.5 w-2.5" aria-hidden="true">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400" />
</span>
<span>{t("machines.status.ok")}</span>
</>
)}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -20,6 +20,16 @@ import {
} from "recharts";
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,
formatTime,
normalizeTimelineSegments,
SEGMENT_MIN_WIDTH_PCT,
TIMELINE_COLORS,
} from "@/components/recap/timelineRender";
type Heartbeat = {
ts: string;
@@ -87,20 +97,6 @@ type Thresholds = {
type TimelineState = "normal" | "slow" | "microstop" | "macrostop";
type TimelineSeg = {
start: number;
end: number;
durationSec: number;
state: TimelineState;
};
type ActiveStoppage = {
state: "microstop" | "macrostop";
startedAt: string;
durationSec: number;
theoreticalCycleTime: number;
};
type UploadState = {
status: "idle" | "parsing" | "uploading" | "success" | "error";
message?: string;
@@ -112,6 +108,9 @@ type WorkOrderUpload = {
sku?: string;
targetQty?: number;
cycleTime?: number;
mold?: string;
cavitiesTotal?: number;
cavitiesActive?: number;
};
type WorkOrderRow = Record<string, string | number | boolean>;
@@ -128,7 +127,7 @@ const TOL = 0.10;
const DEFAULT_MICRO_MULT = 1.5;
const DEFAULT_MACRO_MULT = 5;
const NORMAL_TOL_SEC = 0.1;
const LIVE_REFRESH_MS = 5000;
const LIVE_REFRESH_MS = 15000;
const BUCKET = {
normal: {
@@ -198,8 +197,41 @@ const WORK_ORDER_KEYS = {
"theoretical_cycle_time",
]),
target: new Set(["targetquantity", "targetqty", "target", "target_qty"]),
mold: new Set(["mold", "molde", "moldid", "mold_id"]),
cavitiesTotal: new Set([
"totalcavities",
"cavitiestotal",
"cavities_total",
"total_cavities",
]),
cavitiesActive: new Set([
"activecavities",
"cavitiesactive",
"cavities_active",
"active_cavities",
]),
};
const WORK_ORDER_TEMPLATE_HEADERS = [
"Work Order ID",
"SKU",
"Theoretical Cycle Time (Seconds)",
"Target Quantity",
"Mold",
"Total Cavities",
"Active Cavities",
] as const;
const WORK_ORDER_TEMPLATE_EXAMPLE_ROW = [
"*borra esta fila al subir excel)",
"SKU-12345",
35,
10000,
"MOLD-01",
8,
8,
] as const;
function normalizeKey(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
}
@@ -284,7 +316,26 @@ function rowsToWorkOrders(rows: WorkOrderRow[]): WorkOrderUpload[] {
const targetQty = Number.isFinite(Number(targetRaw)) ? Math.trunc(Number(targetRaw)) : undefined;
const cycleTime = Number.isFinite(Number(cycleRaw)) ? Number(cycleRaw) : undefined;
out.push({ workOrderId, sku: sku || undefined, targetQty, cycleTime });
const moldRaw = pickRowValue(row, WORK_ORDER_KEYS.mold);
const mold = String(moldRaw ?? "").trim();
const totalCavRaw = pickRowValue(row, WORK_ORDER_KEYS.cavitiesTotal);
const activeCavRaw = pickRowValue(row, WORK_ORDER_KEYS.cavitiesActive);
const cavitiesTotal = Number.isFinite(Number(totalCavRaw))
? Math.trunc(Number(totalCavRaw))
: undefined;
const cavitiesActive = Number.isFinite(Number(activeCavRaw))
? Math.trunc(Number(activeCavRaw))
: undefined;
out.push({
workOrderId,
sku: sku || undefined,
targetQty,
cycleTime,
mold: mold || undefined,
cavitiesTotal,
cavitiesActive,
});
});
return out;
@@ -308,6 +359,184 @@ function toErrorMessage(value: unknown, fallback: string): string {
return fallback;
}
type MachineActivityTimelineProps = {
machineId?: string;
locale: string;
t: (key: string, vars?: Record<string, string | number>) => string;
};
function getMinuteFlooredOneHourRange(referenceMs = Date.now()) {
const endMs = Math.floor(referenceMs / 60000) * 60000;
return {
startMs: endMs - 60 * 60 * 1000,
endMs,
};
}
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(() => {
if (!machineId) return;
let alive = true;
timelineHashRef.current = "";
setTimelineLoading(true);
async function loadTimeline() {
try {
const range = getMinuteFlooredOneHourRange();
const params = new URLSearchParams({
start: String(range.startMs),
end: String(range.endMs),
});
const res = await fetch(`/api/recap/${machineId}/timeline?${params.toString()}`, { cache: "no-store" });
const json = await res.json().catch(() => null);
if (!alive || !res.ok || !json) return;
const nextTimeline = json as RecapTimelineResponse;
const nextHash = JSON.stringify({
start: nextTimeline.range.start,
end: nextTimeline.range.end,
hasData: nextTimeline.hasData,
segments: nextTimeline.segments.map((segment) => ({
type: segment.type,
startMs: segment.startMs,
endMs: segment.endMs,
})),
});
if (timelineHashRef.current === nextHash) return;
timelineHashRef.current = nextHash;
setTimeline(nextTimeline);
} finally {
if (alive) setTimelineLoading(false);
}
}
void loadTimeline();
const timer = window.setInterval(() => {
void loadTimeline();
}, 30000);
return () => {
alive = false;
window.clearInterval(timer);
};
}, [machineId]);
const hasData = timeline?.hasData ?? false;
const fallbackRange = getMinuteFlooredOneHourRange();
const startMs = timeline ? new Date(timeline.range.start).getTime() : fallbackRange.startMs;
const endMs = timeline ? new Date(timeline.range.end).getTime() : fallbackRange.endMs;
const totalMs = Math.max(1, endMs - startMs);
const normalized = useMemo(() => {
if (!timeline || !hasData) return [] as RecapTimelineSegment[];
return normalizeTimelineSegments(timeline.segments, startMs, endMs);
}, [timeline, hasData, startMs, endMs]);
const widths = useMemo(
() => computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT),
[normalized, totalMs]
);
return (
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex items-start justify-between gap-4">
<div>
<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>
<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">
{(["production", "mold-change", "macrostop", "microstop", "idle"] as const).map((type) => (
<div key={type} className="flex items-center gap-2">
<span className={`h-2.5 w-2.5 rounded-full ${TIMELINE_COLORS[type]}`} />
<span>
{type === "production" ? t("recap.timeline.type.production") : null}
{type === "mold-change" ? t("recap.timeline.type.moldChange") : null}
{type === "macrostop" ? t("recap.timeline.type.macrostop") : null}
{type === "microstop" ? t("recap.timeline.type.microstop") : null}
{type === "idle" ? t("recap.timeline.type.idle") : null}
</span>
</div>
))}
</div>
<div className="mt-4 rounded-2xl border border-white/10 bg-black/25 p-4">
<div className="mb-2 flex justify-between text-[11px] text-zinc-500">
<span>{timelineLoading ? t("common.loading") : formatTime(startMs, locale)}</span>
<span>{formatTime(endMs, locale)}</span>
</div>
<div className="flex h-14 w-full overflow-hidden rounded-2xl">
{!hasData ? (
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-400">
{t("machine.detail.activity.noData")}
</div>
) : (
normalized.map((segment, idx) => {
const widthPct = widths[idx] ?? 0;
const typeLabel =
segment.type === "production"
? t("recap.timeline.type.production")
: segment.type === "mold-change"
? t("recap.timeline.type.moldChange")
: segment.type === "macrostop"
? t("recap.timeline.type.macrostop")
: segment.type === "microstop" || segment.type === "slow-cycle"
? t("recap.timeline.type.microstop")
: t("recap.timeline.type.idle");
const title = `${typeLabel} · ${formatDuration(segment.startMs, segment.endMs)}`;
return (
<div
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${idx}`}
title={title}
className={`h-full ${TIMELINE_COLORS[segment.type]}`}
style={{ width: `${Math.max(0, widthPct)}%` }}
/>
);
})
)}
</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>
);
}
export default function MachineDetailClient() {
const { t, locale } = useI18n();
const { screenlessMode } = useScreenlessMode();
@@ -324,7 +553,6 @@ export default function MachineDetailClient() {
const [error, setError] = useState<string | null>(null);
const [cycles, setCycles] = useState<CycleRow[]>([]);
const [thresholds, setThresholds] = useState<Thresholds | null>(null);
const [activeStoppage, setActiveStoppage] = useState<ActiveStoppage | null>(null);
const [open, setOpen] = useState<null | "events" | "deviation" | "impact">(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [uploadState, setUploadState] = useState<UploadState>({ status: "idle" });
@@ -372,7 +600,6 @@ export default function MachineDetailClient() {
setEventsCountAll(typeof json.eventsCountAll === "number" ? json.eventsCountAll : null);
setCycles(json.cycles ?? []);
setThresholds(json.thresholds ?? null);
setActiveStoppage(json.activeStoppage ?? null);
setError(null);
if (initial) setLoading(false);
} catch {
@@ -467,6 +694,26 @@ export default function MachineDetailClient() {
return null;
}
async function downloadWorkOrderTemplate() {
const xlsx = await import("xlsx");
const wb = xlsx.utils.book_new();
const ws = xlsx.utils.aoa_to_sheet([
Array.from(WORK_ORDER_TEMPLATE_HEADERS),
Array.from(WORK_ORDER_TEMPLATE_EXAMPLE_ROW),
]);
xlsx.utils.book_append_sheet(wb, ws, "Work Orders");
const wbout = xlsx.write(wb, { bookType: "xlsx", type: "array" });
const blob = new Blob([wbout], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "work-orders-template.xlsx";
a.click();
URL.revokeObjectURL(url);
}
async function handleWorkOrderUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
@@ -586,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) {
@@ -691,145 +938,6 @@ export default function MachineDetailClient() {
);
}
function MachineActivityTimeline({
cycles,
cycleTarget,
thresholds,
activeStoppage,
}: {
cycles: CycleRow[];
cycleTarget: number | null;
thresholds: Thresholds | null;
activeStoppage: ActiveStoppage | null;
}) {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = setInterval(() => setNowMs(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
const timeline = useMemo(() => {
const rows = cycles ?? [];
const windowSec = rows.length < 1 ? 10800 : 3600;
const end = nowMs;
const start = end - windowSec * 1000;
if (rows.length < 1) {
return {
windowSec,
segments: [] as TimelineSeg[],
start,
end,
};
}
const segs: TimelineSeg[] = [];
for (const cycle of rows) {
const ideal = (cycle.ideal ?? cycleTarget ?? 0) as number;
const actual = cycle.actual ?? 0;
if (!ideal || ideal <= 0 || !actual || actual <= 0) continue;
const cycleEnd = cycle.t;
const cycleStart = cycleEnd - actual * 1000;
if (cycleEnd <= start || cycleStart >= end) continue;
const segStart = Math.max(cycleStart, start);
const segEnd = Math.min(cycleEnd, end);
if (segEnd <= segStart) continue;
const state = classifyCycleDuration(actual, ideal, thresholds);
segs.push({
start: segStart,
end: segEnd,
durationSec: (segEnd - segStart) / 1000,
state,
});
}
if (activeStoppage?.startedAt) {
const stoppageStart = new Date(activeStoppage.startedAt).getTime();
const segStart = Math.max(stoppageStart, start);
const segEnd = Math.min(end, nowMs);
if (segEnd > segStart) {
segs.push({
start: segStart,
end: segEnd,
durationSec: (segEnd - segStart) / 1000,
state: activeStoppage.state,
});
}
}
segs.sort((a, b) => a.start - b.start);
return { windowSec, segments: segs, start, end };
}, [activeStoppage, cycles, cycleTarget, nowMs, thresholds]);
const { segments, windowSec } = timeline;
return (
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex items-start justify-between gap-4">
<div>
<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">{windowSec}s</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-zinc-300">
{(["normal", "slow", "microstop", "macrostop"] as const).map((key) => (
<div key={key} className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: BUCKET[key].dot }} />
<span>{t(BUCKET[key].labelKey)}</span>
</div>
))}
</div>
<div className="mt-4 rounded-2xl border border-white/10 bg-black/25 p-4">
<div className="mb-2 flex justify-between text-[11px] text-zinc-500">
<span>0s</span>
<span>1h</span>
</div>
<div className="flex h-14 w-full overflow-hidden rounded-2xl">
{segments.length === 0 ? (
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-400">
{t("machine.detail.activity.noData")}
</div>
) : (
segments.map((seg, idx) => {
const wPct = Math.max(0, (seg.durationSec / windowSec) * 100);
const meta = BUCKET[seg.state];
const glow =
seg.state === "microstop" || seg.state === "macrostop"
? `0 0 22px ${meta.glow}`
: `0 0 12px ${meta.glow}`;
return (
<div
key={`${seg.start}-${seg.end}-${idx}`}
title={`${t(meta.labelKey)}: ${seg.durationSec.toFixed(1)}s`}
className="h-full"
style={{
width: `${wPct}%`,
background: meta.dot,
boxShadow: glow,
opacity: 0.95,
}}
/>
);
})
)}
</div>
</div>
</div>
);
}
function Modal({
open,
onClose,
@@ -1025,6 +1133,13 @@ export default function MachineDetailClient() {
className="hidden"
onChange={handleWorkOrderUpload}
/>
<button
type="button"
onClick={() => void downloadWorkOrderTemplate()}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white transition hover:bg-white/10 sm:w-auto"
>
{t("machine.detail.workOrders.downloadTemplate")}
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
@@ -1105,13 +1220,20 @@ export default function MachineDetailClient() {
<>
<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="text-xs text-zinc-400">{t("machine.detail.kpi.oeeCurrent")}</div>
{kpi?.oee == null || Number.isNaN(kpi.oee) ? (
<div className="mt-2 text-3xl font-bold text-zinc-400"></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">
{t("machine.detail.kpi.updated", {
time: kpi?.ts ? timeAgo(kpi.ts) : t("common.never"),
})}
</div>
{kpi?.oee == null || Number.isNaN(kpi.oee) ? (
<div className="mt-1 text-xs text-zinc-500">{t("recap.kpi.noData")}</div>
) : null}
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
@@ -1131,12 +1253,7 @@ export default function MachineDetailClient() {
</div>
<div className="mt-6">
<MachineActivityTimeline
cycles={cycles}
cycleTarget={cycleTarget}
thresholds={thresholds}
activeStoppage={activeStoppage}
/>
<MachineActivityTimeline machineId={machineId} locale={locale} t={t} />
</div>
{!screenlessMode && (
<div className="mt-6 rounded-2xl border border-white/10 bg-white/5 p-5">

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 = 30000;
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">
@@ -232,6 +278,21 @@ export default function OverviewClient({
{loading && <div className="mb-4 text-sm text-zinc-400">{t("overview.loading")}</div>}
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm font-semibold text-white">{t("overview.recap.title")}</div>
<div className="text-xs text-zinc-400">{t("overview.recap.subtitle")}</div>
</div>
<Link
href="/recap"
className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300 hover:bg-emerald-500/20"
>
{t("overview.recap.cta")}
</Link>
</div>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">{t("overview.fleetHealth")}</div>
@@ -331,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>
@@ -344,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"
@@ -352,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

@@ -0,0 +1,388 @@
"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 = 30000;
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 (
<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="h-4 w-32 rounded bg-white/10" />
<div className="h-3 w-20 rounded bg-white/5" />
</div>
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, idx) => (
<div key={idx} className="h-20 rounded-xl border border-white/10 bg-black/20" />
))}
</div>
</div>
);
}
export default function OverviewClient({
initialMachines = [],
initialEvents = [],
}: {
initialMachines?: MachineRow[];
initialEvents?: EventRow[];
}) {
const { t, locale } = useI18n();
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
const [events, setEvents] = useState<EventRow[]>(() => 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 (
<div className="p-4 sm:p-6">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">{t("overview.title")}</h1>
<p className="text-sm text-zinc-400">{t("overview.subtitle")}</p>
</div>
<Link
href="/machines"
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-center text-sm text-white hover:bg-white/10 sm:w-auto"
>
{t("overview.viewMachines")}
</Link>
</div>
{loading && <div className="mb-4 text-sm text-zinc-400">{t("overview.loading")}</div>}
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm font-semibold text-white">{t("overview.recap.title")}</div>
<div className="text-xs text-zinc-400">{t("overview.recap.subtitle")}</div>
</div>
<Link
href="/recap"
className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300 hover:bg-emerald-500/20"
>
{t("overview.recap.cta")}
</Link>
</div>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">{t("overview.fleetHealth")}</div>
<div className="mt-2 text-3xl font-semibold text-white">{stats.total}</div>
<div className="mt-2 text-xs text-zinc-400">{t("overview.machinesTotal")}</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs">
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-emerald-300">
{t("overview.online")} {stats.online}
</span>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-zinc-300">
{t("overview.offline")} {stats.offline}
</span>
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-emerald-200">
{t("overview.run")} {stats.running}
</span>
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
{t("overview.idle")} {stats.idle}
</span>
<span className="rounded-full bg-red-500/15 px-2 py-0.5 text-red-300">
{t("overview.stop")} {stats.stopped}
</span>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">{t("overview.productionTotals")}</div>
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("overview.good")}</div>
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.goodSum)}</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("overview.scrap")}</div>
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.scrapSum)}</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("overview.target")}</div>
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.targetSum)}</div>
</div>
</div>
<div className="mt-3 text-xs text-zinc-400">{t("overview.kpiSumNote")}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">{t("overview.activityFeed")}</div>
<div className="mt-2 text-3xl font-semibold text-white">{events.length}</div>
<div className="mt-2 text-xs text-zinc-400">
{eventsLoading ? t("overview.eventsRefreshing") : t("overview.eventsLast30")}
</div>
<div className="mt-4 space-y-2">
{events.slice(0, 3).map((e) => (
<div key={e.id} className="flex items-center justify-between text-xs text-zinc-300">
<div className="truncate">
{e.machineName ? `${e.machineName}: ` : ""}
{e.title}
</div>
<div className="shrink-0 text-zinc-500">
{secondsAgo(e.ts, locale, t("common.never"))}
</div>
</div>
))}
{events.length === 0 && !eventsLoading ? (
<div className="text-xs text-zinc-500">{t("overview.eventsNone")}</div>
) : null}
</div>
</div>
</div>
<div className="mt-6 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">{t("overview.oeeAvg")}</div>
<div className="mt-2 text-3xl font-semibold text-emerald-300">{fmtPct(stats.oee)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">{t("overview.availabilityAvg")}</div>
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.availability)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">{t("overview.performanceAvg")}</div>
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.performance)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">{t("overview.qualityAvg")}</div>
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.quality)}</div>
</div>
</div>
<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">{t("overview.attentionList")}</div>
<div className="text-xs text-zinc-400">
{attention.length} {t("overview.shown")}
</div>
</div>
{attention.length === 0 ? (
<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">
<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>
<div className="mt-1 text-xs text-zinc-400">
{machine.code ?? ""} {machine.location ? `- ${machine.location}` : ""}
</div>
</div>
<div className="text-xs text-zinc-400">
{secondsAgo(heartbeatTime(machine.latestHeartbeat), locale, t("common.never"))}
</div>
</div>
<div className="mt-2 flex items-center gap-2 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"
}`}
>
{offline ? t("overview.status.offline") : t("overview.status.online")}
</span>
{oee != null && (
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
OEE {fmtPct(oee)}
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
<Suspense fallback={<OverviewTimelineSkeleton />}>
<OverviewTimeline events={events} eventsLoading={eventsLoading} locale={locale} t={t} />
</Suspense>
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapMachineStatus, RecapSummaryResponse } from "@/lib/recap/types";
import RecapMachineCard from "@/components/recap/RecapMachineCard";
type Props = {
initialData: RecapSummaryResponse;
};
function statusLabel(status: RecapMachineStatus, t: (key: string) => string) {
if (status === "running") return t("recap.status.running");
if (status === "mold-change") return t("recap.status.moldChange");
if (status === "stopped") return t("recap.status.stopped");
return t("recap.status.offline");
}
export default function RecapGridClient({ initialData }: Props) {
const { t } = useI18n();
const [data, setData] = useState<RecapSummaryResponse>(initialData);
const [loading, setLoading] = useState(false);
const [locationFilter, setLocationFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState<"all" | RecapMachineStatus>("all");
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
let alive = true;
async function refresh() {
setLoading(true);
try {
const res = await fetch(`/api/recap/summary?hours=${data.range.hours}`, { cache: "no-store" });
const json = await res.json().catch(() => null);
if (!alive || !json || !res.ok) return;
setData(json as RecapSummaryResponse);
} finally {
if (alive) setLoading(false);
}
}
const onFocus = () => {
void refresh();
};
const interval = window.setInterval(onFocus, 60000);
window.addEventListener("focus", onFocus);
return () => {
alive = false;
window.clearInterval(interval);
window.removeEventListener("focus", onFocus);
};
}, [data.range.hours]);
const locationOptions = useMemo(() => {
const set = new Set<string>();
for (const machine of data.machines) {
if (machine.location) set.add(machine.location);
}
return [...set].sort((a, b) => a.localeCompare(b));
}, [data.machines]);
const filteredMachines = useMemo(() => {
return data.machines.filter((machine) => {
if (locationFilter !== "all" && machine.location !== locationFilter) return false;
if (statusFilter !== "all" && machine.status !== statusFilter) return false;
return true;
});
}, [data.machines, locationFilter, statusFilter]);
const generatedAtMs = new Date(data.generatedAt).getTime();
const freshAgeSec = Number.isFinite(generatedAtMs) ? Math.max(0, Math.floor((nowMs - generatedAtMs) / 1000)) : null;
return (
<div className="p-4 sm:p-6">
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">{t("recap.grid.title")}</h1>
<p className="text-sm text-zinc-400">{t("recap.grid.subtitle")}</p>
{freshAgeSec != null ? (
<p className="mt-1 text-xs text-zinc-500">{t("recap.grid.updatedAgo", { sec: freshAgeSec })}</p>
) : null}
</div>
<div className="flex flex-wrap gap-2 text-sm">
<select
value={locationFilter}
onChange={(event) => setLocationFilter(event.target.value)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
>
<option value="all">{t("recap.filter.allLocations")}</option>
{locationOptions.map((location) => (
<option key={location} value={location}>
{location}
</option>
))}
</select>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as "all" | RecapMachineStatus)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
>
<option value="all">{t("recap.filter.allStatuses")}</option>
{(["running", "mold-change", "stopped", "offline"] as const).map((status) => (
<option key={status} value={status}>
{statusLabel(status, t)}
</option>
))}
</select>
</div>
</div>
</div>
{loading && data.machines.length === 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="h-[220px] animate-pulse rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
) : null}
{loading && data.machines.length > 0 ? (
<div className="mb-3 text-xs text-zinc-500">{t("common.loading")}</div>
) : null}
{filteredMachines.length === 0 ? (
<div className="rounded-2xl border border-white/10 bg-black/30 p-4 text-sm text-zinc-400">
{t("recap.grid.empty")}
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{filteredMachines.map((machine) => (
<RecapMachineCard
key={machine.machineId}
machine={machine}
rangeStart={data.range.start}
rangeEnd={data.range.end}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,30 @@
/**
* Shared markup for loading states (used by `loading.tsx` and explicit `<Suspense>` in pages)
* so the recap UI always shows the same skeleton while server data is pending.
*/
export function RecapGridPageSkeleton() {
return (
<div className="p-4 sm:p-6">
<div className="mb-4 h-24 animate-pulse rounded-2xl border border-white/10 bg-black/40" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="h-[220px] animate-pulse rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
</div>
);
}
export function RecapDetailPageSkeleton() {
return (
<div className="p-4 sm:p-6">
<div className="h-16 animate-pulse rounded-2xl border border-white/10 bg-black/40" />
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-24 animate-pulse rounded-2xl border border-white/10 bg-black/30" />
))}
</div>
<div className="mt-4 h-48 animate-pulse rounded-2xl border border-white/10 bg-black/30" />
</div>
);
}

View File

@@ -0,0 +1,240 @@
"use client";
import Link from "next/link";
import { useEffect, useState, useTransition } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapDetailResponse, RecapRangeMode, RecapTimelineResponse } from "@/lib/recap/types";
import RecapBanners from "@/components/recap/RecapBanners";
import RecapKpiRow from "@/components/recap/RecapKpiRow";
import RecapProductionBySku from "@/components/recap/RecapProductionBySku";
import RecapDowntimeTop from "@/components/recap/RecapDowntimeTop";
import RecapWorkOrders from "@/components/recap/RecapWorkOrders";
import RecapMachineStatus from "@/components/recap/RecapMachineStatus";
import RecapFullTimeline from "@/components/recap/RecapFullTimeline";
type Props = {
machineId: string;
initialData: RecapDetailResponse;
};
function toInputDate(value: string) {
const d = new Date(value);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function normalizeInputDate(value: string) {
const d = new Date(value);
if (!Number.isFinite(d.getTime())) return null;
return d.toISOString();
}
export default function RecapDetailClient({ machineId, initialData }: Props) {
const { t, locale } = useI18n();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
const [timelineLoading, setTimelineLoading] = useState(true);
const [nowMs, setNowMs] = useState(() => Date.now());
const [customStart, setCustomStart] = useState(toInputDate(initialData.range.start));
const [customEnd, setCustomEnd] = useState(toInputDate(initialData.range.end));
const requestedRange =
(searchParams.get("range") as RecapRangeMode | null) ?? initialData.range.requestedMode ?? initialData.range.mode;
const selectedRange = requestedRange;
const shiftAvailable = initialData.range.shiftAvailable ?? true;
const shiftFallbackReason = initialData.range.fallbackReason;
const shiftFallbackActive = selectedRange === "shift" && initialData.range.mode !== "shift";
function pushRange(nextRange: RecapRangeMode, start?: string, end?: string) {
const params = new URLSearchParams(searchParams.toString());
params.set("range", nextRange);
if (nextRange === "custom" && start && end) {
params.set("start", start);
params.set("end", end);
} else {
params.delete("start");
params.delete("end");
}
startTransition(() => {
router.push(`${pathname}?${params.toString()}`);
});
}
function applyCustomRange() {
const start = normalizeInputDate(customStart);
const end = normalizeInputDate(customEnd);
if (!start || !end || end <= start) return;
pushRange("custom", start, end);
}
const machine = initialData.machine;
const generatedAtMs = new Date(initialData.generatedAt).getTime();
const freshAgeSec = Number.isFinite(generatedAtMs) ? Math.max(0, Math.floor((nowMs - generatedAtMs) / 1000)) : null;
const timelineStart = timeline?.range.start ?? initialData.range.start;
const timelineEnd = timeline?.range.end ?? initialData.range.end;
const timelineSegments = timeline?.segments ?? [];
const timelineHasData = timeline?.hasData ?? false;
useEffect(() => {
let alive = true;
setTimeline(null);
setTimelineLoading(true);
async function loadTimeline() {
try {
const params = new URLSearchParams({
start: initialData.range.start,
end: initialData.range.end,
});
const res = await fetch(`/api/recap/${machineId}/timeline?${params.toString()}`, { cache: "no-store" });
const json = await res.json().catch(() => null);
if (!alive || !res.ok || !json) return;
setTimeline(json as RecapTimelineResponse);
} catch {
} finally {
if (alive) setTimelineLoading(false);
}
}
void loadTimeline();
return () => {
alive = false;
};
}, [initialData.range.end, initialData.range.start, machineId]);
useEffect(() => {
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
return (
<div className="p-4 sm:p-6">
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<Link href="/recap" className="text-sm text-zinc-400 hover:text-zinc-200">
{`${t("recap.detail.back")}`}
</Link>
<h1 className="mt-1 text-2xl font-semibold text-white">{machine.name || machineId}</h1>
<div className="text-sm text-zinc-400">{machine.location || t("common.na")}</div>
{freshAgeSec != null ? (
<div className="mt-1 text-xs text-zinc-500">{t("recap.grid.updatedAgo", { sec: freshAgeSec })}</div>
) : null}
</div>
<div className="flex flex-wrap gap-2 text-sm">
{(["24h", "shift", "yesterday", "custom"] as const).map((range) => (
<button
key={range}
type="button"
disabled={range === "shift" && !shiftAvailable}
onClick={() => {
if (range === "shift" && !shiftAvailable) return;
if (range === "custom") {
pushRange("custom", normalizeInputDate(customStart) ?? undefined, normalizeInputDate(customEnd) ?? undefined);
return;
}
pushRange(range);
}}
className={`rounded-xl border px-3 py-2 ${
selectedRange === range
? "border-emerald-300/60 bg-emerald-500/20 text-emerald-100"
: "border-white/10 bg-black/40 text-zinc-200"
} ${range === "shift" && !shiftAvailable ? "cursor-not-allowed opacity-60" : ""}`}
>
{range === "24h" ? t("recap.range.24h") : null}
{range === "shift" ? t("recap.range.shiftCurrent") : null}
{range === "yesterday" ? t("recap.range.yesterday") : null}
{range === "custom" ? t("recap.range.custom") : null}
</button>
))}
</div>
</div>
{!shiftAvailable ? (
<div className="mb-4 rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
{t("recap.range.shiftUnavailable")}
</div>
) : null}
{shiftFallbackActive ? (
<div className="mb-4 rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
{shiftFallbackReason === "shift-inactive" ? t("recap.range.shiftFallbackInactive") : t("recap.range.shiftFallbackUnavailable")}
</div>
) : null}
{selectedRange === "custom" ? (
<div className="mb-4 flex flex-wrap gap-2 text-sm">
<input
type="datetime-local"
value={customStart}
onChange={(event) => setCustomStart(event.target.value)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
/>
<input
type="datetime-local"
value={customEnd}
onChange={(event) => setCustomEnd(event.target.value)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
/>
<button
type="button"
onClick={applyCustomRange}
className="rounded-xl border border-emerald-300/50 bg-emerald-500/20 px-3 py-2 text-emerald-100"
>
{t("recap.range.apply")}
</button>
</div>
) : null}
{isPending ? <div className="mb-3 text-xs text-zinc-500">{t("common.loading")}</div> : null}
<div className="mb-4">
<RecapBanners
moldChangeStartMs={machine.moldChange?.active ? machine.moldChange.startMs : null}
offlineForMin={machine.offlineForMin}
ongoingStopMin={machine.ongoingStopMin}
/>
</div>
<RecapKpiRow
oeeAvg={machine.oee}
goodParts={machine.goodParts}
totalStops={Math.round(machine.stopMinutes)}
scrapParts={machine.scrap}
rangeMode={initialData.range.mode}
/>
<div className="mt-4">
<RecapFullTimeline
rangeStart={timelineStart}
rangeEnd={timelineEnd}
segments={timelineSegments}
hasData={timelineHasData}
loading={timelineLoading}
locale={locale}
rangeMode={initialData.range.mode}
/>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
<RecapProductionBySku rows={machine.productionBySku} />
<RecapDowntimeTop rows={machine.downtimeTop} />
</div>
<div className="mt-4">
<RecapWorkOrders workOrders={machine.workOrders} />
</div>
<div className="mt-4">
<RecapMachineStatus heartbeat={machine.heartbeat} />
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { RecapDetailPageSkeleton } from "../RecapPageSkeletons";
export default function LoadingRecapDetail() {
return <RecapDetailPageSkeleton />;
}

View File

@@ -0,0 +1,51 @@
import { Suspense } from "react";
import { notFound, redirect } from "next/navigation";
import { requireSession } from "@/lib/auth/requireSession";
import { getRecapMachineDetailCached, parseRecapDetailRangeInput } from "@/lib/recap/redesign";
import { RecapDetailPageSkeleton } from "../RecapPageSkeletons";
import RecapDetailClient from "./RecapDetailClient";
async function RecapDetailData({
params,
searchParams,
}: {
params: Promise<{ machineId: string }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const session = await requireSession();
const { machineId } = await params;
if (!session) redirect(`/login?next=/recap/${machineId}`);
const rawSearchParams = (await searchParams) ?? {};
const input = parseRecapDetailRangeInput(rawSearchParams);
const initialData = await getRecapMachineDetailCached({
orgId: session.orgId,
machineId,
input,
});
if (!initialData) notFound();
return (
<RecapDetailClient
key={`${machineId}:${initialData.range.mode}:${initialData.range.start}:${initialData.range.end}`}
machineId={machineId}
initialData={initialData}
/>
);
}
export default function RecapMachineDetailPage({
params,
searchParams,
}: {
params: Promise<{ machineId: string }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
return (
<Suspense fallback={<RecapDetailPageSkeleton />}>
<RecapDetailData params={params} searchParams={searchParams} />
</Suspense>
);
}

View File

@@ -0,0 +1,5 @@
import { RecapGridPageSkeleton } from "./RecapPageSkeletons";
export default function LoadingRecapGrid() {
return <RecapGridPageSkeleton />;
}

26
app/(app)/recap/page.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { Suspense } from "react";
import { redirect } from "next/navigation";
import { requireSession } from "@/lib/auth/requireSession";
import { getRecapSummaryCached } from "@/lib/recap/redesign";
import RecapGridClient from "./RecapGridClient";
import { RecapGridPageSkeleton } from "./RecapPageSkeletons";
async function RecapGridData() {
const session = await requireSession();
if (!session) redirect("/login?next=/recap");
const initialData = await getRecapSummaryCached({
orgId: session.orgId,
hours: 24,
});
return <RecapGridClient initialData={initialData} />;
}
export default function RecapPage() {
return (
<Suspense fallback={<RecapGridPageSkeleton />}>
<RecapGridData />
</Suspense>
);
}

View File

@@ -21,7 +21,7 @@ type SimpleTooltipProps<T> = {
label?: string | number;
};
type ChartPoint = { ts: string; label: string; value: number };
type ChartPoint = { ts: string; label: string; value: number | null };
type CycleHistogramRow = {
label: string;
count: number;
@@ -135,7 +135,14 @@ export default function ReportsCharts({
"OEE",
]}
/>
<Line type="monotone" dataKey="value" stroke="#34d399" dot={false} strokeWidth={2} />
<Line
type="linear"
dataKey="value"
stroke="#34d399"
dot={false}
strokeWidth={2}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
) : (

View File

@@ -0,0 +1,706 @@
"use client";
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
const ReportsCharts = lazy(() => import("./ReportsCharts"));
type RangeKey = "24h" | "7d" | "30d" | "custom";
type ReportSummary = {
oeeAvg: number | null;
availabilityAvg: number | null;
performanceAvg: number | null;
qualityAvg: number | null;
goodTotal: number | null;
scrapTotal: number | null;
targetTotal: number | null;
scrapRate: number | null;
topScrapSku?: string | null;
topScrapWorkOrder?: string | null;
};
type ReportDowntime = {
macrostopSec: number;
microstopSec: number;
slowCycleCount: number;
qualitySpikeCount: number;
performanceDegradationCount: number;
oeeDropCount: number;
};
type ReportTrendPoint = { t: string; v: number | null };
type ReportPayload = {
summary: ReportSummary;
downtime: ReportDowntime;
trend: {
oee: ReportTrendPoint[];
availability: ReportTrendPoint[];
performance: ReportTrendPoint[];
quality: ReportTrendPoint[];
scrapRate: ReportTrendPoint[];
};
distribution: {
cycleTime: {
label: string;
count: number;
rangeStart?: number;
rangeEnd?: number;
overflow?: "low" | "high";
minValue?: number;
maxValue?: number;
}[];
};
insights?: string[];
};
type MachineOption = { id: string; name: string };
type FilterOptions = { workOrders: string[]; skus: string[] };
type Translator = (key: string, vars?: Record<string, string | number>) => string;
function fmtPct(v?: number | null) {
if (v === null || v === undefined || Number.isNaN(v)) return "--";
return `${v.toFixed(1)}%`;
}
function fmtDuration(sec?: number | null) {
if (!sec) return "--";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function downsample<T>(rows: T[], max: number) {
if (rows.length <= max) return rows;
const step = Math.ceil(rows.length / max);
return rows.filter((_, idx) => idx % step === 0);
}
function downsampleTrendPreserveGaps(rows: ReportTrendPoint[], max: number) {
if (rows.length <= max) return rows;
const step = Math.ceil(rows.length / max);
const picked = new Set<number>();
picked.add(0);
picked.add(rows.length - 1);
for (let idx = 0; idx < rows.length; idx += step) picked.add(idx);
// Keep both sides of null/non-null transitions so chart gaps remain visible.
for (let idx = 1; idx < rows.length; idx += 1) {
const prevIsNull = rows[idx - 1]?.v == null;
const currIsNull = rows[idx]?.v == null;
if (prevIsNull !== currIsNull) {
picked.add(idx - 1);
picked.add(idx);
}
}
return [...picked]
.sort((a, b) => a - b)
.map((idx) => rows[idx])
.filter((row): row is ReportTrendPoint => row != null);
}
function formatTickLabel(ts: string, range: RangeKey) {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return ts;
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
if (range === "24h") return `${hh}:${mm}`;
return `${month}-${day}`;
}
function ReportsChartsSkeleton() {
return (
<>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
{Array.from({ length: 2 }).map((_, idx) => (
<div key={idx} className="h-[320px] rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="h-[280px] rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
</>
);
}
function buildCsv(report: ReportPayload, t: Translator) {
const rows = new Map<string, Record<string, string | number | null>>();
const addSeries = (series: ReportTrendPoint[], key: string) => {
for (const p of series) {
const row = rows.get(p.t) ?? { timestamp: p.t };
row[key] = p.v;
rows.set(p.t, row);
}
};
addSeries(report.trend.oee, "oee");
addSeries(report.trend.availability, "availability");
addSeries(report.trend.performance, "performance");
addSeries(report.trend.quality, "quality");
addSeries(report.trend.scrapRate, "scrapRate");
const ordered = [...rows.values()].sort((a, b) => {
const at = new Date(String(a.timestamp)).getTime();
const bt = new Date(String(b.timestamp)).getTime();
return at - bt;
});
const header = ["timestamp", "oee", "availability", "performance", "quality", "scrapRate"].join(",");
const lines = ordered.map((row) =>
[
row.timestamp,
row.oee ?? "",
row.availability ?? "",
row.performance ?? "",
row.quality ?? "",
row.scrapRate ?? "",
]
.map((v) => (v == null ? "" : String(v)))
.join(",")
);
const summary = report.summary;
const downtime = report.downtime;
const sectionLines: string[] = [];
sectionLines.push(
[t("reports.csv.section"), t("reports.csv.key"), t("reports.csv.value")].join(",")
);
const addRow = (section: string, key: string, value: string | number | null | undefined) => {
sectionLines.push(
[section, key, value == null ? "" : String(value)]
.map((v) => (v.includes(",") ? `"${v.replace(/\"/g, '""')}"` : v))
.join(",")
);
};
addRow("summary", "oeeAvg", summary.oeeAvg);
addRow("summary", "availabilityAvg", summary.availabilityAvg);
addRow("summary", "performanceAvg", summary.performanceAvg);
addRow("summary", "qualityAvg", summary.qualityAvg);
addRow("summary", "goodTotal", summary.goodTotal);
addRow("summary", "scrapTotal", summary.scrapTotal);
addRow("summary", "targetTotal", summary.targetTotal);
addRow("summary", "scrapRate", summary.scrapRate);
addRow("summary", "topScrapSku", summary.topScrapSku ?? "");
addRow("summary", "topScrapWorkOrder", summary.topScrapWorkOrder ?? "");
addRow("loss_drivers", "macrostopSec", downtime.macrostopSec);
addRow("loss_drivers", "microstopSec", downtime.microstopSec);
addRow("loss_drivers", "slowCycleCount", downtime.slowCycleCount);
addRow("loss_drivers", "qualitySpikeCount", downtime.qualitySpikeCount);
addRow("loss_drivers", "performanceDegradationCount", downtime.performanceDegradationCount);
addRow("loss_drivers", "oeeDropCount", downtime.oeeDropCount);
for (const bin of report.distribution.cycleTime) {
addRow("cycle_distribution", bin.label, bin.count);
}
if (report.insights?.length) {
report.insights.forEach((note, idx) => addRow("insights", String(idx + 1), note));
}
return [header, ...lines, "", ...sectionLines].join("\n");
}
function downloadText(filename: string, content: string) {
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function buildPdfHtml(
report: ReportPayload,
rangeLabel: string,
filters: { machine: string; workOrder: string; sku: string },
t: Translator
) {
const summary = report.summary;
const downtime = report.downtime;
const cycleBins = report.distribution.cycleTime;
const insights = report.insights ?? [];
return `
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${t("reports.pdf.title")}</title>
<style>
body { font-family: Arial, sans-serif; color: #111; margin: 24px; }
h1 { margin: 0 0 6px; }
.meta { margin-bottom: 16px; color: #555; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.card { border: 1px solid #ddd; border-radius: 8px; padding: 12px; }
.label { color: #666; font-size: 12px; text-transform: uppercase; letter-spacing: .03em; }
.value { font-size: 18px; font-weight: 600; margin-top: 6px; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { border: 1px solid #ddd; padding: 6px 8px; font-size: 12px; }
th { background: #f5f5f5; text-align: left; }
</style>
</head>
<body>
<h1>${t("reports.title")}</h1>
<div class="meta">${t("reports.pdf.range")}: ${rangeLabel} | ${t("reports.pdf.machine")}: ${filters.machine} | ${t("reports.pdf.workOrder")}: ${filters.workOrder} | ${t("reports.pdf.sku")}: ${filters.sku}</div>
<div class="grid">
<div class="card">
<div class="label">OEE (avg)</div>
<div class="value">${summary.oeeAvg != null ? summary.oeeAvg.toFixed(1) + "%" : "--"}</div>
</div>
<div class="card">
<div class="label">Availability (avg)</div>
<div class="value">${summary.availabilityAvg != null ? summary.availabilityAvg.toFixed(1) + "%" : "--"}</div>
</div>
<div class="card">
<div class="label">Performance (avg)</div>
<div class="value">${summary.performanceAvg != null ? summary.performanceAvg.toFixed(1) + "%" : "--"}</div>
</div>
<div class="card">
<div class="label">Quality (avg)</div>
<div class="value">${summary.qualityAvg != null ? summary.qualityAvg.toFixed(1) + "%" : "--"}</div>
</div>
</div>
<div class="card" style="margin-top: 16px;">
<div class="label">${t("reports.pdf.topLoss")}</div>
<table>
<thead>
<tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
</thead>
<tbody>
<tr><td>${t("reports.loss.macrostop")} (sec)</td><td>${downtime.macrostopSec}</td></tr>
<tr><td>${t("reports.loss.microstop")} (sec)</td><td>${downtime.microstopSec}</td></tr>
<tr><td>${t("reports.loss.slowCycle")}</td><td>${downtime.slowCycleCount}</td></tr>
<tr><td>${t("reports.loss.qualitySpike")}</td><td>${downtime.qualitySpikeCount}</td></tr>
<tr><td>${t("reports.loss.perfDegradation")}</td><td>${downtime.performanceDegradationCount}</td></tr>
<tr><td>${t("reports.loss.oeeDrop")}</td><td>${downtime.oeeDropCount}</td></tr>
</tbody>
</table>
</div>
<div class="card" style="margin-top: 16px;">
<div class="label">${t("reports.pdf.qualitySummary")}</div>
<table>
<thead>
<tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
</thead>
<tbody>
<tr><td>${t("reports.scrapRate")}</td><td>${summary.scrapRate != null ? summary.scrapRate.toFixed(1) + "%" : "--"}</td></tr>
<tr><td>${t("overview.good")}</td><td>${summary.goodTotal ?? "--"}</td></tr>
<tr><td>${t("overview.scrap")}</td><td>${summary.scrapTotal ?? "--"}</td></tr>
<tr><td>${t("overview.target")}</td><td>${summary.targetTotal ?? "--"}</td></tr>
<tr><td>${t("reports.topScrapSku")}</td><td>${summary.topScrapSku ?? "--"}</td></tr>
<tr><td>${t("reports.topScrapWorkOrder")}</td><td>${summary.topScrapWorkOrder ?? "--"}</td></tr>
</tbody>
</table>
</div>
<div class="card" style="margin-top: 16px;">
<div class="label">${t("reports.pdf.cycleDistribution")}</div>
<table>
<thead>
<tr><th>${t("reports.tooltip.range")}</th><th>${t("reports.tooltip.cycles")}</th></tr>
</thead>
<tbody>
${cycleBins
.map((bin) => `<tr><td>${bin.label}</td><td>${bin.count}</td></tr>`)
.join("")}
</tbody>
</table>
</div>
<div class="card" style="margin-top: 16px;">
<div class="label">${t("reports.pdf.notes")}</div>
${insights.length ? `<ul>${insights.map((n) => `<li>${n}</li>`).join("")}</ul>` : `<div>${t("reports.pdf.none")}</div>`}
</div>
</body>
</html>
`.trim();
}
export default function ReportsPageClient({
initialMachines = [],
}: {
initialMachines?: MachineOption[];
}) {
const { t, locale } = useI18n();
const [range, setRange] = useState<RangeKey>("24h");
const [report, setReport] = useState<ReportPayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [machines] = useState<MachineOption[]>(() => initialMachines);
const [filterOptions, setFilterOptions] = useState<FilterOptions>({ workOrders: [], skus: [] });
const [machineId, setMachineId] = useState("");
const [workOrderId, setWorkOrderId] = useState("");
const [sku, setSku] = useState("");
const rangeLabel = useMemo(() => {
if (range === "24h") return t("reports.rangeLabel.last24");
if (range === "7d") return t("reports.rangeLabel.last7");
if (range === "30d") return t("reports.rangeLabel.last30");
return t("reports.rangeLabel.custom");
}, [range, t]);
useEffect(() => {
let alive = true;
const controller = new AbortController();
async function load() {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ range });
if (machineId) params.set("machineId", machineId);
if (workOrderId) params.set("workOrderId", workOrderId);
if (sku) params.set("sku", sku);
const res = await fetch(`/api/reports?${params.toString()}`, {
cache: "no-cache",
signal: controller.signal,
});
const json = await res.json();
if (!alive) return;
if (!res.ok || json?.ok === false) {
setError(json?.error ?? t("reports.error.failed"));
setReport(null);
} else {
setReport(json);
}
} catch {
if (!alive) return;
setError(t("reports.error.network"));
setReport(null);
} finally {
if (alive) setLoading(false);
}
}
load();
return () => {
alive = false;
controller.abort();
};
}, [range, machineId, workOrderId, sku, t]);
useEffect(() => {
let alive = true;
const controller = new AbortController();
async function loadFilters() {
try {
const params = new URLSearchParams({ range });
if (machineId) params.set("machineId", machineId);
const res = await fetch(`/api/reports/filters?${params.toString()}`, {
cache: "no-cache",
signal: controller.signal,
});
const json = await res.json();
if (!alive) return;
if (!res.ok || json?.ok === false) {
setFilterOptions({ workOrders: [], skus: [] });
} else {
setFilterOptions({
workOrders: json.workOrders ?? [],
skus: json.skus ?? [],
});
}
} catch {
if (!alive) return;
setFilterOptions({ workOrders: [], skus: [] });
}
}
loadFilters();
return () => {
alive = false;
controller.abort();
};
}, [range, machineId]);
const summary = report?.summary;
const downtime = report?.downtime;
const trend = report?.trend;
const distribution = report?.distribution;
const oeeSeries = useMemo(() => {
const rows = trend?.oee ?? [];
const trimmed = downsampleTrendPreserveGaps(rows, 600);
return trimmed.map((p) => ({
ts: p.t,
label: formatTickLabel(p.t, range),
value: p.v,
}));
}, [trend?.oee, range]);
const scrapSeries = useMemo(() => {
const rows = trend?.scrapRate ?? [];
const trimmed = downsample(rows, 600);
return trimmed.map((p) => ({
ts: p.t,
label: formatTickLabel(p.t, range),
value: p.v,
}));
}, [trend?.scrapRate, range]);
const cycleHistogram = useMemo(() => {
return distribution?.cycleTime ?? [];
}, [distribution?.cycleTime]);
const downtimeSeries = useMemo(() => {
if (!downtime) return [];
return [
{ name: "Macrostop", value: Math.round(downtime.macrostopSec / 60) },
{ name: "Microstop", value: Math.round(downtime.microstopSec / 60) },
];
}, [downtime]);
const downtimeColors: Record<string, string> = {
Macrostop: "#FF3B5C",
Microstop: "#FF7A00",
};
const lossRows = useMemo(
() => [
{ label: t("reports.loss.macrostop"), value: fmtDuration(downtime?.macrostopSec) },
{ label: t("reports.loss.microstop"), value: fmtDuration(downtime?.microstopSec) },
{ label: t("reports.loss.slowCycle"), value: downtime ? `${downtime.slowCycleCount}` : "--" },
{ label: t("reports.loss.qualitySpike"), value: downtime ? `${downtime.qualitySpikeCount}` : "--" },
{ label: t("reports.loss.oeeDrop"), value: downtime ? `${downtime.oeeDropCount}` : "--" },
{
label: t("reports.loss.perfDegradation"),
value: downtime ? `${downtime.performanceDegradationCount}` : "--",
},
],
[downtime, t]
);
const machineLabel = useMemo(() => {
if (!machineId) return t("reports.filter.allMachines");
return machines.find((m) => m.id === machineId)?.name ?? machineId;
}, [machineId, machines, t]);
const workOrderLabel = workOrderId || t("reports.filter.allWorkOrders");
const skuLabel = sku || t("reports.filter.allSkus");
const handleExportCsv = () => {
if (!report) return;
const csv = buildCsv(report, t);
downloadText("reports.csv", csv);
};
const handleExportPdf = () => {
if (!report) return;
const html = buildPdfHtml(
report,
rangeLabel,
{
machine: machineLabel,
workOrder: workOrderLabel,
sku: skuLabel,
},
t
);
const win = window.open("", "_blank", "width=900,height=650");
if (!win) return;
win.document.open();
win.document.write(html);
win.document.close();
win.focus();
setTimeout(() => win.print(), 300);
};
return (
<div className="p-4 sm:p-6">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">{t("reports.title")}</h1>
<p className="text-sm text-zinc-400">{t("reports.subtitle")}</p>
</div>
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
<button
onClick={handleExportCsv}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 sm:w-auto"
>
{t("reports.exportCsv")}
</button>
<button
onClick={handleExportPdf}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 sm:w-auto"
>
{t("reports.exportPdf")}
</button>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="text-sm font-semibold text-white">{t("reports.filters")}</div>
<div className="text-xs text-zinc-400">{rangeLabel}</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("reports.filter.range")}</div>
<div className="mt-2 flex flex-wrap gap-2">
{(["24h", "7d", "30d", "custom"] as RangeKey[]).map((k) => (
<button
key={k}
onClick={() => setRange(k)}
className={`rounded-full border px-3 py-1 text-xs ${
range === k
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-200"
: "border-white/10 bg-white/5 text-zinc-300 hover:bg-white/10"
}`}
>
{k.toUpperCase()}
</button>
))}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("reports.filter.machine")}</div>
<select
value={machineId}
onChange={(e) => setMachineId(e.target.value)}
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300"
>
<option value="">{t("reports.filter.allMachines")}</option>
{machines.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("reports.filter.workOrder")}</div>
<input
list="work-order-list"
value={workOrderId}
onChange={(e) => setWorkOrderId(e.target.value)}
placeholder={t("reports.filter.allWorkOrders")}
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500"
/>
<datalist id="work-order-list">
{filterOptions.workOrders.map((wo) => (
<option key={wo} value={wo} />
))}
</datalist>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("reports.filter.sku")}</div>
<input
list="sku-list"
value={sku}
onChange={(e) => setSku(e.target.value)}
placeholder={t("reports.filter.allSkus")}
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500"
/>
<datalist id="sku-list">
{filterOptions.skus.map((s) => (
<option key={s} value={s} />
))}
</datalist>
</div>
</div>
</div>
<div className="mt-4">
{loading && <div className="text-sm text-zinc-400">{t("reports.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>
)}
</div>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{[
{ label: "OEE", value: fmtPct(summary?.oeeAvg), tone: "text-emerald-300" },
{ label: "Availability", value: fmtPct(summary?.availabilityAvg), tone: "text-white" },
{ label: "Performance", value: fmtPct(summary?.performanceAvg), tone: "text-white" },
{ label: "Quality", value: fmtPct(summary?.qualityAvg), tone: "text-white" },
].map((kpi) => (
<div key={kpi.label} className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">{kpi.label} (avg)</div>
<div className={`mt-2 text-3xl font-semibold ${kpi.tone}`}>{kpi.value}</div>
<div className="mt-2 text-xs text-zinc-500">
{summary ? t("reports.kpi.note.withData") : t("reports.kpi.note.noData")}
</div>
</div>
))}
</div>
<Suspense fallback={<ReportsChartsSkeleton />}>
<ReportsCharts
oeeSeries={oeeSeries}
downtimeSeries={downtimeSeries}
downtimeColors={downtimeColors}
cycleHistogram={cycleHistogram}
scrapSeries={scrapSeries}
lossRows={lossRows}
locale={locale}
t={t}
/>
</Suspense>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 text-sm font-semibold text-white">{t("reports.qualitySummary")}</div>
<div className="space-y-3 text-sm text-zinc-300">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">{t("reports.scrapRate")}</div>
<div className="mt-1 text-lg font-semibold text-white">
{summary?.scrapRate != null ? fmtPct(summary.scrapRate) : "--"}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">{t("reports.topScrapSku")}</div>
<div className="mt-1 text-sm text-zinc-300">{summary?.topScrapSku ?? "--"}</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">{t("reports.topScrapWorkOrder")}</div>
<div className="mt-1 text-sm text-zinc-300">{summary?.topScrapWorkOrder ?? "--"}</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 text-sm font-semibold text-white">{t("reports.notes")}</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-4 text-sm text-zinc-300">
<div className="mb-2 text-xs text-zinc-400">{t("reports.notes.suggested")}</div>
{report?.insights && report.insights.length > 0 ? (
<div className="space-y-2">
{report.insights.map((note, idx) => (
<div key={idx}>{note}</div>
))}
</div>
) : (
<div>{t("reports.notes.none")}</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,713 +1,17 @@
"use client";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import ReportsPageClient from "./ReportsPageClient";
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
export default async function ReportsPage() {
const session = await requireSession();
if (!session) redirect("/login?next=/reports");
const ReportsCharts = lazy(() => import("./ReportsCharts"));
type RangeKey = "24h" | "7d" | "30d" | "custom";
type ReportSummary = {
oeeAvg: number | null;
availabilityAvg: number | null;
performanceAvg: number | null;
qualityAvg: number | null;
goodTotal: number | null;
scrapTotal: number | null;
targetTotal: number | null;
scrapRate: number | null;
topScrapSku?: string | null;
topScrapWorkOrder?: string | null;
};
type ReportDowntime = {
macrostopSec: number;
microstopSec: number;
slowCycleCount: number;
qualitySpikeCount: number;
performanceDegradationCount: number;
oeeDropCount: number;
};
type ReportTrendPoint = { t: string; v: number };
type ReportPayload = {
summary: ReportSummary;
downtime: ReportDowntime;
trend: {
oee: ReportTrendPoint[];
availability: ReportTrendPoint[];
performance: ReportTrendPoint[];
quality: ReportTrendPoint[];
scrapRate: ReportTrendPoint[];
};
distribution: {
cycleTime: {
label: string;
count: number;
rangeStart?: number;
rangeEnd?: number;
overflow?: "low" | "high";
minValue?: number;
maxValue?: number;
}[];
};
insights?: string[];
};
type MachineOption = { id: string; name: string };
type FilterOptions = { workOrders: string[]; skus: string[] };
type Translator = (key: string, vars?: Record<string, string | number>) => string;
function fmtPct(v?: number | null) {
if (v === null || v === undefined || Number.isNaN(v)) return "--";
return `${v.toFixed(1)}%`;
}
function fmtDuration(sec?: number | null) {
if (!sec) return "--";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function downsample<T>(rows: T[], max: number) {
if (rows.length <= max) return rows;
const step = Math.ceil(rows.length / max);
return rows.filter((_, idx) => idx % step === 0);
}
function formatTickLabel(ts: string, range: RangeKey) {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return ts;
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
if (range === "24h") return `${hh}:${mm}`;
return `${month}-${day}`;
}
function ReportsChartsSkeleton() {
return (
<>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
{Array.from({ length: 2 }).map((_, idx) => (
<div key={idx} className="h-[320px] rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="h-[280px] rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
</>
);
}
function toMachineOption(value: unknown): MachineOption | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const id = typeof record.id === "string" ? record.id : "";
const name = typeof record.name === "string" ? record.name : "";
if (!id || !name) return null;
return { id, name };
}
function buildCsv(report: ReportPayload, t: Translator) {
const rows = new Map<string, Record<string, string | number>>();
const addSeries = (series: ReportTrendPoint[], key: string) => {
for (const p of series) {
const row = rows.get(p.t) ?? { timestamp: p.t };
row[key] = p.v;
rows.set(p.t, row);
}
};
addSeries(report.trend.oee, "oee");
addSeries(report.trend.availability, "availability");
addSeries(report.trend.performance, "performance");
addSeries(report.trend.quality, "quality");
addSeries(report.trend.scrapRate, "scrapRate");
const ordered = [...rows.values()].sort((a, b) => {
const at = new Date(String(a.timestamp)).getTime();
const bt = new Date(String(b.timestamp)).getTime();
return at - bt;
const machines = await prisma.machine.findMany({
where: { orgId: session.orgId },
orderBy: { createdAt: "desc" },
select: { id: true, name: true },
});
const header = ["timestamp", "oee", "availability", "performance", "quality", "scrapRate"].join(",");
const lines = ordered.map((row) =>
[
row.timestamp,
row.oee ?? "",
row.availability ?? "",
row.performance ?? "",
row.quality ?? "",
row.scrapRate ?? "",
]
.map((v) => (v == null ? "" : String(v)))
.join(",")
);
const summary = report.summary;
const downtime = report.downtime;
const sectionLines: string[] = [];
sectionLines.push(
[t("reports.csv.section"), t("reports.csv.key"), t("reports.csv.value")].join(",")
);
const addRow = (section: string, key: string, value: string | number | null | undefined) => {
sectionLines.push(
[section, key, value == null ? "" : String(value)]
.map((v) => (v.includes(",") ? `"${v.replace(/\"/g, '""')}"` : v))
.join(",")
);
};
addRow("summary", "oeeAvg", summary.oeeAvg);
addRow("summary", "availabilityAvg", summary.availabilityAvg);
addRow("summary", "performanceAvg", summary.performanceAvg);
addRow("summary", "qualityAvg", summary.qualityAvg);
addRow("summary", "goodTotal", summary.goodTotal);
addRow("summary", "scrapTotal", summary.scrapTotal);
addRow("summary", "targetTotal", summary.targetTotal);
addRow("summary", "scrapRate", summary.scrapRate);
addRow("summary", "topScrapSku", summary.topScrapSku ?? "");
addRow("summary", "topScrapWorkOrder", summary.topScrapWorkOrder ?? "");
addRow("loss_drivers", "macrostopSec", downtime.macrostopSec);
addRow("loss_drivers", "microstopSec", downtime.microstopSec);
addRow("loss_drivers", "slowCycleCount", downtime.slowCycleCount);
addRow("loss_drivers", "qualitySpikeCount", downtime.qualitySpikeCount);
addRow("loss_drivers", "performanceDegradationCount", downtime.performanceDegradationCount);
addRow("loss_drivers", "oeeDropCount", downtime.oeeDropCount);
for (const bin of report.distribution.cycleTime) {
addRow("cycle_distribution", bin.label, bin.count);
}
if (report.insights?.length) {
report.insights.forEach((note, idx) => addRow("insights", String(idx + 1), note));
}
return [header, ...lines, "", ...sectionLines].join("\n");
}
function downloadText(filename: string, content: string) {
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function buildPdfHtml(
report: ReportPayload,
rangeLabel: string,
filters: { machine: string; workOrder: string; sku: string },
t: Translator
) {
const summary = report.summary;
const downtime = report.downtime;
const cycleBins = report.distribution.cycleTime;
const insights = report.insights ?? [];
return `
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${t("reports.pdf.title")}</title>
<style>
body { font-family: Arial, sans-serif; color: #111; margin: 24px; }
h1 { margin: 0 0 6px; }
.meta { margin-bottom: 16px; color: #555; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.card { border: 1px solid #ddd; border-radius: 8px; padding: 12px; }
.label { color: #666; font-size: 12px; text-transform: uppercase; letter-spacing: .03em; }
.value { font-size: 18px; font-weight: 600; margin-top: 6px; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { border: 1px solid #ddd; padding: 6px 8px; font-size: 12px; }
th { background: #f5f5f5; text-align: left; }
</style>
</head>
<body>
<h1>${t("reports.title")}</h1>
<div class="meta">${t("reports.pdf.range")}: ${rangeLabel} | ${t("reports.pdf.machine")}: ${filters.machine} | ${t("reports.pdf.workOrder")}: ${filters.workOrder} | ${t("reports.pdf.sku")}: ${filters.sku}</div>
<div class="grid">
<div class="card">
<div class="label">OEE (avg)</div>
<div class="value">${summary.oeeAvg != null ? summary.oeeAvg.toFixed(1) + "%" : "--"}</div>
</div>
<div class="card">
<div class="label">Availability (avg)</div>
<div class="value">${summary.availabilityAvg != null ? summary.availabilityAvg.toFixed(1) + "%" : "--"}</div>
</div>
<div class="card">
<div class="label">Performance (avg)</div>
<div class="value">${summary.performanceAvg != null ? summary.performanceAvg.toFixed(1) + "%" : "--"}</div>
</div>
<div class="card">
<div class="label">Quality (avg)</div>
<div class="value">${summary.qualityAvg != null ? summary.qualityAvg.toFixed(1) + "%" : "--"}</div>
</div>
</div>
<div class="card" style="margin-top: 16px;">
<div class="label">${t("reports.pdf.topLoss")}</div>
<table>
<thead>
<tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
</thead>
<tbody>
<tr><td>${t("reports.loss.macrostop")} (sec)</td><td>${downtime.macrostopSec}</td></tr>
<tr><td>${t("reports.loss.microstop")} (sec)</td><td>${downtime.microstopSec}</td></tr>
<tr><td>${t("reports.loss.slowCycle")}</td><td>${downtime.slowCycleCount}</td></tr>
<tr><td>${t("reports.loss.qualitySpike")}</td><td>${downtime.qualitySpikeCount}</td></tr>
<tr><td>${t("reports.loss.perfDegradation")}</td><td>${downtime.performanceDegradationCount}</td></tr>
<tr><td>${t("reports.loss.oeeDrop")}</td><td>${downtime.oeeDropCount}</td></tr>
</tbody>
</table>
</div>
<div class="card" style="margin-top: 16px;">
<div class="label">${t("reports.pdf.qualitySummary")}</div>
<table>
<thead>
<tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
</thead>
<tbody>
<tr><td>${t("reports.scrapRate")}</td><td>${summary.scrapRate != null ? summary.scrapRate.toFixed(1) + "%" : "--"}</td></tr>
<tr><td>${t("overview.good")}</td><td>${summary.goodTotal ?? "--"}</td></tr>
<tr><td>${t("overview.scrap")}</td><td>${summary.scrapTotal ?? "--"}</td></tr>
<tr><td>${t("overview.target")}</td><td>${summary.targetTotal ?? "--"}</td></tr>
<tr><td>${t("reports.topScrapSku")}</td><td>${summary.topScrapSku ?? "--"}</td></tr>
<tr><td>${t("reports.topScrapWorkOrder")}</td><td>${summary.topScrapWorkOrder ?? "--"}</td></tr>
</tbody>
</table>
</div>
<div class="card" style="margin-top: 16px;">
<div class="label">${t("reports.pdf.cycleDistribution")}</div>
<table>
<thead>
<tr><th>${t("reports.tooltip.range")}</th><th>${t("reports.tooltip.cycles")}</th></tr>
</thead>
<tbody>
${cycleBins
.map((bin) => `<tr><td>${bin.label}</td><td>${bin.count}</td></tr>`)
.join("")}
</tbody>
</table>
</div>
<div class="card" style="margin-top: 16px;">
<div class="label">${t("reports.pdf.notes")}</div>
${insights.length ? `<ul>${insights.map((n) => `<li>${n}</li>`).join("")}</ul>` : `<div>${t("reports.pdf.none")}</div>`}
</div>
</body>
</html>
`.trim();
}
export default function ReportsPage() {
const { t, locale } = useI18n();
const [range, setRange] = useState<RangeKey>("24h");
const [report, setReport] = useState<ReportPayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [machines, setMachines] = useState<MachineOption[]>([]);
const [filterOptions, setFilterOptions] = useState<FilterOptions>({ workOrders: [], skus: [] });
const [machineId, setMachineId] = useState("");
const [workOrderId, setWorkOrderId] = useState("");
const [sku, setSku] = useState("");
const rangeLabel = useMemo(() => {
if (range === "24h") return t("reports.rangeLabel.last24");
if (range === "7d") return t("reports.rangeLabel.last7");
if (range === "30d") return t("reports.rangeLabel.last30");
return t("reports.rangeLabel.custom");
}, [range, t]);
useEffect(() => {
let alive = true;
async function loadMachines() {
try {
const res = await fetch("/api/machines", { cache: "no-store" });
const json = await res.json();
if (!alive) return;
const rows: unknown[] = Array.isArray(json?.machines) ? json.machines : [];
const options: MachineOption[] = [];
rows.forEach((row) => {
const option = toMachineOption(row);
if (option) options.push(option);
});
setMachines(options);
} catch {
if (!alive) return;
setMachines([]);
}
}
loadMachines();
return () => {
alive = false;
};
}, []);
useEffect(() => {
let alive = true;
const controller = new AbortController();
async function load() {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ range });
if (machineId) params.set("machineId", machineId);
if (workOrderId) params.set("workOrderId", workOrderId);
if (sku) params.set("sku", sku);
const res = await fetch(`/api/reports?${params.toString()}`, {
cache: "no-store",
signal: controller.signal,
});
const json = await res.json();
if (!alive) return;
if (!res.ok || json?.ok === false) {
setError(json?.error ?? t("reports.error.failed"));
setReport(null);
} else {
setReport(json);
}
} catch {
if (!alive) return;
setError(t("reports.error.network"));
setReport(null);
} finally {
if (alive) setLoading(false);
}
}
load();
return () => {
alive = false;
controller.abort();
};
}, [range, machineId, workOrderId, sku, t]);
useEffect(() => {
let alive = true;
const controller = new AbortController();
async function loadFilters() {
try {
const params = new URLSearchParams({ range });
if (machineId) params.set("machineId", machineId);
const res = await fetch(`/api/reports/filters?${params.toString()}`, {
cache: "no-store",
signal: controller.signal,
});
const json = await res.json();
if (!alive) return;
if (!res.ok || json?.ok === false) {
setFilterOptions({ workOrders: [], skus: [] });
} else {
setFilterOptions({
workOrders: json.workOrders ?? [],
skus: json.skus ?? [],
});
}
} catch {
if (!alive) return;
setFilterOptions({ workOrders: [], skus: [] });
}
}
loadFilters();
return () => {
alive = false;
controller.abort();
};
}, [range, machineId]);
const summary = report?.summary;
const downtime = report?.downtime;
const trend = report?.trend;
const distribution = report?.distribution;
const oeeSeries = useMemo(() => {
const rows = trend?.oee ?? [];
const trimmed = downsample(rows, 600);
return trimmed.map((p) => ({
ts: p.t,
label: formatTickLabel(p.t, range),
value: p.v,
}));
}, [trend?.oee, range]);
const scrapSeries = useMemo(() => {
const rows = trend?.scrapRate ?? [];
const trimmed = downsample(rows, 600);
return trimmed.map((p) => ({
ts: p.t,
label: formatTickLabel(p.t, range),
value: p.v,
}));
}, [trend?.scrapRate, range]);
const cycleHistogram = useMemo(() => {
return distribution?.cycleTime ?? [];
}, [distribution?.cycleTime]);
const downtimeSeries = useMemo(() => {
if (!downtime) return [];
return [
{ name: "Macrostop", value: Math.round(downtime.macrostopSec / 60) },
{ name: "Microstop", value: Math.round(downtime.microstopSec / 60) },
];
}, [downtime]);
const downtimeColors: Record<string, string> = {
Macrostop: "#FF3B5C",
Microstop: "#FF7A00",
};
const lossRows = useMemo(
() => [
{ label: t("reports.loss.macrostop"), value: fmtDuration(downtime?.macrostopSec) },
{ label: t("reports.loss.microstop"), value: fmtDuration(downtime?.microstopSec) },
{ label: t("reports.loss.slowCycle"), value: downtime ? `${downtime.slowCycleCount}` : "--" },
{ label: t("reports.loss.qualitySpike"), value: downtime ? `${downtime.qualitySpikeCount}` : "--" },
{ label: t("reports.loss.oeeDrop"), value: downtime ? `${downtime.oeeDropCount}` : "--" },
{
label: t("reports.loss.perfDegradation"),
value: downtime ? `${downtime.performanceDegradationCount}` : "--",
},
],
[downtime, t]
);
const machineLabel = useMemo(() => {
if (!machineId) return t("reports.filter.allMachines");
return machines.find((m) => m.id === machineId)?.name ?? machineId;
}, [machineId, machines, t]);
const workOrderLabel = workOrderId || t("reports.filter.allWorkOrders");
const skuLabel = sku || t("reports.filter.allSkus");
const handleExportCsv = () => {
if (!report) return;
const csv = buildCsv(report, t);
downloadText("reports.csv", csv);
};
const handleExportPdf = () => {
if (!report) return;
const html = buildPdfHtml(
report,
rangeLabel,
{
machine: machineLabel,
workOrder: workOrderLabel,
sku: skuLabel,
},
t
);
const win = window.open("", "_blank", "width=900,height=650");
if (!win) return;
win.document.open();
win.document.write(html);
win.document.close();
win.focus();
setTimeout(() => win.print(), 300);
};
return (
<div className="p-4 sm:p-6">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">{t("reports.title")}</h1>
<p className="text-sm text-zinc-400">{t("reports.subtitle")}</p>
</div>
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
<button
onClick={handleExportCsv}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 sm:w-auto"
>
{t("reports.exportCsv")}
</button>
<button
onClick={handleExportPdf}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 sm:w-auto"
>
{t("reports.exportPdf")}
</button>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="text-sm font-semibold text-white">{t("reports.filters")}</div>
<div className="text-xs text-zinc-400">{rangeLabel}</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("reports.filter.range")}</div>
<div className="mt-2 flex flex-wrap gap-2">
{(["24h", "7d", "30d", "custom"] as RangeKey[]).map((k) => (
<button
key={k}
onClick={() => setRange(k)}
className={`rounded-full border px-3 py-1 text-xs ${
range === k
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-200"
: "border-white/10 bg-white/5 text-zinc-300 hover:bg-white/10"
}`}
>
{k.toUpperCase()}
</button>
))}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("reports.filter.machine")}</div>
<select
value={machineId}
onChange={(e) => setMachineId(e.target.value)}
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300"
>
<option value="">{t("reports.filter.allMachines")}</option>
{machines.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("reports.filter.workOrder")}</div>
<input
list="work-order-list"
value={workOrderId}
onChange={(e) => setWorkOrderId(e.target.value)}
placeholder={t("reports.filter.allWorkOrders")}
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500"
/>
<datalist id="work-order-list">
{filterOptions.workOrders.map((wo) => (
<option key={wo} value={wo} />
))}
</datalist>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("reports.filter.sku")}</div>
<input
list="sku-list"
value={sku}
onChange={(e) => setSku(e.target.value)}
placeholder={t("reports.filter.allSkus")}
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500"
/>
<datalist id="sku-list">
{filterOptions.skus.map((s) => (
<option key={s} value={s} />
))}
</datalist>
</div>
</div>
</div>
<div className="mt-4">
{loading && <div className="text-sm text-zinc-400">{t("reports.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>
)}
</div>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{[
{ label: "OEE", value: fmtPct(summary?.oeeAvg), tone: "text-emerald-300" },
{ label: "Availability", value: fmtPct(summary?.availabilityAvg), tone: "text-white" },
{ label: "Performance", value: fmtPct(summary?.performanceAvg), tone: "text-white" },
{ label: "Quality", value: fmtPct(summary?.qualityAvg), tone: "text-white" },
].map((kpi) => (
<div key={kpi.label} className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">{kpi.label} (avg)</div>
<div className={`mt-2 text-3xl font-semibold ${kpi.tone}`}>{kpi.value}</div>
<div className="mt-2 text-xs text-zinc-500">
{summary ? t("reports.kpi.note.withData") : t("reports.kpi.note.noData")}
</div>
</div>
))}
</div>
<Suspense fallback={<ReportsChartsSkeleton />}>
<ReportsCharts
oeeSeries={oeeSeries}
downtimeSeries={downtimeSeries}
downtimeColors={downtimeColors}
cycleHistogram={cycleHistogram}
scrapSeries={scrapSeries}
lossRows={lossRows}
locale={locale}
t={t}
/>
</Suspense>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 text-sm font-semibold text-white">{t("reports.qualitySummary")}</div>
<div className="space-y-3 text-sm text-zinc-300">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">{t("reports.scrapRate")}</div>
<div className="mt-1 text-lg font-semibold text-white">
{summary?.scrapRate != null ? fmtPct(summary.scrapRate) : "--"}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">{t("reports.topScrapSku")}</div>
<div className="mt-1 text-sm text-zinc-300">{summary?.topScrapSku ?? "--"}</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">{t("reports.topScrapWorkOrder")}</div>
<div className="mt-1 text-sm text-zinc-300">{summary?.topScrapWorkOrder ?? "--"}</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 text-sm font-semibold text-white">{t("reports.notes")}</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-4 text-sm text-zinc-300">
<div className="mb-2 text-xs text-zinc-400">{t("reports.notes.suggested")}</div>
{report?.insights && report.insights.length > 0 ? (
<div className="space-y-2">
{report.insights.map((note, idx) => (
<div key={idx}>{note}</div>
))}
</div>
) : (
<div>{t("reports.notes.none")}</div>
)}
</div>
</div>
</div>
</div>
);
return <ReportsPageClient initialMachines={machines} />;
}

View File

@@ -2,6 +2,14 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
import type { Prisma } from "@prisma/client";
import {
applyDowntimeFilters,
loadDowntimeShiftContext,
normalizeMicrostopLtMin,
normalizeShiftFilter,
resolvePlannedFilter,
} from "@/lib/analytics/downtimeFilters";
const bad = (status: number, error: string) =>
NextResponse.json({ ok: false, error }, { status });
@@ -24,6 +32,10 @@ export async function GET(req: Request) {
const machineId = url.searchParams.get("machineId"); // optional
const reasonCode = url.searchParams.get("reasonCode"); // optional
const includeMoldChange = url.searchParams.get("includeMoldChange") === "true";
const planned = resolvePlannedFilter(url.searchParams.get("planned"), includeMoldChange);
const shift = normalizeShiftFilter(url.searchParams.get("shift"));
const microstopLtMin = normalizeMicrostopLtMin(url.searchParams.get("microstopLtMin"));
const limitRaw = url.searchParams.get("limit");
const limit = Math.min(Math.max(Number(limitRaw || 200), 1), 500);
@@ -44,7 +56,7 @@ export async function GET(req: Request) {
// ✅ Query ReasonEntry as the "episode" table for downtime
// We only return rows that have an episodeId (true downtime episodes)
const where: any = {
const where: Prisma.ReasonEntryWhereInput = {
orgId,
kind: "downtime",
episodeId: { not: null },
@@ -56,10 +68,11 @@ export async function GET(req: Request) {
...(reasonCode ? { reasonCode } : {}),
};
const rows = await prisma.reasonEntry.findMany({
const scanTake = Math.min(Math.max(limit * 8, 1000), 5000);
const rowsRaw = await prisma.reasonEntry.findMany({
where,
orderBy: { capturedAt: "desc" },
take: limit,
take: scanTake,
select: {
id: true,
episodeId: true,
@@ -77,6 +90,14 @@ export async function GET(req: Request) {
},
});
const shiftContext = shift === "all" ? null : await loadDowntimeShiftContext(orgId);
const rows = applyDowntimeFilters(rowsRaw, {
planned,
shift,
microstopLtMin,
shiftContext,
}).slice(0, limit);
const events = rows.map((r) => {
const startAt = r.capturedAt;
const endAt =
@@ -113,7 +134,11 @@ export async function GET(req: Request) {
});
const nextBefore =
events.length > 0 ? events[events.length - 1]?.capturedAt ?? null : null;
events.length > 0
? events[events.length - 1]?.capturedAt ?? null
: rowsRaw.length > 0
? toISO(rowsRaw[rowsRaw.length - 1]?.capturedAt)
: null;
return NextResponse.json({
ok: true,
@@ -122,6 +147,10 @@ export async function GET(req: Request) {
start,
machineId: machineId ?? null,
reasonCode: reasonCode ?? null,
planned,
shift,
microstopLtMin,
includeMoldChange,
limit,
before: before ?? null,
nextBefore, // pass this back for pagination

View File

@@ -2,6 +2,13 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
import {
applyDowntimeFilters,
loadDowntimeShiftContext,
normalizeMicrostopLtMin,
normalizeShiftFilter,
resolvePlannedFilter,
} from "@/lib/analytics/downtimeFilters";
const bad = (status: number, error: string) =>
NextResponse.json({ ok: false, error }, { status });
@@ -20,9 +27,13 @@ export async function GET(req: Request) {
const machineId = url.searchParams.get("machineId"); // optional
const kind = (url.searchParams.get("kind") || "downtime").toLowerCase();
const includeMoldChange = url.searchParams.get("includeMoldChange") === "true";
const planned = resolvePlannedFilter(url.searchParams.get("planned"), includeMoldChange);
const shift = normalizeShiftFilter(url.searchParams.get("shift"));
const microstopLtMin = normalizeMicrostopLtMin(url.searchParams.get("microstopLtMin"));
if (kind !== "downtime" && kind !== "scrap") {
return bad(400, "Invalid kind (downtime|scrap)");
if (kind !== "downtime" && kind !== "scrap" && kind !== "planned-downtime") {
return bad(400, "Invalid kind (downtime|scrap|planned-downtime)");
}
// ✅ If machineId provided, verify it belongs to this org
@@ -34,7 +45,61 @@ export async function GET(req: Request) {
if (!m) return bad(404, "Machine not found");
}
// ✅ Scope by orgId (+ machineId if provided)
let itemsRaw: { reasonCode: string; reasonLabel: string; value: number; count: number }[] = [];
if (kind === "downtime" || kind === "planned-downtime") {
const baseRows = await prisma.reasonEntry.findMany({
where: {
orgId,
...(machineId ? { machineId } : {}),
kind: "downtime",
capturedAt: { gte: start },
},
select: {
reasonCode: true,
reasonLabel: true,
durationSeconds: true,
capturedAt: true,
meta: true,
episodeId: true,
},
});
const effectivePlanned = kind === "planned-downtime" ? "planned" : planned;
const shiftContext = shift === "all" ? null : await loadDowntimeShiftContext(orgId);
const filteredRows = applyDowntimeFilters(baseRows, {
planned: effectivePlanned,
shift,
microstopLtMin,
shiftContext,
});
const grouped = new Map<string, { reasonCode: string; reasonLabel: string; durationSeconds: number; count: number }>();
for (const row of filteredRows) {
const key = `${row.reasonCode}:::${row.reasonLabel ?? row.reasonCode}`;
const slot =
grouped.get(key) ??
{
reasonCode: row.reasonCode,
reasonLabel: row.reasonLabel ?? row.reasonCode,
durationSeconds: 0,
count: 0,
};
slot.durationSeconds += Math.max(0, row.durationSeconds ?? 0);
slot.count += 1;
grouped.set(key, slot);
}
itemsRaw = [...grouped.values()]
.map((g) => ({
reasonCode: g.reasonCode,
reasonLabel: g.reasonLabel,
value: Math.round((g.durationSeconds / 60) * 10) / 10,
count: g.count,
}))
.filter((x) => x.value > 0 || x.count > 0);
} else {
// Scrap path unchanged.
const grouped = await prisma.reasonEntry.groupBy({
by: ["reasonCode", "reasonLabel"],
where: {
@@ -43,28 +108,19 @@ export async function GET(req: Request) {
kind,
capturedAt: { gte: start },
},
_sum: {
durationSeconds: true,
scrapQty: true,
},
_sum: { scrapQty: true },
_count: { _all: true },
});
const itemsRaw = grouped
.map((g) => {
const value =
kind === "downtime"
? Math.round(((g._sum.durationSeconds ?? 0) / 60) * 10) / 10 // minutes, 1 decimal
: g._sum.scrapQty ?? 0;
return {
itemsRaw = grouped
.map((g) => ({
reasonCode: g.reasonCode,
reasonLabel: g.reasonLabel ?? g.reasonCode,
value,
value: g._sum.scrapQty ?? 0,
count: g._count._all,
};
})
.filter((x) => (kind === "downtime" ? x.value > 0 || x.count > 0 : x.value > 0));
}))
.filter((x) => x.value > 0);
}
itemsRaw.sort((a, b) => b.value - a.value);
@@ -83,7 +139,7 @@ export async function GET(req: Request) {
return {
reasonCode: x.reasonCode,
reasonLabel: x.reasonLabel,
minutesLost: kind === "downtime" ? x.value : undefined,
minutesLost: kind === "downtime" || kind === "planned-downtime" ? x.value : undefined,
scrapQty: kind === "scrap" ? x.value : undefined,
pctOfTotal,
cumulativePct,
@@ -106,9 +162,13 @@ export async function GET(req: Request) {
orgId,
machineId: machineId ?? null,
kind,
planned: kind === "downtime" ? planned : kind === "planned-downtime" ? "planned" : "all",
shift,
microstopLtMin,
includeMoldChange,
range, // ✅ now defined correctly
start, // ✅ now defined correctly
totalMinutesLost: kind === "downtime" ? total : undefined,
totalMinutesLost: kind === "downtime" || kind === "planned-downtime" ? total : undefined,
totalScrap: kind === "scrap" ? total : undefined,
rows,
top3,

View File

@@ -68,7 +68,8 @@ function normalizeCycleInput(raw: unknown): Record<string, unknown> | null {
cycle_count: fromRowOrData(["cycle_count", "cycleCount"]),
work_order_id: fromRowOrData(["work_order_id", "workOrderId"]),
good_delta: fromRowOrData(["good_delta", "goodDelta"]),
scrap_delta: fromRowOrData(["scrap_delta", "scrapDelta", "scrap_total"]),
// `scrap_total` is cumulative and should not be persisted as per-cycle delta.
scrap_delta: fromRowOrData(["scrap_delta", "scrapDelta"]),
timestamp: fromRowOrData(["timestamp", "tsMs"]),
ts: fromRowOrData(["ts", "tsMs"]),
event_timestamp: fromRowOrData(["event_timestamp", "eventTimestamp"]),
@@ -171,11 +172,35 @@ export async function POST(req: Request) {
};
});
const result = await prisma.machineCycle.createMany({
data: rows,
skipDuplicates: true,
});
if (rows.length === 1) {
const row = await prisma.machineCycle.create({ data: rows[0] });
return NextResponse.json({ ok: true, id: row.id, ts: row.ts });
const row = await prisma.machineCycle.findFirst({
where: {
orgId: machine.orgId,
machineId: machine.id,
ts: rows[0].ts,
cycleCount: rows[0].cycleCount ?? null,
},
orderBy: { createdAt: "asc" },
select: { id: true, ts: true },
});
return NextResponse.json({
ok: true,
id: row?.id,
ts: row?.ts,
inserted: result.count,
duplicate: result.count === 0,
});
}
const result = await prisma.machineCycle.createMany({ data: rows });
return NextResponse.json({ ok: true, count: result.count });
return NextResponse.json({
ok: true,
inserted: result.count,
requested: rows.length,
count: result.count,
});
}

View File

@@ -40,6 +40,7 @@ const CANON_TYPE: Record<string, string> = {
"down": "stop",
"downtime-acknowledged": "downtime-acknowledged",
"scrap-manual-entry": "scrap-manual-entry",
"mold-change": "mold-change",
};
const ALLOWED_TYPES = new Set([
@@ -54,6 +55,7 @@ const ALLOWED_TYPES = new Set([
"predictive-oee-decline",
"downtime-acknowledged",
"scrap-manual-entry",
"mold-change",
]);
const machineIdSchema = z.string().uuid();
@@ -61,7 +63,12 @@ const MAX_EVENTS = 100;
//when no cycle time is configed
const DEFAULT_MACROSTOP_SEC = 300;
const NON_AUTHORITATIVE_REASON_CODES = new Set(["PENDIENTE", "UNCLASSIFIED"]);
function isNonAuthoritativeReasonCode(code: unknown) {
const normalized = clampText(code, 64)?.toUpperCase();
return !!normalized && NON_AUTHORITATIVE_REASON_CODES.has(normalized);
}
function clampText(value: unknown, maxLen: number) {
if (value === null || value === undefined) return null;
@@ -78,6 +85,15 @@ function numberFrom(value: unknown) {
}
return null;
}
function parseSeqToBigInt(value: unknown): bigint | null {
if (value === null || value === undefined) return null;
if (typeof value === "number") {
if (!Number.isInteger(value) || value < 0) return null;
return BigInt(value);
}
if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value);
return null;
}
function canonicalText(value: unknown) {
return String(value ?? "")
@@ -260,6 +276,10 @@ export async function POST(req: Request) {
const machine = await getMachineAuth(String(machineId), apiKey);
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const bodySeq = parseSeqToBigInt(bodyRecord.seq);
const bodySchemaVersion = clampText(bodyRecord.schemaVersion, 16);
const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: machine.orgId },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true },
@@ -291,8 +311,11 @@ export async function POST(req: Request) {
continue;
}
const evData = asRecord(evRecord.data) ?? {};
const evReason = asRecord(evRecord.reason) ?? asRecord(evData.reason);
// We'll re-check reason again after parsing `data` (it may be a JSON string)
let evReason = asRecord(evRecord.reason) ?? asRecord(evData.reason);
const evDowntime = asRecord(evRecord.downtime) ?? asRecord(evData.downtime);
// Some producers nest the reason under `downtime.reason`
if (!evReason) evReason = asRecord(evDowntime?.reason);
const rawType = evRecord.eventType ?? evRecord.anomaly_type ?? evRecord.topic ?? bodyRecord.topic ?? "";
const typ0 = normalizeType(rawType);
@@ -313,11 +336,13 @@ export async function POST(req: Request) {
// Stop classification -> microstop/macrostop
let finalType = typ;
let stopSecForReason: number | null = null;
if (typ === "stop") {
const stopSec =
(typeof evData.stoppage_duration_seconds === "number" && evData.stoppage_duration_seconds) ||
(typeof evData.stop_duration_seconds === "number" && evData.stop_duration_seconds) ||
null;
stopSecForReason = stopSec != null ? Number(stopSec) : null;
if (stopSec != null) {
const theoretical = Number(evData.theoretical_cycle_time ?? evData.theoreticalCycleTime ?? 0) || 0;
@@ -378,13 +403,46 @@ export async function POST(req: Request) {
if (evReason && dataObj.reason == null) dataObj.reason = evReason;
if (evDowntime && dataObj.downtime == null) dataObj.downtime = evDowntime;
// If `data` was a JSON string, the earlier evReason lookup would miss it.
// Re-check here using the normalized object we will persist.
if (!evReason) evReason = asRecord(dataObj.reason);
if (!evReason) evReason = asRecord(asRecord(dataObj.downtime)?.reason);
// If we have a reasonText but missing ids, derive ids from the path-like string.
if (evReason) {
const hasCat = clampText((evReason as any).categoryId, 64) ?? clampText((evReason as any).categoryLabel, 120);
const hasDet = clampText((evReason as any).detailId, 64) ?? clampText((evReason as any).detailLabel, 120);
const rt = clampText((evReason as any).reasonText, 240);
if ((!hasCat || !hasDet) && rt) {
const parsed = parseReasonTextPath(rt);
const next = { ...evReason } as Record<string, unknown>;
// Preserve any explicit ids; only fill gaps.
if ((next as any).categoryId == null && parsed.category) next.categoryId = canonicalText(parsed.category);
if ((next as any).categoryLabel == null && parsed.category) next.categoryLabel = parsed.category;
if ((next as any).detailId == null && parsed.detail) next.detailId = canonicalText(parsed.detail);
if ((next as any).detailLabel == null && parsed.detail) next.detailLabel = parsed.detail;
evReason = next;
}
}
const activeWorkOrder = asRecord(evRecord.activeWorkOrder);
const dataActiveWorkOrder = asRecord(evData.activeWorkOrder);
const row = await prisma.machineEvent.create({
data: {
// ✨ Cada evento puede traer su propio seq, o usar el del payload raíz
const evSeq =
parseSeqToBigInt(evRecord.seq) ??
parseSeqToBigInt(evData.seq) ??
bodySeq;
const evSchemaVersion =
clampText(evRecord.schemaVersion, 16) ??
bodySchemaVersion;
const eventData = {
orgId: machine.orgId,
machineId: machine.id,
schemaVersion: evSchemaVersion,
seq: evSeq,
ts,
topic: clampText(evRecord.topic ?? finalType, 64) ?? finalType,
eventType: finalType,
@@ -405,24 +463,118 @@ export async function POST(req: Request) {
clampText(activeWorkOrder?.sku, 64) ??
clampText(dataActiveWorkOrder?.sku, 64) ??
null,
},
};
// ✨ Idempotente: si ya existe (mismo orgId+machineId+seq), no inserta
const insertResult = await prisma.machineEvent.createMany({
data: [eventData],
skipDuplicates: true,
});
// ✨ Buscar la fila (la recién creada o la duplicada existente)
let row;
if (evSeq != null) {
row = await prisma.machineEvent.findFirst({
where: {
orgId: machine.orgId,
machineId: machine.id,
seq: evSeq,
},
orderBy: { ts: "asc" },
});
} else {
// Sin seq, buscar por ts (fallback compatibilidad con eventos viejos)
row = await prisma.machineEvent.findFirst({
where: {
orgId: machine.orgId,
machineId: machine.id,
ts,
eventType: finalType,
},
orderBy: { ts: "desc" },
});
}
if (!row) {
skipped.push({ reason: "row_not_found_after_insert", seq: evSeq?.toString() });
continue;
}
const wasDuplicate = insertResult.count === 0;
// Si fue duplicado, no procesar reasonEntry ni alertas (ya se hicieron antes)
if (wasDuplicate) {
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
continue; // ✨ saltar el resto del procesamiento
}
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
if (evReason) {
// If the payload carries a `reason`, create the corresponding ReasonEntry.
// If it doesn't, still create an "UNCLASSIFIED" downtime ReasonEntry for stop events so the dashboard can show coverage.
if (evRecord.is_update || evRecord.is_auto_ack || dataObj.is_update || dataObj.is_auto_ack){
// skip duplicate reasonEntry for refresh/ack
} else if (evReason || finalType === "microstop" || finalType === "macrostop" || finalType === "downtime-acknowledged" || finalType === "mold-change"){
const fallbackIncidentKey =
clampText(
evData.incidentKey ??
dataObj.incidentKey ??
evDowntime?.incidentKey ??
evReason?.incidentKey,
128
) ?? null;
const moldIncidentKey =
clampText(evData.incidentKey ?? dataObj.incidentKey, 128) ??
(numberFrom(evData.start_ms ?? dataObj.start_ms) != null
? `mold-change:${Math.trunc(numberFrom(evData.start_ms ?? dataObj.start_ms) as number)}`
: null);
const reasonRaw: Record<string, unknown> =
evReason ??
(finalType === "mold-change"
? ({
type: "downtime",
categoryId: "cambio-molde",
detailId: "cambio-molde",
categoryLabel: "Cambio molde",
detailLabel: "Cambio molde",
reasonCode: "MOLD_CHANGE",
reasonText: "Cambio molde",
incidentKey: moldIncidentKey ?? fallbackIncidentKey ?? row.id,
} as Record<string, unknown>)
:
({
type: "downtime",
categoryId: "unclassified",
detailId: "unclassified",
categoryLabel: "Unclassified",
detailLabel: "Unclassified",
reasonCode: "UNCLASSIFIED",
reasonText: "Unclassified",
incidentKey: fallbackIncidentKey ?? row.id,
} as Record<string, unknown>));
const inferredKind: ReasonCatalogKind =
String(evReason.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry"
String(reasonRaw.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry"
? "scrap"
: "downtime";
const resolved = resolveReason(evReason, inferredKind, reasonCatalog, reasonCatalog.version);
const resolved = resolveReason(reasonRaw, inferredKind, reasonCatalog, reasonCatalog.version);
if (resolved.reasonCode) {
const continuityIncidentKey =
inferredKind === "downtime"
? clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey ?? fallbackIncidentKey, 128) ?? row.id
: null;
const reasonMetaIncidentKey =
inferredKind === "downtime"
? continuityIncidentKey
: clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128);
const reasonId =
clampText(evReason.reasonId, 128) ??
clampText(reasonRaw.reasonId, 128) ??
(inferredKind === "downtime"
? `evt:${machine.id}:downtime:${clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id}`
: `evt:${machine.id}:scrap:${clampText(evReason.scrapEntryId, 128) ?? row.id}`);
? `evt:${machine.id}:downtime:${continuityIncidentKey ?? row.id}`
: `evt:${machine.id}:scrap:${clampText(reasonRaw.scrapEntryId, 128) ?? row.id}`);
const workOrderId =
clampText(evRecord.work_order_id, 64) ??
@@ -441,7 +593,7 @@ export async function POST(req: Request) {
source: "ingest:event",
eventId: row.id,
eventType: row.eventType,
incidentKey: clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128),
incidentKey: reasonMetaIncidentKey,
anomalyType:
clampText(evRecord.anomalyType, 64) ??
clampText(evDowntime?.anomalyType, 64) ??
@@ -459,17 +611,76 @@ export async function POST(req: Request) {
};
if (inferredKind === "downtime") {
const incidentKey = clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id;
const incidentKey = continuityIncidentKey ?? row.id;
const durationSeconds =
numberFrom(evDowntime?.durationSeconds) ??
numberFrom(evData.duration_sec) ??
numberFrom(evData.stoppage_duration_seconds) ??
numberFrom(evData.stop_duration_seconds) ??
(stopSecForReason != null ? stopSecForReason : null) ??
null;
const episodeEndTsMs =
numberFrom(evData.end_ms) ??
numberFrom(evDowntime?.episodeEndTsMs) ??
numberFrom(evDowntime?.acknowledgedAtMs) ??
null;
let guardedWrite = commonWrite;
const incomingIsNonAuthoritative = isNonAuthoritativeReasonCode(resolved.reasonCode);
const isManualAckEvent = finalType === "downtime-acknowledged";
if (!isManualAckEvent && incomingIsNonAuthoritative) {
const existingEpisode = await prisma.reasonEntry.findFirst({
where: {
orgId: machine.orgId,
kind: "downtime",
episodeId: incidentKey,
},
select: {
reasonCode: true,
reasonLabel: true,
reasonText: true,
meta: true,
},
});
if (existingEpisode && !isNonAuthoritativeReasonCode(existingEpisode.reasonCode)) {
const existingMeta = asRecord(existingEpisode.meta);
const existingMetaReason = asRecord(existingMeta?.reason);
guardedWrite = {
...commonWrite,
reasonCode: existingEpisode.reasonCode,
reasonLabel: existingEpisode.reasonLabel ?? existingEpisode.reasonCode,
reasonText:
existingEpisode.reasonText ??
existingEpisode.reasonLabel ??
existingEpisode.reasonCode,
meta: toJsonValue({
source: "ingest:event",
eventId: row.id,
eventType: row.eventType,
incidentKey: reasonMetaIncidentKey,
anomalyType:
clampText(evRecord.anomalyType, 64) ??
clampText(evDowntime?.anomalyType, 64) ??
clampText(evRecord.anomaly_type, 64),
reason: existingMetaReason ?? {
type: resolved.type,
categoryId: resolved.categoryId,
categoryLabel: resolved.categoryLabel,
detailId: resolved.detailId,
detailLabel: resolved.detailLabel,
reasonText:
existingEpisode.reasonText ??
existingEpisode.reasonLabel ??
existingEpisode.reasonCode,
catalogVersion: resolved.catalogVersion,
},
reasonPreservedFromManual: true,
incomingReasonCode: resolved.reasonCode,
}),
};
}
}
await prisma.reasonEntry.upsert({
where: { reasonId },
create: {
@@ -480,19 +691,19 @@ export async function POST(req: Request) {
episodeId: incidentKey,
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
...commonWrite,
...guardedWrite,
},
update: {
kind: "downtime",
episodeId: incidentKey,
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
...commonWrite,
...guardedWrite,
},
});
} else {
const scrapEntryId =
clampText(evReason.scrapEntryId, 128) ??
clampText((reasonRaw as any).scrapEntryId, 128) ??
clampText(evRecord.id, 128) ??
clampText(evRecord.eventId, 128) ??
row.id;
@@ -512,14 +723,14 @@ export async function POST(req: Request) {
kind: "scrap",
scrapEntryId,
scrapQty,
scrapUnit: clampText(evReason.scrapUnit, 16) ?? null,
scrapUnit: clampText((reasonRaw as any).scrapUnit, 16) ?? null,
...commonWrite,
},
update: {
kind: "scrap",
scrapEntryId,
scrapQty,
scrapUnit: clampText(evReason.scrapUnit, 16) ?? null,
scrapUnit: clampText((reasonRaw as any).scrapUnit, 16) ?? null,
...commonWrite,
},
});

View File

@@ -0,0 +1,670 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getMachineAuth } from "@/lib/machineAuthCache";
import { z } from "zod";
import { evaluateAlertsForEvent } from "@/lib/alerts/engine";
import { toJsonValue } from "@/lib/prismaJson";
import {
findCatalogReason,
loadFallbackReasonCatalog,
normalizeReasonCatalog,
toReasonCode,
type ReasonCatalog,
type ReasonCatalogKind,
} from "@/lib/reasonCatalog";
const normalizeType = (t: unknown) =>
String(t ?? "")
.trim()
.toLowerCase()
.replace(/_/g, "-");
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
const CANON_TYPE: Record<string, string> = {
// Node-RED
"production-stopped": "stop",
"oee-drop": "oee-drop",
"quality-spike": "quality-spike",
"predictive-oee-decline": "predictive-oee-decline",
"performance-degradation": "performance-degradation",
// legacy / synonyms
"macroparo": "macrostop",
"macro-stop": "macrostop",
"microparo": "microstop",
"micro-paro": "microstop",
"down": "stop",
"downtime-acknowledged": "downtime-acknowledged",
"scrap-manual-entry": "scrap-manual-entry",
"mold-change": "mold-change",
};
const ALLOWED_TYPES = new Set([
"slow-cycle",
"microstop",
"macrostop",
"offline",
"error",
"oee-drop",
"quality-spike",
"performance-degradation",
"predictive-oee-decline",
"downtime-acknowledged",
"scrap-manual-entry",
"mold-change",
]);
const machineIdSchema = z.string().uuid();
const MAX_EVENTS = 100;
//when no cycle time is configed
const DEFAULT_MACROSTOP_SEC = 300;
function clampText(value: unknown, maxLen: number) {
if (value === null || value === undefined) return null;
const text = String(value).trim().replace(/[\u0000-\u001f\u007f]/g, "");
if (!text) return null;
return text.length > maxLen ? text.slice(0, maxLen) : text;
}
function numberFrom(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function parseSeqToBigInt(value: unknown): bigint | null {
if (value === null || value === undefined) return null;
if (typeof value === "number") {
if (!Number.isInteger(value) || value < 0) return null;
return BigInt(value);
}
if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value);
return null;
}
function canonicalText(value: unknown) {
return String(value ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function parseReasonPath(rawPath: unknown) {
let category: string | null = null;
let detail: string | null = null;
if (Array.isArray(rawPath)) {
const first = rawPath[0];
const second = rawPath[1];
if (typeof first === "string") category = first;
if (typeof second === "string") detail = second;
if (asRecord(first)) category = clampText(first.id ?? first.label ?? first.value, 120);
if (asRecord(second)) detail = clampText(second.id ?? second.label ?? second.value, 120);
} else if (typeof rawPath === "string") {
const pieces = rawPath
.split(/>|\/|\\|\|/g)
.map((p) => p.trim())
.filter(Boolean);
category = pieces[0] ?? null;
detail = pieces[1] ?? null;
}
return {
category: clampText(category, 120),
detail: clampText(detail, 120),
};
}
function parseReasonTextPath(reasonText: unknown) {
const text = clampText(reasonText, 240);
if (!text) return { category: null as string | null, detail: null as string | null };
const pieces = text
.split(/>|\/|\\|\|/g)
.map((p) => p.trim())
.filter(Boolean);
return {
category: clampText(pieces[0] ?? null, 120),
detail: clampText(pieces[1] ?? null, 120),
};
}
function findCatalogReasonFlexible(
catalog: ReasonCatalog | null,
kind: ReasonCatalogKind,
categoryIdOrLabel: unknown,
detailIdOrLabel: unknown
) {
const direct = findCatalogReason(catalog, kind, categoryIdOrLabel, detailIdOrLabel);
if (direct) return direct;
if (!catalog) return null;
const catNeedle = canonicalText(categoryIdOrLabel);
const detNeedle = canonicalText(detailIdOrLabel);
if (!catNeedle || !detNeedle) return null;
for (const category of catalog[kind] ?? []) {
const catMatch =
canonicalText(category.id) === catNeedle || canonicalText(category.label) === catNeedle;
if (!catMatch) continue;
for (const detail of category.details) {
const detMatch = canonicalText(detail.id) === detNeedle || canonicalText(detail.label) === detNeedle;
if (!detMatch) continue;
return {
categoryId: category.id,
categoryLabel: category.label,
detailId: detail.id,
detailLabel: detail.label,
reasonCode: toReasonCode(category.id, detail.id),
reasonLabel: `${category.label} > ${detail.label}`,
};
}
}
return null;
}
function getCatalogFromDefaults(defaultsJson: unknown) {
const defaults = asRecord(defaultsJson);
if (!defaults) return null;
return normalizeReasonCatalog(defaults.reasonCatalog ?? defaults.reasonCatalogData);
}
function resolveReason(
raw: Record<string, unknown>,
kind: ReasonCatalogKind,
catalog: ReasonCatalog | null,
fallbackVersion: number
) {
const reasonPath = parseReasonPath(raw.reasonPath);
const reasonTextPath = parseReasonTextPath(raw.reasonText);
const categoryIdRaw = clampText(raw.categoryId ?? reasonPath.category ?? reasonTextPath.category, 64);
const detailIdRaw = clampText(raw.detailId ?? reasonPath.detail ?? reasonTextPath.detail, 64);
const fromCatalog = findCatalogReasonFlexible(catalog, kind, categoryIdRaw, detailIdRaw);
const categoryLabelRaw = clampText(raw.categoryLabel ?? reasonPath.category ?? reasonTextPath.category, 120);
const detailLabelRaw = clampText(raw.detailLabel ?? reasonPath.detail ?? reasonTextPath.detail, 120);
const reasonCode =
clampText(raw.reasonCode, 64)?.toUpperCase() ??
fromCatalog?.reasonCode ??
toReasonCode(categoryIdRaw ?? categoryLabelRaw, detailIdRaw ?? detailLabelRaw) ??
null;
const categoryId = fromCatalog?.categoryId ?? categoryIdRaw;
const detailId = fromCatalog?.detailId ?? detailIdRaw;
const categoryLabel = fromCatalog?.categoryLabel ?? categoryLabelRaw;
const detailLabel = fromCatalog?.detailLabel ?? detailLabelRaw;
const pathLabel =
clampText(raw.reasonText, 240) ??
fromCatalog?.reasonLabel ??
(categoryLabel && detailLabel ? `${categoryLabel} > ${detailLabel}` : null) ??
detailLabel ??
categoryLabel ??
reasonCode;
const catalogVersionRaw = numberFrom(raw.catalogVersion);
const catalogVersion = catalogVersionRaw != null ? Math.trunc(catalogVersionRaw) : fallbackVersion;
return {
type: kind,
categoryId,
categoryLabel,
detailId,
detailLabel,
reasonCode,
reasonLabel: pathLabel,
reasonText: pathLabel,
catalogVersion,
};
}
export async function POST(req: Request) {
const apiKey = req.headers.get("x-api-key");
if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
let body: unknown = await req.json().catch(() => null);
// ✅ if Node-RED sent an array as the whole body, unwrap it
if (Array.isArray(body)) body = body[0];
const bodyRecord = asRecord(body) ?? {};
const payloadRecord = asRecord(bodyRecord.payload) ?? {};
// ✅ accept multiple common keys
const machineId =
bodyRecord.machineId ??
bodyRecord.machine_id ??
(asRecord(bodyRecord.machine)?.id ?? null);
let rawEvent =
bodyRecord.event ??
bodyRecord.events ??
bodyRecord.anomalies ??
payloadRecord.event ??
payloadRecord.events ??
payloadRecord.anomalies ??
payloadRecord ??
bodyRecord.data; // sometimes "data"
const rawEventRecord = asRecord(rawEvent);
if (rawEventRecord?.event && typeof rawEventRecord.event === "object") rawEvent = rawEventRecord.event;
if (Array.isArray(rawEventRecord?.events)) rawEvent = rawEventRecord.events;
if (!machineId || !rawEvent) {
return NextResponse.json(
{ ok: false, error: "Invalid payload", got: { hasMachineId: !!machineId, keys: Object.keys(bodyRecord) } },
{ status: 400 }
);
}
if (!machineIdSchema.safeParse(String(machineId)).success) {
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
}
const machine = await getMachineAuth(String(machineId), apiKey);
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: machine.orgId },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true },
});
const fallbackCatalog = await loadFallbackReasonCatalog();
const settingsCatalog = getCatalogFromDefaults(orgSettings?.defaultsJson);
const reasonCatalog = settingsCatalog ?? fallbackCatalog;
const defaultMicroMultiplier = Number(orgSettings?.stoppageMultiplier ?? 1.5);
const defaultMacroMultiplier = Math.max(
defaultMicroMultiplier,
Number(orgSettings?.macroStoppageMultiplier ?? 5)
);
// ✅ normalize to array no matter what
const events = Array.isArray(rawEvent) ? rawEvent : [rawEvent];
if (events.length > MAX_EVENTS) {
return NextResponse.json({ ok: false, error: "Too many events" }, { status: 400 });
}
const created: { id: string; ts: Date; eventType: string }[] = [];
const skipped: Array<Record<string, unknown>> = [];
for (const ev of events) {
const evRecord = asRecord(ev);
if (!evRecord) {
skipped.push({ reason: "invalid_event_object" });
continue;
}
const evData = asRecord(evRecord.data) ?? {};
// We'll re-check reason again after parsing `data` (it may be a JSON string)
let evReason = asRecord(evRecord.reason) ?? asRecord(evData.reason);
const evDowntime = asRecord(evRecord.downtime) ?? asRecord(evData.downtime);
// Some producers nest the reason under `downtime.reason`
if (!evReason) evReason = asRecord(evDowntime?.reason);
const rawType = evRecord.eventType ?? evRecord.anomaly_type ?? evRecord.topic ?? bodyRecord.topic ?? "";
const typ0 = normalizeType(rawType);
const typ = CANON_TYPE[typ0] ?? typ0;
// Determine timestamp
const tsMs =
(typeof evRecord.timestamp === "number" && evRecord.timestamp) ||
(typeof evData.timestamp === "number" && evData.timestamp) ||
(typeof evData.event_timestamp === "number" && evData.event_timestamp) ||
null;
const ts = tsMs ? new Date(tsMs) : new Date();
// Severity defaulting (do not skip on severity — store for audit)
let sev = String(evRecord.severity ?? "").trim().toLowerCase();
if (!sev) sev = "warning";
// Stop classification -> microstop/macrostop
let finalType = typ;
let stopSecForReason: number | null = null;
if (typ === "stop") {
const stopSec =
(typeof evData.stoppage_duration_seconds === "number" && evData.stoppage_duration_seconds) ||
(typeof evData.stop_duration_seconds === "number" && evData.stop_duration_seconds) ||
null;
stopSecForReason = stopSec != null ? Number(stopSec) : null;
if (stopSec != null) {
const theoretical = Number(evData.theoretical_cycle_time ?? evData.theoreticalCycleTime ?? 0) || 0;
const microMultiplier = Number(
evData.micro_threshold_multiplier ?? evData.threshold_multiplier ?? defaultMicroMultiplier
);
const macroMultiplier = Math.max(
microMultiplier,
Number(evData.macro_threshold_multiplier ?? defaultMacroMultiplier)
);
if (theoretical > 0) {
const macroThresholdSec = theoretical * macroMultiplier;
finalType = stopSec >= macroThresholdSec ? "macrostop" : "microstop";
} else {
finalType = stopSec >= DEFAULT_MACROSTOP_SEC ? "macrostop" : "microstop";
}
} else {
// missing duration -> conservative
finalType = "microstop";
}
}
if (!ALLOWED_TYPES.has(finalType)) {
skipped.push({ reason: "type_not_allowed", typ: finalType, sev });
continue;
}
const title =
clampText(evRecord.title, 160) ||
(finalType === "slow-cycle" ? "Slow Cycle Detected" :
finalType === "macrostop" ? "Macrostop Detected" :
finalType === "microstop" ? "Microstop Detected" :
"Event");
const description = clampText(evRecord.description, 1000);
// store full blob, ensure object
const rawData = evRecord.data ?? evRecord;
const parsedData = typeof rawData === "string"
? (() => {
try {
return JSON.parse(rawData);
} catch {
return { raw: rawData };
}
})()
: rawData;
const dataObj: Record<string, unknown> =
parsedData && typeof parsedData === "object" && !Array.isArray(parsedData)
? { ...(parsedData as Record<string, unknown>) }
: { raw: parsedData };
if (evRecord.status != null && dataObj.status == null) dataObj.status = evRecord.status;
if (evRecord.alert_id != null && dataObj.alert_id == null) dataObj.alert_id = evRecord.alert_id;
if (evRecord.is_update != null && dataObj.is_update == null) dataObj.is_update = evRecord.is_update;
if (evRecord.is_auto_ack != null && dataObj.is_auto_ack == null) dataObj.is_auto_ack = evRecord.is_auto_ack;
if (evReason && dataObj.reason == null) dataObj.reason = evReason;
if (evDowntime && dataObj.downtime == null) dataObj.downtime = evDowntime;
// If `data` was a JSON string, the earlier evReason lookup would miss it.
// Re-check here using the normalized object we will persist.
if (!evReason) evReason = asRecord(dataObj.reason);
if (!evReason) evReason = asRecord(asRecord(dataObj.downtime)?.reason);
// If we have a reasonText but missing ids, derive ids from the path-like string.
if (evReason) {
const hasCat = clampText((evReason as any).categoryId, 64) ?? clampText((evReason as any).categoryLabel, 120);
const hasDet = clampText((evReason as any).detailId, 64) ?? clampText((evReason as any).detailLabel, 120);
const rt = clampText((evReason as any).reasonText, 240);
if ((!hasCat || !hasDet) && rt) {
const parsed = parseReasonTextPath(rt);
const next = { ...evReason } as Record<string, unknown>;
// Preserve any explicit ids; only fill gaps.
if ((next as any).categoryId == null && parsed.category) next.categoryId = canonicalText(parsed.category);
if ((next as any).categoryLabel == null && parsed.category) next.categoryLabel = parsed.category;
if ((next as any).detailId == null && parsed.detail) next.detailId = canonicalText(parsed.detail);
if ((next as any).detailLabel == null && parsed.detail) next.detailLabel = parsed.detail;
evReason = next;
}
}
const activeWorkOrder = asRecord(evRecord.activeWorkOrder);
const dataActiveWorkOrder = asRecord(evData.activeWorkOrder);
// ✨ Cada evento puede traer su propio seq, o usar el del payload raíz
const evSeq =
parseSeqToBigInt(evRecord.seq) ??
parseSeqToBigInt(evData.seq) ??
bodySeq;
const evSchemaVersion =
clampText(evRecord.schemaVersion, 16) ??
bodySchemaVersion;
const eventData = {
orgId: machine.orgId,
machineId: machine.id,
schemaVersion: evSchemaVersion,
seq: evSeq,
ts,
topic: clampText(evRecord.topic ?? finalType, 64) ?? finalType,
eventType: finalType,
severity: sev,
requiresAck: !!evRecord.requires_ack,
title,
description,
data: toJsonValue(dataObj),
workOrderId:
clampText(evRecord.work_order_id, 64) ??
clampText(evData.work_order_id, 64) ??
clampText(activeWorkOrder?.id, 64) ??
clampText(dataActiveWorkOrder?.id, 64) ??
null,
sku:
clampText(evRecord.sku, 64) ??
clampText(evData.sku, 64) ??
clampText(activeWorkOrder?.sku, 64) ??
clampText(dataActiveWorkOrder?.sku, 64) ??
null,
};
// ✨ Idempotente: si ya existe (mismo orgId+machineId+seq), no inserta
const insertResult = await prisma.machineEvent.createMany({
data: [eventData],
skipDuplicates: true,
});
// ✨ Buscar la fila (la recién creada o la duplicada existente)
let row;
if (evSeq != null) {
row = await prisma.machineEvent.findFirst({
where: {
orgId: machine.orgId,
machineId: machine.id,
seq: evSeq,
},
orderBy: { ts_server: "asc" },
});
} else {
// Sin seq, buscar por ts (fallback compatibilidad con eventos viejos)
row = await prisma.machineEvent.findFirst({
where: {
orgId: machine.orgId,
machineId: machine.id,
ts,
eventType: finalType,
},
orderBy: { ts_server: "desc" },
});
}
if (!row) {
skipped.push({ reason: "row_not_found_after_insert", seq: evSeq?.toString() });
continue;
}
const wasDuplicate = insertResult.count === 0;
// Si fue duplicado, no procesar reasonEntry ni alertas (ya se hicieron antes)
if (wasDuplicate) {
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
continue; // ✨ saltar el resto del procesamiento
}
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
// If the payload carries a `reason`, create the corresponding ReasonEntry.
// If it doesn't, still create an "UNCLASSIFIED" downtime ReasonEntry for stop events so the dashboard can show coverage.
if (evRecord.is_update || evRecord.is_auto_ack || dataObj.is_update || dataObj.is_auto_ack){
// skip duplicate reasonEntry for refresh/ack
} else if (evReason || finalType === "microstop" || finalType === "macrostop" || finalType === "downtime-acknowledged" || finalType === "mold-change"){
const moldIncidentKey =
clampText(evData.incidentKey ?? dataObj.incidentKey, 128) ??
(numberFrom(evData.start_ms ?? dataObj.start_ms) != null
? `mold-change:${Math.trunc(numberFrom(evData.start_ms ?? dataObj.start_ms) as number)}`
: null);
const reasonRaw: Record<string, unknown> =
evReason ??
(finalType === "mold-change"
? ({
type: "downtime",
categoryId: "cambio-molde",
detailId: "cambio-molde",
categoryLabel: "Cambio molde",
detailLabel: "Cambio molde",
reasonCode: "MOLD_CHANGE",
reasonText: "Cambio molde",
incidentKey: moldIncidentKey ?? row.id,
} as Record<string, unknown>)
:
({
type: "downtime",
categoryId: "unclassified",
detailId: "unclassified",
categoryLabel: "Unclassified",
detailLabel: "Unclassified",
reasonCode: "UNCLASSIFIED",
reasonText: "Unclassified",
incidentKey: row.id,
} as Record<string, unknown>));
const inferredKind: ReasonCatalogKind =
String(reasonRaw.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry"
? "scrap"
: "downtime";
const resolved = resolveReason(reasonRaw, inferredKind, reasonCatalog, reasonCatalog.version);
if (resolved.reasonCode) {
const reasonId =
clampText(reasonRaw.reasonId, 128) ??
(inferredKind === "downtime"
? `evt:${machine.id}:downtime:${clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id}`
: `evt:${machine.id}:scrap:${clampText(reasonRaw.scrapEntryId, 128) ?? row.id}`);
const workOrderId =
clampText(evRecord.work_order_id, 64) ??
clampText(evData.work_order_id, 64) ??
clampText(evRecord.workOrderId, 64) ??
null;
const commonWrite = {
reasonCode: resolved.reasonCode,
reasonLabel: resolved.reasonLabel ?? resolved.reasonCode,
reasonText: resolved.reasonText ?? null,
capturedAt: row.ts,
workOrderId,
schemaVersion: Math.max(1, Math.trunc(resolved.catalogVersion)),
meta: toJsonValue({
source: "ingest:event",
eventId: row.id,
eventType: row.eventType,
incidentKey: clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128),
anomalyType:
clampText(evRecord.anomalyType, 64) ??
clampText(evDowntime?.anomalyType, 64) ??
clampText(evRecord.anomaly_type, 64),
reason: {
type: resolved.type,
categoryId: resolved.categoryId,
categoryLabel: resolved.categoryLabel,
detailId: resolved.detailId,
detailLabel: resolved.detailLabel,
reasonText: resolved.reasonText,
catalogVersion: resolved.catalogVersion,
},
}),
};
if (inferredKind === "downtime") {
const incidentKey = clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id;
const durationSeconds =
numberFrom(evDowntime?.durationSeconds) ??
numberFrom(evData.duration_sec) ??
numberFrom(evData.stoppage_duration_seconds) ??
numberFrom(evData.stop_duration_seconds) ??
(stopSecForReason != null ? stopSecForReason : null) ??
null;
const episodeEndTsMs =
numberFrom(evData.end_ms) ??
numberFrom(evDowntime?.episodeEndTsMs) ??
numberFrom(evDowntime?.acknowledgedAtMs) ??
null;
await prisma.reasonEntry.upsert({
where: { reasonId },
create: {
orgId: machine.orgId,
machineId: machine.id,
reasonId,
kind: "downtime",
episodeId: incidentKey,
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
...commonWrite,
},
update: {
kind: "downtime",
episodeId: incidentKey,
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
...commonWrite,
},
});
} else {
const scrapEntryId =
clampText((reasonRaw as any).scrapEntryId, 128) ??
clampText(evRecord.id, 128) ??
clampText(evRecord.eventId, 128) ??
row.id;
const scrapQtyRaw =
numberFrom(evRecord.scrapDelta) ??
numberFrom(evData.scrapDelta) ??
numberFrom(evData.scrap_delta) ??
0;
const scrapQty = Math.max(0, Math.trunc(scrapQtyRaw));
await prisma.reasonEntry.upsert({
where: { reasonId },
create: {
orgId: machine.orgId,
machineId: machine.id,
reasonId,
kind: "scrap",
scrapEntryId,
scrapQty,
scrapUnit: clampText((reasonRaw as any).scrapUnit, 16) ?? null,
...commonWrite,
},
update: {
kind: "scrap",
scrapEntryId,
scrapQty,
scrapUnit: clampText((reasonRaw as any).scrapUnit, 16) ?? null,
...commonWrite,
},
});
}
}
}
try {
if (row.eventType !== "downtime-acknowledged" && row.eventType !== "scrap-manual-entry") {
await evaluateAlertsForEvent(row.id);
}
} catch (err) {
console.error("[alerts] evaluation failed", err);
}
}
return NextResponse.json({ ok: true, createdCount: created.length, created, skippedCount: skipped.length, skipped });
}

View File

@@ -97,26 +97,38 @@ export async function POST(req: Request) {
// 5) Store heartbeat
// Keep your legacy fields, but store meta fields too.
const tsServerNow = new Date();
const hb = await prisma.machineHeartbeat.create({
data: {
const hbRow = {
orgId,
machineId: machine.id,
// Phase 0 meta
schemaVersion,
seq,
ts: tsDeviceDate,
tsServer: tsServerNow,
// Legacy payload compatibility
status: body.status ? String(body.status) : (body.online ? "RUN" : "STOP"),
message: body.message ? String(body.message) : null,
ip: body.ip ? String(body.ip) : null,
fwVersion: body.fwVersion ? String(body.fwVersion) : null,
},
};
const insertHb = await prisma.machineHeartbeat.createMany({
data: [hbRow],
skipDuplicates: true,
});
// Optional: update machine last seen (same as KPI)
const hb = await prisma.machineHeartbeat.findFirst({
where: {
orgId,
machineId: machine.id,
ts: tsDeviceDate,
},
orderBy: { tsServer: "asc" },
});
if (!hb) {
return NextResponse.json({ ok: false, error: "Server error", detail: "Heartbeat row missing" }, { status: 500 });
}
// Optional: update machine last seen (same as KPI) — also on duplicate HB so lastSeen is fresh
await prisma.machine.update({
where: { id: machine.id },
data: {
@@ -132,6 +144,7 @@ export async function POST(req: Request) {
id: hb.id,
tsDevice: hb.ts,
tsServer: hb.tsServer,
duplicate: insertHb.count === 0,
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Unknown error";

View File

@@ -27,6 +27,29 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>;
}
function toFiniteNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function toFiniteInt(value: unknown): number | null {
const parsed = toFiniteNumber(value);
if (parsed == null) return null;
return Math.trunc(parsed);
}
function pickFirstNumber(...values: unknown[]) {
for (const value of values) {
const parsed = toFiniteNumber(value);
if (parsed != null) return parsed;
}
return null;
}
function readPath(root: unknown, path: string[]): unknown {
let current = root;
for (const key of path) {
@@ -160,28 +183,37 @@ export async function POST(req: Request) {
orgId = machine.orgId;
const woRecord = (body.activeWorkOrder ?? {}) as Record<string, unknown>;
const good =
typeof woRecord.good === "number"
? woRecord.good
: typeof woRecord.goodParts === "number"
? woRecord.goodParts
: typeof woRecord.good_parts === "number"
? woRecord.good_parts
: null;
const scrap =
typeof woRecord.scrap === "number"
? woRecord.scrap
: typeof woRecord.scrapParts === "number"
? woRecord.scrapParts
: typeof woRecord.scrap_parts === "number"
? woRecord.scrap_parts
: null;
const activeWorkOrderId = woRecord.id != null ? String(woRecord.id).trim() : "";
const activeSku = woRecord.sku != null ? String(woRecord.sku).trim() : "";
const activeStatus = woRecord.status != null ? String(woRecord.status).trim() : "";
const activeTargetQty = toFiniteInt(woRecord.target);
const activeCycleTime = toFiniteNumber(woRecord.cycleTime);
const good = pickFirstNumber(woRecord.good, woRecord.goodParts, woRecord.good_parts);
const scrap = pickFirstNumber(woRecord.scrap, woRecord.scrapParts, woRecord.scrap_parts);
const activeGoodParts = Math.max(0, Math.trunc(good ?? 0));
const activeScrapParts = Math.max(0, Math.trunc(scrap ?? 0));
const activeCycleCount = Math.max(
0,
toFiniteInt(woRecord.cycleCount ?? woRecord.cycle_count ?? body.cycle_count) ?? 0
);
const snapshotCycleCount =
toFiniteInt(body.cycle_count) ??
toFiniteInt(woRecord.cycle_count) ??
toFiniteInt(woRecord.cycleCount);
const snapshotGoodParts =
toFiniteInt(body.good_parts) ??
toFiniteInt(woRecord.good_parts) ??
toFiniteInt(woRecord.goodParts);
const snapshotScrapParts =
toFiniteInt(body.scrap_parts) ??
toFiniteInt(woRecord.scrap_parts) ??
toFiniteInt(woRecord.scrapParts);
const k = body.kpis ?? {};
const safeCycleTime =
typeof body.cycleTime === "number" && body.cycleTime > 0
? body.cycleTime
: typeof woRecord.cycleTime === "number" && woRecord.cycleTime > 0
? woRecord.cycleTime
: activeCycleTime != null && activeCycleTime > 0
? activeCycleTime
: null;
const safeCavities =
@@ -190,44 +222,74 @@ export async function POST(req: Request) {
: typeof woRecord.cavities === "number" && woRecord.cavities > 0
? woRecord.cavities
: null;
// Write snapshot (ts = tsDevice; tsServer auto)
const row = await prisma.machineKpiSnapshot.create({
data: {
// Write snapshot (ts = tsDevice; tsServer auto). Idempotent on (org, machine, ts) to absorb retries.
const kpiData = {
orgId,
machineId: machine.id,
// Phase 0 meta
schemaVersion,
seq,
ts: tsDeviceDate, // store device-time in ts; server-time goes to ts_server
// Work order fields
workOrderId: woRecord.id != null ? String(woRecord.id) : null,
sku: woRecord.sku != null ? String(woRecord.sku) : null,
target: typeof woRecord.target === "number" ? Math.trunc(woRecord.target) : null,
ts: tsDeviceDate,
workOrderId: activeWorkOrderId || null,
sku: activeSku || null,
target: activeTargetQty,
good: good != null ? Math.trunc(good) : null,
scrap: scrap != null ? Math.trunc(scrap) : null,
// Counters
cycleCount: typeof body.cycle_count === "number" ? body.cycle_count : null,
goodParts: typeof body.good_parts === "number" ? body.good_parts : null,
scrapParts: typeof body.scrap_parts === "number" ? body.scrap_parts : null,
cycleCount: snapshotCycleCount,
goodParts: snapshotGoodParts,
scrapParts: snapshotScrapParts,
cavities: safeCavities,
// Cycle times
cycleTime: safeCycleTime,
actualCycle: typeof body.actualCycleTime === "number" ? body.actualCycleTime : null,
// KPIs (0..100)
availability: typeof k.availability === "number" ? k.availability : null,
performance: typeof k.performance === "number" ? k.performance : null,
quality: typeof k.quality === "number" ? k.quality : null,
oee: typeof k.oee === "number" ? k.oee : null,
trackingEnabled: typeof body.trackingEnabled === "boolean" ? body.trackingEnabled : null,
productionStarted: typeof body.productionStarted === "boolean" ? body.productionStarted : null,
};
const insertKpi = await prisma.machineKpiSnapshot.createMany({
data: [kpiData],
skipDuplicates: true,
});
const row = await prisma.machineKpiSnapshot.findFirst({
where: { orgId, machineId: machine.id, ts: tsDeviceDate },
orderBy: { tsServer: "asc" },
});
if (!row) {
return NextResponse.json({ ok: false, error: "Server error", detail: "KPI snapshot row missing" }, { status: 500 });
}
if (activeWorkOrderId) {
await prisma.machineWorkOrder.upsert({
where: {
machineId_workOrderId: {
machineId: machine.id,
workOrderId: activeWorkOrderId,
},
},
create: {
orgId: machine.orgId,
machineId: machine.id,
workOrderId: activeWorkOrderId,
sku: activeSku || null,
targetQty: activeTargetQty,
cycleTime: activeCycleTime,
status: activeStatus || "RUNNING",
goodParts: activeGoodParts,
scrapParts: activeScrapParts,
cycleCount: activeCycleCount,
},
update: {
sku: activeSku || undefined,
targetQty: activeTargetQty ?? undefined,
cycleTime: activeCycleTime ?? undefined,
status: activeStatus || undefined,
goodParts: activeGoodParts,
scrapParts: activeScrapParts,
cycleCount: activeCycleCount,
},
});
}
// Optional but useful: update machine "last seen" meta fields
await prisma.machine.update({
@@ -266,6 +328,7 @@ export async function POST(req: Request) {
id: row.id,
tsDevice: row.ts,
tsServer: row.tsServer,
duplicate: insertKpi.count === 0,
trace: traceEnabled ? trace : undefined,
});
} catch (err: unknown) {

View File

@@ -0,0 +1,37 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/requireSession";
import { getRecapMachineDetailCached, parseRecapDetailRangeInput } from "@/lib/recap/redesign";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ machineId: string }> }
) {
const session = await requireSession();
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const { machineId } = await params;
if (!machineId) {
return NextResponse.json({ ok: false, error: "machineId is required" }, { status: 400 });
}
const url = new URL(req.url);
const input = parseRecapDetailRangeInput(url.searchParams);
const detail = await getRecapMachineDetailCached({
orgId: session.orgId,
machineId,
input,
});
if (!detail) {
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
}
return NextResponse.json(detail, {
headers: {
"Cache-Control": "private, max-age=60, stale-while-revalidate=60",
},
});
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { requireSession } from "@/lib/auth/requireSession";
import { prisma } from "@/lib/prisma";
import { getRecapTimelineForMachine, parseRecapTimelineRange } from "@/lib/recap/timelineApi";
function bad(status: number, error: string) {
return NextResponse.json({ ok: false, error }, { status });
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ machineId: string }> }
) {
const session = await requireSession();
if (!session) return bad(401, "Unauthorized");
const { machineId } = await params;
if (!machineId) return bad(400, "machineId is required");
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: { id: true },
});
if (!machine) return bad(404, "Machine not found");
const url = new URL(req.url);
const { start, end, maxSegments } = parseRecapTimelineRange(url.searchParams);
const response = await getRecapTimelineForMachine({
orgId: session.orgId,
machineId,
start,
end,
maxSegments,
});
return NextResponse.json(response, {
headers: {
"Cache-Control": "private, max-age=60, stale-while-revalidate=60",
},
});
}

29
app/api/recap/route.ts Normal file
View File

@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { requireSession } from "@/lib/auth/requireSession";
import { getRecapDataCached, parseRecapQuery } from "@/lib/recap/getRecapData";
export async function GET(req: NextRequest) {
const session = await requireSession();
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const url = new URL(req.url);
const query = parseRecapQuery({
machineId: url.searchParams.get("machineId"),
start: url.searchParams.get("start"),
end: url.searchParams.get("end"),
shift: url.searchParams.get("shift"),
});
const recap = await getRecapDataCached({
orgId: session.orgId,
machineId: query.machineId,
start: query.start ?? undefined,
end: query.end ?? undefined,
shift: query.shift ?? undefined,
});
return NextResponse.json(recap);
}

View File

@@ -0,0 +1,21 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/requireSession";
import { getRecapSummaryCached, parseRecapSummaryHours } from "@/lib/recap/redesign";
export async function GET(req: NextRequest) {
const session = await requireSession();
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const url = new URL(req.url);
const hours = parseRecapSummaryHours(url.searchParams.get("hours"));
const summary = await getRecapSummaryCached({ orgId: session.orgId, hours });
return NextResponse.json(summary, {
headers: {
"Cache-Control": "private, max-age=60, stale-while-revalidate=60",
},
});
}

View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { requireSession } from "@/lib/auth/requireSession";
import { prisma } from "@/lib/prisma";
import { getRecapTimelineForMachine, parseRecapTimelineRange } from "@/lib/recap/timelineApi";
function bad(status: number, error: string) {
return NextResponse.json({ ok: false, error }, { status });
}
export async function GET(req: NextRequest) {
const session = await requireSession();
if (!session) return bad(401, "Unauthorized");
const url = new URL(req.url);
const machineId = url.searchParams.get("machineId");
if (!machineId) return bad(400, "machineId is required");
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: { id: true },
});
if (!machine) return bad(404, "Machine not found");
const { start, end, maxSegments } = parseRecapTimelineRange(url.searchParams);
const response = await getRecapTimelineForMachine({
orgId: session.orgId,
machineId,
start,
end,
maxSegments,
});
return NextResponse.json(response, {
headers: {
"Cache-Control": "private, max-age=60, stale-while-revalidate=60",
},
});
}

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { createHash } from "crypto";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { logLine } from "@/lib/logger";
@@ -42,6 +43,10 @@ function pickRange(req: NextRequest) {
return { start: new Date(now.getTime() - ms), end: now };
}
function toMs(value?: Date | null) {
return value ? value.getTime() : 0;
}
export async function GET(req: NextRequest) {
const perfEnabled = PERF_LOGS_ENABLED;
const totalStart = nowMs();
@@ -67,6 +72,32 @@ export async function GET(req: NextRequest) {
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
const versionStart = nowMs();
const cycleMax = await prisma.machineCycle.aggregate({
where: baseWhere,
_max: { tsServer: true },
});
if (perfEnabled) timings.version = elapsedMs(versionStart);
const versionParts = [
session.orgId,
range,
machineId ?? "",
toMs(cycleMax._max.tsServer),
];
const etag = `W/"${createHash("sha1").update(versionParts.join("|")).digest("hex")}"`;
const responseHeaders = new Headers({
"Cache-Control": "private, no-cache, max-age=0, must-revalidate",
ETag: etag,
"Last-Modified": new Date(toMs(cycleMax._max.tsServer) || 0).toUTCString(),
Vary: "Cookie",
});
const ifNoneMatch = req.headers.get("if-none-match");
if (ifNoneMatch && ifNoneMatch === etag) {
return new NextResponse(null, { status: 304, headers: responseHeaders });
}
const workOrdersStart = nowMs();
const workOrderRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, workOrderId: { not: null } },
@@ -90,7 +121,6 @@ export async function GET(req: NextRequest) {
const payload = { ok: true, workOrders, skus };
const responseHeaders = new Headers();
if (perfEnabled) {
timings.postQuery = elapsedMs(postQueryStart);
timings.total = elapsedMs(totalStart);

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { createHash } from "crypto";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { logLine } from "@/lib/logger";
@@ -46,6 +47,14 @@ function safeNum(v: unknown) {
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
function isProductionSnapshot(trackingEnabled: unknown, productionStarted: unknown) {
return trackingEnabled === true && productionStarted === true;
}
function toMs(value?: Date | null) {
return value ? value.getTime() : 0;
}
export async function GET(req: NextRequest) {
const perfEnabled = PERF_LOGS_ENABLED;
const totalStart = nowMs();
@@ -73,6 +82,52 @@ export async function GET(req: NextRequest) {
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
const versionStart = nowMs();
const [kpiMax, cycleMax, eventMax] = await Promise.all([
prisma.machineKpiSnapshot.aggregate({
where: { ...baseWhere, ts: { gte: start, lte: end } },
_max: { tsServer: true },
}),
prisma.machineCycle.aggregate({
where: { ...baseWhere, ts: { gte: start, lte: end } },
_max: { tsServer: true },
}),
prisma.machineEvent.aggregate({
where: { ...baseWhere, ts: { gte: start, lte: end } },
_max: { tsServer: true },
}),
]);
if (perfEnabled) timings.version = elapsedMs(versionStart);
const lastModifiedMs = Math.max(
toMs(kpiMax._max.tsServer),
toMs(cycleMax._max.tsServer),
toMs(eventMax._max.tsServer)
);
const versionParts = [
session.orgId,
range,
machineId ?? "",
workOrderId ?? "",
sku ?? "",
toMs(kpiMax._max.tsServer),
toMs(cycleMax._max.tsServer),
toMs(eventMax._max.tsServer),
];
const etag = `W/"${createHash("sha1").update(versionParts.join("|")).digest("hex")}"`;
const responseHeaders = new Headers({
"Cache-Control": "private, no-cache, max-age=0, must-revalidate",
ETag: etag,
"Last-Modified": new Date(lastModifiedMs || 0).toUTCString(),
Vary: "Cookie",
});
const ifNoneMatch = req.headers.get("if-none-match");
if (ifNoneMatch && ifNoneMatch === etag) {
return new NextResponse(null, { status: 304, headers: responseHeaders });
}
const kpiStart = nowMs();
const kpiRows = await prisma.machineKpiSnapshot.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } },
@@ -86,6 +141,8 @@ export async function GET(req: NextRequest) {
good: true,
scrap: true,
target: true,
trackingEnabled: true,
productionStarted: true,
machineId: true,
},
});
@@ -100,7 +157,9 @@ export async function GET(req: NextRequest) {
let qualSum = 0;
let qualCount = 0;
// OEE-family summaries are production-only to avoid mixing downtime/off windows.
for (const k of kpiRows) {
if (!isProductionSnapshot(k.trackingEnabled, k.productionStarted)) continue;
if (safeNum(k.oee) != null) {
oeeSum += Number(k.oee);
oeeCount += 1;
@@ -223,7 +282,7 @@ export async function GET(req: NextRequest) {
else if (type === "oee-drop") oeeDropCount += 1;
}
type TrendPoint = { t: string; v: number };
type TrendPoint = { t: string; v: number | null };
const trend: {
oee: TrendPoint[];
@@ -239,17 +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 (safeNum(k.oee) != null) trend.oee.push({ t, v: Number(k.oee) });
if (safeNum(k.availability) != null) trend.availability.push({ t, v: Number(k.availability) });
if (safeNum(k.performance) != null) trend.performance.push({ t, v: Number(k.performance) });
if (safeNum(k.quality) != null) trend.quality.push({ t, v: Number(k.quality) });
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 && scrap != null && good + scrap > 0) {
trend.scrapRate.push({ t, v: (scrap / (good + scrap)) * 100 });
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: 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 total = b.goodSum + b.scrapSum;
if (total > 0) {
trend.scrapRate.push({ t, v: (b.scrapSum / total) * 100 });
}
}
const cycleRowsStart = nowMs();
@@ -405,7 +515,6 @@ export async function GET(req: NextRequest) {
},
};
const responseHeaders = new Headers();
if (perfEnabled) {
timings.postQuery = elapsedMs(postQueryStart);
timings.total = elapsedMs(totalStart);

View File

@@ -43,6 +43,9 @@ export async function GET(
sku: row.sku,
targetQty: row.targetQty,
cycleTime: row.cycleTime,
mold: row.mold,
cavitiesTotal: row.cavitiesTotal,
cavitiesActive: row.cavitiesActive,
status: row.status,
})),
});

View File

@@ -12,8 +12,10 @@ function canManage(role?: string | null) {
const MAX_WORK_ORDERS = 2000;
const MAX_WORK_ORDER_ID_LENGTH = 64;
const MAX_SKU_LENGTH = 64;
const MAX_MOLD_LENGTH = 256;
const MAX_TARGET_QTY = 2_000_000_000;
const MAX_CYCLE_TIME = 86_400;
const MAX_CAVITIES = 100_000;
const WORK_ORDER_ID_RE = /^[A-Za-z0-9._-]+$/;
const uploadBodySchema = z.object({
@@ -51,6 +53,15 @@ type WorkOrderInput = {
sku?: string | null;
targetQty?: number | null;
cycleTime?: number | null;
mold?: string | null;
cavitiesTotal?: number | null;
cavitiesActive?: number | null;
};
type RowIssue = {
row: number;
workOrderId: string | null;
errors: string[];
};
function normalizeWorkOrders(raw: unknown[]) {
@@ -78,17 +89,98 @@ function normalizeWorkOrders(raw: unknown[]) {
const cycleTime =
cycleTimeRaw == null ? null : Math.min(Math.max(cycleTimeRaw, 0), MAX_CYCLE_TIME);
const mold = cleanText(
record.mold ?? record.moldId ?? record.mold_id ?? null,
MAX_MOLD_LENGTH
);
const cavitiesTotalRaw = toIntOrNull(
record.cavitiesTotal ??
record.cavities_total ??
record.totalCavities ??
record.total_cavities
);
const cavitiesActiveRaw = toIntOrNull(
record.cavitiesActive ??
record.cavities_active ??
record.activeCavities ??
record.active_cavities
);
const cavitiesTotal =
cavitiesTotalRaw == null
? null
: Math.min(Math.max(cavitiesTotalRaw, 0), MAX_CAVITIES);
const cavitiesActive =
cavitiesActiveRaw == null
? null
: Math.min(Math.max(cavitiesActiveRaw, 0), MAX_CAVITIES);
cleaned.push({
workOrderId: idRaw,
sku: sku ?? null,
targetQty: targetQty ?? null,
cycleTime: cycleTime ?? null,
mold: mold ?? null,
cavitiesTotal: cavitiesTotal ?? null,
cavitiesActive: cavitiesActive ?? null,
});
}
return cleaned;
}
// ✨ NUEVO: validación estricta del Excel
// Cada fila debe tener mold (no vacío), cavitiesTotal (>=1), cavitiesActive (>=1, <=cavitiesTotal)
// Si UNA SOLA fila falla, se rechaza el archivo completo (Opción A)
function validateRows(rows: WorkOrderInput[], rawList: unknown[]): RowIssue[] {
const issues: RowIssue[] = [];
// Validar lista cruda primero (si hay duplicados o IDs inválidos no llegaron a `cleaned`)
// Pero aquí enfocamos en la validación de mold/cavidades sobre filas ya normalizadas.
rows.forEach((row, idx) => {
const errors: string[] = [];
// Mold requerido
if (!row.mold || row.mold.length === 0) {
errors.push("Mold is required");
}
// Cavities Total requerido y >= 1
if (row.cavitiesTotal == null) {
errors.push("Total Cavities is required");
} else if (row.cavitiesTotal < 1) {
errors.push("Total Cavities must be at least 1");
}
// Cavities Active requerido y >= 1
if (row.cavitiesActive == null) {
errors.push("Active Cavities is required");
} else if (row.cavitiesActive < 1) {
errors.push("Active Cavities must be at least 1");
}
// Active <= Total
if (
row.cavitiesActive != null &&
row.cavitiesTotal != null &&
row.cavitiesActive > row.cavitiesTotal
) {
errors.push(
`Active Cavities (${row.cavitiesActive}) cannot exceed Total Cavities (${row.cavitiesTotal})`
);
}
if (errors.length > 0) {
issues.push({
row: idx + 1, // 1-indexed para el operador
workOrderId: row.workOrderId,
errors,
});
}
});
return issues;
}
export async function POST(req: NextRequest) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
@@ -138,6 +230,21 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ ok: false, error: "No valid work orders provided" }, { status: 400 });
}
// ✨ NUEVO: validación estricta de mold/cavidades
// Si una sola fila falla, rechazamos el archivo completo
const issues = validateRows(cleaned, listRaw);
if (issues.length > 0) {
return NextResponse.json(
{
ok: false,
error: "Validation failed",
summary: `Excel rejected: ${issues.length} of ${cleaned.length} work order(s) have errors. All work orders must include mold name, total cavities, and active cavities. Fix and re-upload.`,
issues,
},
{ status: 400 }
);
}
const created = await prisma.machineWorkOrder.createMany({
data: cleaned.map((row) => ({
orgId: session.orgId,
@@ -146,6 +253,9 @@ export async function POST(req: NextRequest) {
sku: row.sku ?? null,
targetQty: row.targetQty ?? null,
cycleTime: row.cycleTime ?? null,
mold: row.mold ?? null,
cavitiesTotal: row.cavitiesTotal ?? null,
cavitiesActive: row.cavitiesActive ?? null,
status: "PENDING",
})),
skipDuplicates: true,

View File

@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/machines");
redirect("/recap");
}

View File

@@ -44,6 +44,14 @@ type ApiParetoRes = {
total?: number;
};
type LegacyParetoItem = {
reasonCode?: string;
reasonLabel?: string;
value?: number; // minutes (downtime) or qty (scrap)
count?: number;
cumPct?: number;
};
type ApiDowntimeEvent = {
id: string;
episodeId: string | null;
@@ -104,18 +112,139 @@ function fmtDT(iso: string | null) {
return d.toLocaleString("en-US", { hour12: true });
}
function normalizeParetoRes(input: ApiParetoRes): ApiParetoRes {
const rows = Array.isArray(input?.rows) ? input.rows : [];
if (rows.length > 0) return input;
// Support a legacy envelope where the server returns `items[]` instead of `rows[]`.
const legacyItems = (input as any)?.items as unknown;
if (!Array.isArray(legacyItems) || legacyItems.length === 0) return input;
const items = legacyItems as LegacyParetoItem[];
const safeItems = items
.map((it) => ({
reasonCode: String(it?.reasonCode ?? "").trim(),
reasonLabel: String(it?.reasonLabel ?? it?.reasonCode ?? "").trim(),
value: typeof it?.value === "number" && Number.isFinite(it.value) ? it.value : 0,
count: typeof it?.count === "number" && Number.isFinite(it.count) ? it.count : 0,
}))
.filter((x) => x.reasonCode);
// Legacy `items` are usually pre-sorted by value desc; enforce it anyway.
safeItems.sort((a, b) => b.value - a.value);
const total = safeItems.reduce((acc, x) => acc + x.value, 0);
let cum = 0;
let threshold80Index: number | null = null;
const outRows: ApiParetoRow[] = safeItems.map((x, idx) => {
const pctOfTotal = total > 0 ? (x.value / total) * 100 : 0;
cum += x.value;
const cumulativePct = total > 0 ? (cum / total) * 100 : 0;
if (threshold80Index === null && cumulativePct >= 80) threshold80Index = idx;
return {
reasonCode: x.reasonCode,
reasonLabel: x.reasonLabel || x.reasonCode,
minutesLost: input.kind === "scrap" ? undefined : x.value,
scrapQty: input.kind === "scrap" ? x.value : undefined,
pctOfTotal,
cumulativePct,
count: x.count,
};
});
const threshold80 =
threshold80Index === null
? null
: {
index: threshold80Index,
reasonCode: outRows[threshold80Index].reasonCode,
reasonLabel: outRows[threshold80Index].reasonLabel,
};
return {
...input,
rows: outRows,
top3: outRows.slice(0, 3),
threshold80,
totalMinutesLost: input.kind === "scrap" ? undefined : total,
totalScrap: input.kind === "scrap" ? total : undefined,
total,
};
}
function buildParetoFromEvents(events: ApiDowntimeEvent[]): ApiParetoRes | null {
if (!Array.isArray(events) || events.length === 0) return null;
const byCode = new Map<
string,
{ reasonCode: string; reasonLabel: string; minutes: number; count: number }
>();
for (const e of events) {
const reasonCode = String(e?.reasonCode ?? "").trim();
if (!reasonCode) continue;
const reasonLabel = String(e?.reasonLabel ?? reasonCode).trim() || reasonCode;
const minutes =
(typeof e?.durationMinutes === "number" && Number.isFinite(e.durationMinutes)
? e.durationMinutes
: null) ??
(typeof e?.durationSeconds === "number" && Number.isFinite(e.durationSeconds)
? e.durationSeconds / 60
: 0);
const slot =
byCode.get(reasonCode) ?? { reasonCode, reasonLabel, minutes: 0, count: 0 };
slot.minutes += Math.max(0, minutes);
slot.count += 1;
// prefer the most recent non-empty label if they differ
if (reasonLabel && reasonLabel !== reasonCode) slot.reasonLabel = reasonLabel;
byCode.set(reasonCode, slot);
}
const items = [...byCode.values()].filter((x) => x.minutes > 0 || x.count > 0);
items.sort((a, b) => b.minutes - a.minutes);
const totalMinutesLost = items.reduce((acc, x) => acc + x.minutes, 0);
let cum = 0;
let threshold80Index: number | null = null;
const rows: ApiParetoRow[] = items.map((x, idx) => {
const pctOfTotal = totalMinutesLost > 0 ? (x.minutes / totalMinutesLost) * 100 : 0;
cum += x.minutes;
const cumulativePct = totalMinutesLost > 0 ? (cum / totalMinutesLost) * 100 : 0;
if (threshold80Index === null && cumulativePct >= 80) threshold80Index = idx;
return {
reasonCode: x.reasonCode,
reasonLabel: x.reasonLabel,
minutesLost: Math.round(x.minutes * 10) / 10,
pctOfTotal,
cumulativePct,
count: x.count,
};
});
const threshold80 =
threshold80Index === null
? null
: {
index: threshold80Index,
reasonCode: rows[threshold80Index].reasonCode,
reasonLabel: rows[threshold80Index].reasonLabel,
};
return {
ok: true,
kind: "downtime",
totalMinutesLost: Math.round(totalMinutesLost * 10) / 10,
rows,
top3: rows.slice(0, 3),
threshold80,
total: totalMinutesLost,
};
}
type ApiCoverageRes = {
ok: boolean;
error?: string;
orgId?: string;
machineId?: string | null;
range?: "24h" | "7d" | "30d";
start?: string;
receivedEpisodes?: number;
receivedMinutes?: number;
note?: string;
};
type Range = "24h" | "7d" | "30d";
type Metric = "minutes" | "count";
@@ -1156,6 +1285,9 @@ export default function DowntimePageClient() {
// client-only filters (shareable)
const metric = ((sp.get("metric") as Metric) || "minutes") as Metric;
const reasonCode = sp.get("reasonCode") || null;
const shift = (sp.get("shift") || "all").toUpperCase();
const planned = (sp.get("planned") as "all" | "planned" | "unplanned") || "all";
const microstopLtMin = sp.get("microstopLtMin") || "2";
const hmDay = sp.get("hmDay");
const hmHour = sp.get("hmHour");
@@ -1167,7 +1299,6 @@ export default function DowntimePageClient() {
const [pareto, setPareto] = useState<ApiParetoRes | null>(null);
const [coverage, setCoverage] = useState<ApiCoverageRes | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [eventsRes, setEventsRes] = useState<ApiDowntimeEventsRes | null>(null);
@@ -1178,6 +1309,7 @@ export default function DowntimePageClient() {
const [eventsLimit, setEventsLimit] = useState<number>(200);
const [eventsBefore, setEventsBefore] = useState<string | null>(null);
const debug = sp.get("debug") === "1";
// simple client filter (fast): text search on machine/reason/wo
const [eventSearch, setEventSearch] = useState("");
@@ -1222,41 +1354,28 @@ export default function DowntimePageClient() {
qs.set("kind", "downtime");
qs.set("range", range);
if (machineId) qs.set("machineId", machineId);
qs.set("shift", shift);
qs.set("planned", planned);
qs.set("microstopLtMin", microstopLtMin);
const [r1, r2] = await Promise.all([
fetch(`/api/analytics/pareto?${qs.toString()}`, {
const r1 = await fetch(`/api/analytics/pareto?${qs.toString()}`, {
cache: "no-cache",
credentials: "include",
signal: ac.signal,
}),
fetch(`/api/analytics/coverage?${qs.toString()}`, {
cache: "no-cache",
credentials: "include",
signal: ac.signal,
}),
]);
});
const j1 = (await r1.json().catch(() => ({}))) as ApiParetoRes;
const j2 = (await r2.json().catch(() => ({}))) as ApiCoverageRes;
const j1raw = (await r1.json().catch(() => ({}))) as ApiParetoRes;
if (!alive) return;
if (!r1.ok || j1.ok === false) {
setErr(j1?.error ?? "Failed to load pareto");
if (!r1.ok || j1raw.ok === false) {
setErr(j1raw?.error ?? "Failed to load pareto");
setPareto(null);
setCoverage(null);
setLoading(false);
return;
}
if (!r2.ok || j2.ok === false) {
// coverage is “nice to have” — dont kill the page
setCoverage(null);
} else {
setCoverage(j2);
}
setPareto(j1);
setPareto(normalizeParetoRes(j1raw));
setLoading(false);
} catch (e: any) {
if (!alive) return;
@@ -1270,7 +1389,7 @@ export default function DowntimePageClient() {
alive = false;
ac.abort();
};
}, [range, machineId]);
}, [range, machineId, shift, planned, microstopLtMin]);
useEffect(() => {
let alive = true;
@@ -1320,6 +1439,9 @@ export default function DowntimePageClient() {
qs.set("limit", String(eventsLimit));
if (machineId) qs.set("machineId", machineId);
if (reasonCode) qs.set("reasonCode", reasonCode);
qs.set("shift", shift);
qs.set("planned", planned);
qs.set("microstopLtMin", microstopLtMin);
if (eventsBefore) qs.set("before", eventsBefore);
const r = await fetch(`/api/analytics/downtime-events?${qs.toString()}`, {
@@ -1352,10 +1474,26 @@ export default function DowntimePageClient() {
alive = false;
ac.abort();
};
}, [range, machineId, reasonCode, eventsLimit, eventsBefore]);
}, [range, machineId, reasonCode, shift, planned, microstopLtMin, eventsLimit, eventsBefore]);
// Derived data
const baseRows = pareto?.rows ?? [];
const events = eventsRes?.events ?? [];
const paretoEffective = useMemo(() => {
const normalized = pareto ? normalizeParetoRes(pareto) : null;
if (normalized?.rows && normalized.rows.length > 0) return normalized;
const fromEvents = buildParetoFromEvents(events);
if (!fromEvents) return normalized;
return {
...fromEvents,
range: (eventsRes?.range as any) ?? normalized?.range,
start: eventsRes?.start ?? normalized?.start,
orgId: eventsRes?.orgId ?? normalized?.orgId,
machineId: eventsRes?.machineId ?? normalized?.machineId ?? null,
};
}, [pareto, events, eventsRes?.orgId, eventsRes?.machineId, eventsRes?.range, eventsRes?.start]);
const usingEventsFallback = (paretoEffective?.rows?.length ?? 0) > 0 && (pareto?.rows?.length ?? 0) === 0 && events.length > 0;
const baseRows = paretoEffective?.rows ?? [];
const metricRowsAll = useMemo(() => computeMetricRows(baseRows, metric), [baseRows, metric]);
const metricRowsFiltered = useMemo(() => {
@@ -1386,7 +1524,7 @@ export default function DowntimePageClient() {
}));
}, [catalogRows]);
const totalMinutes = pareto?.totalMinutesLost ?? 0;
const totalMinutes = paretoEffective?.totalMinutesLost ?? 0;
const totalStops = useMemo(
() => baseRows.reduce((acc, r) => acc + (r.count ?? 0), 0),
[baseRows]
@@ -1401,10 +1539,10 @@ export default function DowntimePageClient() {
const threshold80Index = useMemo(() => {
// If API threshold80 exists, its based on minutes. For count metric, compute locally.
if (metric === "minutes") return pareto?.threshold80?.index ?? null;
if (metric === "minutes") return paretoEffective?.threshold80?.index ?? null;
const idx = metricRowsAll.findIndex((r) => (r.cumulativePct ?? 0) >= 80);
return idx >= 0 ? idx : null;
}, [metric, pareto?.threshold80?.index, metricRowsAll]);
}, [metric, paretoEffective?.threshold80?.index, metricRowsAll]);
const heroData = useMemo(() => {
// Keep hero readable: top 12 (like your screenshot)
@@ -1420,12 +1558,11 @@ export default function DowntimePageClient() {
}));
}, [metricRowsAll]);
const totalDowntimeMin = pareto?.totalMinutesLost ?? 0;
const events = eventsRes?.events ?? [];
const totalDowntimeMin = paretoEffective?.totalMinutesLost ?? 0;
useEffect(() => {
setEventsBefore(null);
}, [range, machineId, reasonCode]);
}, [range, machineId, reasonCode, shift, planned, microstopLtMin]);
const filteredEvents = useMemo(() => {
let list = events;
@@ -1455,8 +1592,8 @@ const filteredEvents = useMemo(() => {
// Use distinct episodes as "stops" (best available now)
const stops = coverage?.receivedEpisodes ?? totalStops;
// Use filtered pareto totals so top filters always affect the KPI.
const stops = totalStops;
// Window minutes for MTBF/Availability
const windowMin =
@@ -1571,11 +1708,6 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
);
const shift = sp.get("shift") || "all";
const planned = (sp.get("planned") as "all" | "planned" | "unplanned") || "all";
const microstopLtMin = sp.get("microstopLtMin") || "2";
const filtersRow = (
<div className="mt-4 flex items-center justify-between gap-4">
{/* LEFT: range + metric + reset (never wrap) */}
@@ -1805,8 +1937,51 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
</div>
) : null}
{debug ? (
<div className="mt-6 rounded-2xl border border-white/10 bg-black/30 p-4 text-xs text-zinc-300">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="font-semibold text-white">Debug</div>
<div className="text-[11px] text-zinc-500">
Disable with <span className="text-zinc-300">debug=0</span>
</div>
</div>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
<div className="text-[11px] text-zinc-500">Status</div>
<div className="mt-1 text-zinc-200">
loading={String(loading)} · err={err ?? "null"} · eventsLoading={String(eventsLoading)} · eventsErr=
{eventsErr ?? "null"}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
<div className="text-[11px] text-zinc-500">Filters</div>
<div className="mt-1 text-zinc-200">
range={range} · machineId={machineId ?? "null"} · reasonCode={reasonCode ?? "null"}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
<div className="text-[11px] text-zinc-500">API payload sizes</div>
<div className="mt-1 text-zinc-200">
pareto.rows={(pareto?.rows?.length ?? 0)} · events={(eventsRes?.events?.length ?? 0)}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
<div className="text-[11px] text-zinc-500">Effective (used by UI)</div>
<div className="mt-1 text-zinc-200">
rows={(paretoEffective?.rows?.length ?? 0)} · usingEventsFallback={String(usingEventsFallback)}
</div>
</div>
</div>
</div>
) : null}
{!loading && !err && (
<>
{eventsErr ? (
<div className="mt-6 rounded-2xl border border-amber-500/20 bg-amber-500/10 p-4 text-sm text-amber-100">
Events list unavailable: {eventsErr}
</div>
) : null}
{/* KPI strip */}
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-8">
<KPI
@@ -1818,7 +1993,7 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
<KPI
label="Stops count"
value={fmtNum(stops, 0)}
sub="Distinct episodes (coverage)"
sub="Distinct episodes (filtered)"
accent="zinc"
/>
<KPI
@@ -2047,29 +2222,25 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
{/* Coverage mini */}
<div className="mt-4 rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="text-sm font-semibold text-white">Coverage received</div>
<div className="text-sm font-semibold text-white">Filtered downtime summary</div>
<div className="mt-1 text-xs text-zinc-400">
Sync health from Control Tower ingest
Reflects the active range/machine/shift/planned/microstop filters
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Episodes</div>
<div className="mt-1 text-base font-semibold text-white">
{coverage?.receivedEpisodes != null ? fmtNum(coverage.receivedEpisodes, 0) : "—"}
{fmtNum(stops, 0)}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Minutes</div>
<div className="mt-1 text-base font-semibold text-white">
{coverage?.receivedMinutes != null ? fmtNum(coverage.receivedMinutes, 1) : "—"}
{fmtNum(totalDowntimeMin, 1)}
</div>
</div>
</div>
{coverage?.note ? (
<div className="mt-3 text-[11px] text-zinc-500">{coverage.note}</div>
) : null}
</div>
</div>
</div>

View File

@@ -3,7 +3,18 @@
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { BarChart3, Bell, DollarSign, LayoutGrid, Loader2, LogOut, Settings, Wrench, X } from "lucide-react";
import {
BarChart3,
Bell,
DollarSign,
LayoutGrid,
Loader2,
LogOut,
Settings,
Sunrise,
Wrench,
X,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useI18n } from "@/lib/i18n/useI18n";
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
@@ -19,15 +30,15 @@ type NavItem = {
};
const items: NavItem[] = [
{ href: "/recap", labelKey: "nav.recap", icon: Sunrise },
{ href: "/overview", labelKey: "nav.overview", icon: LayoutGrid },
{ href: "/machines", labelKey: "nav.machines", icon: Wrench },
{ href: "/reports", labelKey: "nav.reports", icon: BarChart3 },
{ href: "/alerts", labelKey: "nav.alerts", icon: Bell },
{ href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true },
{ href: "/settings", labelKey: "nav.settings", icon: Settings },
{ href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 },
];
const settingsItem: NavItem = { href: "/settings", labelKey: "nav.settings", icon: Settings };
type SidebarProps = {
variant?: "desktop" | "drawer";
@@ -97,16 +108,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
}
}, [screenlessMode, pathname, router]);
useEffect(() => {
if (!pendingHref) return;
if (pathname === pendingHref || pathname.startsWith(`${pendingHref}/`)) {
setPendingHref(null);
} else if (!isPending) {
setPendingHref(null);
}
}, [pathname, pendingHref, isPending]);
const markNavStart = (href: string) => {
const markNavStart = (href: string, ts: number) => {
if (!PERF_ENABLED) return;
try {
sessionStorage.setItem(
@@ -114,7 +116,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
JSON.stringify({
href,
from: pathname,
ts: Date.now(),
ts,
})
);
} catch {
@@ -128,32 +130,12 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
"relative z-20 flex flex-col border-r border-white/10 bg-black/40 shrink-0",
variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]",
].join(" ");
const navLocked = isPending;
return (
<aside className={shellClass} aria-label={t("sidebar.productTitle")}>
<div className="px-5 py-4 flex items-center justify-between gap-3">
<div>
<div className="text-white font-semibold tracking-wide">{t("sidebar.productTitle")}</div>
<div className="text-xs text-zinc-500">{t("sidebar.productSubtitle")}</div>
</div>
{variant === "drawer" && onClose && (
<button
type="button"
onClick={onClose}
aria-label={t("common.close")}
className="rounded-lg border border-white/10 bg-white/5 p-2 text-zinc-300 hover:bg-white/10 hover:text-white md:hidden"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<nav className="px-3 py-2 flex-1 space-y-1">
{visibleItems.map((it) => {
const renderNavItem = (it: NavItem) => {
const isCurrent = pathname === it.href;
const active = isCurrent || pathname.startsWith(it.href + "/");
const isPendingItem = isPending && pendingHref === it.href;
const navLocked = isPending;
const Icon = it.icon;
return (
<Link
@@ -178,7 +160,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
return;
}
event.preventDefault();
markNavStart(it.href);
markNavStart(it.href, Math.round(performance.timeOrigin + event.timeStamp));
setPendingHref(it.href);
startTransition(() => {
router.push(it.href);
@@ -199,7 +181,30 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
{isPendingItem ? <Loader2 className="ml-auto h-4 w-4 animate-spin text-emerald-300" /> : null}
</Link>
);
})}
};
return (
<aside className={shellClass} aria-label={t("sidebar.productTitle")}>
<div className="px-5 py-4 flex items-center justify-between gap-3">
<div>
<div className="text-white font-semibold tracking-wide">{t("sidebar.productTitle")}</div>
<div className="text-xs text-zinc-500">{t("sidebar.productSubtitle")}</div>
</div>
{variant === "drawer" && onClose && (
<button
type="button"
onClick={onClose}
aria-label={t("common.close")}
className="rounded-lg border border-white/10 bg-white/5 p-2 text-zinc-300 hover:bg-white/10 hover:text-white md:hidden"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<nav className="px-3 py-2 flex-1 flex flex-col gap-2">
<div className="space-y-1">{visibleItems.map(renderNavItem)}</div>
<div className="mt-auto space-y-1 border-t border-white/10 pt-2">{renderNavItem(settingsItem)}</div>
</nav>
<div className="px-5 py-4 border-t border-white/10 space-y-3">

View File

@@ -0,0 +1,46 @@
"use client";
import { useI18n } from "@/lib/i18n/useI18n";
type Props = {
moldChangeStartMs: number | null;
offlineForMin: number | null;
ongoingStopMin: number | null;
};
function toInt(value: number | null | undefined) {
if (value == null || Number.isNaN(value)) return 0;
return Math.max(0, Math.round(value));
}
export default function RecapBanners({ moldChangeStartMs, offlineForMin, ongoingStopMin }: Props) {
const { t, locale } = useI18n();
const moldStartLabel = moldChangeStartMs
? new Date(moldChangeStartMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })
: "--:--";
const showOffline = offlineForMin != null && offlineForMin > 10;
const hideMoldBecauseOffline = showOffline && moldChangeStartMs != null;
return (
<div className="space-y-2">
{moldChangeStartMs && !hideMoldBecauseOffline ? (
<div className="rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-sm text-amber-200">
{t("recap.banner.moldChange", { time: moldStartLabel })}
</div>
) : null}
{showOffline ? (
<div className="rounded-xl border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{t("recap.banner.offline", { min: toInt(offlineForMin) })}
</div>
) : null}
{ongoingStopMin != null && ongoingStopMin > 0 ? (
<div className="rounded-xl border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{t("recap.banner.ongoingStop", { min: toInt(ongoingStopMin) })}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapDowntimeTopRow } from "@/lib/recap/types";
type Props = {
rows: RecapDowntimeTopRow[];
};
export default function RecapDowntimeTop({ rows }: Props) {
const { t } = useI18n();
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.downtime.top")}</div>
{rows.length === 0 ? (
<div className="text-sm text-zinc-400">{t("recap.empty.production")}</div>
) : (
<div className="space-y-3">
{rows.slice(0, 3).map((row) => (
<div key={row.reasonLabel} className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-sm font-medium text-white">{row.reasonLabel}</div>
<div className="mt-1 text-xs text-zinc-300">
{row.minutes.toFixed(1)} min · {row.percent.toFixed(1)}%
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import type { RecapRangeMode, RecapTimelineSegment } from "@/lib/recap/types";
import {
computeWidths,
formatDuration,
formatTime,
LABEL_MIN_WIDTH_PCT,
normalizeTimelineSegments,
SEGMENT_MIN_WIDTH_PCT,
TIMELINE_COLORS,
} from "@/components/recap/timelineRender";
import { useI18n } from "@/lib/i18n/useI18n";
type Props = {
rangeStart: string;
rangeEnd: string;
segments: RecapTimelineSegment[];
locale: string;
hasData?: boolean;
loading?: boolean;
rangeMode?: RecapRangeMode;
};
export default function RecapFullTimeline({
rangeStart,
rangeEnd,
segments,
locale,
hasData = false,
loading = false,
rangeMode,
}: Props) {
const { t } = useI18n();
const startMs = new Date(rangeStart).getTime();
const endMs = new Date(rangeEnd).getTime();
const totalMs = Math.max(1, endMs - startMs);
const normalized = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : [];
const widths = computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT);
const rangeSuffix =
rangeMode === "shift"
? t("recap.range.shiftCurrent")
: rangeMode === "yesterday"
? t("recap.range.yesterday")
: rangeMode === "custom"
? t("recap.range.custom")
: t("recap.range.24h");
const titleText = `${t("recap.timeline.title")} · ${rangeSuffix}`;
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{titleText}</div>
{loading ? (
<div className="overflow-x-auto">
<div className="min-w-[560px]">
<div className="flex h-14 w-full animate-pulse overflow-hidden rounded-xl bg-white/5">
<div className="h-full w-[12%] bg-zinc-700/70" />
<div className="h-full w-[8%] bg-orange-500/60" />
<div className="h-full w-[14%] bg-zinc-700/70" />
<div className="h-full w-[7%] bg-red-500/60" />
<div className="h-full w-[59%] bg-zinc-700/70" />
</div>
</div>
</div>
) : null}
{!loading && !hasData ? (
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 p-4 text-sm text-zinc-400">
{t("recap.timeline.noData")}
</div>
) : null}
{!loading && hasData ? (
<div className="overflow-x-auto">
<div className="min-w-[560px]">
<div className="flex h-14 w-full overflow-hidden rounded-xl">
{normalized.map((segment, index) => {
const widthPct = widths[index] ?? 0;
const typeLabel =
segment.type === "production"
? t("recap.timeline.type.production")
: segment.type === "mold-change"
? t("recap.timeline.type.moldChange")
: segment.type === "macrostop"
? t("recap.timeline.type.macrostop")
: segment.type === "microstop" || segment.type === "slow-cycle"
? t("recap.timeline.type.microstop")
: t("recap.timeline.type.idle");
const title = `${typeLabel} · ${formatTime(segment.startMs, locale)}-${formatTime(
segment.endMs,
locale
)} · ${formatDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
return (
<div
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${segment.label}`}
className={`flex h-full shrink-0 items-center justify-center truncate px-2 text-xs font-semibold ${
TIMELINE_COLORS[segment.type]
} ${index === 0 ? "rounded-l-xl" : ""} ${
index === normalized.length - 1 ? "rounded-r-xl" : ""
}`}
style={{ width: `${Math.max(0, widthPct)}%` }}
title={title}
>
{widthPct > LABEL_MIN_WIDTH_PCT ? segment.label : ""}
</div>
);
})}
</div>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapRangeMode } from "@/lib/recap/types";
type Props = {
oeeAvg: number | null;
goodParts: number;
totalStops: number;
scrapParts: number;
rangeMode?: RecapRangeMode;
};
export default function RecapKpiRow({ oeeAvg, goodParts, totalStops, scrapParts, rangeMode = "24h" }: Props) {
const { t } = useI18n();
const oeeLabel =
rangeMode === "shift"
? t("recap.kpi.oeeShift")
: rangeMode === "yesterday"
? t("recap.kpi.oeeYesterday")
: rangeMode === "custom"
? t("recap.kpi.oeeCustom")
: t("recap.kpi.oee24h");
const items = [
{ label: t("recap.kpi.good"), value: String(goodParts), valueClass: "text-white" },
{ label: t("recap.kpi.stops"), value: String(totalStops), valueClass: totalStops > 0 ? "text-amber-300" : "text-white" },
{ label: t("recap.kpi.scrap"), value: String(scrapParts), valueClass: scrapParts > 0 ? "text-red-300" : "text-white" },
];
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className={`text-2xl font-semibold ${oeeAvg == null || Number.isNaN(oeeAvg) ? "text-zinc-400" : "text-emerald-300"}`}>
{oeeAvg == null || Number.isNaN(oeeAvg) ? "—" : `${oeeAvg.toFixed(1)}%`}
</div>
<div className="mt-1 text-xs uppercase tracking-wide text-zinc-400">{oeeLabel}</div>
{oeeAvg == null || Number.isNaN(oeeAvg) ? (
<div className="mt-1 text-xs text-zinc-500">{t("recap.kpi.noData")}</div>
) : null}
</div>
{items.map((item) => (
<div key={item.label} className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className={`text-2xl font-semibold ${item.valueClass}`}>{item.value}</div>
<div className="mt-1 text-xs uppercase tracking-wide text-zinc-400">{item.label}</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,142 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapSummaryMachine, RecapTimelineResponse } from "@/lib/recap/types";
import RecapMiniTimeline from "@/components/recap/RecapMiniTimeline";
type Props = {
machine: RecapSummaryMachine;
rangeStart: string;
rangeEnd: string;
};
const STATUS_DOT: Record<RecapSummaryMachine["status"], string> = {
running: "bg-emerald-400",
"mold-change": "bg-amber-400",
stopped: "bg-red-500",
offline: "bg-zinc-500",
};
function statusLabel(status: RecapSummaryMachine["status"], t: (key: string) => string) {
if (status === "running") return t("recap.status.running");
if (status === "mold-change") return t("recap.status.moldChange");
if (status === "stopped") return t("recap.status.stopped");
return t("recap.status.offline");
}
function toInt(value: number | null | undefined) {
if (value == null || Number.isNaN(value)) return 0;
return Math.max(0, Math.round(value));
}
export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Props) {
const { t, locale } = useI18n();
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
const zeroActivity = machine.goodParts === 0 && machine.scrap === 0 && machine.stopsCount === 0;
const primaryMetric = machine.oee == null ? "—" : `${machine.oee.toFixed(1)}%`;
const timelineSegments = timeline?.segments ?? machine.miniTimeline;
const timelineStart = timeline?.range.start ?? rangeStart;
const timelineEnd = timeline?.range.end ?? rangeEnd;
const hasTimelineData = timeline?.hasData ?? timelineSegments.length > 0;
const lastSeenLabel =
machine.lastActivityMin == null
? t("common.never")
: t("recap.card.lastActivity", { min: toInt(machine.lastActivityMin) });
const footerText = machine.activeWorkOrderId
? t("recap.card.activeWorkOrder", { id: machine.activeWorkOrderId })
: lastSeenLabel;
const moldMinutes = machine.moldChange?.active ? machine.moldChange.elapsedMin : null;
useEffect(() => {
let alive = true;
async function loadTimeline() {
try {
const res = await fetch(
`/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=60`,
{ cache: "no-store" }
);
const json = await res.json().catch(() => null);
if (!alive || !res.ok || !json) return;
setTimeline(json as RecapTimelineResponse);
} catch {
}
}
void loadTimeline();
const timer = window.setInterval(() => {
void loadTimeline();
}, 60000);
return () => {
alive = false;
window.clearInterval(timer);
};
}, [machine.machineId]);
return (
<Link
href={`/recap/${machine.machineId}`}
className="rounded-2xl border border-white/10 bg-white/5 p-4 transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/80"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-lg font-semibold text-white">{machine.name}</div>
<div className="mt-1 truncate text-xs text-zinc-400">{machine.location || t("common.na")}</div>
</div>
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 px-2 py-1 text-xs text-zinc-200">
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${STATUS_DOT[machine.status]}`}
aria-label={statusLabel(machine.status, t)}
/>
{statusLabel(machine.status, t)}
</span>
</div>
<div className="mt-4 flex items-baseline gap-2">
<div className={`text-3xl font-semibold ${machine.oee == null ? "text-zinc-400" : "text-white"}`}>{primaryMetric}</div>
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.card.oee")}</div>
</div>
{machine.oee == null ? <div className="mt-1 text-xs text-zinc-500">{t("recap.kpi.noData")}</div> : null}
{zeroActivity ? <div className="mt-1 text-xs text-zinc-500">{t("recap.card.noProduction")}</div> : null}
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-300">
<span>{t("recap.card.good")}: {machine.goodParts}</span>
<span>{t("recap.card.scrap")}: {machine.scrap}</span>
<span>{t("recap.card.stops")}: {machine.stopsCount}</span>
</div>
<div className="mt-3">
<RecapMiniTimeline
rangeStart={timelineStart}
rangeEnd={timelineEnd}
segments={timelineSegments}
locale={locale}
hasData={hasTimelineData}
muted={zeroActivity}
/>
</div>
{machine.moldChange?.active ? (
<div className="mt-3 rounded-lg border border-amber-400/40 bg-amber-400/10 px-2 py-1.5 text-xs text-amber-200">
{t("recap.card.moldChangeActive", { min: toInt(moldMinutes) })}
</div>
) : null}
{machine.offlineForMin != null && machine.offlineForMin > 10 ? (
<div className="mt-2 rounded-lg border border-red-500/40 bg-red-500/10 px-2 py-1.5 text-xs text-red-200">
{t("recap.banner.offline", { min: toInt(machine.offlineForMin) })}
</div>
) : null}
<div className="mt-3 text-xs text-zinc-400">{footerText}</div>
</Link>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { useI18n } from "@/lib/i18n/useI18n";
type Props = {
heartbeat: {
lastSeenAt: string | null;
uptimePct: number | null;
connectionStatus: "online" | "offline";
};
};
export default function RecapMachineStatus({ heartbeat }: Props) {
const { t, locale } = useI18n();
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.machine.title")}</div>
<ul className="space-y-2 text-sm text-zinc-200">
<li>
<span className={heartbeat.connectionStatus === "online" ? "text-emerald-300" : "text-red-300"}>
{heartbeat.connectionStatus === "online" ? t("recap.machine.online") : t("recap.machine.offline")}
</span>
</li>
<li className="text-zinc-400">
{t("recap.machine.lastHeartbeat")}: {heartbeat.lastSeenAt ? new Date(heartbeat.lastSeenAt).toLocaleString(locale) : "--"}
</li>
<li className="text-zinc-400">
{t("recap.machine.uptime")}: {heartbeat.uptimePct == null ? "--" : `${heartbeat.uptimePct.toFixed(1)}%`}
</li>
</ul>
</div>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import type { RecapTimelineSegment } from "@/lib/recap/types";
import {
computeWidths,
formatDuration,
formatTime,
normalizeTimelineSegments,
TIMELINE_COLORS,
} from "@/components/recap/timelineRender";
import { useI18n } from "@/lib/i18n/useI18n";
type Props = {
rangeStart: string;
rangeEnd: string;
segments: RecapTimelineSegment[];
locale: string;
muted?: boolean;
hasData?: boolean;
};
const MIN_SEGMENT_PCT = 0.5;
export default function RecapMiniTimeline({
rangeStart,
rangeEnd,
segments,
locale,
muted = false,
hasData = true,
}: Props) {
const { t } = useI18n();
const startMs = new Date(rangeStart).getTime();
const endMs = new Date(rangeEnd).getTime();
const totalMs = Math.max(1, endMs - startMs);
const normalized = normalizeTimelineSegments(segments, startMs, endMs);
const widths = computeWidths(normalized, totalMs, MIN_SEGMENT_PCT);
if (!hasData) {
return (
<div className="flex h-5 w-full items-center justify-center rounded-md bg-zinc-800/70 text-[10px] text-zinc-400">
{t("recap.timeline.noData")}
</div>
);
}
if (!normalized.length) {
return <div className="h-5 w-full rounded-md bg-zinc-700/70" />;
}
return (
<div className="flex h-5 w-full overflow-hidden rounded-md">
{normalized.map((segment, index) => {
const widthPct = widths[index] ?? 0;
const typeLabel =
segment.type === "production"
? t("recap.timeline.type.production")
: segment.type === "mold-change"
? t("recap.timeline.type.moldChange")
: segment.type === "macrostop"
? t("recap.timeline.type.macrostop")
: segment.type === "microstop" || segment.type === "slow-cycle"
? t("recap.timeline.type.microstop")
: t("recap.timeline.type.idle");
const title = `${typeLabel} · ${formatTime(segment.startMs, locale)}-${formatTime(segment.endMs, locale)} · ${formatDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
const color = muted ? "bg-zinc-700 text-zinc-300" : TIMELINE_COLORS[segment.type];
return (
<div
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${segment.label}`}
className={`h-full shrink-0 ${color} ${index === 0 ? "rounded-l-md" : ""} ${
index === normalized.length - 1 ? "rounded-r-md" : ""
}`}
style={{ width: `${Math.max(0, widthPct)}%` }}
title={title}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapSkuRow } from "@/lib/recap/types";
type Props = {
rows: RecapSkuRow[];
};
export default function RecapProductionBySku({ rows }: Props) {
const { t } = useI18n();
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.production.bySku")}</div>
{rows.length === 0 ? (
<div className="text-sm text-zinc-400">{t("recap.empty.production")}</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm text-zinc-200">
<thead>
<tr className="border-b border-white/10 text-left text-xs uppercase tracking-wide text-zinc-400">
<th className="py-2 pr-3">{t("recap.production.sku")}</th>
<th className="py-2 pr-3">{t("recap.production.good")}</th>
<th className="py-2">{t("recap.production.scrap")}</th>
</tr>
</thead>
<tbody>
{rows.slice(0, 10).map((row) => (
<tr key={`${row.sku}:${row.machineName}`} className="border-b border-white/5">
<td className="py-2 pr-3">{row.sku}</td>
<td className="py-2 pr-3">{row.good}</td>
<td className={`py-2 ${row.scrap > 0 ? "text-red-300" : ""}`}>{row.scrap}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,173 @@
"use client";
import type { RecapTimelineSegment } from "@/lib/recap/types";
type Props = {
rangeStart: string;
rangeEnd: string;
segments: RecapTimelineSegment[];
locale: string;
};
const COLORS: Record<RecapTimelineSegment["type"], string> = {
production: "bg-emerald-500 text-black",
"mold-change": "bg-sky-400 text-black",
macrostop: "bg-red-500 text-white",
microstop: "bg-orange-500 text-black",
"slow-cycle": "bg-amber-500 text-black",
idle: "bg-zinc-600 text-zinc-300",
};
const MIN_SEGMENT_PCT = 0.3;
const LABEL_MIN_PCT = 5;
function fmtTime(valueMs: number, locale: string) {
return new Date(valueMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
}
function fmtDuration(startMs: number, endMs: number) {
const totalMin = Math.max(0, Math.round((endMs - startMs) / 60000));
if (totalMin < 60) return `${totalMin}m`;
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return `${h}h ${m}m`;
}
function shouldMergeByType(type: RecapTimelineSegment["type"]) {
return type === "macrostop" || type === "microstop" || type === "slow-cycle" || type === "idle";
}
function normalizeForRender(segments: RecapTimelineSegment[], startMs: number, endMs: number) {
const ordered = segments
.map((segment) => ({
...segment,
startMs: Math.max(startMs, segment.startMs),
endMs: Math.min(endMs, segment.endMs),
}))
.filter((segment) => segment.endMs > segment.startMs)
.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
const out: RecapTimelineSegment[] = [];
let cursor = startMs;
for (const segment of ordered) {
if (segment.startMs > cursor) {
const prev = out[out.length - 1];
if (prev) {
prev.endMs = segment.startMs;
} else {
out.push({
type: "idle",
startMs: cursor,
endMs: segment.startMs,
durationSec: Math.max(0, Math.trunc((segment.startMs - cursor) / 1000)),
label: "Idle",
});
}
}
const normalizedStart = Math.max(cursor, segment.startMs);
const normalizedEnd = Math.min(endMs, segment.endMs);
if (normalizedEnd <= normalizedStart) continue;
const normalizedSegment: RecapTimelineSegment = {
...segment,
startMs: normalizedStart,
endMs: normalizedEnd,
};
const prev = out[out.length - 1];
if (
prev &&
prev.type === normalizedSegment.type &&
shouldMergeByType(prev.type) &&
prev.endMs === normalizedSegment.startMs
) {
prev.endMs = normalizedSegment.endMs;
} else {
out.push(normalizedSegment);
}
cursor = normalizedEnd;
if (cursor >= endMs) break;
}
if (cursor < endMs) {
const prev = out[out.length - 1];
if (prev) {
prev.endMs = endMs;
} else {
out.push({
type: "idle",
startMs: cursor,
endMs,
durationSec: Math.max(0, Math.trunc((endMs - cursor) / 1000)),
label: "Idle",
});
}
}
return out.filter((segment) => segment.endMs > segment.startMs);
}
function computeWidths(segments: RecapTimelineSegment[], totalMs: number, minPct: number) {
if (!segments.length) return [];
const base = segments.map((segment) => ((segment.endMs - segment.startMs) / totalMs) * 100);
const effectiveMin = Math.min(minPct, 100 / segments.length);
let widths = base.map((pct) => Math.max(pct, effectiveMin));
const sum = widths.reduce((acc, value) => acc + value, 0);
if (sum > 100) {
const overflow = sum - 100;
const slacks = widths.map((value) => Math.max(0, value - effectiveMin));
const totalSlack = slacks.reduce((acc, value) => acc + value, 0);
if (totalSlack > 0) {
widths = widths.map((value, index) => value - (overflow * slacks[index]) / totalSlack);
} else {
const scale = 100 / sum;
widths = widths.map((value) => value * scale);
}
} else if (sum < 100) {
const deficit = 100 - sum;
const totalBase = base.reduce((acc, value) => acc + (value > 0 ? value : 1), 0);
widths = widths.map((value, index) => value + (deficit * (base[index] > 0 ? base[index] : 1)) / totalBase);
}
const rounded = widths.map((value) => Number(value.toFixed(4)));
const roundedSum = rounded.reduce((acc, value) => acc + value, 0);
const delta = Number((100 - roundedSum).toFixed(4));
if (rounded.length > 0) {
rounded[rounded.length - 1] = Number(Math.max(0, rounded[rounded.length - 1] + delta).toFixed(4));
}
return rounded;
}
export default function RecapTimeline({ rangeStart, rangeEnd, segments, locale }: Props) {
const startMs = new Date(rangeStart).getTime();
const endMs = new Date(rangeEnd).getTime();
const totalMs = Math.max(1, endMs - startMs);
const normalized = normalizeForRender(segments, startMs, endMs);
const widths = computeWidths(normalized, totalMs, MIN_SEGMENT_PCT);
return (
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-3">
<div className="mb-2 text-xs uppercase tracking-wide text-zinc-400">Timeline 24h</div>
<div className="flex h-14 w-full overflow-hidden rounded-xl border border-white/10">
{normalized.map((segment, index) => {
const widthPct = widths[index] ?? 0;
const title = `${segment.type} · ${fmtTime(segment.startMs, locale)}-${fmtTime(segment.endMs, locale)} · ${fmtDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
return (
<div
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${segment.label}`}
className={`flex h-full shrink-0 items-center justify-center truncate px-2 text-xs font-semibold ${COLORS[segment.type]} ${
index === 0 ? "rounded-l-xl" : ""
} ${index === normalized.length - 1 ? "rounded-r-xl" : ""}`}
style={{ width: `${Math.max(0, widthPct)}%` }}
title={title}
>
{widthPct > LABEL_MIN_PCT ? segment.label : ""}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { useI18n } from "@/lib/i18n/useI18n";
import { formatRecapProgressPercent, progressBarWidthPercent } from "@/lib/recap/progressDisplay";
import type { RecapMachine } from "@/lib/recap/types";
type Props = {
workOrders: RecapMachine["workOrders"];
};
export default function RecapWorkOrderStatus({ workOrders }: Props) {
const { t, locale } = useI18n();
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.workOrders.title")}</div>
<div className="mb-3">
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.workOrders.active")}</div>
{!workOrders.active ? (
<div className="mt-1 text-sm text-zinc-400">{t("recap.workOrders.none")}</div>
) : (
<div className="mt-2 rounded-xl border border-white/10 bg-black/30 p-3 text-sm text-zinc-200">
<div className="font-medium text-white">{workOrders.active.id}</div>
<div className="text-zinc-400">SKU: {workOrders.active.sku || "--"}</div>
<div className="mt-1 text-xs text-zinc-300">
{t("recap.production.progress")}: {formatRecapProgressPercent(workOrders.active.progressPct, locale)}
</div>
<div className="mt-2 h-2 rounded-full bg-white/10">
<div
className="h-2 rounded-full bg-emerald-400 transition-[width]"
style={{ width: `${progressBarWidthPercent(workOrders.active.progressPct)}%` }}
/>
</div>
<div className="mt-2 text-xs text-zinc-400">
{t("recap.workOrders.startedAt")}: {workOrders.active.startedAt ? new Date(workOrders.active.startedAt).toLocaleString(locale) : "--"}
</div>
</div>
)}
</div>
<div>
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.workOrders.completed")}</div>
{workOrders.completed.length === 0 ? (
<div className="mt-1 text-sm text-zinc-400">{t("recap.workOrders.none")}</div>
) : (
<div className="mt-2 space-y-2">
{workOrders.completed.slice(0, 4).map((row) => (
<div key={row.id} className="rounded-xl border border-white/10 bg-black/30 p-3 text-xs text-zinc-300">
<div className="font-medium text-white">{row.id}</div>
<div>SKU: {row.sku || "--"}</div>
<div>{t("recap.workOrders.goodParts")}: {row.goodParts}</div>
<div>{t("recap.workOrders.duration")}: {row.durationHrs.toFixed(2)}h</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useI18n } from "@/lib/i18n/useI18n";
import { formatRecapProgressPercent, progressBarWidthPercent } from "@/lib/recap/progressDisplay";
import type { RecapWorkOrders as RecapWorkOrdersType } from "@/lib/recap/types";
type Props = {
workOrders: RecapWorkOrdersType;
};
export default function RecapWorkOrders({ workOrders }: Props) {
const { t, locale } = useI18n();
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.workOrders.title")}</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.workOrders.completed")}</div>
{workOrders.completed.length === 0 ? (
<div className="mt-2 text-sm text-zinc-400">{t("recap.workOrders.none")}</div>
) : (
<div className="mt-2 space-y-2">
{workOrders.completed.slice(0, 6).map((row) => (
<div key={row.id} className="rounded-lg border border-white/10 bg-black/20 p-2 text-xs text-zinc-300">
<div className="font-medium text-white">{row.id}</div>
<div>{t("recap.workOrders.sku")}: {row.sku || "--"}</div>
<div>{t("recap.workOrders.goodParts")}: {row.goodParts}</div>
<div>{t("recap.workOrders.duration")}: {row.durationHrs.toFixed(2)}h</div>
</div>
))}
</div>
)}
</div>
<div>
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.workOrders.active")}</div>
{!workOrders.active ? (
<div className="mt-2 text-sm text-zinc-400">{t("recap.workOrders.none")}</div>
) : (
<div className="mt-2 rounded-lg border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
<div className="font-medium text-white">{workOrders.active.id}</div>
<div className="text-zinc-400">{t("recap.workOrders.sku")}: {workOrders.active.sku || "--"}</div>
<div className="mt-1 text-xs text-zinc-300">
{t("recap.production.progress")}:{" "}
{formatRecapProgressPercent(workOrders.active.progressPct, locale)}
</div>
<div className="mt-2 h-2 rounded-full bg-white/10">
<div
className="h-2 rounded-full bg-emerald-400 transition-[width]"
style={{ width: `${progressBarWidthPercent(workOrders.active.progressPct)}%` }}
/>
</div>
<div className="mt-2 text-xs text-zinc-400">
{t("recap.workOrders.startedAt")}: {workOrders.active.startedAt ? new Date(workOrders.active.startedAt).toLocaleString(locale) : "--"}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
import type { RecapTimelineSegment } from "@/lib/recap/types";
export const TIMELINE_COLORS: Record<RecapTimelineSegment["type"], string> = {
production: "bg-emerald-500 text-black",
"mold-change": "bg-sky-400 text-black",
macrostop: "bg-red-500 text-white",
microstop: "bg-orange-500 text-black",
"slow-cycle": "bg-orange-500 text-black",
idle: "bg-zinc-700 text-zinc-300",
};
export const LABEL_MIN_WIDTH_PCT = 5;
export const SEGMENT_MIN_WIDTH_PCT = 0.3;
export function formatTime(valueMs: number, locale: string) {
return new Date(valueMs).toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
});
}
export function formatDuration(startMs: number, endMs: number) {
const totalMin = Math.max(0, Math.round((endMs - startMs) / 60000));
if (totalMin < 60) return `${totalMin}m`;
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return `${h}h ${m}m`;
}
export function normalizeTimelineSegments(
segments: RecapTimelineSegment[],
rangeStartMs: number,
rangeEndMs: number
) {
const ordered = [...segments]
.map((segment) => ({
...segment,
startMs: Math.max(rangeStartMs, segment.startMs),
endMs: Math.min(rangeEndMs, segment.endMs),
}))
.filter((segment) => segment.endMs > segment.startMs)
.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
const out: RecapTimelineSegment[] = [];
let cursor = rangeStartMs;
for (const segment of ordered) {
if (segment.startMs > cursor) {
out.push({
type: "idle",
startMs: cursor,
endMs: segment.startMs,
durationSec: Math.max(0, Math.trunc((segment.startMs - cursor) / 1000)),
label: "Idle",
});
}
const startMs = Math.max(cursor, segment.startMs);
const endMs = Math.min(rangeEndMs, segment.endMs);
if (endMs <= startMs) continue;
if (segment.type === "production") {
out.push({
type: "production",
startMs,
endMs,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
workOrderId: segment.workOrderId,
sku: segment.sku,
label: segment.label,
});
} else if (segment.type === "mold-change") {
out.push({
type: "mold-change",
startMs,
endMs,
fromMoldId: segment.fromMoldId,
toMoldId: segment.toMoldId,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: segment.label,
});
} else if (segment.type === "macrostop" || segment.type === "microstop" || segment.type === "slow-cycle") {
out.push({
type: segment.type === "slow-cycle" ? "microstop" : segment.type,
startMs,
endMs,
reason: segment.reason,
reasonLabel: segment.reasonLabel ?? segment.reason,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: segment.label,
});
} else {
out.push({
type: "idle",
startMs,
endMs,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: segment.label,
});
}
cursor = endMs;
if (cursor >= rangeEndMs) break;
}
if (cursor < rangeEndMs) {
out.push({
type: "idle",
startMs: cursor,
endMs: rangeEndMs,
durationSec: Math.max(0, Math.trunc((rangeEndMs - cursor) / 1000)),
label: "Idle",
});
}
return out;
}
export function computeWidths(segments: RecapTimelineSegment[], totalMs: number, minPct: number) {
if (!segments.length) return [];
const base = segments.map((segment) => ((segment.endMs - segment.startMs) / totalMs) * 100);
const effectiveMin = Math.min(minPct, 100 / segments.length);
let widths = base.map((pct) => Math.max(pct, effectiveMin));
const sum = widths.reduce((acc, value) => acc + value, 0);
if (sum > 100) {
const overflow = sum - 100;
const slacks = widths.map((value) => Math.max(0, value - effectiveMin));
const totalSlack = slacks.reduce((acc, value) => acc + value, 0);
if (totalSlack > 0) {
widths = widths.map((value, index) => value - (overflow * slacks[index]) / totalSlack);
} else {
const scale = 100 / sum;
widths = widths.map((value) => value * scale);
}
} else if (sum < 100) {
const deficit = 100 - sum;
const totalBase = base.reduce((acc, value) => acc + (value > 0 ? value : 1), 0);
widths = widths.map(
(value, index) => value + (deficit * (base[index] > 0 ? base[index] : 1)) / totalBase
);
}
const rounded = widths.map((value) => Number(value.toFixed(4)));
const roundedSum = rounded.reduce((acc, value) => acc + value, 0);
const delta = Number((100 - roundedSum).toFixed(4));
if (rounded.length > 0) {
rounded[rounded.length - 1] = Number(Math.max(0, rounded[rounded.length - 1] + delta).toFixed(4));
}
return rounded;
}

106
fix.md Normal file
View File

@@ -0,0 +1,106 @@
Root cause found — CT has no authoritative WO counters
The actual bug
Your Node-RED writes goodParts/scrapParts/cycleCount to the Pi's MariaDB work_orders table. That's local. It never gets written to CT's Postgres machine_work_orders table — which the Prisma model doesn't even declare those columns. Confirmed:
model MachineWorkOrder {
id, orgId, machineId, workOrderId, sku, targetQty, cycleTime, status, createdAt, updatedAt
// NO good_parts, scrap_parts, cycle_count
}
lib/recap/getRecapData.ts line 259 probes information_schema.columns for those columns → always finds zero → workOrderCountersAvailable = false → loadWorkOrderCounterRows returns null → falls back to KPI delta math.
The KPI delta math (line 700 area):
const rangeGood = Math.max(0, (agg.maxGood ?? 0) - (agg.minGood ?? agg.maxGood ?? 0));
Node-RED sends the cumulative activeWorkOrder.goodParts in every KPI snapshot (not delta). If the WO has 353 parts now and has been stable (mold change, no new parts) across the 24h window, min=maxGood=353 → rangeGood = 0. If there was a brief scrap entry or counter drift, you get rangeGood = 1. That's where your "1 good part" comes from.
Recap is structurally unable to match Node-RED until CT persists the authoritative counter.
Fix — 3 changes, in order
1. Add counter columns to CT schema
File: prisma/schema.prisma
model MachineWorkOrder {
id String @id @default(uuid())
orgId String
machineId String
workOrderId String
sku String?
targetQty Int?
cycleTime Float?
status String @default("PENDING")
goodParts Int @default(0) @map("good_parts") // NEW
scrapParts Int @default(0) @map("scrap_parts") // NEW
cycleCount Int @default(0) @map("cycle_count") // NEW
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// rest unchanged
}
Generate migration: npx prisma migrate dev --name add_wo_counters. Run on prod DB.
2. Have KPI ingest upsert the counters
File: app/api/ingest/kpi/route.ts
Each KPI payload from Node-RED contains:
"activeWorkOrder": { "id": "OTBM-002", "sku": "RAMBOX", "goodParts": 353, "scrapParts": 1, "cycleCount": 353 }
Inside the handler, after creating the MachineKpiSnapshot, add:
const awo = payload?.activeWorkOrder;
if (awo?.id) {
await prisma.machineWorkOrder.upsert({
where: { machineId_workOrderId: { machineId: machine.id, workOrderId: String(awo.id) } },
create: {
orgId: machine.orgId,
machineId: machine.id,
workOrderId: String(awo.id),
sku: awo.sku ?? null,
targetQty: Number(awo.target) || null,
cycleTime: Number(awo.cycleTime) || null,
status: awo.status ?? "RUNNING",
goodParts: Number(awo.goodParts) || 0,
scrapParts: Number(awo.scrapParts) || 0,
cycleCount: Number(awo.cycleCount) || 0,
},
update: {
sku: awo.sku ?? undefined,
targetQty: Number(awo.target) || undefined,
cycleTime: Number(awo.cycleTime) || undefined,
status: awo.status ?? undefined,
goodParts: Number(awo.goodParts) || 0,
scrapParts: Number(awo.scrapParts) || 0,
cycleCount: Number(awo.cycleCount) || 0,
},
});
}
This makes CT's machine_work_orders rows track Pi's live state minute-by-minute.
3. Simplify recap aggregation
File: lib/recap/getRecapData.ts
Now that the columns exist, loadWorkOrderCounterRows will work. But also drop the updatedAt BETWEEN filter — it excludes WOs that haven't ticked recently (e.g. during mold change):
// REMOVE:
AND "updatedAt" >= ${params.start}
AND "updatedAt" <= ${params.end}
// KEEP only:
WHERE "orgId" = ${params.orgId}
AND "machineId" IN (${machineIdList})
Return all WOs for the machine; filter client-side or by another criterion if needed. For the "last 24h production" metric, sum goodParts across all WOs (simple, matches Home UI).
Also remove the whole KPI-delta fallback block (lines ~600-760) — don't need it anymore. The authoritative counter is always present once changes 1+2 are deployed.
Other issues you flagged
OEE 75% vs 47%: Recap uses time-weighted average across the window (24h including hours of stopped machine → pulls avg down). Machine detail shows a shorter-window or last-snapshot value. Decision: Recap avg is technically correct for "24h avg"; Machine detail's 75% is the "current instantaneous" OEE. Label them clearly: "OEE promedio 24h: 47%" vs "OEE actual: 75%". Don't make them the same number — they measure different things. Show both if you want.
Machine detail timeline flickering: probably a client useEffect dependency loop or a polling interval too short. Check app/(app)/machines/[machineId]/MachineDetailClient.tsx for a setInterval or SWR revalidation. Likely you're re-fetching every 2-3s and the data comes back with slightly different timestamps, causing re-render. Fix: increase poll to 15-30s and compare by segment hash before updating state.
"1 good part" bug: will self-fix once #1 and #2 are deployed (recap reads authoritative column instead of computing bad delta).
Deployment order
Merge schema migration to main. Run prisma migrate deploy in prod.
Ship KPI ingest change — Node-RED starts populating counters immediately.
Ship recap simplification — hits the now-populated columns.
Watch for ~5 min for CT to catch up (KPI ticks every minute from Pi).
Verify: SELECT work_order_id, good_parts, scrap_parts FROM machine_work_orders WHERE machine_id = '<uuid>' ORDER BY updated_at DESC LIMIT 5; — should match Home UI (353).
No Node-RED changes needed. Pi is already sending the right data; CT just wasn't storing it.

42
fix2.md Normal file
View File

@@ -0,0 +1,42 @@
Traced. Here's the truth for each filter.
Range logic (lib/recap/redesign.ts line 458-490)
Filter Window computed What it shows
24h now - 24h → now Rolling last 24h. Mold change at right edge = started ~9:46pm today
Turno actual Current-shift window from orgShift table Needs configured shifts; else falls back to 24h
Ayer now-48h → now-24h (rolling!) NOT "yesterday 00:00-23:59" — it's "24-48h ago". Label is misleading
Personalizado User-picked Explicit
What's actually wrong
1. "Paros totales 3,102,444 min" and "144,409 min" are stale data. Those are huge because old duplicate ReasonEntry rows (from before the is_update/is_auto_ack filter was deployed) are still in DB. The filter stops NEW duplicates but doesn't delete old ones.
Fix:
TRUNCATE TABLE "ReasonEntry";
Or targeted:
DELETE FROM "ReasonEntry" WHERE "capturedAt" < '<date-when-filter-deployed>';
After that, numbers will be realistic.
2. "Ayer" window is wrong. Current code is rolling 48-24h ago, not calendar yesterday. Fix in resolveDetailRange:
if (mode === "yesterday") {
// use local timezone calendar day, not rolling 24h
const tz = orgSettings.timezone ?? "America/Mexico_City";
const today0 = zonedToUtcDate({ ...localDayStart(now, tz), hours: 0, minutes: 0, timeZone: tz });
const yesterday0 = new Date(today0.getTime() - 24 * 60 * 60 * 1000);
return { mode, start: yesterday0, end: today0 };
}
3. "Sin señal hace 376 min" is real. Pi stopped sending heartbeats 6h ago. Simultaneously "Cambio de molde en curso" is stuck active because Pi went offline DURING the mold change — no resolved event ever arrived. Both facts are true. Banner logic is correct, UX could be improved:
If offlineMin > moldChangeAgeMin, show only the offline banner (more severe). Or combined: "Sin señal hace 376m — último estado: cambio de molde".
4. Different OEE across filters is expected (different windows, different math). Labels should make it obvious: OEE PROMEDIO 24h, OEE DEL TURNO, OEE AYER. Currently they all say "OEE PROMEDIO 24H" regardless of filter → confusing. Check RecapKpiRow.tsx — the label should come from the range mode, not be hardcoded.
5. Shift mode falls through to 24h if no shifts configured. That's why the numbers are slightly different — it actually ran with a real shift. Verify: SELECT * FROM "OrgShift" WHERE "orgId" = '<id>';. If empty, shifts aren't set; the filter is silently showing 24h and labeling it "Turno actual" → more confusion.
Priority order
Truncate ReasonEntry (kills 99% of the insanity).
Fix "Ayer" to be calendar-based.
Fix KPI row label to reflect selected range.
If no OrgShift rows exist, show a toast or disable "Turno actual" button instead of silently falling back.
Improve dual-banner priority (offline > mold-change)

54
fix3.md Normal file
View File

@@ -0,0 +1,54 @@
The fix created the production segment, but the mold-change active event never got a matching resolved event in CT. Two checks will tell us which.
On CT Postgres
SELECT ts, data->>'status' AS status,
data->>'incidentKey' AS ikey,
data->>'is_update' AS is_update
FROM "MachineEvent"
WHERE "machineId" = '<uuid-M4-5>'
AND "eventType" = 'mold-change'
ORDER BY ts DESC LIMIT 10;
Expected when working: active + resolved rows with same incidentKey.
If only active exists → resolved event never reached CT. Likely causes:
Flow wasn't redeployed after the edit (Node-RED still running old version — check if node.warn in auto-close is firing in debug sidebar).
state.moldChange persisted stale from before (cleared active manually somewhere).
User hit COMENZAR before deploying the updated flow → no close event ever emitted for that episode.
If both exist but incidentKey differs → my close event and start event used different startMs. Send me both rows and I'll trace.
Manual cleanup for the stuck episode
Until a new resolved event arrives, the banner won't clear. Force it:
-- Insert a synthetic resolved event matching the stuck active one
INSERT INTO "MachineEvent" (id, "orgId", "machineId", ts, topic, "eventType", severity, "requiresAck", title, description, data, "createdAt")
SELECT gen_random_uuid(), "orgId", "machineId", NOW(), 'mold-change', 'mold-change', 'info', false,
'Cambio de molde cerrado manualmente', 'cierre manual',
jsonb_build_object(
'status','resolved',
'incidentKey', data->>'incidentKey',
'start_ms', (data->>'start_ms')::bigint,
'end_ms', extract(epoch from NOW())*1000
),
NOW()
FROM "MachineEvent"
WHERE "machineId" = '<uuid-M4-5>' AND "eventType" = 'mold-change' AND data->>'status' = 'active'
ORDER BY ts DESC LIMIT 1;
Banner disappears on next recap refresh (cache 60s).
Permanent safeguard (CT)
In lib/recap/getRecapData.ts ~line 817, add a freshness cap: an "active" mold-change older than 12h is almost always stuck data. Treat as resolved:
const STALE_ACTIVE_MS = 12 * 60 * 60 * 1000;
for (const event of machineMoldEvents) {
const key = eventIncidentKey(event.data, "mold-change", event.ts);
const status = eventStatus(event.data);
if (status === "resolved") { moldActiveByIncident.delete(key); continue; }
if (status === "active" || !status) {
// ignore if too old to be real
if (params.end.getTime() - event.ts.getTime() > STALE_ACTIVE_MS) continue;
moldActiveByIncident.set(key, moldStartMs(event.data, event.ts));
}
}
Same for the timeline extension logic in lib/recap/timeline.ts line 662 — cap isFreshActive at the same threshold.

244
fix4.md Normal file
View File

@@ -0,0 +1,244 @@
Task: Implement Control Tower changes only (no Node-RED edits), then run full verification with SQL + backfill script.
Repository context:
- Workspace root: Plastic-Dashboard
- Target branch assumption: sandbox-main
- Database: PostgreSQL via Prisma
- Scope strictly limited to Control Tower code and scripts in this repo
Hard constraints:
1. Do NOT edit any Node-RED flow files or Node-RED runtime code.
2. Do NOT change behavior outside the requested areas unless required for correctness.
3. Preserve existing non-authoritative guard behavior for downtime reasons (PENDIENTE / UNCLASSIFIED).
4. Run verification before and after backfill, and report results clearly.
5. If lint/test has unrelated pre-existing failures, do not refactor unrelated modules.
Implementation requirements:
A) Downtime continuity fallback key fix
File:
- app/api/ingest/event/route.ts
Goal:
- Ensure fallback downtime reason identity/continuity uses episode continuity key (incidentKey) whenever present.
- Use row.id only when incidentKey is truly absent.
- Preserve guard that prevents non-authoritative values from overwriting authoritative manual reasons.
Details:
1. In the event ingestion logic where ReasonEntry payload is created for downtime-like events (including fallback UNCLASSIFIED and mold-change):
- Derive a fallbackIncidentKey from available payload fields in this preference order:
- evData.incidentKey
- dataObj.incidentKey
- evDowntime?.incidentKey
- evReason?.incidentKey (if available)
- Only if all are missing, fallback to row.id.
2. For fallback reasonRaw objects:
- For mold-change fallback, set incidentKey to moldIncidentKey ?? fallbackIncidentKey ?? row.id.
- For unclassified fallback, set incidentKey to fallbackIncidentKey ?? row.id.
3. Create one continuityIncidentKey (single source of truth) used consistently for:
- downtime reasonId construction (evt:<machineId>:downtime:<continuityIncidentKey>)
- ReasonEntry episodeId for downtime
- meta.incidentKey in reason entry writes
- manual-preservation guard queries by episodeId
4. Keep non-authoritative guard semantics unchanged:
- incoming non-authoritative reason should not overwrite existing authoritative reason for same episode
- downtime-acknowledged/manual authoritative path remains preserved
B) OEE trend from production-only snapshots
File:
- app/api/reports/route.ts
Goal:
- Build OEE trend from production-only snapshots:
- trackingEnabled = true
- productionStarted = true
- Keep summary metrics behavior explicit and consistent with this filtering decision.
Details:
1. Include trackingEnabled and productionStarted in KPI snapshot select.
2. Add helper like isProductionSnapshot(trackingEnabled, productionStarted).
3. Compute OEE/Availability/Performance/Quality averages using production-only rows.
4. For trend generation:
- Iterate timeline in ts order.
- For non-production snapshots, emit null points (for OEE and related KPI lines) so chart can render true gaps.
- For production snapshots, emit actual numeric values (or null if value is missing).
5. Keep downtime/event aggregates and cycle-based totals behavior intact unless explicitly tied to OEE production-only requirement.
6. Keep logic explicit in code comments (short, concrete comments only where needed).
C) Chart rendering behavior: no smoothing across gaps
Files:
- app/(app)/reports/ReportsCharts.tsx
- app/(app)/reports/ReportsPageClient.tsx (if types/downsampling need updates)
Goal:
- OEE line interpolation must be linear.
- Gaps must be rendered as gaps (no fake continuity through filtered/non-production windows).
Details:
1. In OEE line chart:
- change Line type from monotone to linear
- set connectNulls={false}
2. Ensure frontend types allow nullable trend values for OEE points.
3. If downsampling exists, preserve gap markers so null separators are not removed.
- Keep null transition points when reducing point count.
4. Ensure tooltip/value formatting handles nulls gracefully.
Verification and execution steps:
1) Run targeted checks first
- run tests related to downtime guard if available:
- npm run test:downtime-reason-guard
- run lint at least for changed files (or full lint if practical):
- npx eslint app/api/ingest/event/route.ts app/api/reports/route.ts app/(app)/reports/ReportsCharts.tsx app/(app)/reports/ReportsPageClient.tsx
2) SQL Verification Pack (PRE-BACKFILL)
Execute these exactly and capture output snapshots:
A. Recent downtime reason quality mix
SELECT
reasonCode,
COUNT(*) AS rows
FROM "ReasonEntry"
WHERE kind = 'downtime'
AND "capturedAt" >= NOW() - INTERVAL '7 days'
GROUP BY reasonCode
ORDER BY rows DESC;
B. Episodes with conflicting reason codes
SELECT
"orgId",
"machineId",
"episodeId",
COUNT(DISTINCT "reasonCode") AS distinct_codes,
MIN("capturedAt") AS first_seen,
MAX("capturedAt") AS last_seen
FROM "ReasonEntry"
WHERE kind = 'downtime'
AND "episodeId" IS NOT NULL
AND "capturedAt" >= NOW() - INTERVAL '14 days'
GROUP BY "orgId", "machineId", "episodeId"
HAVING COUNT(DISTINCT "reasonCode") > 1
ORDER BY last_seen DESC
LIMIT 200;
C. Potential manual overwritten by non-authoritative check
SELECT
re."orgId",
re."machineId",
re."episodeId",
re."reasonCode",
re."capturedAt",
re.meta
FROM "ReasonEntry" re
WHERE re.kind = 'downtime'
AND re."capturedAt" >= NOW() - INTERVAL '14 days'
AND re."reasonCode" IN ('PENDIENTE', 'UNCLASSIFIED')
ORDER BY re."capturedAt" DESC
LIMIT 200;
D. Event continuity around downtime + ack
SELECT
"machineId",
"eventType",
ts,
data->>'incidentKey' AS incident_key,
data->>'status' AS status,
data->>'is_update' AS is_update,
data->>'is_auto_ack' AS is_auto_ack
FROM "MachineEvent"
WHERE ts >= NOW() - INTERVAL '3 days'
AND "eventType" IN ('microstop', 'macrostop', 'downtime-acknowledged')
ORDER BY ts DESC
LIMIT 500;
E. KPI production vs non-production counts
SELECT
COALESCE("trackingEnabled", false) AS tracking_enabled,
COALESCE("productionStarted", false) AS production_started,
COUNT(*) AS rows
FROM "MachineKpiSnapshot"
WHERE ts >= NOW() - INTERVAL '7 days'
GROUP BY 1,2
ORDER BY rows DESC;
F. Sharp OEE jumps in production snapshots
WITH k AS (
SELECT
"machineId",
ts,
oee,
LAG(oee) OVER (PARTITION BY "machineId" ORDER BY ts) AS prev_oee
FROM "MachineKpiSnapshot"
WHERE ts >= NOW() - INTERVAL '7 days'
AND "trackingEnabled" = true
AND "productionStarted" = true
AND oee IS NOT NULL
)
SELECT
"machineId",
ts,
prev_oee,
oee,
ABS(oee - prev_oee) AS delta
FROM k
WHERE prev_oee IS NOT NULL
AND ABS(oee - prev_oee) >= 25
ORDER BY delta DESC, ts DESC
LIMIT 200;
G. Trend point count comparison
SELECT
'all' AS series,
COUNT(*) AS points
FROM "MachineKpiSnapshot"
WHERE ts >= NOW() - INTERVAL '24 hours'
AND oee IS NOT NULL
UNION ALL
SELECT
'production_only' AS series,
COUNT(*) AS points
FROM "MachineKpiSnapshot"
WHERE ts >= NOW() - INTERVAL '24 hours'
AND oee IS NOT NULL
AND "trackingEnabled" = true
AND "productionStarted" = true;
3) Backfill run plan (must follow this order)
A. Dry-run first:
node scripts/backfill-downtime-reasons.mjs --dry-run --since 30d
B. Review dry-run output:
- candidates
- sampleUpdates
- incident distribution by machine
- any suspicious replacements
C. Apply scoped first (single machine from dry-run sample):
node scripts/backfill-downtime-reasons.mjs --since 30d --machine-id <machine_uuid>
4) SQL Verification Pack (POST-BACKFILL)
- Re-run queries A, B, C at minimum.
- Optionally rerun D/F/G for confidence.
- Confirm reduction in stale PENDIENTE/UNCLASSIFIED rows where authoritative reason exists.
- Confirm conflicting episode reason cases reduced or shifted as expected.
Acceptance criteria checklist:
- New downtime episodes retain authoritative manual reason and do not regress to PENDIENTE/UNCLASSIFIED.
- Fallback downtime continuity now keys by incidentKey whenever available; row.id only when absent.
- OEE trend no longer shows implausible 0/100 jumps from non-production snapshots.
- OEE chart is linear and visually shows true gaps (no smoothing continuity across filtered windows).
- Backfill dry-run and scoped apply outputs are captured and reasonable.
- Post-run SQL confirms expected improvements without obvious regressions.
Output format required from you:
1. Files changed with concise reason per file.
2. Exact diff summary for each modified file.
3. Test/lint commands run + result.
4. Pre-backfill SQL results (compact tables or summarized counts).
5. Dry-run output summary (key fields + sample updates).
6. Scoped apply command used and output summary.
7. Post-backfill SQL delta summary (before vs after).
8. Any blockers (env vars, DB auth, migration state, etc.) and exactly what is needed to unblock.

252
fix5.md Normal file
View File

@@ -0,0 +1,252 @@
Filter context noted: turno actual + ayer both flow through resolveDetailRange → getRecapDataCached → same loadWorkOrderCounterRows lifetime bug. Patch 1 below fixes lifetime numbers across all filter modes. The "Timeline 24h" label is hardcoded i18n key recap.timeline.title regardless of selected range — separate cosmetic bug. Patch list at end.
PATCH 1 — Window-scoped SKU + production totals
File: lib/recap/getRecapData.ts
Replaces: lines ~642678 (the for (const row of machineWorkOrderCounters) loop, the openWorkOrders ensure loop, and the latestTelemetry ensure block)
BEFORE:
ts for (const row of machineWorkOrderCounters) {
const safeGood = Math.max(0, Math.trunc(safeNum(row.goodParts) ?? 0));
const safeScrap = Math.max(0, Math.trunc(safeNum(row.scrapParts) ?? 0));
const safeCycleCount = Math.max(0, Math.trunc(safeNum(row.cycleCount) ?? 0));
const target = safeNum(row.targetQty);
const skuAgg = ensureAuthoritativeSku(row.sku, target, false);
skuAgg.good += safeGood;
skuAgg.scrap += safeScrap;
goodParts += safeGood;
scrapParts += safeScrap;
authoritativeCycleCount += safeCycleCount;
const woKey = workOrderKey(row.workOrderId);
if (!woKey) continue;
const progress = authoritativeWorkOrderProgress.get(woKey) ?? {
goodParts: 0,
scrapParts: 0,
cycleCount: 0,
firstTs: null,
lastTs: null,
};
progress.goodParts += safeGood;
progress.scrapParts += safeScrap;
progress.cycleCount += safeCycleCount;
if (!progress.firstTs || row.createdAt < progress.firstTs) progress.firstTs = row.createdAt;
if (!progress.lastTs || row.updatedAt > progress.lastTs) progress.lastTs = row.updatedAt;
authoritativeWorkOrderProgress.set(woKey, progress);
}
for (const wo of openWorkOrders) {
ensureAuthoritativeSku(normalizeToken(wo.sku) || null);
}
if (latestTelemetry?.sku) {
ensureAuthoritativeSku(latestTelemetry.sku);
}
AFTER:
ts // Step 1: WO-level LIFETIME progress map.
// Used downstream for completed-WO totals (goodParts/durationHrs) and active-WO progressPct,
// both of which intentionally want lifetime, not window-scoped, values.
for (const row of machineWorkOrderCounters) {
const safeGood = Math.max(0, Math.trunc(safeNum(row.goodParts) ?? 0));
const safeScrap = Math.max(0, Math.trunc(safeNum(row.scrapParts) ?? 0));
const safeCycleCount = Math.max(0, Math.trunc(safeNum(row.cycleCount) ?? 0));
const woKey = workOrderKey(row.workOrderId);
if (!woKey) continue;
const progress = authoritativeWorkOrderProgress.get(woKey) ?? {
goodParts: 0,
scrapParts: 0,
cycleCount: 0,
firstTs: null,
lastTs: null,
};
progress.goodParts += safeGood;
progress.scrapParts += safeScrap;
progress.cycleCount += safeCycleCount;
if (!progress.firstTs || row.createdAt < progress.firstTs) progress.firstTs = row.createdAt;
if (!progress.lastTs || row.updatedAt > progress.lastTs) progress.lastTs = row.updatedAt;
authoritativeWorkOrderProgress.set(woKey, progress);
}
// Step 2: WINDOW-SCOPED production totals + per-SKU breakdown from in-window cycle deltas.
// dedupedCycles is already filtered by ts >= start && ts <= end at the Prisma query level.
// Each cycle row contributes its own goodDelta/scrapDelta to the SKU it belongs to.
for (const cycle of dedupedCycles) {
const skuRaw = normalizeToken(cycle.sku);
const g = Math.max(0, Math.trunc(safeNum(cycle.goodDelta) ?? 0));
const s = Math.max(0, Math.trunc(safeNum(cycle.scrapDelta) ?? 0));
// Count the cycle row toward total cycles regardless of SKU (timing-only cycles still happened).
authoritativeCycleCount += 1;
if (g === 0 && s === 0) continue; // no production to attribute
goodParts += g;
scrapParts += s;
if (!skuRaw) continue; // production exists but no SKU tag — count totals, skip SKU table row
const skuAgg = ensureAuthoritativeSku(skuRaw, null, true);
skuAgg.good += g;
skuAgg.scrap += s;
}
What changes for the user:
BUENAS / SCRAP / SKU table = in-window only
Empty SKUs (open WOs that produced nothing in window, latest telemetry SKU) no longer pad the table
Completed WO list, active WO progress%, mold change logic = unchanged (still use lifetime via authoritativeWorkOrderProgress)
PATCH 2 — Unify machine-detail timeline range to 24h
File: app/(app)/machines/[machineId]/MachineDetailClient.tsx
Change 1 — function rename + range: find getMinuteFlooredOneHourRange (around line 365373):
BEFORE:
tsfunction getMinuteFlooredOneHourRange() {
const endMs = Math.floor(Date.now() / 60000) * 60000;
return {
startMs: endMs - 60 * 60 * 1000,
endMs,
};
}
AFTER:
tsfunction getMinuteFlooredDefaultRange() {
const endMs = Math.floor(Date.now() / 60000) * 60000;
return {
startMs: endMs - 24 * 60 * 60 * 1000,
endMs,
};
}
Change 2 — call sites: there are two of them in MachineActivityTimeline (line ~388 inside loadTimeline, line ~427 for the fallback). Replace both:
BEFORE:
tsconst range = getMinuteFlooredOneHourRange();
tsconst fallbackRange = getMinuteFlooredOneHourRange();
AFTER:
tsconst range = getMinuteFlooredDefaultRange();
tsconst fallbackRange = getMinuteFlooredDefaultRange();
Change 3 — UI label: line ~447:
BEFORE:
tsx<div className="text-xs text-zinc-400">1h</div>
AFTER:
tsx<div className="text-xs text-zinc-400">24h</div>
After this, machine detail timeline = same backend, same range, same input as recap detail timeline → identical content (modulo cache age).
PATCH 3 — Dynamic timeline title that reflects the active filter
Reuses existing recap.range.* translation keys. No i18n file changes needed.
File A: components/recap/RecapFullTimeline.tsx
Change 1 — imports + type:
BEFORE (lines 122):
tsx"use client";
import type { RecapTimelineSegment } from "@/lib/recap/types";
import {
computeWidths,
formatDuration,
formatTime,
LABEL_MIN_WIDTH_PCT,
normalizeTimelineSegments,
SEGMENT_MIN_WIDTH_PCT,
TIMELINE_COLORS,
} from "@/components/recap/timelineRender";
import { useI18n } from "@/lib/i18n/useI18n";
type Props = {
rangeStart: string;
rangeEnd: string;
segments: RecapTimelineSegment[];
locale: string;
hasData?: boolean;
loading?: boolean;
};
AFTER:
tsx"use client";
import type { RecapRangeMode, RecapTimelineSegment } from "@/lib/recap/types";
import {
computeWidths,
formatDuration,
formatTime,
LABEL_MIN_WIDTH_PCT,
normalizeTimelineSegments,
SEGMENT_MIN_WIDTH_PCT,
TIMELINE_COLORS,
} from "@/components/recap/timelineRender";
import { useI18n } from "@/lib/i18n/useI18n";
type Props = {
rangeStart: string;
rangeEnd: string;
segments: RecapTimelineSegment[];
locale: string;
hasData?: boolean;
loading?: boolean;
rangeMode?: RecapRangeMode;
};
Change 2 — destructure prop + render dynamic title:
BEFORE (lines 2442):
tsxexport default function RecapFullTimeline({
rangeStart,
rangeEnd,
segments,
locale,
hasData = false,
loading = false,
}: Props) {
const { t } = useI18n();
const startMs = new Date(rangeStart).getTime();
const endMs = new Date(rangeEnd).getTime();
const totalMs = Math.max(1, endMs - startMs);
const normalized = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : [];
const widths = computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT);
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.timeline.title")}</div>
AFTER:
tsxexport default function RecapFullTimeline({
rangeStart,
rangeEnd,
segments,
locale,
hasData = false,
loading = false,
rangeMode,
}: Props) {
const { t } = useI18n();
const startMs = new Date(rangeStart).getTime();
const endMs = new Date(rangeEnd).getTime();
const totalMs = Math.max(1, endMs - startMs);
const normalized = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : [];
const widths = computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT);
const rangeSuffix =
rangeMode === "shift"
? t("recap.range.shiftCurrent")
: rangeMode === "yesterday"
? t("recap.range.yesterday")
: rangeMode === "custom"
? t("recap.range.custom")
: t("recap.range.24h");
const titleText = `${t("recap.timeline.title")} · ${rangeSuffix}`;
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{titleText}</div>
File B: app/(app)/recap/[machineId]/RecapDetailClient.tsx
BEFORE (around lines 215222):
tsx <RecapFullTimeline
rangeStart={timelineStart}
rangeEnd={timelineEnd}
segments={timelineSegments}
hasData={timelineHasData}
loading={timelineLoading}
locale={locale}
/>
AFTER:
tsx <RecapFullTimeline
rangeStart={timelineStart}
rangeEnd={timelineEnd}
segments={timelineSegments}
hasData={timelineHasData}
loading={timelineLoading}
locale={locale}
rangeMode={initialData.range.mode}
/>
Optional bonus — change i18n value: lib/i18n/es-MX.json and lib/i18n/en.json, find key recap.timeline.title and change value from "Timeline 24h" (or whatever it currently is) to just "Timeline". The dynamic suffix will append the actual range. If you don't strip the "24h" from the value, the title will read "Timeline 24h · Ayer" when ayer is selected — still better than current, but cleaner if stripped.

107
fix6.md Normal file
View File

@@ -0,0 +1,107 @@
Patch 1 — Apply settings + update UI function node (PRIMARY)
Node: Apply settings + update UI (function node)
Action: Replace the entire normalizeCatalogItems definition.
FIND this block (lines ~5876 of the function):
javascriptconst normalizeCatalogItems = (list, fallbackLabelPrefix) => {
if (!Array.isArray(list)) return [];
return list
.map((c, idx) => {
const categoryId = String(c.id || c.categoryId || ("cat_" + idx));
const categoryLabel = String(c.label || c.categoryLabel || (fallbackLabelPrefix + " " + (idx + 1)));
const detailsRaw = Array.isArray(c.children) ? c.children : (Array.isArray(c.details) ? c.details : []);
const details = detailsRaw.map((d, jdx) => ({
id: String(d.id || d.detailId || (categoryId + "_d" + jdx)),
label: String(d.label || d.detailLabel || ("Detalle " + (jdx + 1)))
}));
return {
id: categoryId,
label: categoryLabel,
children: details
};
})
.filter((c) => c.label && c.children.length > 0);
};
REPLACE with:
javascript// ============================================================
// CATALOG SANITIZER
// Defense against leaked markdown/spec text being stored as
// catalog labels in Control Tower. Rejects entries whose label
// looks like documentation/notes rather than a real reason.
// Tune MAX_LABEL_LEN if your real labels are longer.
// ============================================================
const MAX_LABEL_LEN = 40;
const isCleanLabel = (s) => {
if (typeof s !== "string") return false;
const t = s.trim();
if (!t) return false;
if (t.length > MAX_LABEL_LEN) return false; // sentence-length text
if (/[\r\n\t]/.test(t)) return false; // multi-line content
if (/^[-*#>|`\[\]]/.test(t)) return false; // markdown leaders: - * # > | ` [ ]
if (/\*\*|__|```|~~~|###/.test(t)) return false; // markdown bold/code/heading
if (/[(\[<{][^)\]>}]*$/.test(t)) return false; // unbalanced opening bracket → truncated
if (/=/.test(t)) return false; // code-like assignment (e.g. type=event)
return true;
};
const normalizeCatalogItems = (list, fallbackLabelPrefix) => {
if (!Array.isArray(list)) return [];
const dropped = [];
const cleaned = list
.map((c, idx) => {
const categoryId = String(c.id || c.categoryId || ("cat_" + idx));
const categoryLabel = String(
c.label || c.categoryLabel || (fallbackLabelPrefix + " " + (idx + 1))
).trim();
const detailsRaw = Array.isArray(c.children)
? c.children
: (Array.isArray(c.details) ? c.details : []);
const details = detailsRaw
.map((d, jdx) => ({
id: String(d.id || d.detailId || (categoryId + "_d" + jdx)),
label: String(d.label || d.detailLabel || ("Detalle " + (jdx + 1))).trim()
}))
.filter((d) => {
if (isCleanLabel(d.label)) return true;
dropped.push("detail<" + categoryLabel.slice(0, 20) + ">: " + d.label.slice(0, 50));
return false;
});
return { id: categoryId, label: categoryLabel, children: details };
})
.filter((c) => {
if (!isCleanLabel(c.label)) {
dropped.push("category: " + c.label.slice(0, 50));
return false;
}
if (c.children.length === 0) {
dropped.push("empty: " + c.label.slice(0, 50));
return false;
}
return true;
});
if (dropped.length > 0) {
node.warn(
"[CATALOG SANITIZER " + fallbackLabelPrefix + "] Dropped " +
dropped.length + " polluted entries:\n - " +
dropped.slice(0, 15).join("\n - ") +
(dropped.length > 15 ? "\n ... (+" + (dropped.length - 15) + " more)" : "")
);
}
return cleaned;
};
Side effects:
Function signature unchanged → no other code in this node needs to change.
The two call sites (incomingCatalog.downtime, incomingCatalog.scrap) work identically.
node.warn will fire on every settings sync that has dirty data — this is intentional so you see when CT pushes garbage.
A category whose children are all polluted will be dropped (it'd be useless anyway).
dropped only logs first 15 to avoid debug-pane spam.
Risk on legit data: MAX_LABEL_LEN = 40 will reject labels longer than 40 chars. If your real catalog has labels like "Falla mecánica del extrusor principal con sensor" (49), bump this to 60. The shortest known false-negative in your current data ("Tap Acknowledge on anomaly panel", 32 chars) still slips through — see Patch 2 below or upstream cleanup.

1
flows (61) (1).json Normal file

File diff suppressed because one or more lines are too long

3867
flows (63).json Normal file

File diff suppressed because one or more lines are too long

4246
flows (64).json Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,204 @@
import { prisma } from "@/lib/prisma";
import { normalizeShiftOverrides } from "@/lib/settings";
type PlannedFilter = "all" | "planned" | "unplanned";
type ShiftFilter = "all" | "A" | "B" | "C";
type ShiftLike = {
name: string;
startTime?: string | null;
endTime?: string | null;
start?: string | null;
end?: string | null;
enabled?: boolean;
};
type ShiftContext = {
timeZone: string;
shifts: ShiftLike[];
overrides: Record<string, ShiftLike[]> | undefined;
};
const SHIFT_ALIAS: ShiftFilter[] = ["A", "B", "C"];
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
const WEEKDAY_KEY_MAP: Record<string, string> = {
Sun: "sun",
Mon: "mon",
Tue: "tue",
Wed: "wed",
Thu: "thu",
Fri: "fri",
Sat: "sat",
};
const WEEKDAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
function asRecord(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function parseTimeMinutes(value?: string | null) {
if (!value || !TIME_RE.test(value)) return null;
const [hh, mm] = value.split(":");
return Number(hh) * 60 + Number(mm);
}
function getLocalMinutes(ts: Date, timeZone: string) {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(ts);
const hours = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
const minutes = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
return hours * 60 + minutes;
} catch {
return ts.getUTCHours() * 60 + ts.getUTCMinutes();
}
}
function getLocalDayKey(ts: Date, timeZone: string) {
try {
const weekday = new Intl.DateTimeFormat("en-US", {
timeZone,
weekday: "short",
}).format(ts);
return WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()];
} catch {
return WEEKDAY_KEYS[ts.getUTCDay()];
}
}
function resolveShiftAlias(context: ShiftContext, ts: Date): ShiftFilter | null {
const dayKey = getLocalDayKey(ts, context.timeZone);
const dayOverrides = context.overrides?.[dayKey];
const activeShifts = dayOverrides ?? context.shifts;
if (!activeShifts.length) return null;
const nowMin = getLocalMinutes(ts, context.timeZone);
let enabledOrdinal = 0;
for (const shift of activeShifts) {
if (shift.enabled === false) continue;
const start = parseTimeMinutes(shift.startTime ?? shift.start ?? null);
const end = parseTimeMinutes(shift.endTime ?? shift.end ?? null);
if (start == null || end == null) continue;
const alias = SHIFT_ALIAS[enabledOrdinal] ?? null;
enabledOrdinal += 1;
if (!alias) continue;
if (start <= end) {
if (nowMin >= start && nowMin < end) return alias;
} else if (nowMin >= start || nowMin < end) {
return alias;
}
}
return null;
}
function isMicrostopLike(row: {
episodeId?: string | null;
meta?: unknown;
}) {
const episodeId = String(row.episodeId ?? "").toLowerCase();
if (episodeId.startsWith("microstop:")) return true;
const meta = asRecord(row.meta);
const anomalyType = String(meta?.anomalyType ?? "").toLowerCase();
if (anomalyType === "microstop") return true;
const eventType = String(meta?.eventType ?? "").toLowerCase();
return eventType === "microstop";
}
function normalizePlanned(raw: string | null): PlannedFilter {
const v = String(raw ?? "").trim().toLowerCase();
if (v === "planned") return "planned";
if (v === "unplanned") return "unplanned";
return "all";
}
export function resolvePlannedFilter(raw: string | null, includeMoldChange: boolean): PlannedFilter {
const normalized = normalizePlanned(raw);
if (raw != null && String(raw).trim() !== "") return normalized;
return includeMoldChange ? "all" : "unplanned";
}
export function normalizeShiftFilter(raw: string | null): ShiftFilter {
const v = String(raw ?? "").trim().toUpperCase();
if (v === "A" || v === "B" || v === "C") return v;
return "all";
}
export function normalizeMicrostopLtMin(raw: string | null) {
if (!raw) return null;
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return null;
return n;
}
function passesPlannedFilter(reasonCode: string, planned: PlannedFilter) {
if (planned === "planned") return reasonCode === "MOLD_CHANGE";
if (planned === "unplanned") return reasonCode !== "MOLD_CHANGE";
return true;
}
export async function loadDowntimeShiftContext(orgId: string): Promise<ShiftContext> {
const [shifts, settings] = await Promise.all([
prisma.orgShift.findMany({
where: { orgId },
orderBy: { sortOrder: "asc" },
select: { name: true, startTime: true, endTime: true, enabled: true },
}),
prisma.orgSettings.findUnique({
where: { orgId },
select: { timezone: true, shiftScheduleOverridesJson: true },
}),
]);
return {
timeZone: settings?.timezone || "UTC",
shifts,
overrides: normalizeShiftOverrides(settings?.shiftScheduleOverridesJson),
};
}
export function applyDowntimeFilters<T extends {
reasonCode: string;
capturedAt: Date;
durationSeconds?: number | null;
episodeId?: string | null;
meta?: unknown;
}>(
rows: T[],
options: {
planned: PlannedFilter;
shift: ShiftFilter;
microstopLtMin: number | null;
shiftContext: ShiftContext | null;
}
) {
return rows.filter((row) => {
if (!passesPlannedFilter(row.reasonCode, options.planned)) return false;
if (options.shift !== "all") {
if (!options.shiftContext) return false;
const alias = resolveShiftAlias(options.shiftContext, row.capturedAt);
if (alias !== options.shift) return false;
}
if (options.microstopLtMin != null && isMicrostopLike(row)) {
if (row.durationSeconds == null) return false;
const durationMin = row.durationSeconds / 60;
if (!(durationMin < options.microstopLtMin)) return false;
}
return true;
});
}

View File

@@ -244,6 +244,7 @@ export async function computeFinancialImpact(params: FinancialImpactParams): Pro
for (const ev of events) {
const eventType = String(ev.eventType ?? "").toLowerCase();
if (eventType === "mold-change") continue;
const { blob, inner } = parseBlob(ev.data);
const status = String(blob?.status ?? inner?.status ?? "").toLowerCase();
const severity = String(ev.severity ?? "").toLowerCase();

View File

@@ -9,6 +9,8 @@
"common.close": "Close",
"common.save": "Save",
"common.copy": "Copy",
"common.yes": "Yes",
"common.no": "No",
"nav.overview": "Overview",
"nav.machines": "Machines",
"nav.reports": "Reports",
@@ -104,6 +106,91 @@
"overview.event.slow-cycle": "slow-cycle",
"overview.status.offline": "OFFLINE",
"overview.status.online": "ONLINE",
"overview.recap.title": "Daily recap",
"overview.recap.subtitle": "Production, downtime, and work orders in one glance.",
"overview.recap.cta": "Open daily recap",
"recap.title": "Recap",
"recap.subtitle": "Last 24h",
"recap.grid.title": "Machine recap",
"recap.grid.subtitle": "Last 24h · click to open details",
"recap.grid.updatedAgo": "Updated {sec}s ago",
"recap.grid.empty": "No machines match the current filters.",
"recap.detail.back": "All machines",
"recap.allMachines": "All machines",
"recap.filter.allLocations": "All locations",
"recap.filter.allStatuses": "All statuses",
"recap.status.running": "Running",
"recap.status.moldChange": "Mold change",
"recap.status.stopped": "Stopped",
"recap.status.offline": "Offline",
"recap.range.24h": "24h",
"recap.range.shift": "Shift",
"recap.range.shiftCurrent": "Current shift",
"recap.range.yesterday": "Yesterday",
"recap.range.custom": "Custom",
"recap.range.apply": "Apply",
"recap.range.shiftUnavailable": "Current shift is unavailable because no shifts are configured.",
"recap.range.shiftFallbackUnavailable": "Current shift is unavailable. Showing the last 24h instead.",
"recap.range.shiftFallbackInactive": "No active shift right now. Showing the last 24h instead.",
"recap.shift.1": "Shift 1",
"recap.shift.2": "Shift 2",
"recap.shift.3": "Shift 3",
"recap.kpi.oee": "OEE Avg 24h",
"recap.kpi.oee24h": "OEE Avg 24h",
"recap.kpi.oeeShift": "OEE Shift",
"recap.kpi.oeeYesterday": "OEE Yesterday",
"recap.kpi.oeeCustom": "OEE Custom Range",
"recap.kpi.noData": "No KPI data",
"recap.kpi.good": "Good parts",
"recap.kpi.stops": "Total stops (min)",
"recap.kpi.scrap": "Scrap",
"recap.card.oee": "OEE Avg 24h",
"recap.card.good": "Good parts",
"recap.card.scrap": "Scrap",
"recap.card.stops": "Stops",
"recap.card.noProduction": "No production",
"recap.card.lastActivity": "Last activity {min} min ago",
"recap.card.activeWorkOrder": "Active WO: {id}",
"recap.card.moldChangeActive": "Mold change in progress · {min}m",
"recap.card.desynced": "CT desynchronized",
"recap.production.title": "Production by SKU",
"recap.production.bySku": "Production by SKU",
"recap.production.sku": "SKU",
"recap.production.good": "Good",
"recap.production.scrap": "Scrap",
"recap.production.target": "Target",
"recap.production.progress": "Progress%",
"recap.downtime.title": "Top downtime",
"recap.downtime.top": "Top stops",
"recap.workOrders.title": "Work orders",
"recap.workOrders.active": "Active",
"recap.workOrders.completed": "Completed",
"recap.workOrders.none": "No production recorded",
"recap.workOrders.sku": "SKU",
"recap.workOrders.startedAt": "Started",
"recap.workOrders.goodParts": "Good parts",
"recap.workOrders.duration": "Duration",
"recap.machine.title": "Machine status",
"recap.machine.running": "Running",
"recap.machine.stopped": "Stopped",
"recap.machine.mold": "Mold change",
"recap.machine.online": "Connected",
"recap.machine.offline": "Disconnected",
"recap.machine.lastHeartbeat": "Last heartbeat",
"recap.machine.uptime": "Uptime",
"recap.banner.mold": "Mold change in progress since",
"recap.banner.moldChange": "Mold change in progress since {time}",
"recap.banner.offline": "No signal for {min} min",
"recap.banner.ongoingStop": "Machine stopped for {min} min",
"recap.banner.stopped": "Machine stopped for {minutes} min",
"recap.timeline.title": "Timeline",
"recap.timeline.noData": "No timeline data",
"recap.timeline.type.production": "Production",
"recap.timeline.type.moldChange": "Mold change",
"recap.timeline.type.macrostop": "Macrostop",
"recap.timeline.type.microstop": "Microstop",
"recap.timeline.type.idle": "Idle",
"recap.empty.production": "No production recorded",
"machines.title": "Machines",
"machines.subtitle": "Select a machine to view live KPIs.",
"machines.cancel": "Cancel",
@@ -147,9 +234,10 @@
"machine.detail.error.network": "Network error",
"machine.detail.back": "Back",
"machine.detail.workOrders.upload": "Upload Work Orders",
"machine.detail.workOrders.downloadTemplate": "Download Template",
"machine.detail.workOrders.uploading": "Uploading...",
"machine.detail.workOrders.uploadParsing": "Parsing file...",
"machine.detail.workOrders.uploadHint": "CSV or XLSX with Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity.",
"machine.detail.workOrders.uploadHint": "CSV or XLSX: Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity, Mold, Total Cavities, Active Cavities (first four columns are enough for legacy files).",
"machine.detail.workOrders.uploadSuccess": "Uploaded {count} work orders",
"machine.detail.workOrders.uploadError": "Upload failed",
"machine.detail.workOrders.uploadInvalid": "No valid work orders found",
@@ -167,11 +255,15 @@
"machine.detail.bucket.unknown": "Unknown",
"machine.detail.activity.title": "Machine Activity Timeline",
"machine.detail.activity.subtitle": "Real-time analysis of production cycles",
"machine.detail.activity.windowBadge": "1h",
"machine.detail.activity.windowModalTitle": "Timeline window",
"machine.detail.activity.windowModalBody": "This timeline always shows the last 1 hour of machine activity.",
"machine.detail.activity.noData": "No timeline data yet.",
"machine.detail.tooltip.cycle": "Cycle: {label}",
"machine.detail.tooltip.duration": "Duration",
"machine.detail.tooltip.ideal": "Ideal",
"machine.detail.tooltip.deviation": "Deviation",
"machine.detail.kpi.oeeCurrent": "Current OEE",
"machine.detail.kpi.updated": "Updated {time}",
"machine.detail.currentWorkOrder": "Current Work Order",
"machine.detail.recentEvents": "Critical Events",
@@ -526,10 +618,18 @@
"financial.field.scrapCostPerUnit": "Scrap cost / unit",
"financial.field.rawMaterialCostPerUnit": "Raw material / unit",
"nav.downtime": "Downtime",
"nav.recap": "Daily recap",
"settings.tabs.modules": "Modules",
"settings.modules.title": "Modules",
"settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.",
"settings.modules.screenless.title": "Screenless mode",
"settings.modules.screenless.helper": "Hide the Downtime module from navigation (for plants without Node-RED reason capture).",
"settings.modules.note": "This setting is org-wide."
"settings.modules.note": "This setting is org-wide.",
"overview.attention.offline": "Offline — no heartbeat",
"overview.attention.stopped": "Currently stopped",
"overview.attention.oeeCritical": "OEE critical: {value}%",
"overview.attention.oeeLow": "OEE low: {value}%",
"overview.attention.scrapHigh": "Scrap rate high: {value}%",
"overview.attention.scrapMod": "Scrap rate elevated: {value}%",
"overview.attention.availLow": "Availability low: {value}%"
}

View File

@@ -9,6 +9,8 @@
"common.close": "Cerrar",
"common.save": "Guardar",
"common.copy": "Copiar",
"common.yes": "Sí",
"common.no": "No",
"nav.overview": "Resumen",
"nav.machines": "Máquinas",
"nav.reports": "Reportes",
@@ -102,8 +104,100 @@
"overview.event.macrostop": "macroparo",
"overview.event.microstop": "microparo",
"overview.event.slow-cycle": "ciclo lento",
"overview.attention.offline": "Sin señal",
"overview.attention.stopped": "Detenida ahora",
"overview.attention.oeeCritical": "OEE crítica: {value}%",
"overview.attention.oeeLow": "OEE baja: {value}%",
"overview.attention.scrapHigh": "Scrap alto: {value}%",
"overview.attention.scrapMod": "Scrap elevado: {value}%",
"overview.attention.availLow": "Disponibilidad baja: {value}%",
"overview.status.offline": "FUERA DE LÍNEA",
"overview.status.online": "EN LÍNEA",
"overview.recap.title": "Resumen diario de turno",
"overview.recap.subtitle": "Consulta producción, paros y órdenes en una sola vista.",
"overview.recap.cta": "Abrir resumen diario",
"recap.title": "Resumen",
"recap.subtitle": "Últimas 24h",
"recap.grid.title": "Resumen de máquinas",
"recap.grid.subtitle": "Últimas 24h · click para ver detalle",
"recap.grid.updatedAgo": "Actualizado hace {sec}s",
"recap.grid.empty": "No hay máquinas que coincidan con los filtros.",
"recap.detail.back": "Todas las máquinas",
"recap.allMachines": "Todas las máquinas",
"recap.filter.allLocations": "Todas las ubicaciones",
"recap.filter.allStatuses": "Todos los estados",
"recap.status.running": "En marcha",
"recap.status.moldChange": "Cambio de molde",
"recap.status.stopped": "Detenida",
"recap.status.offline": "Sin señal",
"recap.range.24h": "24h",
"recap.range.shift": "Turno",
"recap.range.shiftCurrent": "Turno actual",
"recap.range.yesterday": "Ayer",
"recap.range.custom": "Personalizado",
"recap.range.apply": "Aplicar",
"recap.range.shiftUnavailable": "Turno actual no disponible porque no hay turnos configurados.",
"recap.range.shiftFallbackUnavailable": "Turno actual no disponible. Mostrando últimas 24h.",
"recap.range.shiftFallbackInactive": "No hay turno activo en este momento. Mostrando últimas 24h.",
"recap.shift.1": "Turno 1",
"recap.shift.2": "Turno 2",
"recap.shift.3": "Turno 3",
"recap.kpi.oee": "OEE promedio 24h",
"recap.kpi.oee24h": "OEE promedio 24h",
"recap.kpi.oeeShift": "OEE del turno",
"recap.kpi.oeeYesterday": "OEE ayer",
"recap.kpi.oeeCustom": "OEE rango personalizado",
"recap.kpi.noData": "Sin datos de KPI",
"recap.kpi.good": "Buenas",
"recap.kpi.stops": "Paros totales (min)",
"recap.kpi.scrap": "Scrap",
"recap.card.oee": "OEE promedio 24h",
"recap.card.good": "Piezas buenas",
"recap.card.scrap": "Scrap",
"recap.card.stops": "Paros",
"recap.card.noProduction": "Sin producción",
"recap.card.lastActivity": "Última actividad hace {min} min",
"recap.card.activeWorkOrder": "WO activa: {id}",
"recap.card.moldChangeActive": "Cambio de molde en curso · {min}m",
"recap.card.desynced": "CT desincronizado",
"recap.production.title": "Producción por SKU",
"recap.production.bySku": "Producción por SKU",
"recap.production.sku": "SKU",
"recap.production.good": "Buenas",
"recap.production.scrap": "Scrap",
"recap.production.target": "Meta",
"recap.production.progress": "Avance%",
"recap.downtime.title": "Top downtime",
"recap.downtime.top": "Top paros",
"recap.workOrders.title": "Órdenes de trabajo",
"recap.workOrders.active": "Activa",
"recap.workOrders.completed": "Completadas",
"recap.workOrders.none": "Sin producción registrada",
"recap.workOrders.sku": "SKU",
"recap.workOrders.startedAt": "Inicio",
"recap.workOrders.goodParts": "Buenas",
"recap.workOrders.duration": "Duración",
"recap.machine.title": "Estado máquina",
"recap.machine.running": "En marcha",
"recap.machine.stopped": "Detenida",
"recap.machine.mold": "Cambio de molde",
"recap.machine.online": "Conectada",
"recap.machine.offline": "Sin conexión",
"recap.machine.lastHeartbeat": "Último heartbeat",
"recap.machine.uptime": "Uptime",
"recap.banner.mold": "Cambio de molde en curso desde",
"recap.banner.moldChange": "Cambio de molde en curso desde {time}",
"recap.banner.offline": "Sin señal hace {min} min",
"recap.banner.ongoingStop": "Máquina detenida hace {min} min",
"recap.banner.stopped": "Máquina detenida hace {minutes} min",
"recap.timeline.title": "Timeline",
"recap.timeline.noData": "Sin datos de línea de tiempo",
"recap.timeline.type.production": "Producción",
"recap.timeline.type.moldChange": "Cambio de molde",
"recap.timeline.type.macrostop": "Macroparo",
"recap.timeline.type.microstop": "Microparo",
"recap.timeline.type.idle": "Idle",
"recap.empty.production": "Sin producción registrada",
"machines.title": "Máquinas",
"machines.subtitle": "Selecciona una máquina para ver KPIs en vivo.",
"machines.cancel": "Cancelar",
@@ -147,9 +241,10 @@
"machine.detail.error.network": "Error de red",
"machine.detail.back": "Volver",
"machine.detail.workOrders.upload": "Subir ordenes de trabajo",
"machine.detail.workOrders.downloadTemplate": "Descargar plantilla",
"machine.detail.workOrders.uploading": "Subiendo...",
"machine.detail.workOrders.uploadParsing": "Leyendo archivo...",
"machine.detail.workOrders.uploadHint": "CSV o XLSX con Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity.",
"machine.detail.workOrders.uploadHint": "CSV o XLSX: Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity, Molde, Total de cavidades, Cavidades activas (los primeros cuatro campos bastan para archivos antiguos).",
"machine.detail.workOrders.uploadSuccess": "Se cargaron {count} ordenes de trabajo",
"machine.detail.workOrders.uploadError": "No se pudo cargar",
"machine.detail.workOrders.uploadInvalid": "No se encontraron ordenes de trabajo validas",
@@ -167,11 +262,15 @@
"machine.detail.bucket.unknown": "Desconocido",
"machine.detail.activity.title": "Línea de tiempo de actividad",
"machine.detail.activity.subtitle": "Análisis en tiempo real de ciclos de producción",
"machine.detail.activity.windowBadge": "1h",
"machine.detail.activity.windowModalTitle": "Ventana de timeline",
"machine.detail.activity.windowModalBody": "Este timeline siempre muestra la última 1 hora de actividad de la máquina.",
"machine.detail.activity.noData": "Sin datos de línea de tiempo.",
"machine.detail.tooltip.cycle": "Ciclo: {label}",
"machine.detail.tooltip.duration": "Duración",
"machine.detail.tooltip.ideal": "Ideal",
"machine.detail.tooltip.deviation": "Desviación",
"machine.detail.kpi.oeeCurrent": "OEE actual",
"machine.detail.kpi.updated": "Actualizado {time}",
"machine.detail.currentWorkOrder": "Orden de trabajo actual",
"machine.detail.recentEvents": "Eventos críticos",
@@ -526,6 +625,7 @@
"financial.field.scrapCostPerUnit": "Costo scrap / unidad",
"financial.field.rawMaterialCostPerUnit": "Costo materia prima / unidad",
"nav.downtime": "Downtime",
"nav.recap": "Resumen diario",
"settings.tabs.modules": "Módulos",
"settings.modules.title": "Módulos",
"settings.modules.subtitle": "Activa/desactiva módulos según cómo opera la planta.",

907
lib/recap/getRecapData.ts Normal file
View File

@@ -0,0 +1,907 @@
import { unstable_cache } from "next/cache";
import { prisma } from "@/lib/prisma";
import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
import type { RecapMachine, RecapQuery, RecapResponse } from "@/lib/recap/types";
type ShiftLike = {
name: string;
startTime?: string | null;
endTime?: string | null;
start?: string | null;
end?: string | null;
enabled?: boolean;
};
const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
Mon: "mon",
Tue: "tue",
Wed: "wed",
Thu: "thu",
Fri: "fri",
Sat: "sat",
Sun: "sun",
};
const STOP_TYPES = new Set(["microstop", "macrostop"]);
const STOP_STATUS = new Set(["STOP", "DOWN", "OFFLINE"]);
const CACHE_TTL_SEC = 60;
const MOLD_LOOKBACK_MS = 14 * 24 * 60 * 60 * 1000;
const MOLD_ACTIVE_STALE_MS = 12 * 60 * 60 * 1000;
function safeNum(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const n = Number(value);
if (Number.isFinite(n)) return n;
}
return null;
}
function safeBool(value: unknown) {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (!normalized) return false;
return normalized === "true" || normalized === "1" || normalized === "yes";
}
return false;
}
function normalizeToken(value: unknown) {
return String(value ?? "").trim();
}
function workOrderKey(value: unknown) {
const token = normalizeToken(value);
return token ? token.toUpperCase() : "";
}
function skuKey(value: unknown) {
const token = normalizeToken(value);
return token ? token.toUpperCase() : "";
}
function dedupeByKey<T>(rows: T[], keyFn: (row: T) => string) {
const seen = new Set<string>();
const out: T[] = [];
for (const row of rows) {
const key = keyFn(row);
if (seen.has(key)) continue;
seen.add(key);
out.push(row);
}
return out;
}
function toIso(value?: Date | null) {
return value ? value.toISOString() : null;
}
function round2(value: number) {
return Math.round(value * 100) / 100;
}
function parseDate(input?: string | null) {
if (!input) return null;
const n = Number(input);
if (!Number.isNaN(n)) return new Date(n);
const d = new Date(input);
return Number.isNaN(d.getTime()) ? null : d;
}
function normalizeRange(start?: Date, end?: Date) {
const now = new Date();
const safeEnd = end && Number.isFinite(end.getTime()) ? end : now;
const defaultStart = new Date(safeEnd.getTime() - 24 * 60 * 60 * 1000);
const safeStart = start && Number.isFinite(start.getTime()) ? start : defaultStart;
if (safeStart.getTime() > safeEnd.getTime()) {
return { start: new Date(safeEnd.getTime() - 24 * 60 * 60 * 1000), end: safeEnd };
}
return { start: safeStart, end: safeEnd };
}
function parseTimeMinutes(input?: string | null) {
if (!input) return null;
const match = /^(\d{2}):(\d{2})$/.exec(input.trim());
if (!match) return null;
const h = Number(match[1]);
const m = Number(match[2]);
if (!Number.isInteger(h) || !Number.isInteger(m) || h < 0 || h > 23 || m < 0 || m > 59) return null;
return h * 60 + m;
}
function getLocalMinutes(ts: Date, timeZone: string) {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
hour12: false,
hour: "2-digit",
minute: "2-digit",
}).formatToParts(ts);
const h = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
const m = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
return h * 60 + m;
} catch {
return ts.getUTCHours() * 60 + ts.getUTCMinutes();
}
}
function getLocalDayKey(ts: Date, timeZone: string): ShiftOverrideDay {
try {
const weekday = new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short" }).format(ts);
return WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()];
} catch {
return WEEKDAY_KEYS[ts.getUTCDay()];
}
}
function resolveShiftName(
shifts: ShiftLike[],
overrides: Record<string, ShiftLike[]> | undefined,
ts: Date,
timeZone: string
) {
const dayKey = getLocalDayKey(ts, timeZone);
const dayOverrides = overrides?.[dayKey];
const activeShifts = dayOverrides ?? shifts;
if (!activeShifts.length) return null;
const nowMin = getLocalMinutes(ts, timeZone);
for (const shift of activeShifts) {
if (shift.enabled === false) continue;
const start = parseTimeMinutes(shift.startTime ?? shift.start ?? null);
const end = parseTimeMinutes(shift.endTime ?? shift.end ?? null);
if (start == null || end == null) continue;
if (start <= end) {
if (nowMin >= start && nowMin < end) return shift.name;
} else if (nowMin >= start || nowMin < end) {
return shift.name;
}
}
return null;
}
function normalizeShiftAlias(shift?: string | null) {
const normalized = String(shift ?? "").trim().toLowerCase();
if (!normalized) return null;
if (normalized === "shift1" || normalized === "shift2" || normalized === "shift3") return normalized;
return null;
}
function eventDurationSec(data: unknown) {
const inner = extractEventData(data);
return (
safeNum(inner.stoppage_duration_seconds) ??
safeNum(inner.stop_duration_seconds) ??
safeNum(inner.duration_seconds) ??
safeNum(inner.duration_sec) ??
safeNum(inner.durationSeconds) ??
0
);
}
function extractEventData(data: unknown) {
let blob = data;
if (typeof blob === "string") {
try {
blob = JSON.parse(blob);
} catch {
blob = null;
}
}
const record = typeof blob === "object" && blob ? (blob as Record<string, unknown>) : null;
const innerCandidate = record?.data ?? record ?? {};
const inner =
typeof innerCandidate === "object" && innerCandidate !== null
? (innerCandidate as Record<string, unknown>)
: {};
return inner;
}
function eventStatus(data: unknown) {
const inner = extractEventData(data);
return String(inner.status ?? "").trim().toLowerCase();
}
function isRealStopEvent(data: unknown) {
const inner = extractEventData(data);
const status = String(inner.status ?? "").trim().toLowerCase();
const isUpdate = safeBool(inner.is_update ?? inner.isUpdate);
const isAutoAck = safeBool(inner.is_auto_ack ?? inner.isAutoAck);
return status !== "active" && !isUpdate && !isAutoAck;
}
function eventIncidentKey(data: unknown, eventType: string, ts: Date) {
const inner = extractEventData(data);
const direct = String(inner.incidentKey ?? inner.incident_key ?? "").trim();
if (direct) return direct;
const alertId = String(inner.alert_id ?? inner.alertId ?? "").trim();
if (alertId) return `${eventType}:${alertId}`;
const startMs = safeNum(inner.start_ms) ?? safeNum(inner.startMs);
if (startMs != null) return `${eventType}:${Math.trunc(startMs)}`;
return `${eventType}:${ts.getTime()}`;
}
function moldStartMs(data: unknown, fallbackTs: Date) {
const inner = extractEventData(data);
return Math.trunc(safeNum(inner.start_ms) ?? safeNum(inner.startMs) ?? fallbackTs.getTime());
}
export function parseRecapQuery(input: {
machineId?: string | null;
start?: string | null;
end?: string | null;
shift?: string | null;
}) {
return {
machineId: input.machineId ? String(input.machineId).trim() : undefined,
start: parseDate(input.start),
end: parseDate(input.end),
shift: normalizeShiftAlias(input.shift),
};
}
async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
machineId?: string;
start: Date;
end: Date;
shift?: string;
}): Promise<RecapResponse> {
const machineFilter = params.machineId ? { id: params.machineId } : {};
const machines = await prisma.machine.findMany({
where: { orgId: params.orgId, ...machineFilter },
orderBy: { name: "asc" },
select: { id: true, name: true, location: true, tsServer: true },
});
if (!machines.length) {
return {
range: { start: params.start.toISOString(), end: params.end.toISOString() },
availableShifts: [],
machines: [],
};
}
const machineIds = machines.map((m) => m.id);
const moldStartLookback = new Date(params.end.getTime() - MOLD_LOOKBACK_MS);
const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] =
await Promise.all([
prisma.orgSettings.findUnique({
where: { orgId: params.orgId },
select: { timezone: true, shiftScheduleOverridesJson: true },
}),
prisma.orgShift.findMany({
where: { orgId: params.orgId },
orderBy: { sortOrder: "asc" },
select: { name: true, startTime: true, endTime: true, enabled: true, sortOrder: true },
}),
prisma.machineCycle.findMany({
where: {
orgId: params.orgId,
machineId: { in: machineIds },
ts: { gte: params.start, lte: params.end },
},
select: {
machineId: true,
ts: true,
cycleCount: true,
workOrderId: true,
sku: true,
goodDelta: true,
scrapDelta: true,
},
}),
prisma.machineKpiSnapshot.findMany({
where: {
orgId: params.orgId,
machineId: { in: machineIds },
ts: { gte: params.start, lte: params.end },
},
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
select: {
machineId: true,
ts: true,
workOrderId: true,
sku: true,
good: true,
scrap: true,
goodParts: true,
scrapParts: true,
cycleCount: true,
oee: true,
availability: true,
performance: true,
quality: true,
},
}),
prisma.machineEvent.findMany({
where: {
orgId: params.orgId,
machineId: { in: machineIds },
ts: { gte: params.start, lte: params.end },
},
select: {
machineId: true,
ts: true,
eventType: true,
data: true,
},
}),
prisma.reasonEntry.findMany({
where: {
orgId: params.orgId,
machineId: { in: machineIds },
kind: "downtime",
reasonCode: { not: "MOLD_CHANGE" },
capturedAt: { gte: params.start, lte: params.end },
},
select: {
machineId: true,
capturedAt: true,
reasonCode: true,
reasonLabel: true,
durationSeconds: true,
},
}),
prisma.machineWorkOrder.findMany({
where: {
orgId: params.orgId,
machineId: { in: machineIds },
},
orderBy: { updatedAt: "desc" },
select: {
machineId: true,
workOrderId: true,
sku: true,
targetQty: true,
status: true,
createdAt: true,
updatedAt: true,
},
}),
prisma.machineHeartbeat.findMany({
where: {
orgId: params.orgId,
machineId: { in: machineIds },
ts: { gte: params.start, lte: params.end },
},
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
select: {
machineId: true,
ts: true,
tsServer: true,
status: true,
},
}),
prisma.machineHeartbeat.findMany({
where: {
orgId: params.orgId,
machineId: { in: machineIds },
tsServer: { lte: params.end },
},
orderBy: [{ machineId: "asc" }, { tsServer: "desc" }],
distinct: ["machineId"],
select: {
machineId: true,
ts: true,
tsServer: true,
status: true,
},
}),
prisma.machineEvent.findMany({
where: {
orgId: params.orgId,
machineId: { in: machineIds },
eventType: "mold-change",
ts: { gte: moldStartLookback, lte: params.end },
},
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
select: {
machineId: true,
ts: true,
data: true,
},
}),
]);
const timeZone = settings?.timezone || "UTC";
const shiftOverrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
const orderedEnabledShifts = shifts.filter((s) => s.enabled !== false).sort((a, b) => a.sortOrder - b.sortOrder);
const shiftIndex = params.shift ? Number(params.shift.replace("shift", "")) - 1 : -1;
const targetShiftName = shiftIndex >= 0 ? orderedEnabledShifts[shiftIndex]?.name ?? "__missing_shift__" : null;
const inTargetShift = (ts: Date) => {
if (!targetShiftName) return true;
const resolved = resolveShiftName(shifts, shiftOverrides, ts, timeZone);
return resolved === targetShiftName;
};
const cycles = targetShiftName ? cyclesRaw.filter((row) => inTargetShift(row.ts)) : cyclesRaw;
const kpis = targetShiftName ? kpisRaw.filter((row) => inTargetShift(row.ts)) : kpisRaw;
const events = targetShiftName ? eventsRaw.filter((row) => inTargetShift(row.ts)) : eventsRaw;
const reasons = targetShiftName ? reasonsRaw.filter((row) => inTargetShift(row.capturedAt)) : reasonsRaw;
const hbRange = targetShiftName ? hbRangeRaw.filter((row) => inTargetShift(row.ts)) : hbRangeRaw;
const cyclesByMachine = new Map<string, typeof cycles>();
const kpisByMachine = new Map<string, typeof kpis>();
const eventsByMachine = new Map<string, typeof events>();
const reasonsByMachine = new Map<string, typeof reasons>();
const workOrdersByMachine = new Map<string, typeof workOrdersRaw>();
const hbRangeByMachine = new Map<string, typeof hbRange>();
const hbLatestByMachine = new Map(hbLatestRaw.map((row) => [row.machineId, row]));
const moldEventsByMachine = new Map<string, typeof moldEventsRaw>();
for (const row of cycles) {
const list = cyclesByMachine.get(row.machineId) ?? [];
list.push(row);
cyclesByMachine.set(row.machineId, list);
}
for (const row of kpis) {
const list = kpisByMachine.get(row.machineId) ?? [];
list.push(row);
kpisByMachine.set(row.machineId, list);
}
for (const row of events) {
const list = eventsByMachine.get(row.machineId) ?? [];
list.push(row);
eventsByMachine.set(row.machineId, list);
}
for (const row of reasons) {
const list = reasonsByMachine.get(row.machineId) ?? [];
list.push(row);
reasonsByMachine.set(row.machineId, list);
}
for (const row of workOrdersRaw) {
const list = workOrdersByMachine.get(row.machineId) ?? [];
list.push(row);
workOrdersByMachine.set(row.machineId, list);
}
for (const row of hbRange) {
const list = hbRangeByMachine.get(row.machineId) ?? [];
list.push(row);
hbRangeByMachine.set(row.machineId, list);
}
for (const row of moldEventsRaw) {
const list = moldEventsByMachine.get(row.machineId) ?? [];
list.push(row);
moldEventsByMachine.set(row.machineId, list);
}
const machineRows: RecapMachine[] = machines.map((machine) => {
const machineCycles = cyclesByMachine.get(machine.id) ?? [];
const machineKpis = kpisByMachine.get(machine.id) ?? [];
const machineEvents = eventsByMachine.get(machine.id) ?? [];
const machineReasons = reasonsByMachine.get(machine.id) ?? [];
const machineWorkOrders = workOrdersByMachine.get(machine.id) ?? [];
const machineHbRange = hbRangeByMachine.get(machine.id) ?? [];
const latestHb = hbLatestByMachine.get(machine.id) ?? null;
const machineMoldEvents = moldEventsByMachine.get(machine.id) ?? [];
const dedupedCycles = dedupeByKey(
machineCycles,
(cycle) =>
`${cycle.ts.getTime()}:${safeNum(cycle.cycleCount) ?? "na"}:${workOrderKey(cycle.workOrderId)}:${skuKey(cycle.sku)}:${safeNum(cycle.goodDelta) ?? "na"}:${safeNum(cycle.scrapDelta) ?? "na"}`
);
const dedupedKpis = dedupeByKey(
machineKpis,
(kpi) =>
`${kpi.ts.getTime()}:${workOrderKey(kpi.workOrderId)}:${skuKey(kpi.sku)}:${safeNum(kpi.goodParts) ?? safeNum(kpi.good) ?? "na"}:${safeNum(kpi.scrapParts) ?? safeNum(kpi.scrap) ?? "na"}:${safeNum(kpi.cycleCount) ?? "na"}`
);
const machineWorkOrdersSorted = [...machineWorkOrders].sort(
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
);
const targetBySku = new Map<string, { sku: string; target: number }>();
for (const wo of machineWorkOrdersSorted) {
const sku = normalizeToken(wo.sku);
const target = safeNum(wo.targetQty);
if (!sku || target == null || target <= 0) continue;
const key = skuKey(sku);
const current = targetBySku.get(key);
if (current) {
current.target += Math.max(0, Math.trunc(target));
} else {
targetBySku.set(key, { sku, target: Math.max(0, Math.trunc(target)) });
}
}
type SkuAggregate = {
machineName: string;
sku: string;
good: number;
scrap: number;
target: number | null;
};
let latestTelemetry: { ts: Date; workOrderId: string | null; sku: string | null } | null = null;
for (const kpi of dedupedKpis) {
if (!latestTelemetry || kpi.ts > latestTelemetry.ts) {
latestTelemetry = {
ts: kpi.ts,
workOrderId: normalizeToken(kpi.workOrderId) || null,
sku: normalizeToken(kpi.sku) || null,
};
}
}
if (!latestTelemetry) {
for (const cycle of dedupedCycles) {
if (!latestTelemetry || cycle.ts > latestTelemetry.ts) {
latestTelemetry = {
ts: cycle.ts,
workOrderId: normalizeToken(cycle.workOrderId) || null,
sku: normalizeToken(cycle.sku) || null,
};
}
}
}
const openWorkOrders = machineWorkOrdersSorted.filter(
(wo) => String(wo.status).toUpperCase() !== "COMPLETED"
);
const rangeWorkOrderProgress = new Map<
string,
{ goodParts: number; scrapParts: number; cycleCount: number; firstTs: Date | null; lastTs: Date | null }
>();
const authoritativeSkuMap = new Map<string, SkuAggregate>();
let goodParts = 0;
let scrapParts = 0;
let authoritativeCycleCount = 0;
const ensureAuthoritativeSku = (
skuInput: string | null,
targetInput?: number | null,
useFallbackTarget = true
) => {
const skuToken = normalizeToken(skuInput) || "N/A";
const skuTokenKey = skuKey(skuToken);
const targetFallback = useFallbackTarget ? targetBySku.get(skuTokenKey)?.target ?? null : null;
const explicitTarget =
targetInput != null && targetInput > 0 ? Math.max(0, Math.trunc(targetInput)) : null;
const normalizedTarget = explicitTarget ?? targetFallback;
const existing = authoritativeSkuMap.get(skuTokenKey);
if (existing) {
if (explicitTarget != null) {
existing.target = (existing.target ?? 0) + explicitTarget;
} else if (normalizedTarget != null && existing.target == null) {
existing.target = normalizedTarget;
}
return existing;
}
const created: SkuAggregate = {
machineName: machine.name,
sku: skuToken,
good: 0,
scrap: 0,
target: normalizedTarget,
};
authoritativeSkuMap.set(skuTokenKey, created);
return created;
};
for (const cycle of dedupedCycles) {
const skuRaw = normalizeToken(cycle.sku);
const g = Math.max(0, Math.trunc(safeNum(cycle.goodDelta) ?? 0));
const s = Math.max(0, Math.trunc(safeNum(cycle.scrapDelta) ?? 0));
const woKey = workOrderKey(cycle.workOrderId);
authoritativeCycleCount += 1;
if (g === 0 && s === 0) continue;
goodParts += g;
scrapParts += s;
if (woKey) {
const progress = rangeWorkOrderProgress.get(woKey) ?? {
goodParts: 0,
scrapParts: 0,
cycleCount: 0,
firstTs: null,
lastTs: null,
};
progress.goodParts += g;
progress.scrapParts += s;
progress.cycleCount += 1;
if (!progress.firstTs || cycle.ts < progress.firstTs) progress.firstTs = cycle.ts;
if (!progress.lastTs || cycle.ts > progress.lastTs) progress.lastTs = cycle.ts;
rangeWorkOrderProgress.set(woKey, progress);
}
if (!skuRaw) continue;
const skuAgg = ensureAuthoritativeSku(skuRaw, null, true);
skuAgg.good += g;
skuAgg.scrap += s;
}
const bySku = [...authoritativeSkuMap.values()]
.map((row) => ({
machineName: row.machineName,
sku: row.sku,
good: row.good,
scrap: row.scrap,
target: null as number | null,
progressPct: null as number | null,
}))
.sort((a, b) => b.good - a.good);
const sortedKpis = [...dedupedKpis].sort((a, b) => a.ts.getTime() - b.ts.getTime());
const weightedAvg = (field: "oee" | "availability" | "performance" | "quality") => {
if (!sortedKpis.length) return null;
let totalMs = 0;
let weightedSum = 0;
for (let i = 0; i < sortedKpis.length; i += 1) {
const current = sortedKpis[i];
const nextTsMs = (sortedKpis[i + 1]?.ts ?? params.end).getTime();
const dt = Math.max(0, nextTsMs - current.ts.getTime());
if (dt <= 0) continue;
weightedSum += (safeNum(current[field]) ?? 0) * dt;
totalMs += dt;
}
return totalMs > 0 ? round2(weightedSum / totalMs) : null;
};
let stopDurSecFromEvents = 0;
let stopsCount = 0;
for (const event of machineEvents) {
const type = String(event.eventType || "").toLowerCase();
if (!STOP_TYPES.has(type)) continue;
if (!isRealStopEvent(event.data)) continue;
stopsCount += 1;
stopDurSecFromEvents += eventDurationSec(event.data);
}
const reasonAgg = new Map<string, { reasonLabel: string; seconds: number; count: number }>();
let stopDurSecFromReasons = 0;
for (const reason of machineReasons) {
const label = reason.reasonLabel?.trim() || reason.reasonCode || "Sin razón";
const seconds = Math.max(0, safeNum(reason.durationSeconds) ?? 0);
stopDurSecFromReasons += seconds;
const agg = reasonAgg.get(label) ?? { reasonLabel: label, seconds: 0, count: 0 };
agg.seconds += seconds;
agg.count += 1;
reasonAgg.set(label, agg);
}
const topReasons = [...reasonAgg.values()]
.sort((a, b) => b.seconds - a.seconds)
.slice(0, 3)
.map((row) => ({
reasonLabel: row.reasonLabel,
minutes: round2(row.seconds / 60),
count: row.count,
}));
const totalMin = round2(Math.max(stopDurSecFromEvents, stopDurSecFromReasons) / 60);
let ongoingStopMin: number | null = null;
const latestStatus = String(latestHb?.status ?? "").toUpperCase();
const latestTs = latestHb?.tsServer ?? latestHb?.ts ?? null;
if (latestTs && STOP_STATUS.has(latestStatus)) {
let downStart = latestTs;
for (let i = machineHbRange.length - 1; i >= 0; i -= 1) {
const hb = machineHbRange[i];
const hbStatus = String(hb.status ?? "").toUpperCase();
if (!STOP_STATUS.has(hbStatus)) break;
downStart = hb.tsServer ?? hb.ts;
}
ongoingStopMin = round2(Math.max(0, (params.end.getTime() - downStart.getTime()) / 60000));
}
const completed = machineWorkOrdersSorted
.filter((wo) => String(wo.status).toUpperCase() === "COMPLETED")
.filter((wo) => wo.updatedAt >= params.start && wo.updatedAt <= params.end)
.map((wo) => {
const progress = rangeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? {
goodParts: 0,
scrapParts: 0,
cycleCount: 0,
firstTs: null,
lastTs: null,
};
const durationHrs =
progress.firstTs && progress.lastTs
? round2((progress.lastTs.getTime() - progress.firstTs.getTime()) / 3600000)
: 0;
return {
id: wo.workOrderId,
sku: wo.sku,
goodParts: progress.goodParts,
durationHrs,
};
})
.sort((a, b) => b.goodParts - a.goodParts);
const telemetryWorkOrderKey = workOrderKey(latestTelemetry?.workOrderId);
const matchedTelemetryWo = telemetryWorkOrderKey
? openWorkOrders.find((wo) => workOrderKey(wo.workOrderId) === telemetryWorkOrderKey) ?? null
: null;
const activeWo = matchedTelemetryWo ?? openWorkOrders[0] ?? null;
const activeWorkOrderId =
normalizeToken(latestTelemetry?.workOrderId) || normalizeToken(activeWo?.workOrderId) || null;
const activeWorkOrderSku =
normalizeToken(latestTelemetry?.sku) || normalizeToken(activeWo?.sku) || null;
const activeWorkOrderKey = workOrderKey(activeWorkOrderId);
const activeTargetSource =
activeWorkOrderKey
? machineWorkOrdersSorted.find((wo) => workOrderKey(wo.workOrderId) === activeWorkOrderKey) ??
activeWo
: activeWo;
let activeProgressPct: number | null = null;
let activeStartedAt: string | null = null;
if (activeWorkOrderId) {
const rangeProgress = activeWorkOrderKey ? rangeWorkOrderProgress.get(activeWorkOrderKey) ?? null : null;
const producedForProgress = rangeProgress
? rangeProgress.goodParts + rangeProgress.scrapParts
: 0;
const targetQty = safeNum(activeTargetSource?.targetQty);
if (targetQty && targetQty > 0) {
activeProgressPct = round2((producedForProgress / targetQty) * 100);
}
activeStartedAt = toIso(rangeProgress?.firstTs ?? latestTelemetry?.ts ?? null);
}
const firstProductionMsAfterMoldStart = (startMs: number) => {
let best: number | null = null;
for (const cycle of dedupedCycles) {
const t = cycle.ts.getTime();
if (t <= startMs) continue;
const g = safeNum(cycle.goodDelta) ?? 0;
const s = safeNum(cycle.scrapDelta) ?? 0;
if (g > 0 || s > 0) {
if (best == null || t < best) best = t;
}
}
for (const kpi of dedupedKpis) {
const t = kpi.ts.getTime();
if (t <= startMs) continue;
const g = safeNum(kpi.good) ?? safeNum(kpi.goodParts) ?? 0;
const s = safeNum(kpi.scrap) ?? safeNum(kpi.scrapParts) ?? 0;
if (g > 0 || s > 0) {
if (best == null || t < best) best = t;
}
}
return best;
};
const moldActiveByIncident = new Map<string, number>();
for (const event of machineMoldEvents) {
const inner = extractEventData(event.data);
const isUpdate = safeBool(inner.is_update ?? inner.isUpdate);
const isAutoAck = safeBool(inner.is_auto_ack ?? inner.isAutoAck);
if (isUpdate || isAutoAck) continue;
const key = eventIncidentKey(event.data, "mold-change", event.ts);
const status = eventStatus(event.data);
if (status === "resolved") {
moldActiveByIncident.delete(key);
continue;
}
if (status === "active" || !status) {
if (params.end.getTime() - event.ts.getTime() > MOLD_ACTIVE_STALE_MS) continue;
moldActiveByIncident.set(key, moldStartMs(event.data, event.ts));
}
}
for (const [k, startMs] of [...moldActiveByIncident.entries()]) {
const resumeMs = firstProductionMsAfterMoldStart(startMs);
if (resumeMs != null && resumeMs <= params.end.getTime()) {
moldActiveByIncident.delete(k);
}
}
let moldChangeStartMs: number | null = null;
for (const startMs of moldActiveByIncident.values()) {
if (moldChangeStartMs == null || startMs > moldChangeStartMs) moldChangeStartMs = startMs;
}
const moldChangeInProgress = moldChangeStartMs != null;
let uptimePct: number | null = null;
if (machineHbRange.length) {
let onlineCount = 0;
for (const hb of machineHbRange) {
const status = String(hb.status ?? "").toUpperCase();
if (!STOP_STATUS.has(status)) onlineCount += 1;
}
uptimePct = round2((onlineCount / machineHbRange.length) * 100);
}
return {
machineId: machine.id,
machineName: machine.name,
location: machine.location,
production: {
goodParts,
scrapParts,
totalCycles: authoritativeCycleCount,
bySku,
},
oee: {
avg: weightedAvg("oee"),
availability: weightedAvg("availability"),
performance: weightedAvg("performance"),
quality: weightedAvg("quality"),
},
downtime: {
totalMin,
stopsCount,
topReasons,
ongoingStopMin,
},
workOrders: {
completed,
active: activeWorkOrderId
? {
id: activeWorkOrderId,
sku: activeWorkOrderSku,
progressPct: activeProgressPct,
startedAt: activeStartedAt,
}
: null,
moldChangeInProgress,
moldChangeStartMs,
},
heartbeat: {
lastSeenAt: toIso(
(() => {
const hbMs = latestHb ? (latestHb.tsServer ?? latestHb.ts).getTime() : null;
const machineMs = machine.tsServer.getTime();
if (hbMs != null) return new Date(Math.max(hbMs, machineMs));
return machine.tsServer;
})()
),
uptimePct,
},
};
});
return {
range: {
start: params.start.toISOString(),
end: params.end.toISOString(),
},
availableShifts: orderedEnabledShifts.map((shift, idx) => ({
id: `shift${idx + 1}`,
name: shift.name,
})),
machines: machineRows,
};
}
export async function getRecapDataCached(params: RecapQuery): Promise<RecapResponse> {
const { start, end } = normalizeRange(params.start, params.end);
const machineId = params.machineId?.trim() || undefined;
const shift = normalizeShiftAlias(params.shift) ?? undefined;
const cacheKey = [
"recap",
params.orgId,
machineId ?? "all",
String(start.getTime()),
String(end.getTime()),
shift ?? "all",
];
const cached = unstable_cache(
() =>
computeRecap({
orgId: params.orgId,
machineId,
start,
end,
shift,
}),
cacheKey,
{
revalidate: CACHE_TTL_SEC,
tags: [`recap:${params.orgId}`],
}
);
return cached();
}

View File

@@ -0,0 +1,27 @@
/**
* Recap & work-order progress: large targets (e.g. 301k) make raw % < 1.
* Rounding to integer shows 0%; bar width 0.17% is invisible. Use decimals + a visual floor for the bar.
*/
/** "0.17%" with enough precision when needed; "—" for null. */
export function formatRecapProgressPercent(
pct: number | null | undefined,
locale: string
): string {
if (pct == null || Number.isNaN(pct)) return "—";
if (pct <= 0) return "0%";
if (pct < 10) {
return `${pct.toLocaleString(locale, { maximumFractionDigits: 2, minimumFractionDigits: 0 })}%`;
}
return `${Math.round(pct).toLocaleString(locale)}%`;
}
/**
* For CSS width %: keep proportional when ≥2%; below that, any positive progress
* needs a minimum or the bar looks like a single pixel.
*/
export function progressBarWidthPercent(pct: number | null | undefined): number {
if (pct == null || Number.isNaN(pct) || pct <= 0) return 0;
if (pct < 2) return Math.max(2, Math.min(100, pct));
return Math.min(100, pct);
}

View File

@@ -0,0 +1,4 @@
/**
* Client-safe recap thresholds. Kept in sync with OFFLINE logic in lib/recap/redesign.ts.
*/
export const RECAP_HEARTBEAT_STALE_MS = 5 * 60 * 1000;

776
lib/recap/redesign.ts Normal file
View File

@@ -0,0 +1,776 @@
import { unstable_cache } from "next/cache";
import { prisma } from "@/lib/prisma";
import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
import { getRecapDataCached } from "@/lib/recap/getRecapData";
import {
buildTimelineSegments,
compressTimelineSegments,
TIMELINE_EVENT_TYPES,
type TimelineCycleRow,
type TimelineEventRow,
} from "@/lib/recap/timeline";
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
import type {
RecapDetailResponse,
RecapMachine,
RecapMachineDetail,
RecapMachineStatus,
RecapRangeMode,
RecapSummaryMachine,
RecapSummaryResponse,
} from "@/lib/recap/types";
type DetailRangeInput = {
mode?: string | null;
start?: string | null;
end?: string | null;
};
const OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS;
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000;
const RECAP_CACHE_TTL_SEC = 60;
const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
Mon: "mon",
Tue: "tue",
Wed: "wed",
Thu: "thu",
Fri: "fri",
Sat: "sat",
Sun: "sun",
};
function round2(value: number) {
return Math.round(value * 100) / 100;
}
function parseDate(input?: string | null) {
if (!input) return null;
const n = Number(input);
if (Number.isFinite(n)) {
const d = new Date(n);
return Number.isFinite(d.getTime()) ? d : null;
}
const d = new Date(input);
return Number.isFinite(d.getTime()) ? d : null;
}
function parseHours(input: string | null) {
const parsed = Math.trunc(Number(input ?? "24"));
if (!Number.isFinite(parsed)) return 24;
return Math.max(1, Math.min(72, parsed));
}
function parseTimeMinutes(input?: string | null) {
if (!input) return null;
const match = /^(\d{2}):(\d{2})$/.exec(input.trim());
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
return null;
}
return hours * 60 + minutes;
}
function getLocalParts(ts: Date, timeZone: string) {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
weekday: "short",
hour12: false,
}).formatToParts(ts);
const value = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
const year = Number(value("year"));
const month = Number(value("month"));
const day = Number(value("day"));
const hour = Number(value("hour"));
const minute = Number(value("minute"));
const weekday = value("weekday");
return {
year,
month,
day,
hour,
minute,
weekday: WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()],
minutesOfDay: hour * 60 + minute,
};
} catch {
return {
year: ts.getUTCFullYear(),
month: ts.getUTCMonth() + 1,
day: ts.getUTCDate(),
hour: ts.getUTCHours(),
minute: ts.getUTCMinutes(),
weekday: WEEKDAY_KEYS[ts.getUTCDay()],
minutesOfDay: ts.getUTCHours() * 60 + ts.getUTCMinutes(),
};
}
}
function parseOffsetMinutes(offsetLabel: string | null) {
if (!offsetLabel) return null;
const normalized = offsetLabel.replace("UTC", "GMT");
const match = /^GMT([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(normalized);
if (!match) return null;
const sign = match[1] === "-" ? -1 : 1;
const hour = Number(match[2]);
const minute = Number(match[3] ?? "0");
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
return sign * (hour * 60 + minute);
}
function getTzOffsetMinutes(utcDate: Date, timeZone: string) {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
timeZoneName: "shortOffset",
hour: "2-digit",
}).formatToParts(utcDate);
const offsetPart = parts.find((part) => part.type === "timeZoneName")?.value ?? null;
return parseOffsetMinutes(offsetPart);
} catch {
return null;
}
}
function zonedToUtcDate(input: {
year: number;
month: number;
day: number;
hours: number;
minutes: number;
timeZone: string;
}) {
const baseUtc = Date.UTC(input.year, input.month - 1, input.day, input.hours, input.minutes, 0, 0);
const guessDate = new Date(baseUtc);
const offsetA = getTzOffsetMinutes(guessDate, input.timeZone);
if (offsetA == null) return guessDate;
let corrected = new Date(baseUtc - offsetA * 60000);
const offsetB = getTzOffsetMinutes(corrected, input.timeZone);
if (offsetB != null && offsetB !== offsetA) {
corrected = new Date(baseUtc - offsetB * 60000);
}
return corrected;
}
function addDays(input: { year: number; month: number; day: number }, days: number) {
const base = new Date(Date.UTC(input.year, input.month - 1, input.day));
base.setUTCDate(base.getUTCDate() + days);
return {
year: base.getUTCFullYear(),
month: base.getUTCMonth() + 1,
day: base.getUTCDate(),
};
}
function statusFromMachine(machine: RecapMachine, endMs: number) {
const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null;
const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs);
const offline = !Number.isFinite(lastSeenMs ?? Number.NaN) || offlineForMs > OFFLINE_THRESHOLD_MS;
const ongoingStopMin = machine.downtime.ongoingStopMin ?? 0;
const moldActive = machine.workOrders.moldChangeInProgress;
let status: RecapMachineStatus = "running";
if (offline) status = "offline";
else if (moldActive) status = "mold-change";
else if (ongoingStopMin > 0) status = "stopped";
return {
status,
lastSeenMs,
offlineForMin: offline ? Math.max(0, Math.floor(offlineForMs / 60000)) : null,
ongoingStopMin: machine.downtime.ongoingStopMin,
};
}
async function loadTimelineRowsForMachines(params: {
orgId: string;
machineIds: string[];
start: Date;
end: Date;
}) {
if (!params.machineIds.length) {
return {
cyclesByMachine: new Map<string, TimelineCycleRow[]>(),
eventsByMachine: new Map<string, TimelineEventRow[]>(),
};
}
const [cycles, events] = await Promise.all([
prisma.machineCycle.findMany({
where: {
orgId: params.orgId,
machineId: { in: params.machineIds },
ts: {
gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
lte: params.end,
},
},
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
select: {
machineId: true,
ts: true,
cycleCount: true,
actualCycleTime: true,
workOrderId: true,
sku: true,
},
}),
prisma.machineEvent.findMany({
where: {
orgId: params.orgId,
machineId: { in: params.machineIds },
eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] },
ts: {
gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS),
lte: params.end,
},
},
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
select: {
machineId: true,
ts: true,
eventType: true,
data: true,
},
}),
]);
const cyclesByMachine = new Map<string, TimelineCycleRow[]>();
const eventsByMachine = new Map<string, TimelineEventRow[]>();
for (const row of cycles) {
const list = cyclesByMachine.get(row.machineId) ?? [];
list.push({
ts: row.ts,
cycleCount: row.cycleCount,
actualCycleTime: row.actualCycleTime,
workOrderId: row.workOrderId,
sku: row.sku,
});
cyclesByMachine.set(row.machineId, list);
}
for (const row of events) {
const list = eventsByMachine.get(row.machineId) ?? [];
list.push({
ts: row.ts,
eventType: row.eventType,
data: row.data,
});
eventsByMachine.set(row.machineId, list);
}
return { cyclesByMachine, eventsByMachine };
}
function toSummaryMachine(params: {
machine: RecapMachine;
miniTimeline: ReturnType<typeof compressTimelineSegments>;
rangeEndMs: number;
}): RecapSummaryMachine {
const { machine, miniTimeline, rangeEndMs } = params;
const status = statusFromMachine(machine, rangeEndMs);
return {
machineId: machine.machineId,
name: machine.machineName,
location: machine.location,
status: status.status,
oee: machine.oee.avg,
goodParts: machine.production.goodParts,
scrap: machine.production.scrapParts,
stopsCount: machine.downtime.stopsCount,
lastSeenMs: status.lastSeenMs,
lastActivityMin:
status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)),
offlineForMin: status.offlineForMin,
ongoingStopMin: status.ongoingStopMin,
activeWorkOrderId: machine.workOrders.active?.id ?? null,
moldChange: {
active: machine.workOrders.moldChangeInProgress,
startMs: machine.workOrders.moldChangeStartMs,
elapsedMin:
machine.workOrders.moldChangeStartMs == null
? null
: Math.max(0, Math.floor((rangeEndMs - machine.workOrders.moldChangeStartMs) / 60000)),
},
miniTimeline,
};
}
async function computeRecapSummary(params: { orgId: string; hours: number }) {
const now = new Date();
const end = new Date(Math.floor(now.getTime() / 60000) * 60000);
const start = new Date(end.getTime() - params.hours * 60 * 60 * 1000);
const recap = await getRecapDataCached({
orgId: params.orgId,
start,
end,
});
const machineIds = recap.machines.map((machine) => machine.machineId);
const timelineRows = await loadTimelineRowsForMachines({
orgId: params.orgId,
machineIds,
start,
end,
});
const machines = recap.machines.map((machine) => {
const segments = buildTimelineSegments({
cycles: timelineRows.cyclesByMachine.get(machine.machineId) ?? [],
events: timelineRows.eventsByMachine.get(machine.machineId) ?? [],
rangeStart: start,
rangeEnd: end,
});
const miniTimeline = compressTimelineSegments({
segments,
rangeStart: start,
rangeEnd: end,
maxSegments: 60,
});
return toSummaryMachine({
machine,
miniTimeline,
rangeEndMs: end.getTime(),
});
});
const response: RecapSummaryResponse = {
generatedAt: new Date().toISOString(),
range: {
start: start.toISOString(),
end: end.toISOString(),
hours: params.hours,
},
machines,
};
return response;
}
function normalizedRangeMode(mode?: string | null): RecapRangeMode {
const raw = String(mode ?? "").trim().toLowerCase();
if (raw === "shift") return "shift";
if (raw === "yesterday") return "yesterday";
if (raw === "custom") return "custom";
return "24h";
}
async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
const settings = await prisma.orgSettings.findUnique({
where: { orgId: params.orgId },
select: {
timezone: true,
shiftScheduleOverridesJson: true,
},
});
const shifts = await prisma.orgShift.findMany({
where: { orgId: params.orgId },
orderBy: { sortOrder: "asc" },
select: {
name: true,
startTime: true,
endTime: true,
enabled: true,
sortOrder: true,
},
});
const enabledShifts = shifts.filter((shift) => shift.enabled !== false);
if (!enabledShifts.length) {
return {
hasEnabledShifts: false,
range: null,
} as const;
}
const timeZone = settings?.timezone || "UTC";
const local = getLocalParts(params.now, timeZone);
const overrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
const dayOverrides = overrides?.[local.weekday];
const activeShifts = (dayOverrides?.length
? dayOverrides.map((shift) => ({
enabled: shift.enabled !== false,
start: shift.start,
end: shift.end,
}))
: enabledShifts.map((shift) => ({
enabled: shift.enabled !== false,
start: shift.startTime,
end: shift.endTime,
}))
).filter((shift) => shift.enabled);
for (const shift of activeShifts) {
const startMin = parseTimeMinutes(shift.start ?? null);
const endMin = parseTimeMinutes(shift.end ?? null);
if (startMin == null || endMin == null) continue;
const minutesNow = local.minutesOfDay;
let inRange = false;
let startDate = { year: local.year, month: local.month, day: local.day };
let endDate = { year: local.year, month: local.month, day: local.day };
if (startMin <= endMin) {
inRange = minutesNow >= startMin && minutesNow < endMin;
} else {
inRange = minutesNow >= startMin || minutesNow < endMin;
if (minutesNow >= startMin) {
endDate = addDays(endDate, 1);
} else {
startDate = addDays(startDate, -1);
}
}
if (!inRange) continue;
const start = zonedToUtcDate({
...startDate,
hours: Math.floor(startMin / 60),
minutes: startMin % 60,
timeZone,
});
const shiftEndUtc = zonedToUtcDate({
...endDate,
hours: Math.floor(endMin / 60),
minutes: endMin % 60,
timeZone,
});
if (shiftEndUtc <= start) continue;
// Cap end at "now" so we render shift-so-far, not shift-as-planned.
// Without cap:
// - timeline fills future minutes with idle (visual lie)
// - offline calc = (shift_end_future - last_seen) = looks 5h offline
// even on a machine producing right now
const end = params.now < shiftEndUtc ? params.now : shiftEndUtc;
return {
hasEnabledShifts: true,
range: { start, end },
};
}
return {
hasEnabledShifts: true,
range: null,
} as const;
}
async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
const now = new Date(Math.floor(Date.now() / 60000) * 60000);
const requestedMode = normalizedRangeMode(params.input.mode);
const shiftEnabledCount = await prisma.orgShift.count({
where: {
orgId: params.orgId,
enabled: { not: false },
},
});
const shiftAvailable = shiftEnabledCount > 0;
if (requestedMode === "custom") {
const start = parseDate(params.input.start);
const end = parseDate(params.input.end);
if (start && end && end > start) {
return {
requestedMode,
mode: requestedMode,
start,
end,
shiftAvailable,
} as const;
}
}
if (requestedMode === "yesterday") {
const settings = await prisma.orgSettings.findUnique({
where: { orgId: params.orgId },
select: { timezone: true },
});
const timeZone = settings?.timezone || "America/Mexico_City";
const localNow = getLocalParts(now, timeZone);
const today = { year: localNow.year, month: localNow.month, day: localNow.day };
const yesterday = addDays(today, -1);
const start = zonedToUtcDate({
...yesterday,
hours: 0,
minutes: 0,
timeZone,
});
const end = zonedToUtcDate({
...today,
hours: 0,
minutes: 0,
timeZone,
});
return {
requestedMode,
mode: requestedMode,
start,
end,
shiftAvailable,
} as const;
}
if (requestedMode === "shift") {
const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
if (shiftRange.range) {
return {
requestedMode,
mode: requestedMode,
start: shiftRange.range.start,
end: shiftRange.range.end,
shiftAvailable,
} as const;
}
if (!shiftRange.hasEnabledShifts) {
return {
requestedMode,
mode: "24h" as const,
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
end: now,
shiftAvailable,
fallbackReason: "shift-unavailable" as const,
} as const;
}
return {
requestedMode,
mode: "24h" as const,
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
end: now,
shiftAvailable,
fallbackReason: "shift-inactive" as const,
} as const;
}
return {
requestedMode,
mode: "24h" as const,
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
end: now,
shiftAvailable,
} as const;
}
async function computeRecapMachineDetail(params: {
orgId: string;
machineId: string;
range: {
requestedMode: RecapRangeMode;
mode: RecapRangeMode;
start: Date;
end: Date;
shiftAvailable: boolean;
fallbackReason?: "shift-unavailable" | "shift-inactive";
};
}) {
const { range } = params;
const recap = await getRecapDataCached({
orgId: params.orgId,
machineId: params.machineId,
start: range.start,
end: range.end,
});
const machine = recap.machines.find((row) => row.machineId === params.machineId) ?? null;
if (!machine) return null;
const timelineRows = await loadTimelineRowsForMachines({
orgId: params.orgId,
machineIds: [params.machineId],
start: range.start,
end: range.end,
});
const timeline = buildTimelineSegments({
cycles: timelineRows.cyclesByMachine.get(params.machineId) ?? [],
events: timelineRows.eventsByMachine.get(params.machineId) ?? [],
rangeStart: range.start,
rangeEnd: range.end,
});
const status = statusFromMachine(machine, range.end.getTime());
const downtimeTotalMin = Math.max(0, machine.downtime.totalMin);
const downtimeTop = machine.downtime.topReasons.slice(0, 3).map((row) => ({
reasonLabel: row.reasonLabel,
minutes: row.minutes,
count: row.count,
percent: downtimeTotalMin > 0 ? round2((row.minutes / downtimeTotalMin) * 100) : 0,
}));
const machineDetail: RecapMachineDetail = {
machineId: machine.machineId,
name: machine.machineName,
location: machine.location,
status: status.status,
oee: machine.oee.avg,
goodParts: machine.production.goodParts,
scrap: machine.production.scrapParts,
stopsCount: machine.downtime.stopsCount,
stopMinutes: downtimeTotalMin,
activeWorkOrderId: machine.workOrders.active?.id ?? null,
lastSeenMs: status.lastSeenMs,
offlineForMin: status.offlineForMin,
ongoingStopMin: status.ongoingStopMin,
moldChange: {
active: machine.workOrders.moldChangeInProgress,
startMs: machine.workOrders.moldChangeStartMs,
},
timeline,
productionBySku: machine.production.bySku,
downtimeTop,
workOrders: {
completed: machine.workOrders.completed,
active: machine.workOrders.active,
},
heartbeat: {
lastSeenAt: machine.heartbeat.lastSeenAt,
uptimePct: machine.heartbeat.uptimePct,
connectionStatus: status.status === "offline" ? "offline" : "online",
},
};
const response: RecapDetailResponse = {
generatedAt: new Date().toISOString(),
range: {
requestedMode: range.requestedMode,
mode: range.mode,
start: range.start.toISOString(),
end: range.end.toISOString(),
shiftAvailable: range.shiftAvailable,
fallbackReason: range.fallbackReason,
},
machine: machineDetail,
};
return response;
}
function summaryCacheKey(params: { orgId: string; hours: number }) {
return ["recap-summary-v1", params.orgId, String(params.hours)];
}
function detailCacheKey(params: {
orgId: string;
machineId: string;
requestedMode: RecapRangeMode;
mode: RecapRangeMode;
shiftAvailable: boolean;
fallbackReason?: "shift-unavailable" | "shift-inactive";
startMs: number;
endMs: number;
}) {
return [
"recap-detail-v1",
params.orgId,
params.machineId,
params.requestedMode,
params.mode,
params.shiftAvailable ? "shift-on" : "shift-off",
params.fallbackReason ?? "",
String(Math.trunc(params.startMs / 60000)),
String(Math.trunc(params.endMs / 60000)),
];
}
export function parseRecapSummaryHours(raw: string | null) {
return parseHours(raw);
}
export function parseRecapDetailRangeInput(searchParams: URLSearchParams | Record<string, string | string[] | undefined>) {
if (searchParams instanceof URLSearchParams) {
return {
mode: searchParams.get("range") ?? undefined,
start: searchParams.get("start") ?? undefined,
end: searchParams.get("end") ?? undefined,
};
}
const pick = (key: string) => {
const value = searchParams[key];
if (Array.isArray(value)) return value[0] ?? undefined;
return value ?? undefined;
};
return {
mode: pick("range"),
start: pick("start"),
end: pick("end"),
};
}
export async function getRecapSummaryCached(params: { orgId: string; hours: number }) {
const cache = unstable_cache(
() => computeRecapSummary(params),
summaryCacheKey(params),
{
revalidate: RECAP_CACHE_TTL_SEC,
tags: [`recap:${params.orgId}`],
}
);
return cache();
}
export async function getRecapMachineDetailCached(params: {
orgId: string;
machineId: string;
input: DetailRangeInput;
}) {
const resolved = await resolveDetailRange({
orgId: params.orgId,
input: params.input,
});
const cache = unstable_cache(
() =>
computeRecapMachineDetail({
orgId: params.orgId,
machineId: params.machineId,
range: {
requestedMode: resolved.requestedMode,
mode: resolved.mode,
start: resolved.start,
end: resolved.end,
shiftAvailable: resolved.shiftAvailable,
fallbackReason: resolved.fallbackReason,
},
}),
detailCacheKey({
orgId: params.orgId,
machineId: params.machineId,
requestedMode: resolved.requestedMode,
mode: resolved.mode,
shiftAvailable: resolved.shiftAvailable,
fallbackReason: resolved.fallbackReason,
startMs: resolved.start.getTime(),
endMs: resolved.end.getTime(),
}),
{
revalidate: RECAP_CACHE_TTL_SEC,
tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`],
}
);
return cache();
}

789
lib/recap/timeline.ts Normal file
View File

@@ -0,0 +1,789 @@
import type { RecapTimelineSegment } from "@/lib/recap/types";
const ACTIVE_STALE_MS = 2 * 60 * 1000;
const MOLD_ACTIVE_STALE_MS = 12 * 60 * 60 * 1000;
const MERGE_GAP_MS = 30 * 1000;
const MICRO_CLUSTER_GAP_MS = 60 * 1000;
const ABSORB_SHORT_SEGMENT_MS = 30 * 1000;
export const TIMELINE_EVENT_TYPES = ["mold-change", "macrostop", "microstop"] as const;
type TimelineEventType = (typeof TIMELINE_EVENT_TYPES)[number];
type RawSegment =
| {
type: "production";
startMs: number;
endMs: number;
priority: number;
workOrderId: string | null;
sku: string | null;
label: string;
}
| {
type: "mold-change";
startMs: number;
endMs: number;
priority: number;
fromMoldId: string | null;
toMoldId: string | null;
durationSec: number;
label: string;
}
| {
type: "macrostop" | "microstop" | "slow-cycle";
startMs: number;
endMs: number;
priority: number;
reason: string | null;
durationSec: number;
label: string;
};
export type TimelineCycleRow = {
ts: Date;
cycleCount: number | null;
actualCycleTime: number;
workOrderId: string | null;
sku: string | null;
};
export type TimelineEventRow = {
ts: Date;
eventType: string;
data: unknown;
};
const PRIORITY: Record<string, number> = {
idle: 0,
production: 1,
microstop: 2,
"slow-cycle": 2,
macrostop: 3,
"mold-change": 4,
};
function safeNum(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function safeBool(value: unknown) {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (!normalized) return false;
return normalized === "true" || normalized === "1" || normalized === "yes";
}
return false;
}
function normalizeToken(value: unknown) {
return String(value ?? "").trim();
}
function dedupeByKey<T>(rows: T[], keyFn: (row: T) => string) {
const seen = new Set<string>();
const out: T[] = [];
for (const row of rows) {
const key = keyFn(row);
if (seen.has(key)) continue;
seen.add(key);
out.push(row);
}
return out;
}
function extractData(value: unknown) {
let parsed: unknown = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
parsed = null;
}
}
const record =
typeof parsed === "object" && parsed && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
const nested = record.data;
if (typeof nested === "object" && nested && !Array.isArray(nested)) {
return nested as Record<string, unknown>;
}
return record;
}
function clampToRange(startMs: number, endMs: number, rangeStartMs: number, rangeEndMs: number) {
const clampedStart = Math.max(rangeStartMs, Math.min(rangeEndMs, startMs));
const clampedEnd = Math.max(rangeStartMs, Math.min(rangeEndMs, endMs));
if (clampedEnd <= clampedStart) return null;
return { startMs: clampedStart, endMs: clampedEnd };
}
function eventIncidentKey(eventType: string, data: Record<string, unknown>, fallbackTsMs: number) {
const key = String(data.incidentKey ?? data.incident_key ?? "").trim();
if (key) return key;
const alertId = String(data.alert_id ?? data.alertId ?? "").trim();
if (alertId) return `${eventType}:${alertId}`;
const startMs = safeNum(data.start_ms) ?? safeNum(data.startMs);
if (startMs != null) return `${eventType}:${Math.trunc(startMs)}`;
return `${eventType}:${fallbackTsMs}`;
}
function reasonLabelFromData(data: Record<string, unknown>) {
const direct =
String(data.reasonText ?? data.reason_label ?? data.reasonLabel ?? "").trim() || null;
if (direct) return direct;
const reason = data.reason;
if (typeof reason === "string") {
const text = reason.trim();
return text || null;
}
if (reason && typeof reason === "object" && !Array.isArray(reason)) {
const rec = reason as Record<string, unknown>;
const reasonText =
String(rec.reasonText ?? rec.reason_label ?? rec.reasonLabel ?? "").trim() || null;
if (reasonText) return reasonText;
const detail =
String(rec.detailLabel ?? rec.detail_label ?? rec.detailId ?? rec.detail_id ?? "").trim() ||
null;
const category =
String(rec.categoryLabel ?? rec.category_label ?? rec.categoryId ?? rec.category_id ?? "").trim() ||
null;
if (category && detail) return `${category} > ${detail}`;
if (detail) return detail;
if (category) return category;
}
return null;
}
function labelForStop(type: "macrostop" | "microstop" | "slow-cycle", reason: string | null) {
if (type === "macrostop") return reason ? `Paro: ${reason}` : "Paro";
if (type === "microstop") return reason ? `Microparo: ${reason}` : "Microparo";
return reason ? `Ciclo lento: ${reason}` : "Ciclo lento";
}
function normalizeStopType(type: "macrostop" | "microstop" | "slow-cycle"): "macrostop" | "microstop" {
return type === "macrostop" ? "macrostop" : "microstop";
}
function isEquivalent(a: RecapTimelineSegment, b: RecapTimelineSegment) {
if (a.type !== b.type) return false;
if (a.type === "idle" && b.type === "idle") return true;
if (a.type === "production" && b.type === "production") {
return a.workOrderId === b.workOrderId && a.sku === b.sku && a.label === b.label;
}
if (a.type === "mold-change" && b.type === "mold-change") {
return a.fromMoldId === b.fromMoldId && a.toMoldId === b.toMoldId;
}
if (
(a.type === "macrostop" || a.type === "microstop" || a.type === "slow-cycle") &&
(b.type === "macrostop" || b.type === "microstop" || b.type === "slow-cycle")
) {
return a.type === b.type && a.reason === b.reason;
}
return false;
}
function withDuration(segment: RecapTimelineSegment): RecapTimelineSegment {
if (segment.type === "production") {
return {
...segment,
durationSec: Math.max(0, Math.trunc((segment.endMs - segment.startMs) / 1000)),
};
}
if (segment.type === "mold-change") {
return {
...segment,
durationSec: Math.max(0, Math.trunc((segment.endMs - segment.startMs) / 1000)),
};
}
if (segment.type === "macrostop" || segment.type === "microstop" || segment.type === "slow-cycle") {
return {
...segment,
durationSec: Math.max(0, Math.trunc((segment.endMs - segment.startMs) / 1000)),
};
}
return {
...segment,
durationSec: Math.max(0, Math.trunc((segment.endMs - segment.startMs) / 1000)),
};
}
function cloneSegment(segment: RecapTimelineSegment): RecapTimelineSegment {
return { ...segment };
}
function mergeNearbyEquivalentSegments(segments: RecapTimelineSegment[], maxGapMs: number) {
const ordered = [...segments]
.map((segment) => withDuration(segment))
.filter((segment) => segment.endMs > segment.startMs)
.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
const merged: RecapTimelineSegment[] = [];
for (const current of ordered) {
const prev = merged[merged.length - 1];
if (!prev) {
merged.push(cloneSegment(current));
continue;
}
const gapMs = current.startMs - prev.endMs;
if (gapMs <= maxGapMs && isEquivalent(prev, current)) {
prev.endMs = Math.max(prev.endMs, current.endMs);
const normalized = withDuration(prev);
Object.assign(prev, normalized);
continue;
}
if (current.startMs < prev.endMs) {
const clipped = { ...current, startMs: prev.endMs };
if (clipped.endMs <= clipped.startMs) continue;
merged.push(withDuration(clipped));
continue;
}
merged.push(cloneSegment(current));
}
return merged;
}
function fillGapsWithIdle(segments: RecapTimelineSegment[], rangeStartMs: number, rangeEndMs: number) {
const ordered = [...segments]
.map((segment) => {
const startMs = Math.max(rangeStartMs, segment.startMs);
const endMs = Math.min(rangeEndMs, segment.endMs);
if (endMs <= startMs) return null;
return withDuration({ ...segment, startMs, endMs });
})
.filter((segment): segment is RecapTimelineSegment => !!segment)
.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
const out: RecapTimelineSegment[] = [];
let cursor = rangeStartMs;
for (const segment of ordered) {
if (segment.startMs > cursor) {
out.push({
type: "idle",
startMs: cursor,
endMs: segment.startMs,
durationSec: Math.max(0, Math.trunc((segment.startMs - cursor) / 1000)),
label: "Idle",
});
}
const startMs = Math.max(cursor, segment.startMs);
const endMs = Math.min(rangeEndMs, segment.endMs);
if (endMs <= startMs) continue;
out.push(withDuration({ ...segment, startMs, endMs }));
cursor = endMs;
if (cursor >= rangeEndMs) break;
}
if (cursor < rangeEndMs) {
out.push({
type: "idle",
startMs: cursor,
endMs: rangeEndMs,
durationSec: Math.max(0, Math.trunc((rangeEndMs - cursor) / 1000)),
label: "Idle",
});
}
return mergeNearbyEquivalentSegments(out, 0);
}
function absorbMicroStopClusters(segments: RecapTimelineSegment[], maxGapMs: number) {
const out: RecapTimelineSegment[] = [];
let i = 0;
while (i < segments.length) {
const first = segments[i];
if (first.type !== "microstop") {
out.push(cloneSegment(first));
i += 1;
continue;
}
let clusterEndMs = first.endMs;
let count = 1;
const reasons = new Set<string>();
if (first.reason) reasons.add(first.reason);
let cursor = i;
while (cursor + 2 < segments.length) {
const gap = segments[cursor + 1];
const next = segments[cursor + 2];
if (next.type !== "microstop") break;
if (gap.type === "macrostop" || gap.type === "mold-change") break;
const gapMs = Math.max(0, gap.endMs - gap.startMs);
if (gapMs >= maxGapMs) break;
clusterEndMs = next.endMs;
if (next.reason) reasons.add(next.reason);
count += 1;
cursor += 2;
}
if (count === 1) {
out.push(cloneSegment(first));
i += 1;
continue;
}
const reason = reasons.size === 1 ? (Array.from(reasons)[0] ?? null) : null;
out.push({
type: "microstop",
startMs: first.startMs,
endMs: clusterEndMs,
reason,
reasonLabel: reason,
durationSec: Math.max(0, Math.trunc((clusterEndMs - first.startMs) / 1000)),
label: reason ? `Microparo (${count}) · ${reason}` : `Microparo (${count})`,
});
i = cursor + 1;
}
return mergeNearbyEquivalentSegments(out, 0);
}
function absorbShortSegments(segments: RecapTimelineSegment[], minDurationMs: number) {
const out = segments.map((segment) => withDuration(cloneSegment(segment)));
let index = 0;
while (index < out.length) {
const current = out[index];
const durationMs = Math.max(0, current.endMs - current.startMs);
if (durationMs >= minDurationMs || out.length === 1) {
index += 1;
continue;
}
const prev = out[index - 1] ?? null;
const next = out[index + 1] ?? null;
if (!prev && !next) break;
if (!prev && next) {
next.startMs = current.startMs;
out.splice(index, 1);
continue;
}
if (prev && !next) {
prev.endMs = current.endMs;
out.splice(index, 1);
index = Math.max(0, index - 1);
continue;
}
const prevDurationMs = Math.max(0, (prev?.endMs ?? 0) - (prev?.startMs ?? 0));
const nextDurationMs = Math.max(0, (next?.endMs ?? 0) - (next?.startMs ?? 0));
const absorbIntoPrev = prevDurationMs >= nextDurationMs;
if (absorbIntoPrev && prev) {
prev.endMs = current.endMs;
out.splice(index, 1);
index = Math.max(0, index - 1);
continue;
}
if (next) {
next.startMs = current.startMs;
out.splice(index, 1);
continue;
}
index += 1;
}
return mergeNearbyEquivalentSegments(out.map((segment) => withDuration(segment)), MERGE_GAP_MS);
}
function buildSegmentsFromBoundaries(rawSegments: RawSegment[], rangeStartMs: number, rangeEndMs: number) {
const clipped = rawSegments
.map((segment) => {
const range = clampToRange(segment.startMs, segment.endMs, rangeStartMs, rangeEndMs);
return range ? { ...segment, ...range } : null;
})
.filter((segment): segment is RawSegment => !!segment);
const boundaries = new Set<number>([rangeStartMs, rangeEndMs]);
for (const segment of clipped) {
boundaries.add(segment.startMs);
boundaries.add(segment.endMs);
}
const orderedBoundaries = Array.from(boundaries).sort((a, b) => a - b);
const timeline: RecapTimelineSegment[] = [];
for (let i = 0; i < orderedBoundaries.length - 1; i += 1) {
const intervalStart = orderedBoundaries[i];
const intervalEnd = orderedBoundaries[i + 1];
if (intervalEnd <= intervalStart) continue;
const covering = clipped
.filter((segment) => segment.startMs < intervalEnd && segment.endMs > intervalStart)
.sort((a, b) => b.priority - a.priority || b.startMs - a.startMs);
const winner = covering[0];
if (!winner) continue;
if (winner.type === "production") {
timeline.push({
type: "production",
startMs: intervalStart,
endMs: intervalEnd,
durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)),
workOrderId: winner.workOrderId,
sku: winner.sku,
label: winner.label,
});
continue;
}
if (winner.type === "mold-change") {
timeline.push({
type: "mold-change",
startMs: intervalStart,
endMs: intervalEnd,
fromMoldId: winner.fromMoldId,
toMoldId: winner.toMoldId,
durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)),
label: winner.label,
});
continue;
}
const stopType = normalizeStopType(winner.type);
timeline.push({
type: stopType,
startMs: intervalStart,
endMs: intervalEnd,
reason: winner.reason,
reasonLabel: winner.reason,
durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)),
label: labelForStop(stopType, winner.reason),
});
}
return timeline;
}
function segmentPriority(type: RecapTimelineSegment["type"]) {
if (type === "mold-change") return 4;
if (type === "macrostop") return 3;
if (type === "microstop" || type === "slow-cycle") return 2;
if (type === "production") return 1;
return 0;
}
function cloneForRange(segment: RecapTimelineSegment, startMs: number, endMs: number): RecapTimelineSegment {
if (segment.type === "production") {
return {
type: "production",
startMs,
endMs,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
workOrderId: segment.workOrderId,
sku: segment.sku,
label: segment.label,
};
}
if (segment.type === "mold-change") {
return {
type: "mold-change",
startMs,
endMs,
fromMoldId: segment.fromMoldId,
toMoldId: segment.toMoldId,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: segment.label,
};
}
if (segment.type === "macrostop" || segment.type === "microstop" || segment.type === "slow-cycle") {
const stopType = normalizeStopType(segment.type);
return {
type: stopType,
startMs,
endMs,
reason: segment.reason,
reasonLabel: segment.reasonLabel ?? segment.reason,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: segment.label,
};
}
return {
type: "idle",
startMs,
endMs,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: segment.label,
};
}
export function buildTimelineSegments(input: {
cycles: TimelineCycleRow[];
events: TimelineEventRow[];
rangeStart: Date;
rangeEnd: Date;
}) {
const rangeStartMs = input.rangeStart.getTime();
const rangeEndMs = input.rangeEnd.getTime();
if (!Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs) || rangeEndMs <= rangeStartMs) {
return [] as RecapTimelineSegment[];
}
const dedupedCycles = dedupeByKey(
input.cycles,
(cycle) =>
`${cycle.ts.getTime()}:${safeNum(cycle.cycleCount) ?? "na"}:${normalizeToken(cycle.workOrderId).toUpperCase()}:${normalizeToken(cycle.sku).toUpperCase()}:${safeNum(cycle.actualCycleTime) ?? "na"}`
);
const rawSegments: RawSegment[] = [];
let currentProduction: RawSegment | null = null;
for (const cycle of dedupedCycles) {
if (!cycle.workOrderId) continue;
const cycleStartMs = cycle.ts.getTime();
const cycleDurationMs = Math.max(
1000,
Math.min(600000, Math.trunc((safeNum(cycle.actualCycleTime) ?? 1) * 1000))
);
const cycleEndMs = cycleStartMs + cycleDurationMs;
if (
currentProduction &&
currentProduction.type === "production" &&
currentProduction.workOrderId === cycle.workOrderId &&
currentProduction.sku === cycle.sku &&
cycleStartMs <= currentProduction.endMs + 5 * 60 * 1000
) {
currentProduction.endMs = Math.max(currentProduction.endMs, cycleEndMs);
continue;
}
if (currentProduction) rawSegments.push(currentProduction);
currentProduction = {
type: "production",
startMs: cycleStartMs,
endMs: cycleEndMs,
priority: PRIORITY.production,
workOrderId: cycle.workOrderId,
sku: cycle.sku,
label: cycle.workOrderId,
};
}
if (currentProduction) rawSegments.push(currentProduction);
// If production evidence appears after a mold-change "active" event, we cap that
// mold-change segment at the first production timestamp to avoid stale overwrite.
const productionWindows = rawSegments
.filter((segment): segment is Extract<RawSegment, { type: "production" }> => segment.type === "production")
.map((segment) => ({ startMs: segment.startMs, endMs: segment.endMs }))
.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
const firstProductionMsAfter = (startMs: number) => {
for (const window of productionWindows) {
if (window.endMs <= startMs) continue;
return Math.max(startMs, window.startMs);
}
return null;
};
const eventEpisodes = new Map<
string,
{
type: "mold-change" | "macrostop" | "microstop";
firstTsMs: number;
lastTsMs: number;
startMs: number | null;
endMs: number | null;
durationSec: number | null;
statusActive: boolean;
statusResolved: boolean;
reason: string | null;
fromMoldId: string | null;
toMoldId: string | null;
}
>();
for (const event of input.events) {
const eventType = String(event.eventType || "").toLowerCase() as TimelineEventType;
if (!TIMELINE_EVENT_TYPES.includes(eventType)) continue;
const data = extractData(event.data);
const isUpdate = safeBool(data.is_update ?? data.isUpdate);
const isAutoAck = safeBool(data.is_auto_ack ?? data.isAutoAck);
if (isUpdate || isAutoAck) continue;
const tsMs = event.ts.getTime();
const key = eventIncidentKey(eventType, data, tsMs);
const status = String(data.status ?? "").trim().toLowerCase();
const episode = eventEpisodes.get(key) ?? {
type: eventType,
firstTsMs: tsMs,
lastTsMs: tsMs,
startMs: null,
endMs: null,
durationSec: null,
statusActive: false,
statusResolved: false,
reason: null,
fromMoldId: null,
toMoldId: null,
};
episode.firstTsMs = Math.min(episode.firstTsMs, tsMs);
episode.lastTsMs = Math.max(episode.lastTsMs, tsMs);
const startMs = safeNum(data.start_ms) ?? safeNum(data.startMs);
const endMs = safeNum(data.end_ms) ?? safeNum(data.endMs);
const durationSec =
safeNum(data.duration_sec) ??
safeNum(data.stoppage_duration_seconds) ??
safeNum(data.stop_duration_seconds) ??
safeNum(data.duration_seconds);
if (startMs != null) episode.startMs = episode.startMs == null ? startMs : Math.min(episode.startMs, startMs);
if (endMs != null) episode.endMs = episode.endMs == null ? endMs : Math.max(episode.endMs, endMs);
if (durationSec != null) episode.durationSec = Math.max(0, Math.trunc(durationSec));
if (status === "active") episode.statusActive = true;
if (status === "resolved") episode.statusResolved = true;
const reason = reasonLabelFromData(data);
if (reason) episode.reason = reason;
const fromMoldId = String(data.from_mold_id ?? data.fromMoldId ?? "").trim() || null;
const toMoldId = String(data.to_mold_id ?? data.toMoldId ?? "").trim() || null;
if (fromMoldId) episode.fromMoldId = fromMoldId;
if (toMoldId) episode.toMoldId = toMoldId;
eventEpisodes.set(key, episode);
}
for (const episode of eventEpisodes.values()) {
const startMs = Math.trunc(episode.startMs ?? episode.firstTsMs);
let endMs = Math.trunc(episode.endMs ?? episode.lastTsMs);
if (episode.statusActive && !episode.statusResolved) {
const activeStaleMs = episode.type === "mold-change" ? MOLD_ACTIVE_STALE_MS : ACTIVE_STALE_MS;
const isFreshActive = rangeEndMs - episode.lastTsMs <= activeStaleMs;
endMs = isFreshActive ? rangeEndMs : episode.lastTsMs;
if (episode.type === "mold-change") {
const productionResumeMs = firstProductionMsAfter(startMs);
if (productionResumeMs != null) {
endMs = Math.min(endMs, productionResumeMs);
}
}
} else if (endMs <= startMs && episode.durationSec != null && episode.durationSec > 0) {
endMs = startMs + episode.durationSec * 1000;
}
if (endMs <= startMs) continue;
if (episode.type === "mold-change") {
rawSegments.push({
type: "mold-change",
startMs,
endMs,
priority: PRIORITY["mold-change"],
fromMoldId: episode.fromMoldId,
toMoldId: episode.toMoldId,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: episode.toMoldId ? `Cambio molde ${episode.toMoldId}` : "Cambio molde",
});
continue;
}
rawSegments.push({
type: episode.type,
startMs,
endMs,
priority: PRIORITY[episode.type],
reason: episode.reason,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: labelForStop(episode.type, episode.reason),
});
}
const initial = buildSegmentsFromBoundaries(rawSegments, rangeStartMs, rangeEndMs);
const merged = mergeNearbyEquivalentSegments(initial, MERGE_GAP_MS);
const withIdle = fillGapsWithIdle(merged, rangeStartMs, rangeEndMs);
const clustered = absorbMicroStopClusters(withIdle, MICRO_CLUSTER_GAP_MS);
const normalized = fillGapsWithIdle(clustered, rangeStartMs, rangeEndMs);
const absorbed = absorbShortSegments(normalized, ABSORB_SHORT_SEGMENT_MS);
const finalSegments = fillGapsWithIdle(absorbed, rangeStartMs, rangeEndMs);
return finalSegments;
}
export function compressTimelineSegments(input: {
segments: RecapTimelineSegment[];
rangeStart: Date;
rangeEnd: Date;
maxSegments: number;
}) {
const rangeStartMs = input.rangeStart.getTime();
const rangeEndMs = input.rangeEnd.getTime();
const maxSegments = Math.max(1, Math.trunc(input.maxSegments || 1));
const normalized = fillGapsWithIdle(input.segments, rangeStartMs, rangeEndMs);
if (normalized.length <= maxSegments) return normalized;
const totalMs = Math.max(1, rangeEndMs - rangeStartMs);
const bucketMs = totalMs / maxSegments;
const buckets: RecapTimelineSegment[] = [];
for (let i = 0; i < maxSegments; i += 1) {
const bucketStart = Math.trunc(rangeStartMs + i * bucketMs);
const bucketEnd = i === maxSegments - 1 ? rangeEndMs : Math.trunc(rangeStartMs + (i + 1) * bucketMs);
if (bucketEnd <= bucketStart) continue;
let winner: RecapTimelineSegment | null = null;
let winnerPriority = -1;
let winnerOverlap = -1;
for (const segment of normalized) {
const overlapStart = Math.max(bucketStart, segment.startMs);
const overlapEnd = Math.min(bucketEnd, segment.endMs);
if (overlapEnd <= overlapStart) continue;
const overlap = overlapEnd - overlapStart;
const priority = segmentPriority(segment.type);
if (priority > winnerPriority || (priority === winnerPriority && overlap > winnerOverlap)) {
winner = segment;
winnerPriority = priority;
winnerOverlap = overlap;
}
}
if (!winner) {
buckets.push({
type: "idle",
startMs: bucketStart,
endMs: bucketEnd,
durationSec: Math.max(0, Math.trunc((bucketEnd - bucketStart) / 1000)),
label: "Idle",
});
continue;
}
buckets.push(cloneForRange(winner, bucketStart, bucketEnd));
}
const merged = mergeNearbyEquivalentSegments(buckets, 0);
return fillGapsWithIdle(merged, rangeStartMs, rangeEndMs);
}

193
lib/recap/timelineApi.ts Normal file
View File

@@ -0,0 +1,193 @@
import { prisma } from "@/lib/prisma";
import {
buildTimelineSegments,
compressTimelineSegments,
TIMELINE_EVENT_TYPES,
type TimelineCycleRow,
type TimelineEventRow,
} from "@/lib/recap/timeline";
import type { RecapTimelineResponse } from "@/lib/recap/types";
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 60 * 1000;
const DEFAULT_RANGE_MS = 24 * 60 * 60 * 1000;
const MIN_RANGE_MS = 60 * 1000;
const MAX_RANGE_MS = 72 * 60 * 60 * 1000;
function parseDateInput(raw: string | null) {
if (!raw) return null;
const asNum = Number(raw);
if (Number.isFinite(asNum)) {
const d = new Date(asNum);
return Number.isFinite(d.getTime()) ? d : null;
}
const d = new Date(raw);
return Number.isFinite(d.getTime()) ? d : null;
}
function parseRangeDurationMs(raw: string | null) {
if (!raw) return null;
const normalized = raw.trim().toLowerCase();
const match = /^(\d+)\s*([hm])$/.exec(normalized);
if (!match) return null;
const amount = Number(match[1]);
if (!Number.isFinite(amount) || amount <= 0) return null;
const unit = match[2];
const durationMs = unit === "m" ? amount * 60_000 : amount * 60 * 60_000;
return Math.max(MIN_RANGE_MS, Math.min(MAX_RANGE_MS, durationMs));
}
function parseHours(raw: string | null) {
if (!raw) return null;
const parsed = Math.trunc(Number(raw));
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return Math.max(1, Math.min(72, parsed));
}
function parseMaxSegments(searchParams: URLSearchParams) {
const compact = searchParams.get("compact");
const maxSegmentsRaw = searchParams.get("maxSegments");
if (compact !== "1" && compact !== "true" && !maxSegmentsRaw) return null;
const parsed = Math.trunc(Number(maxSegmentsRaw ?? "30"));
if (!Number.isFinite(parsed) || parsed <= 0) return 30;
return Math.max(5, Math.min(120, parsed));
}
export function parseRecapTimelineRange(searchParams: URLSearchParams) {
const defaultEnd = new Date(Math.floor(Date.now() / 60000) * 60000);
const end = parseDateInput(searchParams.get("end")) ?? defaultEnd;
const startParam = parseDateInput(searchParams.get("start"));
if (startParam && startParam < end) {
return {
start: startParam,
end,
maxSegments: parseMaxSegments(searchParams),
};
}
const rangeDurationMs =
parseRangeDurationMs(searchParams.get("range")) ??
(() => {
const hours = parseHours(searchParams.get("hours"));
return hours ? hours * 60 * 60 * 1000 : null;
})() ??
DEFAULT_RANGE_MS;
const start = new Date(end.getTime() - Math.max(MIN_RANGE_MS, Math.min(MAX_RANGE_MS, rangeDurationMs)));
return {
start,
end,
maxSegments: parseMaxSegments(searchParams),
};
}
export async function getRecapTimelineForMachine(params: {
orgId: string;
machineId: string;
start: Date;
end: Date;
maxSegments?: number | null;
}) {
const [cyclesRaw, eventsRaw, cycleCount, eventCount] = await Promise.all([
prisma.machineCycle.findMany({
where: {
orgId: params.orgId,
machineId: params.machineId,
ts: {
gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
lte: params.end,
},
},
orderBy: { ts: "asc" },
select: {
ts: true,
cycleCount: true,
actualCycleTime: true,
workOrderId: true,
sku: true,
},
}),
prisma.machineEvent.findMany({
where: {
orgId: params.orgId,
machineId: params.machineId,
eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] },
ts: {
gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS),
lte: params.end,
},
},
orderBy: { ts: "asc" },
select: {
ts: true,
eventType: true,
data: true,
},
}),
prisma.machineCycle.count({
where: {
orgId: params.orgId,
machineId: params.machineId,
ts: {
gte: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
lte: params.end,
},
},
}),
prisma.machineEvent.count({
where: {
orgId: params.orgId,
machineId: params.machineId,
ts: { gte: params.start, lte: params.end },
},
}),
]);
const hasData = cycleCount > 0 || eventCount > 0;
const cycles: TimelineCycleRow[] = cyclesRaw.map((row) => ({
ts: row.ts,
cycleCount: row.cycleCount,
actualCycleTime: row.actualCycleTime,
workOrderId: row.workOrderId,
sku: row.sku,
}));
const events: TimelineEventRow[] = eventsRaw.map((row) => ({
ts: row.ts,
eventType: row.eventType,
data: row.data,
}));
let segments = hasData
? buildTimelineSegments({
cycles,
events,
rangeStart: params.start,
rangeEnd: params.end,
})
: [];
if (hasData && params.maxSegments && params.maxSegments > 0) {
segments = compressTimelineSegments({
segments,
rangeStart: params.start,
rangeEnd: params.end,
maxSegments: params.maxSegments,
});
}
const response: RecapTimelineResponse = {
range: {
start: params.start.toISOString(),
end: params.end.toISOString(),
},
segments,
hasData,
generatedAt: new Date().toISOString(),
};
return response;
}

222
lib/recap/types.ts Normal file
View File

@@ -0,0 +1,222 @@
export type RecapSkuRow = {
machineName: string;
sku: string;
good: number;
scrap: number;
target: number | null;
progressPct: number | null;
};
export type RecapMachine = {
machineId: string;
machineName: string;
location: string | null;
production: {
goodParts: number;
scrapParts: number;
totalCycles: number;
bySku: RecapSkuRow[];
};
oee: {
avg: number | null;
availability: number | null;
performance: number | null;
quality: number | null;
};
downtime: {
totalMin: number;
stopsCount: number;
topReasons: Array<{
reasonLabel: string;
minutes: number;
count: number;
}>;
ongoingStopMin: number | null;
};
workOrders: {
completed: Array<{
id: string;
sku: string | null;
goodParts: number;
durationHrs: number;
}>;
active: {
id: string;
sku: string | null;
progressPct: number | null;
startedAt: string | null;
} | null;
moldChangeInProgress: boolean;
moldChangeStartMs: number | null;
};
heartbeat: {
lastSeenAt: string | null;
uptimePct: number | null;
};
};
export type RecapTimelineSegment =
| {
type: "production";
startMs: number;
endMs: number;
durationSec: number;
workOrderId: string | null;
sku: string | null;
label: string;
}
| {
type: "mold-change";
startMs: number;
endMs: number;
fromMoldId: string | null;
toMoldId: string | null;
durationSec: number;
label: string;
}
| {
type: "macrostop" | "microstop" | "slow-cycle";
startMs: number;
endMs: number;
reason: string | null;
reasonLabel?: string | null;
durationSec: number;
label: string;
}
| {
type: "idle";
startMs: number;
endMs: number;
durationSec: number;
label: string;
};
export type RecapTimelineResponse = {
range: {
start: string;
end: string;
};
segments: RecapTimelineSegment[];
hasData: boolean;
generatedAt: string;
};
export type RecapResponse = {
range: {
start: string;
end: string;
};
availableShifts: Array<{
id: string;
name: string;
}>;
machines: RecapMachine[];
};
export type RecapQuery = {
orgId: string;
machineId?: string;
start?: Date;
end?: Date;
shift?: string;
};
export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "offline";
export type RecapSummaryMachine = {
machineId: string;
name: string;
location: string | null;
status: RecapMachineStatus;
oee: number | null;
goodParts: number;
scrap: number;
stopsCount: number;
lastSeenMs: number | null;
lastActivityMin: number | null;
offlineForMin: number | null;
ongoingStopMin: number | null;
activeWorkOrderId: string | null;
moldChange: {
active: boolean;
startMs: number | null;
elapsedMin: number | null;
} | null;
miniTimeline: RecapTimelineSegment[];
};
export type RecapSummaryResponse = {
generatedAt: string;
range: {
start: string;
end: string;
hours: number;
};
machines: RecapSummaryMachine[];
};
export type RecapRangeMode = "24h" | "shift" | "yesterday" | "custom";
export type RecapDowntimeTopRow = {
reasonLabel: string;
minutes: number;
count: number;
percent: number;
};
export type RecapWorkOrders = {
completed: Array<{
id: string;
sku: string | null;
goodParts: number;
durationHrs: number;
}>;
active: {
id: string;
sku: string | null;
progressPct: number | null;
startedAt: string | null;
} | null;
};
export type RecapMachineDetail = {
machineId: string;
name: string;
location: string | null;
status: RecapMachineStatus;
oee: number | null;
goodParts: number;
scrap: number;
stopsCount: number;
stopMinutes: number;
activeWorkOrderId: string | null;
lastSeenMs: number | null;
offlineForMin: number | null;
ongoingStopMin: number | null;
moldChange: {
active: boolean;
startMs: number | null;
} | null;
timeline: RecapTimelineSegment[];
productionBySku: RecapSkuRow[];
downtimeTop: RecapDowntimeTopRow[];
workOrders: RecapWorkOrders;
heartbeat: {
lastSeenAt: string | null;
uptimePct: number | null;
connectionStatus: "online" | "offline";
};
};
export type RecapDetailResponse = {
generatedAt: string;
range: {
requestedMode?: RecapRangeMode;
mode: RecapRangeMode;
start: string;
end: string;
shiftAvailable?: boolean;
fallbackReason?: "shift-unavailable" | "shift-inactive";
};
machine: RecapMachineDetail;
};

0
mis-control-tower@0.1.0 Normal file
View File

0
next Normal file
View File

2394
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,11 @@
"dev": "next dev --turbopack",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test:downtime-reason-guard": "node scripts/test-downtime-reason-guard.mjs",
"backfill:downtime-reasons": "node scripts/backfill-downtime-reasons.mjs",
"prisma:generate": "prisma generate",
"prisma:migrate:deploy": "prisma migrate deploy"
},
"dependencies": {
"@prisma/client": "^6.19.1",
@@ -29,6 +33,7 @@
"@types/nodemailer": "^7.0.4",
"@types/react": "^19",
"@types/react-dom": "^19",
"baseline-browser-mapping": "^2.10.22",
"dotenv-cli": "^11.0.0",
"eslint": "^9",
"eslint-config-next": "16.0.10",

View File

@@ -0,0 +1,4 @@
ALTER TABLE "machine_work_orders"
ADD COLUMN "good_parts" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "scrap_parts" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "cycle_count" INTEGER NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,18 @@
-- Dedupe existing rows (keep oldest by createdAt, then id) before unique constraint.
WITH ranked AS (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "orgId", "machineId", "ts", "cycleCount"
ORDER BY "createdAt" ASC, "id" ASC
) AS rn
FROM "MachineCycle"
)
DELETE FROM "MachineCycle" mc
USING ranked r
WHERE mc."id" = r."id"
AND r.rn > 1;
-- One row per (org, machine, device ts, cycle counter) — blocks retry / fan-out duplicates.
CREATE UNIQUE INDEX "MachineCycle_orgId_machineId_ts_cycleCount_key"
ON "MachineCycle" ("orgId", "machineId", "ts", "cycleCount");

View File

@@ -0,0 +1,35 @@
-- Heartbeat: same device ts + machine = one row (retries / double POST).
WITH ranked_hb AS (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "orgId", "machineId", "ts"
ORDER BY "ts_server" ASC, "id" ASC
) AS rn
FROM "MachineHeartbeat"
)
DELETE FROM "MachineHeartbeat" h
USING ranked_hb r
WHERE h."id" = r."id"
AND r.rn > 1;
CREATE UNIQUE INDEX "MachineHeartbeat_orgId_machineId_ts_key"
ON "MachineHeartbeat" ("orgId", "machineId", "ts");
-- KPI snapshot: same minute bucket (device ts) per machine — Node-RED aligns ts to minute.
WITH ranked_kpi AS (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "orgId", "machineId", "ts"
ORDER BY "ts_server" ASC, "id" ASC
) AS rn
FROM "MachineKpiSnapshot"
)
DELETE FROM "MachineKpiSnapshot" k
USING ranked_kpi r
WHERE k."id" = r."id"
AND r.rn > 1;
CREATE UNIQUE INDEX "MachineKpiSnapshot_orgId_machineId_ts_key"
ON "MachineKpiSnapshot" ("orgId", "machineId", "ts");

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "machine_work_orders" ADD COLUMN "mold" TEXT;
ALTER TABLE "machine_work_orders" ADD COLUMN "cavities_total" INTEGER;
ALTER TABLE "machine_work_orders" ADD COLUMN "cavities_active" INTEGER;

View File

@@ -12,59 +12,55 @@ model Org {
name String
slug String @unique
createdAt DateTime @default(now())
members OrgUser[]
sessions Session[]
machines Machine[]
events MachineEvent[]
heartbeats MachineHeartbeat[]
kpiSnapshots MachineKpiSnapshot[]
events MachineEvent[]
workOrders MachineWorkOrder[]
settings OrgSettings?
shifts OrgShift[]
machineSettings MachineSettings[]
settingsAudits SettingsAudit[]
invites OrgInvite[]
alertPolicies AlertPolicy[]
members OrgUser[]
reasonEntries ReasonEntry[]
sessions Session[]
alertContacts AlertContact[]
alertNotifications AlertNotification[]
financialProfile OrgFinancialProfile?
alertPolicies AlertPolicy?
downtimeActions DowntimeAction[]
locationFinancialOverrides LocationFinancialOverride[]
machineFinancialOverrides MachineFinancialOverride[]
machineSettings MachineSettings[]
workOrders MachineWorkOrder[]
financialProfile OrgFinancialProfile?
invites OrgInvite[]
settings OrgSettings?
shifts OrgShift[]
productCostOverrides ProductCostOverride[]
reasonEntries ReasonEntry[]
downtimeActions DowntimeAction[]
settingsAudits SettingsAudit[]
}
model User {
id String @id @default(uuid())
email String @unique
name String?
phone String? @map("phone")
passwordHash String
isActive Boolean @default(true)
createdAt DateTime @default(now())
emailVerifiedAt DateTime? @map("email_verified_at")
emailVerificationToken String? @unique @map("email_verification_token")
emailVerificationExpiresAt DateTime? @map("email_verification_expires_at")
emailVerificationToken String? @unique @map("email_verification_token")
emailVerifiedAt DateTime? @map("email_verified_at")
phone String? @map("phone")
orgs OrgUser[]
sessions Session[]
sentInvites OrgInvite[] @relation("OrgInviteInviter")
alertContacts AlertContact[]
alertNotifications AlertNotification[]
downtimeActionsOwned DowntimeAction[] @relation("DowntimeActionOwner")
downtimeActionsCreated DowntimeAction[] @relation("DowntimeActionCreator")
downtimeActionsOwned DowntimeAction[] @relation("DowntimeActionOwner")
sentInvites OrgInvite[] @relation("OrgInviteInviter")
}
model OrgUser {
id String @id @default(uuid())
orgId String
userId String
role String @default("MEMBER") // OWNER | ADMIN | MEMBER
role String @default("MEMBER")
createdAt DateTime @default(now())
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -77,16 +73,15 @@ model OrgInvite {
id String @id @default(uuid())
orgId String @map("org_id")
email String
role String @default("MEMBER") // OWNER | ADMIN | MEMBER
role String @default("MEMBER")
token String @unique
invitedBy String? @map("invited_by")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
acceptedAt DateTime? @map("accepted_at")
revokedAt DateTime? @map("revoked_at")
inviter User? @relation("OrgInviteInviter", fields: [invitedBy], references: [id])
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
inviter User? @relation("OrgInviteInviter", fields: [invitedBy], references: [id], onDelete: SetNull)
@@index([orgId])
@@index([orgId, email])
@@ -95,7 +90,7 @@ model OrgInvite {
}
model Session {
id String @id @default(uuid()) // cookie value
id String @id @default(uuid())
orgId String
userId String
createdAt DateTime @default(now())
@@ -104,7 +99,6 @@ model Session {
revokedAt DateTime?
ip String?
userAgent String?
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -117,36 +111,33 @@ model Machine {
id String @id @default(uuid())
orgId String
name String
apiKey String? @unique
code String?
location String?
createdAt DateTime @default(now())
location String?
updatedAt DateTime @updatedAt
tsDevice DateTime @default(now()) @map("ts")
tsServer DateTime @default(now()) @map("ts_server")
apiKey String? @unique
schemaVersion String? @map("schema_version")
seq BigInt? @map("seq")
tsDevice DateTime @default(now()) @map("ts")
tsServer DateTime @default(now()) @map("ts_server")
pairingCode String? @unique @map("pairing_code")
pairingCodeExpiresAt DateTime? @map("pairing_code_expires_at")
pairingCodeUsedAt DateTime? @map("pairing_code_used_at")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
cycles MachineCycle[]
events MachineEvent[]
heartbeats MachineHeartbeat[]
kpiSnapshots MachineKpiSnapshot[]
events MachineEvent[]
cycles MachineCycle[]
workOrders MachineWorkOrder[]
settings MachineSettings?
settingsAudits SettingsAudit[]
alertNotifications AlertNotification[]
financialOverrides MachineFinancialOverride[]
reasonEntries ReasonEntry[]
alertNotifications AlertNotification[]
downtimeActions DowntimeAction[]
financialOverrides MachineFinancialOverride[]
settings MachineSettings?
workOrders MachineWorkOrder[]
settingsAudits SettingsAudit[]
@@unique([orgId, name])
@@index([orgId])
@@index([orgId, createdAt])
}
model MachineHeartbeat {
@@ -154,20 +145,17 @@ model MachineHeartbeat {
orgId String
machineId String
ts DateTime @default(now())
tsServer DateTime @default(now()) @map("ts_server")
schemaVersion String? @map("schema_version")
seq BigInt? @map("seq")
status String
message String?
ip String?
fwVersion String?
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
schemaVersion String? @map("schema_version")
seq BigInt? @map("seq")
tsServer DateTime @default(now()) @map("ts_server")
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@index([orgId, machineId, ts])
@@index([orgId, machineId, tsServer])
}
model MachineKpiSnapshot {
@@ -175,10 +163,8 @@ model MachineKpiSnapshot {
orgId String
machineId String
ts DateTime @default(now())
workOrderId String?
sku String?
target Int?
good Int?
scrap Int?
@@ -186,23 +172,21 @@ model MachineKpiSnapshot {
goodParts Int?
scrapParts Int?
cavities Int?
cycleTime Float? // theoretical/target
actualCycle Float? // if you want (optional)
cycleTime Float?
actualCycle Float?
availability Float?
performance Float?
quality Float?
oee Float?
trackingEnabled Boolean?
productionStarted Boolean?
tsServer DateTime @default(now()) @map("ts_server")
schemaVersion String? @map("schema_version")
seq BigInt? @map("seq")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
tsServer DateTime @default(now()) @map("ts_server")
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@unique([orgId, machineId, seq], map: "uq_kpi_org_machine_seq")
@@index([orgId, machineId, ts])
}
@@ -211,26 +195,22 @@ model MachineEvent {
orgId String
machineId String
ts DateTime @default(now())
topic String // "anomaly-detected"
eventType String // "slow-cycle"
severity String // "critical"
topic String
eventType String
severity String
requiresAck Boolean @default(false)
title String
description String?
tsServer DateTime @default(now()) @map("ts_server")
schemaVersion String? @map("schema_version")
seq BigInt? @map("seq")
// store the raw data blob so we don't lose fields
data Json?
workOrderId String?
sku String?
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
schemaVersion String? @map("schema_version")
seq BigInt? @map("seq")
tsServer DateTime @default(now()) @map("ts_server")
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@unique([orgId, machineId, seq], map: "uq_event_org_machine_seq")
@@index([orgId, machineId, ts])
@@index([orgId, machineId, eventType, ts])
}
@@ -240,25 +220,22 @@ model MachineCycle {
orgId String
machineId String
ts DateTime @default(now())
cycleCount Int?
actualCycleTime Float
theoreticalCycleTime Float?
workOrderId String?
sku String?
cavities Int?
goodDelta Int?
scrapDelta Int?
tsServer DateTime @default(now()) @map("ts_server")
createdAt DateTime @default(now())
schemaVersion String? @map("schema_version")
seq BigInt? @map("seq")
createdAt DateTime @default(now())
tsServer DateTime @default(now()) @map("ts_server")
machine Machine @relation(fields: [machineId], references: [id])
@@unique([orgId, machineId, ts, cycleCount])
@@unique([orgId, machineId, seq], map: "uq_cycle_org_machine_seq")
@@index([orgId, machineId, ts])
@@index([orgId, machineId, cycleCount])
}
@@ -274,9 +251,14 @@ model MachineWorkOrder {
status String @default("PENDING")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
goodParts Int @default(0) @map("good_parts")
scrapParts Int @default(0) @map("scrap_parts")
cycleCount Int @default(0) @map("cycle_count")
mold String?
cavitiesTotal Int? @map("cavities_total")
cavitiesActive Int? @map("cavities_active")
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@unique([machineId, workOrderId])
@@index([orgId, machineId])
@@ -293,7 +275,6 @@ model IngestLog {
seq BigInt?
tsDevice DateTime?
tsServer DateTime @default(now())
ok Boolean
status Int
errorCode String?
@@ -312,7 +293,6 @@ model OrgSettings {
timezone String @default("UTC")
shiftChangeCompMin Int @default(10) @map("shift_change_comp_min")
lunchBreakMin Int @default(30) @map("lunch_break_min")
shiftScheduleOverridesJson Json? @map("shift_schedule_overrides_json")
stoppageMultiplier Float @default(1.5) @map("stoppage_multiplier")
oeeAlertThresholdPct Float @default(90) @map("oee_alert_threshold_pct")
macroStoppageMultiplier Float @default(5) @map("macro_stoppage_multiplier")
@@ -323,7 +303,7 @@ model OrgSettings {
version Int @default(1)
updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by")
shiftScheduleOverridesJson Json? @map("shift_schedule_overrides_json")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@map("org_settings")
@@ -344,7 +324,6 @@ model OrgFinancialProfile {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@map("org_financial_profiles")
@@ -367,7 +346,6 @@ model LocationFinancialOverride {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@unique([orgId, location])
@@ -392,9 +370,8 @@ model MachineFinancialOverride {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@unique([orgId, machineId])
@@index([orgId])
@@ -410,7 +387,6 @@ model ProductCostOverride {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@unique([orgId, sku])
@@ -420,14 +396,12 @@ model ProductCostOverride {
model AlertPolicy {
id String @id @default(uuid())
orgId String @map("org_id")
orgId String @unique @map("org_id")
policyJson Json @map("policy_json")
updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@unique([orgId])
@@index([orgId])
@@map("alert_policies")
}
@@ -437,16 +411,15 @@ model AlertContact {
orgId String @map("org_id")
userId String? @map("user_id")
name String
roleScope String @map("role_scope") // MEMBER | ADMIN | OWNER | CUSTOM
roleScope String @map("role_scope")
email String?
phone String?
eventTypes Json? @map("event_types") // optional allowlist (array of strings)
eventTypes Json? @map("event_types")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id])
notifications AlertNotification[]
@@unique([orgId, userId])
@@ -469,11 +442,10 @@ model AlertNotification {
sentAt DateTime @default(now()) @map("sent_at")
status String
error String?
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
contact AlertContact? @relation(fields: [contactId], references: [id])
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
contact AlertContact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id])
@@index([orgId, machineId, sentAt])
@@index([orgId, eventId, role, channel])
@@ -490,7 +462,6 @@ model OrgShift {
endTime String @map("end_time")
sortOrder Int @map("sort_order")
enabled Boolean @default(true)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@index([orgId])
@@ -504,9 +475,8 @@ model MachineSettings {
overridesJson Json? @map("overrides_json")
updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@index([orgId])
@@map("machine_settings")
@@ -520,9 +490,8 @@ model SettingsAudit {
source String
payloadJson Json @map("payload_json")
createdAt DateTime @default(now()) @map("created_at")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine? @relation(fields: [machineId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@index([orgId, createdAt])
@@index([machineId, createdAt])
@@ -533,42 +502,29 @@ model ReasonEntry {
id String @id @default(uuid())
orgId String
machineId String
// idempotency key from Edge (rsn_<ulid>)
reasonId String @unique
// "downtime" | "scrap"
kind String
// For downtime reasons
episodeId String?
durationSeconds Int?
episodeEndTs DateTime?
// For scrap reasons
scrapEntryId String?
scrapQty Int?
scrapUnit String?
// Required reason
reasonCode String
reasonLabel String?
reasonText String?
capturedAt DateTime
workOrderId String?
meta Json?
schemaVersion Int @default(1)
createdAt DateTime @default(now())
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@index([orgId, machineId, capturedAt])
@@index([orgId, kind, capturedAt])
@@unique([orgId, kind, episodeId])
@@unique([orgId, kind, scrapEntryId])
@@index([orgId, machineId, capturedAt])
@@index([orgId, kind, capturedAt])
}
model DowntimeAction {
@@ -578,7 +534,6 @@ model DowntimeAction {
reasonCode String? @map("reason_code")
hmDay Int? @map("hm_day")
hmHour Int? @map("hm_hour")
title String
notes String?
status String @default("open")
@@ -586,19 +541,16 @@ model DowntimeAction {
dueDate DateTime? @map("due_date")
reminderAt DateTime? @map("reminder_at")
lastReminderAt DateTime? @map("last_reminder_at")
reminderStage String? @map("reminder_stage")
completedAt DateTime? @map("completed_at")
ownerUserId String? @map("owner_user_id")
createdBy String? @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
reminderStage String? @map("reminder_stage")
creator User? @relation("DowntimeActionCreator", fields: [createdBy], references: [id])
machine Machine? @relation(fields: [machineId], references: [id])
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine? @relation(fields: [machineId], references: [id], onDelete: SetNull)
ownerUser User? @relation("DowntimeActionOwner", fields: [ownerUserId], references: [id], onDelete: SetNull)
creator User? @relation("DowntimeActionCreator", fields: [createdBy], references: [id], onDelete: SetNull)
ownerUser User? @relation("DowntimeActionOwner", fields: [ownerUserId], references: [id])
@@index([orgId])
@@index([orgId, machineId])

144
recap_fix.md Normal file
View File

@@ -0,0 +1,144 @@
Recap Redesign — Handoff Prompt
Goal
Replace the current aggregated /recap view with a two-level drill-down: machine grid → machine-specific 24h detail. One machine = one clear story. No mixed averages.
Architecture
/recap → grid of machine cards (overview)
/recap/[machineId] → full recap detail for one machine
Level 1: /recap (grid)
Layout
Reuse the pattern from app/(app)/machines/MachinesClient.tsx. Same card grid, same responsive breakpoints, same filters (location, status).
Card contents (per machine)
Header: machine name + location + status dot (green=running, amber=mold-change, red=stopped, gray=offline)
Big number: today's OEE % (or good parts — pick one primary metric and stick with it)
Secondary row: good parts · scrap · stops count
Mini timeline bar: compressed 24h bar (height 20px), same color coding as detail page. No labels, tooltip only. Clicking anywhere navigates to detail.
Footer: "Última actividad hace X min" or current WO id if active
Banners (inline, colored):
If mold-change active → amber: "Cambio de molde en curso · Xm"
If machine offline >10 min → red: "Sin señal hace Xm"
Data source
New endpoint app/api/recap/summary/route.ts — returns array of per-machine summaries in one query. Cache 60s.
GET /api/recap/summary?hours=24
→ {
machines: [{
machineId, name, location, status,
oee, goodParts, scrap, stopsCount,
lastSeenMs, activeWorkOrderId,
moldChange: { active, startMs } | null,
miniTimeline: Segment[] // compressed, max ~30 segments
}]
}
Empty / loading states
Skeleton cards while loading (pulse animation, same size as real card).
Zero-activity machine: card renders but with "Sin producción" muted text, gray mini bar, metric "—".
Level 2: /recap/[machineId]
Layout (top to bottom)
Back arrow + machine name breadcrumb — ← Todas las máquinas / M4-5
Range picker — 24h / Turno actual / Ayer / Personalizado (top-right)
Banners — mold-change / offline / ongoing-stop (full-width, colored)
KPI row (4 cards) — OEE, Buenas, Paros totales (min), Scrap
Timeline 24h — full-width smooth bar (see fix from previous message: min 1.5% width, no dots, merged consecutive stops)
Two-column row:
Left: Producción por SKU (table) — SKU | Buenas | Scrap | Meta | Avance%
Right: Top downtime (pareto) — top 3 reasons with minutes + percent
Work orders — two side-by-side lists:
Completadas: id, SKU, parts, duration
Activa: id, SKU, progress bar, started-at
Estado máquina — last heartbeat, uptime %, connection status
Data source
Endpoint app/api/recap/[machineId]/route.ts — the detailed payload (shape I already documented in the earlier handoff). Cache 60s keyed by {machineId, range}.
Navigation
Sidebar "Resumen" stays → routes to /recap grid.
MachineCard onClick → router.push('/recap/' + machineId).
Breadcrumb on detail page navigates back to grid.
Deep link safe: /recap/<uuid> works standalone.
Shared components
Build these in components/recap/:
RecapMachineCard.tsx — the grid card. Props: machine summary object.
RecapMiniTimeline.tsx — 20px-high compressed bar, no labels, tooltip only.
RecapFullTimeline.tsx — 48-56px bar, labels on wide segments (>5% width), minimum segment width 1.5%, rounded only on first/last child.
RecapKpiRow.tsx — reused from prior design.
RecapProductionBySku.tsx, RecapDowntimeTop.tsx, RecapWorkOrders.tsx, RecapMachineStatus.tsx — detail-page sections.
RecapBanners.tsx — mold-change / offline / ongoing-stop alert bars.
Timeline specifics (fix the ugly-dots issue)
Both mini and full versions share segment-builder logic. Server-side:
Walk 24h chronologically, produce raw segments from MachineCycle, MachineEvent, MachineWorkOrder.
Gap-fill — any time between segments with no data → idle segment.
Merge pass — consecutive same-type segments separated by <30s → merge.
Absorb micro-runs — runs of microstops closer than 60s → single microstop-cluster segment with aggregated duration and count.
Minimum display width — server returns raw segments; client enforces Math.max(1.5, pct) so nothing renders as a dot.
Client:
display: flex; overflow: hidden; border-radius: 0.75rem on container.
Each child: width % only, no margin, no gap, no border-right.
Only labels if segment width >5% (else title tooltip).
Color map exactly:
production: bg-emerald-500 text-black
mold-change: bg-sky-400 text-black
macrostop: bg-red-500 text-white
microstop: bg-orange-500 text-black
idle: bg-zinc-700 text-zinc-300
i18n
Every user-visible string routes through useI18n(). Add to Spanish locale (primary):
recap.grid.title = "Resumen de máquinas"
recap.grid.subtitle = "Últimas 24h · click para ver detalle"
recap.detail.back = "Todas las máquinas"
recap.card.oee = "OEE"
recap.card.good = "Piezas buenas"
recap.card.stops = "Paros"
recap.banner.moldChange = "Cambio de molde en curso desde {time}"
recap.banner.offline = "Sin señal hace {min} min"
recap.banner.ongoingStop = "Máquina detenida hace {min} min"
recap.production.bySku = "Producción por SKU"
recap.downtime.top = "Top paros"
...
English keys mirror.
Accessibility / responsive
Cards collapse to single column <640px.
Timeline stays readable — horizontal scroll if really tight.
Keyboard navigable: cards are <button> or <Link>, not divs.
Status dots have aria-label.
Permissions
Same as /machines — any authenticated org member. No OWNER gate.
Files to create
app/(app)/recap/page.tsx (server, fetches summary)
app/(app)/recap/RecapGridClient.tsx (client, renders cards + filters)
app/(app)/recap/[machineId]/page.tsx (server, fetches detail)
app/(app)/recap/[machineId]/RecapDetailClient.tsx (client, renders detail)
app/api/recap/summary/route.ts
app/api/recap/[machineId]/route.ts
components/recap/* (per list above)
Files to delete / repurpose
The current aggregated recap (if it exists at /recap with mixed-machine view) — replace with the grid.
Any "global OEE average across all machines" widget — remove. Too misleading.
Testing checklist
not done
Grid renders for org with 5+ machines without lag (1 query, not N+1)
not done
Clicking a card navigates to correct detail page
not done
Detail page works for offline machine (no panic)
not done
Mold-change banner appears on both grid card AND detail page
not done
Timeline shows no dots — segments have visible width or get merged
not done
Mini timeline and full timeline use identical color palette
not done
Back navigation works, range picker persists in URL query
not done
Mobile layout: cards stack, detail sections stack
Non-goals
No real-time websockets — polling on focus is fine
No PDF/email export in this iteration
No shift-boundary magic (use wall-clock 24h unless user picks "Turno actual")
No schema changes

View File

@@ -0,0 +1,308 @@
#!/usr/bin/env node
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const NON_AUTHORITATIVE_REASON_CODES = new Set(["PENDIENTE", "UNCLASSIFIED"]);
function parseArgs(argv) {
const out = {
dryRun: false,
since: "30d",
orgId: null,
machineId: null,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--dry-run") {
out.dryRun = true;
continue;
}
if (token === "--since") {
out.since = argv[i + 1] || out.since;
i += 1;
continue;
}
if (token === "--org-id") {
out.orgId = argv[i + 1] || null;
i += 1;
continue;
}
if (token === "--machine-id") {
out.machineId = argv[i + 1] || null;
i += 1;
continue;
}
throw new Error(`Unknown argument: ${token}`);
}
return out;
}
function parseSince(value) {
const now = Date.now();
const text = String(value || "30d").trim().toLowerCase();
const relative = text.match(/^(\d+)\s*([dhm])$/);
if (relative) {
const amount = Number(relative[1]);
const unit = relative[2];
const factor = unit === "d" ? 24 * 60 * 60 * 1000 : unit === "h" ? 60 * 60 * 1000 : 60 * 1000;
return new Date(now - amount * factor);
}
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) {
throw new Error(`Invalid --since value: ${value}. Use ISO date, or relative like 30d / 12h / 90m.`);
}
return dt;
}
function asRecord(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
}
function clampText(value, maxLen) {
if (value === null || value === undefined) return null;
const text = String(value).trim().replace(/[\u0000-\u001f\u007f]/g, "");
if (!text) return null;
return text.length > maxLen ? text.slice(0, maxLen) : text;
}
function canonicalId(input) {
const text = String(input ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return text || null;
}
function toReasonCode(categoryId, detailId) {
const cat = canonicalId(categoryId);
const det = canonicalId(detailId);
if (!cat || !det) return null;
return `${cat}__${det}`.toUpperCase();
}
function isNonAuthoritativeReasonCode(code) {
const normalized = clampText(code, 64)?.toUpperCase();
return !!normalized && NON_AUTHORITATIVE_REASON_CODES.has(normalized);
}
function extractReasonPayload(data) {
const rec = asRecord(data);
if (!rec) return null;
const direct = asRecord(rec.reason);
if (direct) return direct;
const downtime = asRecord(rec.downtime);
const nested = asRecord(downtime?.reason);
return nested || null;
}
function extractIncidentKey(data, reason) {
const rec = asRecord(data);
const downtime = asRecord(rec?.downtime);
return (
clampText(rec?.incidentKey, 128) ??
clampText(downtime?.incidentKey, 128) ??
clampText(reason?.incidentKey, 128) ??
null
);
}
function normalizeAckReason(reasonRaw) {
const categoryId = clampText(reasonRaw?.categoryId, 64);
const detailId = clampText(reasonRaw?.detailId, 64);
const categoryLabel = clampText(reasonRaw?.categoryLabel, 120);
const detailLabel = clampText(reasonRaw?.detailLabel, 120);
const reasonCode =
clampText(reasonRaw?.reasonCode, 64)?.toUpperCase() ??
toReasonCode(categoryId ?? categoryLabel, detailId ?? detailLabel) ??
null;
if (!reasonCode) return null;
const reasonLabel =
clampText(reasonRaw?.reasonText, 240) ??
(categoryLabel && detailLabel ? `${categoryLabel} > ${detailLabel}` : null) ??
detailLabel ??
categoryLabel ??
reasonCode;
return {
type: "downtime",
categoryId,
categoryLabel,
detailId,
detailLabel,
reasonCode,
reasonLabel,
reasonText: reasonLabel,
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const since = parseSince(args.since);
const where = {
eventType: "downtime-acknowledged",
ts: { gte: since },
...(args.orgId ? { orgId: args.orgId } : {}),
...(args.machineId ? { machineId: args.machineId } : {}),
};
const ackEvents = await prisma.machineEvent.findMany({
where,
orderBy: { ts: "desc" },
select: {
id: true,
orgId: true,
machineId: true,
ts: true,
data: true,
},
});
const latestByIncident = new Map();
for (const event of ackEvents) {
const reasonRaw = extractReasonPayload(event.data);
if (!reasonRaw) continue;
const normalized = normalizeAckReason(reasonRaw);
if (!normalized) continue;
if (isNonAuthoritativeReasonCode(normalized.reasonCode)) continue;
const incidentKey = extractIncidentKey(event.data, reasonRaw);
if (!incidentKey) continue;
const mapKey = `${event.orgId}::${incidentKey}`;
if (latestByIncident.has(mapKey)) continue;
latestByIncident.set(mapKey, {
orgId: event.orgId,
machineId: event.machineId,
incidentKey,
eventId: event.id,
eventTs: event.ts,
reason: normalized,
});
}
let scanned = 0;
let candidates = 0;
let updated = 0;
let missingReasonEntry = 0;
let alreadyManual = 0;
let skippedNonPendingIncoming = 0;
const samples = [];
for (const item of latestByIncident.values()) {
scanned += 1;
const existing = await prisma.reasonEntry.findFirst({
where: {
orgId: item.orgId,
kind: "downtime",
episodeId: item.incidentKey,
},
select: {
id: true,
reasonCode: true,
reasonLabel: true,
reasonText: true,
capturedAt: true,
schemaVersion: true,
},
});
if (!existing) {
missingReasonEntry += 1;
continue;
}
if (!isNonAuthoritativeReasonCode(existing.reasonCode)) {
alreadyManual += 1;
continue;
}
if (isNonAuthoritativeReasonCode(item.reason.reasonCode)) {
skippedNonPendingIncoming += 1;
continue;
}
candidates += 1;
const next = {
reasonCode: item.reason.reasonCode,
reasonLabel: item.reason.reasonLabel ?? item.reason.reasonCode,
reasonText: item.reason.reasonText ?? item.reason.reasonLabel ?? item.reason.reasonCode,
schemaVersion: Math.max(1, Number(existing.schemaVersion || 1)),
meta: {
source: "backfill:downtime-acknowledged",
eventId: item.eventId,
eventTs: item.eventTs.toISOString(),
incidentKey: item.incidentKey,
reason: {
type: "downtime",
categoryId: item.reason.categoryId,
categoryLabel: item.reason.categoryLabel,
detailId: item.reason.detailId,
detailLabel: item.reason.detailLabel,
reasonText: item.reason.reasonText,
},
},
};
samples.push({
reasonEntryId: existing.id,
orgId: item.orgId,
machineId: item.machineId,
incidentKey: item.incidentKey,
from: {
reasonCode: existing.reasonCode,
reasonLabel: existing.reasonLabel,
reasonText: existing.reasonText,
},
to: {
reasonCode: next.reasonCode,
reasonLabel: next.reasonLabel,
reasonText: next.reasonText,
},
});
if (!args.dryRun) {
await prisma.reasonEntry.update({
where: { id: existing.id },
data: next,
});
updated += 1;
}
}
const summary = {
ok: true,
mode: args.dryRun ? "dry-run" : "apply",
since: since.toISOString(),
filters: {
orgId: args.orgId,
machineId: args.machineId,
},
eventsRead: ackEvents.length,
incidentsDeduped: latestByIncident.size,
scanned,
candidates,
updated,
missingReasonEntry,
alreadyManual,
skippedNonPendingIncoming,
sampleUpdates: samples.slice(0, 25),
};
console.log(JSON.stringify(summary, null, 2));
}
main()
.catch((err) => {
console.error("[backfill-downtime-reasons] failed:", err);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,8 @@
-- Run on the Pi/MariaDB instance used by Node-RED (local `work_orders` table).
-- Required before importing updated flows that INSERT/UPDATE mold, cavities_total, cavities_active.
ALTER TABLE work_orders ADD COLUMN mold VARCHAR(256) NULL;
ALTER TABLE work_orders ADD COLUMN cavities_total INT NULL;
ALTER TABLE work_orders ADD COLUMN cavities_active INT NULL;
-- If columns already exist, skip this script or adjust manually.

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
const NON_AUTHORITATIVE_REASON_CODES = new Set(["PENDIENTE", "UNCLASSIFIED"]);
function isNonAuthoritativeReasonCode(code) {
const normalized = String(code ?? "").trim().toUpperCase();
return !!normalized && NON_AUTHORITATIVE_REASON_CODES.has(normalized);
}
function shouldPreserveManualReason({
incomingReasonCode,
existingReasonCode,
isManualAckEvent,
}) {
if (isManualAckEvent) return false;
if (!isNonAuthoritativeReasonCode(incomingReasonCode)) return false;
if (!existingReasonCode) return false;
return !isNonAuthoritativeReasonCode(existingReasonCode);
}
function run() {
// 1) pending -> manual ack -> later pending: preserve manual
assert.equal(
shouldPreserveManualReason({
incomingReasonCode: "PENDIENTE",
existingReasonCode: "OPERACION__OTRO",
isManualAckEvent: false,
}),
true
);
// 2) manual ack followed by another manual reason: latest manual should be allowed
assert.equal(
shouldPreserveManualReason({
incomingReasonCode: "SERVICIOS__OTRO",
existingReasonCode: "OPERACION__OTRO",
isManualAckEvent: true,
}),
false
);
// 3) no manual reason ever applied: pending stays pending
assert.equal(
shouldPreserveManualReason({
incomingReasonCode: "UNCLASSIFIED",
existingReasonCode: "PENDIENTE",
isManualAckEvent: false,
}),
false
);
console.log(
JSON.stringify(
{
ok: true,
testedAt: new Date().toISOString(),
scenarios: 3,
},
null,
2
)
);
}
run();