recent changes

This commit is contained in:
Marcelo
2026-04-29 05:05:00 +00:00
parent 7e0fe5c2e1
commit 62169b163c
25 changed files with 6698 additions and 1013 deletions

View File

@@ -75,6 +75,36 @@ sudo systemctl daemon-reload
sudo systemctl enable --now mis-control-tower-reminders.timer 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 ## 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: **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:

View File

@@ -31,7 +31,7 @@ function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
function isOffline(ts?: string) { function isOffline(ts?: string) {
if (!ts) return true; if (!ts) return true;
return Date.now() - new Date(ts).getTime() > 30000; // 30s threshold return Date.now() - new Date(ts).getTime() > 10 * 60 * 1000; // 10 min (sincronizado con RECAP_HEARTBEAT_STALE_MS)
} }
function normalizeStatus(status?: string) { 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

@@ -26,6 +26,7 @@ import {
formatDuration, formatDuration,
formatTime, formatTime,
normalizeTimelineSegments, normalizeTimelineSegments,
SEGMENT_MIN_WIDTH_PCT,
TIMELINE_COLORS, TIMELINE_COLORS,
} from "@/components/recap/timelineRender"; } from "@/components/recap/timelineRender";
@@ -106,6 +107,9 @@ type WorkOrderUpload = {
sku?: string; sku?: string;
targetQty?: number; targetQty?: number;
cycleTime?: number; cycleTime?: number;
mold?: string;
cavitiesTotal?: number;
cavitiesActive?: number;
}; };
type WorkOrderRow = Record<string, string | number | boolean>; type WorkOrderRow = Record<string, string | number | boolean>;
@@ -192,8 +196,31 @@ const WORK_ORDER_KEYS = {
"theoretical_cycle_time", "theoretical_cycle_time",
]), ]),
target: new Set(["targetquantity", "targetqty", "target", "target_qty"]), 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;
function normalizeKey(value: string) { function normalizeKey(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]/g, ""); return value.toLowerCase().replace(/[^a-z0-9]/g, "");
} }
@@ -278,7 +305,26 @@ function rowsToWorkOrders(rows: WorkOrderRow[]): WorkOrderUpload[] {
const targetQty = Number.isFinite(Number(targetRaw)) ? Math.trunc(Number(targetRaw)) : undefined; const targetQty = Number.isFinite(Number(targetRaw)) ? Math.trunc(Number(targetRaw)) : undefined;
const cycleTime = Number.isFinite(Number(cycleRaw)) ? Number(cycleRaw) : 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; return out;
@@ -308,6 +354,14 @@ type MachineActivityTimelineProps = {
t: (key: string, vars?: Record<string, string | number>) => 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) { function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimelineProps) {
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null); const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
const [timelineLoading, setTimelineLoading] = useState(true); const [timelineLoading, setTimelineLoading] = useState(true);
@@ -321,11 +375,18 @@ function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimeli
async function loadTimeline() { async function loadTimeline() {
try { try {
const res = await fetch(`/api/recap/${machineId}/timeline?range=1h`, { cache: "no-store" }); 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); const json = await res.json().catch(() => null);
if (!alive || !res.ok || !json) return; if (!alive || !res.ok || !json) return;
const nextTimeline = json as RecapTimelineResponse; const nextTimeline = json as RecapTimelineResponse;
const nextHash = JSON.stringify({ const nextHash = JSON.stringify({
start: nextTimeline.range.start,
end: nextTimeline.range.end,
hasData: nextTimeline.hasData, hasData: nextTimeline.hasData,
segments: nextTimeline.segments.map((segment) => ({ segments: nextTimeline.segments.map((segment) => ({
type: segment.type, type: segment.type,
@@ -353,14 +414,18 @@ function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimeli
}, [machineId]); }, [machineId]);
const hasData = timeline?.hasData ?? false; const hasData = timeline?.hasData ?? false;
const startMs = timeline ? new Date(timeline.range.start).getTime() : Date.now() - 60 * 60 * 1000; const fallbackRange = getMinuteFlooredOneHourRange();
const endMs = timeline ? new Date(timeline.range.end).getTime() : Date.now(); 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 totalMs = Math.max(1, endMs - startMs);
const normalized = useMemo(() => { const normalized = useMemo(() => {
if (!timeline || !hasData) return [] as RecapTimelineSegment[]; if (!timeline || !hasData) return [] as RecapTimelineSegment[];
return normalizeTimelineSegments(timeline.segments, startMs, endMs); return normalizeTimelineSegments(timeline.segments, startMs, endMs);
}, [timeline, hasData, startMs, endMs]); }, [timeline, hasData, startMs, endMs]);
const widths = useMemo(() => computeWidths(normalized, totalMs, 1.5), [normalized, totalMs]); const widths = useMemo(
() => computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT),
[normalized, totalMs]
);
return ( return (
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
@@ -586,6 +651,23 @@ export default function MachineDetailClient() {
return null; 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)]);
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>) { async function handleWorkOrderUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
@@ -1005,6 +1087,13 @@ export default function MachineDetailClient() {
className="hidden" className="hidden"
onChange={handleWorkOrderUpload} 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 <button
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}

View File

@@ -5,7 +5,7 @@ import { Suspense, lazy, useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import type { EventRow, Heartbeat, MachineRow } from "./types"; import type { EventRow, Heartbeat, MachineRow } from "./types";
const OFFLINE_MS = 30000; const OFFLINE_MS = 10 * 60 * 1000; // 10 min (sincronizado con RECAP_HEARTBEAT_STALE_MS)
const MAX_EVENT_MACHINES = 6; const MAX_EVENT_MACHINES = 6;
const OverviewTimeline = lazy(() => import("./OverviewTimeline")); const OverviewTimeline = lazy(() => import("./OverviewTimeline"));

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

@@ -37,6 +37,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null); const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
const [timelineLoading, setTimelineLoading] = useState(true);
const [nowMs, setNowMs] = useState(() => Date.now()); const [nowMs, setNowMs] = useState(() => Date.now());
const [customStart, setCustomStart] = useState(toInputDate(initialData.range.start)); const [customStart, setCustomStart] = useState(toInputDate(initialData.range.start));
@@ -78,11 +79,13 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
const freshAgeSec = Number.isFinite(generatedAtMs) ? Math.max(0, Math.floor((nowMs - generatedAtMs) / 1000)) : null; const freshAgeSec = Number.isFinite(generatedAtMs) ? Math.max(0, Math.floor((nowMs - generatedAtMs) / 1000)) : null;
const timelineStart = timeline?.range.start ?? initialData.range.start; const timelineStart = timeline?.range.start ?? initialData.range.start;
const timelineEnd = timeline?.range.end ?? initialData.range.end; const timelineEnd = timeline?.range.end ?? initialData.range.end;
const timelineSegments = timeline?.segments ?? machine.timeline; const timelineSegments = timeline?.segments ?? [];
const timelineHasData = timeline?.hasData ?? true; const timelineHasData = timeline?.hasData ?? false;
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setTimeline(null);
setTimelineLoading(true);
async function loadTimeline() { async function loadTimeline() {
try { try {
@@ -95,6 +98,8 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
if (!alive || !res.ok || !json) return; if (!alive || !res.ok || !json) return;
setTimeline(json as RecapTimelineResponse); setTimeline(json as RecapTimelineResponse);
} catch { } catch {
} finally {
if (alive) setTimelineLoading(false);
} }
} }
@@ -212,6 +217,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
rangeEnd={timelineEnd} rangeEnd={timelineEnd}
segments={timelineSegments} segments={timelineSegments}
hasData={timelineHasData} hasData={timelineHasData}
loading={timelineLoading}
locale={locale} locale={locale}
/> />
</div> </div>

View File

@@ -63,7 +63,12 @@ const MAX_EVENTS = 100;
//when no cycle time is configed //when no cycle time is configed
const DEFAULT_MACROSTOP_SEC = 300; 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) { function clampText(value: unknown, maxLen: number) {
if (value === null || value === undefined) return null; if (value === null || value === undefined) return null;
@@ -271,8 +276,10 @@ export async function POST(req: Request) {
const machine = await getMachineAuth(String(machineId), apiKey); const machine = await getMachineAuth(String(machineId), apiKey);
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const bodySeq = parseSeqToBigInt(bodyRecord.seq); const bodySeq = parseSeqToBigInt(bodyRecord.seq);
const bodySchemaVersion = clampText(bodyRecord.schemaVersion, 16); const bodySchemaVersion = clampText(bodyRecord.schemaVersion, 16);
const orgSettings = await prisma.orgSettings.findUnique({ const orgSettings = await prisma.orgSettings.findUnique({
where: { orgId: machine.orgId }, where: { orgId: machine.orgId },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true }, select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true },
@@ -602,6 +609,62 @@ export async function POST(req: Request) {
numberFrom(evDowntime?.acknowledgedAtMs) ?? numberFrom(evDowntime?.acknowledgedAtMs) ??
null; 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: clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128),
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({ await prisma.reasonEntry.upsert({
where: { reasonId }, where: { reasonId },
create: { create: {
@@ -612,14 +675,14 @@ export async function POST(req: Request) {
episodeId: incidentKey, episodeId: incidentKey,
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null, durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null, episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
...commonWrite, ...guardedWrite,
}, },
update: { update: {
kind: "downtime", kind: "downtime",
episodeId: incidentKey, episodeId: incidentKey,
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null, durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null, episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
...commonWrite, ...guardedWrite,
}, },
}); });
} else { } else {

View File

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

View File

@@ -12,8 +12,10 @@ function canManage(role?: string | null) {
const MAX_WORK_ORDERS = 2000; const MAX_WORK_ORDERS = 2000;
const MAX_WORK_ORDER_ID_LENGTH = 64; const MAX_WORK_ORDER_ID_LENGTH = 64;
const MAX_SKU_LENGTH = 64; const MAX_SKU_LENGTH = 64;
const MAX_MOLD_LENGTH = 256;
const MAX_TARGET_QTY = 2_000_000_000; const MAX_TARGET_QTY = 2_000_000_000;
const MAX_CYCLE_TIME = 86_400; const MAX_CYCLE_TIME = 86_400;
const MAX_CAVITIES = 100_000;
const WORK_ORDER_ID_RE = /^[A-Za-z0-9._-]+$/; const WORK_ORDER_ID_RE = /^[A-Za-z0-9._-]+$/;
const uploadBodySchema = z.object({ const uploadBodySchema = z.object({
@@ -51,6 +53,15 @@ type WorkOrderInput = {
sku?: string | null; sku?: string | null;
targetQty?: number | null; targetQty?: number | null;
cycleTime?: 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[]) { function normalizeWorkOrders(raw: unknown[]) {
@@ -78,17 +89,98 @@ function normalizeWorkOrders(raw: unknown[]) {
const cycleTime = const cycleTime =
cycleTimeRaw == null ? null : Math.min(Math.max(cycleTimeRaw, 0), MAX_CYCLE_TIME); 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({ cleaned.push({
workOrderId: idRaw, workOrderId: idRaw,
sku: sku ?? null, sku: sku ?? null,
targetQty: targetQty ?? null, targetQty: targetQty ?? null,
cycleTime: cycleTime ?? null, cycleTime: cycleTime ?? null,
mold: mold ?? null,
cavitiesTotal: cavitiesTotal ?? null,
cavitiesActive: cavitiesActive ?? null,
}); });
} }
return cleaned; 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) { export async function POST(req: NextRequest) {
const session = await requireSession(); const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); 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 }); 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({ const created = await prisma.machineWorkOrder.createMany({
data: cleaned.map((row) => ({ data: cleaned.map((row) => ({
orgId: session.orgId, orgId: session.orgId,
@@ -146,6 +253,9 @@ export async function POST(req: NextRequest) {
sku: row.sku ?? null, sku: row.sku ?? null,
targetQty: row.targetQty ?? null, targetQty: row.targetQty ?? null,
cycleTime: row.cycleTime ?? null, cycleTime: row.cycleTime ?? null,
mold: row.mold ?? null,
cavitiesTotal: row.cavitiesTotal ?? null,
cavitiesActive: row.cavitiesActive ?? null,
status: "PENDING", status: "PENDING",
})), })),
skipDuplicates: true, skipDuplicates: true,
@@ -167,4 +277,4 @@ export async function POST(req: NextRequest) {
inserted: created.count, inserted: created.count,
total: cleaned.length, total: cleaned.length,
}); });
} }

View File

@@ -7,6 +7,7 @@ import {
formatTime, formatTime,
LABEL_MIN_WIDTH_PCT, LABEL_MIN_WIDTH_PCT,
normalizeTimelineSegments, normalizeTimelineSegments,
SEGMENT_MIN_WIDTH_PCT,
TIMELINE_COLORS, TIMELINE_COLORS,
} from "@/components/recap/timelineRender"; } from "@/components/recap/timelineRender";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
@@ -17,28 +18,47 @@ type Props = {
segments: RecapTimelineSegment[]; segments: RecapTimelineSegment[];
locale: string; locale: string;
hasData?: boolean; hasData?: boolean;
loading?: boolean;
}; };
const MIN_SEGMENT_PCT = 1.5; export default function RecapFullTimeline({
rangeStart,
export default function RecapFullTimeline({ rangeStart, rangeEnd, segments, locale, hasData = true }: Props) { rangeEnd,
segments,
locale,
hasData = false,
loading = false,
}: Props) {
const { t } = useI18n(); const { t } = useI18n();
const startMs = new Date(rangeStart).getTime(); const startMs = new Date(rangeStart).getTime();
const endMs = new Date(rangeEnd).getTime(); const endMs = new Date(rangeEnd).getTime();
const totalMs = Math.max(1, endMs - startMs); const totalMs = Math.max(1, endMs - startMs);
const normalized = normalizeTimelineSegments(segments, startMs, endMs); const normalized = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : [];
const widths = computeWidths(normalized, totalMs, MIN_SEGMENT_PCT); const widths = computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT);
return ( return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4"> <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> <div className="mb-3 text-sm font-semibold text-white">{t("recap.timeline.title")}</div>
{!hasData ? ( {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"> <div className="rounded-xl border border-dashed border-white/10 bg-black/20 p-4 text-sm text-zinc-400">
{t("recap.timeline.noData")} {t("recap.timeline.noData")}
</div> </div>
) : null} ) : null}
{hasData ? ( {!loading && hasData ? (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="min-w-[560px]"> <div className="min-w-[560px]">
<div className="flex h-14 w-full overflow-hidden rounded-xl"> <div className="flex h-14 w-full overflow-hidden rounded-xl">

View File

@@ -3,7 +3,6 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
import type { RecapSummaryMachine, RecapTimelineResponse } from "@/lib/recap/types"; import type { RecapSummaryMachine, RecapTimelineResponse } from "@/lib/recap/types";
import RecapMiniTimeline from "@/components/recap/RecapMiniTimeline"; import RecapMiniTimeline from "@/components/recap/RecapMiniTimeline";
@@ -35,7 +34,6 @@ function toInt(value: number | null | undefined) {
export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Props) { export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Props) {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null); const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const zeroActivity = machine.goodParts === 0 && machine.scrap === 0 && machine.stopsCount === 0; const zeroActivity = machine.goodParts === 0 && machine.scrap === 0 && machine.stopsCount === 0;
const primaryMetric = machine.oee == null ? "—" : `${machine.oee.toFixed(1)}%`; const primaryMetric = machine.oee == null ? "—" : `${machine.oee.toFixed(1)}%`;
@@ -43,8 +41,6 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
const timelineStart = timeline?.range.start ?? rangeStart; const timelineStart = timeline?.range.start ?? rangeStart;
const timelineEnd = timeline?.range.end ?? rangeEnd; const timelineEnd = timeline?.range.end ?? rangeEnd;
const hasTimelineData = timeline?.hasData ?? timelineSegments.length > 0; const hasTimelineData = timeline?.hasData ?? timelineSegments.length > 0;
const staleHeartbeat =
machine.lastSeenMs == null ? true : nowMs - machine.lastSeenMs > RECAP_HEARTBEAT_STALE_MS;
const lastSeenLabel = const lastSeenLabel =
machine.lastActivityMin == null machine.lastActivityMin == null
@@ -57,11 +53,6 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
const moldMinutes = machine.moldChange?.active ? machine.moldChange.elapsedMin : null; const moldMinutes = machine.moldChange?.active ? machine.moldChange.elapsedMin : null;
useEffect(() => {
const timer = window.setInterval(() => setNowMs(Date.now()), 60000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -144,11 +135,6 @@ export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Prop
{t("recap.banner.offline", { min: toInt(machine.offlineForMin) })} {t("recap.banner.offline", { min: toInt(machine.offlineForMin) })}
</div> </div>
) : null} ) : null}
{staleHeartbeat ? (
<div className="mt-2 rounded-lg border border-amber-400/40 bg-amber-400/10 px-2 py-1.5 text-xs text-amber-200">
{t("recap.card.desynced")}
</div>
) : null}
<div className="mt-3 text-xs text-zinc-400">{footerText}</div> <div className="mt-3 text-xs text-zinc-400">{footerText}</div>
</Link> </Link>

View File

@@ -10,6 +10,7 @@ export const TIMELINE_COLORS: Record<RecapTimelineSegment["type"], string> = {
}; };
export const LABEL_MIN_WIDTH_PCT = 5; export const LABEL_MIN_WIDTH_PCT = 5;
export const SEGMENT_MIN_WIDTH_PCT = 1.5;
export function formatTime(valueMs: number, locale: string) { export function formatTime(valueMs: number, locale: string) {
return new Date(valueMs).toLocaleTimeString(locale, { return new Date(valueMs).toLocaleTimeString(locale, {

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 it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -468,7 +468,7 @@ async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
} }
async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) { async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
const now = new Date(); const now = new Date(Math.floor(Date.now() / 60000) * 60000);
const requestedMode = normalizedRangeMode(params.input.mode); const requestedMode = normalizedRangeMode(params.input.mode);
const shiftEnabledCount = await prisma.orgShift.count({ const shiftEnabledCount = await prisma.orgShift.count({
where: { where: {

View File

@@ -55,7 +55,8 @@ function parseMaxSegments(searchParams: URLSearchParams) {
} }
export function parseRecapTimelineRange(searchParams: URLSearchParams) { export function parseRecapTimelineRange(searchParams: URLSearchParams) {
const end = parseDateInput(searchParams.get("end")) ?? new Date(); const defaultEnd = new Date(Math.floor(Date.now() / 60000) * 60000);
const end = parseDateInput(searchParams.get("end")) ?? defaultEnd;
const startParam = parseDateInput(searchParams.get("start")); const startParam = parseDateInput(searchParams.get("start"));
if (startParam && startParam < end) { if (startParam && startParam < end) {
return { return {

View File

@@ -7,6 +7,8 @@
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start", "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:generate": "prisma generate",
"prisma:migrate:deploy": "prisma migrate deploy" "prisma:migrate:deploy": "prisma migrate deploy"
}, },

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

@@ -254,6 +254,9 @@ model MachineWorkOrder {
goodParts Int @default(0) @map("good_parts") goodParts Int @default(0) @map("good_parts")
scrapParts Int @default(0) @map("scrap_parts") scrapParts Int @default(0) @map("scrap_parts")
cycleCount Int @default(0) @map("cycle_count") 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) machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)

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();