Compare commits
11 Commits
80d27f83b6
...
b2214ec46f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2214ec46f | ||
|
|
5e7ddaa0db | ||
|
|
62169b163c | ||
|
|
7e0fe5c2e1 | ||
|
|
66c89f9bf4 | ||
|
|
30513ff73d | ||
|
|
5d3a2c533f | ||
|
|
6aaafb9115 | ||
|
|
4973c18dc3 | ||
|
|
e705f5e965 | ||
|
|
2707fd974a |
30
README.md
30
README.md
@@ -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:
|
||||||
|
|||||||
161
Reliability.md
Normal file
161
Reliability.md
Normal 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
|
||||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, type KeyboardEvent } from "react";
|
import { useEffect, useState, type KeyboardEvent } from "react";
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||||
|
|
||||||
type MachineRow = {
|
type MachineRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,6 +21,7 @@ type MachineRow = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
const LIVE_REFRESH_MS = 5000;
|
const LIVE_REFRESH_MS = 5000;
|
||||||
|
const OFFLINE_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||||
|
|
||||||
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||||
if (!ts) return fallback;
|
if (!ts) return fallback;
|
||||||
@@ -31,7 +33,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() > OFFLINE_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStatus(status?: string) {
|
function normalizeStatus(status?: string) {
|
||||||
|
|||||||
347
app/(app)/machines/MachinesClient.tsx.bak
Normal file
347
app/(app)/machines/MachinesClient.tsx.bak
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,16 @@ import {
|
|||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
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 = {
|
type Heartbeat = {
|
||||||
ts: string;
|
ts: string;
|
||||||
@@ -87,20 +97,6 @@ type Thresholds = {
|
|||||||
|
|
||||||
type TimelineState = "normal" | "slow" | "microstop" | "macrostop";
|
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 = {
|
type UploadState = {
|
||||||
status: "idle" | "parsing" | "uploading" | "success" | "error";
|
status: "idle" | "parsing" | "uploading" | "success" | "error";
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -112,6 +108,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>;
|
||||||
@@ -128,7 +127,7 @@ const TOL = 0.10;
|
|||||||
const DEFAULT_MICRO_MULT = 1.5;
|
const DEFAULT_MICRO_MULT = 1.5;
|
||||||
const DEFAULT_MACRO_MULT = 5;
|
const DEFAULT_MACRO_MULT = 5;
|
||||||
const NORMAL_TOL_SEC = 0.1;
|
const NORMAL_TOL_SEC = 0.1;
|
||||||
const LIVE_REFRESH_MS = 5000;
|
const LIVE_REFRESH_MS = 15000;
|
||||||
|
|
||||||
const BUCKET = {
|
const BUCKET = {
|
||||||
normal: {
|
normal: {
|
||||||
@@ -198,8 +197,41 @@ 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;
|
||||||
|
|
||||||
|
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) {
|
function normalizeKey(value: string) {
|
||||||
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
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 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 +359,184 @@ function toErrorMessage(value: unknown, fallback: string): string {
|
|||||||
return fallback;
|
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() {
|
export default function MachineDetailClient() {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const { screenlessMode } = useScreenlessMode();
|
const { screenlessMode } = useScreenlessMode();
|
||||||
@@ -324,7 +553,6 @@ export default function MachineDetailClient() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [cycles, setCycles] = useState<CycleRow[]>([]);
|
const [cycles, setCycles] = useState<CycleRow[]>([]);
|
||||||
const [thresholds, setThresholds] = useState<Thresholds | null>(null);
|
const [thresholds, setThresholds] = useState<Thresholds | null>(null);
|
||||||
const [activeStoppage, setActiveStoppage] = useState<ActiveStoppage | null>(null);
|
|
||||||
const [open, setOpen] = useState<null | "events" | "deviation" | "impact">(null);
|
const [open, setOpen] = useState<null | "events" | "deviation" | "impact">(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [uploadState, setUploadState] = useState<UploadState>({ status: "idle" });
|
const [uploadState, setUploadState] = useState<UploadState>({ status: "idle" });
|
||||||
@@ -372,7 +600,6 @@ export default function MachineDetailClient() {
|
|||||||
setEventsCountAll(typeof json.eventsCountAll === "number" ? json.eventsCountAll : null);
|
setEventsCountAll(typeof json.eventsCountAll === "number" ? json.eventsCountAll : null);
|
||||||
setCycles(json.cycles ?? []);
|
setCycles(json.cycles ?? []);
|
||||||
setThresholds(json.thresholds ?? null);
|
setThresholds(json.thresholds ?? null);
|
||||||
setActiveStoppage(json.activeStoppage ?? null);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
if (initial) setLoading(false);
|
if (initial) setLoading(false);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -467,6 +694,26 @@ 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),
|
||||||
|
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>) {
|
async function handleWorkOrderUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -586,7 +833,7 @@ export default function MachineDetailClient() {
|
|||||||
|
|
||||||
function isOffline(ts?: string) {
|
function isOffline(ts?: string) {
|
||||||
if (!ts) return true;
|
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) {
|
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({
|
function Modal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -1025,6 +1133,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()}
|
||||||
@@ -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="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="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-2 text-3xl font-bold text-emerald-300">{fmtPct(kpi?.oee)}</div>
|
||||||
|
)}
|
||||||
<div className="mt-1 text-xs text-zinc-400">
|
<div className="mt-1 text-xs text-zinc-400">
|
||||||
{t("machine.detail.kpi.updated", {
|
{t("machine.detail.kpi.updated", {
|
||||||
time: kpi?.ts ? timeAgo(kpi.ts) : t("common.never"),
|
time: kpi?.ts ? timeAgo(kpi.ts) : t("common.never"),
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<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">
|
||||||
@@ -1131,12 +1253,7 @@ export default function MachineDetailClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<MachineActivityTimeline
|
<MachineActivityTimeline machineId={machineId} locale={locale} t={t} />
|
||||||
cycles={cycles}
|
|
||||||
cycleTarget={cycleTarget}
|
|
||||||
thresholds={thresholds}
|
|
||||||
activeStoppage={activeStoppage}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{!screenlessMode && (
|
{!screenlessMode && (
|
||||||
<div className="mt-6 rounded-2xl border border-white/10 bg-white/5 p-5">
|
<div className="mt-6 rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
import { Suspense, lazy, useEffect, useMemo, 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 { EventRow, Heartbeat, MachineRow } from "./types";
|
import type { EventRow, Heartbeat, MachineRow } from "./types";
|
||||||
|
|
||||||
const OFFLINE_MS = 30000;
|
const OFFLINE_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||||
const MAX_EVENT_MACHINES = 6;
|
const MAX_EVENT_MACHINES = 6;
|
||||||
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
|
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
|
||||||
|
|
||||||
@@ -199,20 +200,65 @@ export default function OverviewClient({
|
|||||||
.map((m) => {
|
.map((m) => {
|
||||||
const hb = m.latestHeartbeat;
|
const hb = m.latestHeartbeat;
|
||||||
const offline = isOffline(heartbeatTime(hb));
|
const offline = isOffline(heartbeatTime(hb));
|
||||||
|
const status = normalizeStatus(hb?.status);
|
||||||
const k = m.latestKpi;
|
const k = m.latestKpi;
|
||||||
const oee = k?.oee ?? null;
|
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;
|
let score = 0;
|
||||||
if (offline) score += 100;
|
|
||||||
if (oee != null && oee < 75) score += 50;
|
// Trigger 1: offline (highest priority — can't tell what's wrong)
|
||||||
if (oee != null && oee < 85) score += 25;
|
if (offline) {
|
||||||
return { machine: m, offline, oee, score };
|
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)
|
.filter((x) => x.score > 0)
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
.slice(0, 6);
|
.slice(0, 6);
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}, [machines]);
|
}, [machines, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6">
|
<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>}
|
{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="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="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
<div className="text-xs text-zinc-400">{t("overview.fleetHealth")}</div>
|
<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="text-sm text-zinc-400">{t("overview.noUrgent")}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{attention.map(({ machine, offline, oee }) => (
|
{attention.map(({ machine, offline, oee, reasons }) => (
|
||||||
<div key={machine.id} className="rounded-xl border border-white/10 bg-black/20 p-3">
|
<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="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold text-white">{machine.name}</div>
|
<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"))}
|
{secondsAgo(heartbeatTime(machine.latestHeartbeat), locale, t("common.never"))}
|
||||||
</div>
|
</div>
|
||||||
</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
|
<span
|
||||||
className={`rounded-full px-2 py-0.5 ${
|
className={`rounded-full px-2 py-0.5 ${
|
||||||
offline ? "bg-white/10 text-zinc-300" : "bg-emerald-500/15 text-emerald-300"
|
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")}
|
{offline ? t("overview.status.offline") : t("overview.status.online")}
|
||||||
</span>
|
</span>
|
||||||
{oee != null && (
|
{oee != null && !offline && (
|
||||||
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
|
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
|
||||||
OEE {fmtPct(oee)}
|
OEE {fmtPct(oee)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
388
app/(app)/overview/OverviewClient.tsx.bak
Normal file
388
app/(app)/overview/OverviewClient.tsx.bak
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
app/(app)/recap/RecapGridClient.tsx
Normal file
153
app/(app)/recap/RecapGridClient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
app/(app)/recap/RecapPageSkeletons.tsx
Normal file
30
app/(app)/recap/RecapPageSkeletons.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
app/(app)/recap/[machineId]/RecapDetailClient.tsx
Normal file
240
app/(app)/recap/[machineId]/RecapDetailClient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(app)/recap/[machineId]/loading.tsx
Normal file
5
app/(app)/recap/[machineId]/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { RecapDetailPageSkeleton } from "../RecapPageSkeletons";
|
||||||
|
|
||||||
|
export default function LoadingRecapDetail() {
|
||||||
|
return <RecapDetailPageSkeleton />;
|
||||||
|
}
|
||||||
51
app/(app)/recap/[machineId]/page.tsx
Normal file
51
app/(app)/recap/[machineId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(app)/recap/loading.tsx
Normal file
5
app/(app)/recap/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { RecapGridPageSkeleton } from "./RecapPageSkeletons";
|
||||||
|
|
||||||
|
export default function LoadingRecapGrid() {
|
||||||
|
return <RecapGridPageSkeleton />;
|
||||||
|
}
|
||||||
26
app/(app)/recap/page.tsx
Normal file
26
app/(app)/recap/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ type SimpleTooltipProps<T> = {
|
|||||||
label?: string | number;
|
label?: string | number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChartPoint = { ts: string; label: string; value: number };
|
type ChartPoint = { ts: string; label: string; value: number | null };
|
||||||
type CycleHistogramRow = {
|
type CycleHistogramRow = {
|
||||||
label: string;
|
label: string;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -135,7 +135,14 @@ export default function ReportsCharts({
|
|||||||
"OEE",
|
"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>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
706
app/(app)/reports/ReportsPageClient.tsx
Normal file
706
app/(app)/reports/ReportsPageClient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
export default async function ReportsPage() {
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
const session = await requireSession();
|
||||||
|
if (!session) redirect("/login?next=/reports");
|
||||||
|
|
||||||
const ReportsCharts = lazy(() => import("./ReportsCharts"));
|
const machines = await prisma.machine.findMany({
|
||||||
|
where: { orgId: session.orgId },
|
||||||
type RangeKey = "24h" | "7d" | "30d" | "custom";
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { id: true, name: true },
|
||||||
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 header = ["timestamp", "oee", "availability", "performance", "quality", "scrapRate"].join(",");
|
return <ReportsPageClient initialMachines={machines} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import { NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireSession } from "@/lib/auth/requireSession";
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
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) =>
|
const bad = (status: number, error: string) =>
|
||||||
NextResponse.json({ ok: false, error }, { status });
|
NextResponse.json({ ok: false, error }, { status });
|
||||||
@@ -24,6 +32,10 @@ export async function GET(req: Request) {
|
|||||||
|
|
||||||
const machineId = url.searchParams.get("machineId"); // optional
|
const machineId = url.searchParams.get("machineId"); // optional
|
||||||
const reasonCode = url.searchParams.get("reasonCode"); // 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 limitRaw = url.searchParams.get("limit");
|
||||||
const limit = Math.min(Math.max(Number(limitRaw || 200), 1), 500);
|
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
|
// ✅ Query ReasonEntry as the "episode" table for downtime
|
||||||
// We only return rows that have an episodeId (true downtime episodes)
|
// We only return rows that have an episodeId (true downtime episodes)
|
||||||
const where: any = {
|
const where: Prisma.ReasonEntryWhereInput = {
|
||||||
orgId,
|
orgId,
|
||||||
kind: "downtime",
|
kind: "downtime",
|
||||||
episodeId: { not: null },
|
episodeId: { not: null },
|
||||||
@@ -56,10 +68,11 @@ export async function GET(req: Request) {
|
|||||||
...(reasonCode ? { reasonCode } : {}),
|
...(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,
|
where,
|
||||||
orderBy: { capturedAt: "desc" },
|
orderBy: { capturedAt: "desc" },
|
||||||
take: limit,
|
take: scanTake,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
episodeId: 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 events = rows.map((r) => {
|
||||||
const startAt = r.capturedAt;
|
const startAt = r.capturedAt;
|
||||||
const endAt =
|
const endAt =
|
||||||
@@ -113,7 +134,11 @@ export async function GET(req: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const nextBefore =
|
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({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -122,6 +147,10 @@ export async function GET(req: Request) {
|
|||||||
start,
|
start,
|
||||||
machineId: machineId ?? null,
|
machineId: machineId ?? null,
|
||||||
reasonCode: reasonCode ?? null,
|
reasonCode: reasonCode ?? null,
|
||||||
|
planned,
|
||||||
|
shift,
|
||||||
|
microstopLtMin,
|
||||||
|
includeMoldChange,
|
||||||
limit,
|
limit,
|
||||||
before: before ?? null,
|
before: before ?? null,
|
||||||
nextBefore, // pass this back for pagination
|
nextBefore, // pass this back for pagination
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireSession } from "@/lib/auth/requireSession";
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
||||||
|
import {
|
||||||
|
applyDowntimeFilters,
|
||||||
|
loadDowntimeShiftContext,
|
||||||
|
normalizeMicrostopLtMin,
|
||||||
|
normalizeShiftFilter,
|
||||||
|
resolvePlannedFilter,
|
||||||
|
} from "@/lib/analytics/downtimeFilters";
|
||||||
|
|
||||||
const bad = (status: number, error: string) =>
|
const bad = (status: number, error: string) =>
|
||||||
NextResponse.json({ ok: false, error }, { status });
|
NextResponse.json({ ok: false, error }, { status });
|
||||||
@@ -20,9 +27,13 @@ export async function GET(req: Request) {
|
|||||||
|
|
||||||
const machineId = url.searchParams.get("machineId"); // optional
|
const machineId = url.searchParams.get("machineId"); // optional
|
||||||
const kind = (url.searchParams.get("kind") || "downtime").toLowerCase();
|
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") {
|
if (kind !== "downtime" && kind !== "scrap" && kind !== "planned-downtime") {
|
||||||
return bad(400, "Invalid kind (downtime|scrap)");
|
return bad(400, "Invalid kind (downtime|scrap|planned-downtime)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ If machineId provided, verify it belongs to this org
|
// ✅ 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");
|
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({
|
const grouped = await prisma.reasonEntry.groupBy({
|
||||||
by: ["reasonCode", "reasonLabel"],
|
by: ["reasonCode", "reasonLabel"],
|
||||||
where: {
|
where: {
|
||||||
@@ -43,28 +108,19 @@ export async function GET(req: Request) {
|
|||||||
kind,
|
kind,
|
||||||
capturedAt: { gte: start },
|
capturedAt: { gte: start },
|
||||||
},
|
},
|
||||||
_sum: {
|
_sum: { scrapQty: true },
|
||||||
durationSeconds: true,
|
|
||||||
scrapQty: true,
|
|
||||||
},
|
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const itemsRaw = grouped
|
itemsRaw = grouped
|
||||||
.map((g) => {
|
.map((g) => ({
|
||||||
const value =
|
|
||||||
kind === "downtime"
|
|
||||||
? Math.round(((g._sum.durationSeconds ?? 0) / 60) * 10) / 10 // minutes, 1 decimal
|
|
||||||
: g._sum.scrapQty ?? 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
reasonCode: g.reasonCode,
|
reasonCode: g.reasonCode,
|
||||||
reasonLabel: g.reasonLabel ?? g.reasonCode,
|
reasonLabel: g.reasonLabel ?? g.reasonCode,
|
||||||
value,
|
value: g._sum.scrapQty ?? 0,
|
||||||
count: g._count._all,
|
count: g._count._all,
|
||||||
};
|
}))
|
||||||
})
|
.filter((x) => x.value > 0);
|
||||||
.filter((x) => (kind === "downtime" ? x.value > 0 || x.count > 0 : x.value > 0));
|
}
|
||||||
|
|
||||||
itemsRaw.sort((a, b) => b.value - a.value);
|
itemsRaw.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
@@ -83,7 +139,7 @@ export async function GET(req: Request) {
|
|||||||
return {
|
return {
|
||||||
reasonCode: x.reasonCode,
|
reasonCode: x.reasonCode,
|
||||||
reasonLabel: x.reasonLabel,
|
reasonLabel: x.reasonLabel,
|
||||||
minutesLost: kind === "downtime" ? x.value : undefined,
|
minutesLost: kind === "downtime" || kind === "planned-downtime" ? x.value : undefined,
|
||||||
scrapQty: kind === "scrap" ? x.value : undefined,
|
scrapQty: kind === "scrap" ? x.value : undefined,
|
||||||
pctOfTotal,
|
pctOfTotal,
|
||||||
cumulativePct,
|
cumulativePct,
|
||||||
@@ -106,9 +162,13 @@ export async function GET(req: Request) {
|
|||||||
orgId,
|
orgId,
|
||||||
machineId: machineId ?? null,
|
machineId: machineId ?? null,
|
||||||
kind,
|
kind,
|
||||||
|
planned: kind === "downtime" ? planned : kind === "planned-downtime" ? "planned" : "all",
|
||||||
|
shift,
|
||||||
|
microstopLtMin,
|
||||||
|
includeMoldChange,
|
||||||
range, // ✅ now defined correctly
|
range, // ✅ now defined correctly
|
||||||
start, // ✅ 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,
|
totalScrap: kind === "scrap" ? total : undefined,
|
||||||
rows,
|
rows,
|
||||||
top3,
|
top3,
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ function normalizeCycleInput(raw: unknown): Record<string, unknown> | null {
|
|||||||
cycle_count: fromRowOrData(["cycle_count", "cycleCount"]),
|
cycle_count: fromRowOrData(["cycle_count", "cycleCount"]),
|
||||||
work_order_id: fromRowOrData(["work_order_id", "workOrderId"]),
|
work_order_id: fromRowOrData(["work_order_id", "workOrderId"]),
|
||||||
good_delta: fromRowOrData(["good_delta", "goodDelta"]),
|
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"]),
|
timestamp: fromRowOrData(["timestamp", "tsMs"]),
|
||||||
ts: fromRowOrData(["ts", "tsMs"]),
|
ts: fromRowOrData(["ts", "tsMs"]),
|
||||||
event_timestamp: fromRowOrData(["event_timestamp", "eventTimestamp"]),
|
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) {
|
if (rows.length === 1) {
|
||||||
const row = await prisma.machineCycle.create({ data: rows[0] });
|
const row = await prisma.machineCycle.findFirst({
|
||||||
return NextResponse.json({ ok: true, id: row.id, ts: row.ts });
|
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({
|
||||||
return NextResponse.json({ ok: true, count: result.count });
|
ok: true,
|
||||||
|
inserted: result.count,
|
||||||
|
requested: rows.length,
|
||||||
|
count: result.count,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const CANON_TYPE: Record<string, string> = {
|
|||||||
"down": "stop",
|
"down": "stop",
|
||||||
"downtime-acknowledged": "downtime-acknowledged",
|
"downtime-acknowledged": "downtime-acknowledged",
|
||||||
"scrap-manual-entry": "scrap-manual-entry",
|
"scrap-manual-entry": "scrap-manual-entry",
|
||||||
|
"mold-change": "mold-change",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALLOWED_TYPES = new Set([
|
const ALLOWED_TYPES = new Set([
|
||||||
@@ -54,6 +55,7 @@ const ALLOWED_TYPES = new Set([
|
|||||||
"predictive-oee-decline",
|
"predictive-oee-decline",
|
||||||
"downtime-acknowledged",
|
"downtime-acknowledged",
|
||||||
"scrap-manual-entry",
|
"scrap-manual-entry",
|
||||||
|
"mold-change",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const machineIdSchema = z.string().uuid();
|
const machineIdSchema = z.string().uuid();
|
||||||
@@ -61,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;
|
||||||
@@ -78,6 +85,15 @@ function numberFrom(value: unknown) {
|
|||||||
}
|
}
|
||||||
return null;
|
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) {
|
function canonicalText(value: unknown) {
|
||||||
return String(value ?? "")
|
return String(value ?? "")
|
||||||
@@ -260,6 +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 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 },
|
||||||
@@ -291,8 +311,11 @@ export async function POST(req: Request) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const evData = asRecord(evRecord.data) ?? {};
|
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);
|
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 rawType = evRecord.eventType ?? evRecord.anomaly_type ?? evRecord.topic ?? bodyRecord.topic ?? "";
|
||||||
const typ0 = normalizeType(rawType);
|
const typ0 = normalizeType(rawType);
|
||||||
@@ -313,11 +336,13 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
// Stop classification -> microstop/macrostop
|
// Stop classification -> microstop/macrostop
|
||||||
let finalType = typ;
|
let finalType = typ;
|
||||||
|
let stopSecForReason: number | null = null;
|
||||||
if (typ === "stop") {
|
if (typ === "stop") {
|
||||||
const stopSec =
|
const stopSec =
|
||||||
(typeof evData.stoppage_duration_seconds === "number" && evData.stoppage_duration_seconds) ||
|
(typeof evData.stoppage_duration_seconds === "number" && evData.stoppage_duration_seconds) ||
|
||||||
(typeof evData.stop_duration_seconds === "number" && evData.stop_duration_seconds) ||
|
(typeof evData.stop_duration_seconds === "number" && evData.stop_duration_seconds) ||
|
||||||
null;
|
null;
|
||||||
|
stopSecForReason = stopSec != null ? Number(stopSec) : null;
|
||||||
|
|
||||||
if (stopSec != null) {
|
if (stopSec != null) {
|
||||||
const theoretical = Number(evData.theoretical_cycle_time ?? evData.theoreticalCycleTime ?? 0) || 0;
|
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 (evReason && dataObj.reason == null) dataObj.reason = evReason;
|
||||||
if (evDowntime && dataObj.downtime == null) dataObj.downtime = evDowntime;
|
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 activeWorkOrder = asRecord(evRecord.activeWorkOrder);
|
||||||
const dataActiveWorkOrder = asRecord(evData.activeWorkOrder);
|
const dataActiveWorkOrder = asRecord(evData.activeWorkOrder);
|
||||||
|
|
||||||
const row = await prisma.machineEvent.create({
|
// ✨ Cada evento puede traer su propio seq, o usar el del payload raíz
|
||||||
data: {
|
const evSeq =
|
||||||
|
parseSeqToBigInt(evRecord.seq) ??
|
||||||
|
parseSeqToBigInt(evData.seq) ??
|
||||||
|
bodySeq;
|
||||||
|
|
||||||
|
const evSchemaVersion =
|
||||||
|
clampText(evRecord.schemaVersion, 16) ??
|
||||||
|
bodySchemaVersion;
|
||||||
|
|
||||||
|
const eventData = {
|
||||||
orgId: machine.orgId,
|
orgId: machine.orgId,
|
||||||
machineId: machine.id,
|
machineId: machine.id,
|
||||||
|
schemaVersion: evSchemaVersion,
|
||||||
|
seq: evSeq,
|
||||||
ts,
|
ts,
|
||||||
topic: clampText(evRecord.topic ?? finalType, 64) ?? finalType,
|
topic: clampText(evRecord.topic ?? finalType, 64) ?? finalType,
|
||||||
eventType: finalType,
|
eventType: finalType,
|
||||||
@@ -405,24 +463,118 @@ export async function POST(req: Request) {
|
|||||||
clampText(activeWorkOrder?.sku, 64) ??
|
clampText(activeWorkOrder?.sku, 64) ??
|
||||||
clampText(dataActiveWorkOrder?.sku, 64) ??
|
clampText(dataActiveWorkOrder?.sku, 64) ??
|
||||||
null,
|
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 });
|
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 =
|
const inferredKind: ReasonCatalogKind =
|
||||||
String(evReason.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry"
|
String(reasonRaw.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry"
|
||||||
? "scrap"
|
? "scrap"
|
||||||
: "downtime";
|
: "downtime";
|
||||||
const resolved = resolveReason(evReason, inferredKind, reasonCatalog, reasonCatalog.version);
|
const resolved = resolveReason(reasonRaw, inferredKind, reasonCatalog, reasonCatalog.version);
|
||||||
|
|
||||||
if (resolved.reasonCode) {
|
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 =
|
const reasonId =
|
||||||
clampText(evReason.reasonId, 128) ??
|
clampText(reasonRaw.reasonId, 128) ??
|
||||||
(inferredKind === "downtime"
|
(inferredKind === "downtime"
|
||||||
? `evt:${machine.id}:downtime:${clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id}`
|
? `evt:${machine.id}:downtime:${continuityIncidentKey ?? row.id}`
|
||||||
: `evt:${machine.id}:scrap:${clampText(evReason.scrapEntryId, 128) ?? row.id}`);
|
: `evt:${machine.id}:scrap:${clampText(reasonRaw.scrapEntryId, 128) ?? row.id}`);
|
||||||
|
|
||||||
const workOrderId =
|
const workOrderId =
|
||||||
clampText(evRecord.work_order_id, 64) ??
|
clampText(evRecord.work_order_id, 64) ??
|
||||||
@@ -441,7 +593,7 @@ export async function POST(req: Request) {
|
|||||||
source: "ingest:event",
|
source: "ingest:event",
|
||||||
eventId: row.id,
|
eventId: row.id,
|
||||||
eventType: row.eventType,
|
eventType: row.eventType,
|
||||||
incidentKey: clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128),
|
incidentKey: reasonMetaIncidentKey,
|
||||||
anomalyType:
|
anomalyType:
|
||||||
clampText(evRecord.anomalyType, 64) ??
|
clampText(evRecord.anomalyType, 64) ??
|
||||||
clampText(evDowntime?.anomalyType, 64) ??
|
clampText(evDowntime?.anomalyType, 64) ??
|
||||||
@@ -459,17 +611,76 @@ export async function POST(req: Request) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (inferredKind === "downtime") {
|
if (inferredKind === "downtime") {
|
||||||
const incidentKey = clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id;
|
const incidentKey = continuityIncidentKey ?? row.id;
|
||||||
const durationSeconds =
|
const durationSeconds =
|
||||||
numberFrom(evDowntime?.durationSeconds) ??
|
numberFrom(evDowntime?.durationSeconds) ??
|
||||||
|
numberFrom(evData.duration_sec) ??
|
||||||
numberFrom(evData.stoppage_duration_seconds) ??
|
numberFrom(evData.stoppage_duration_seconds) ??
|
||||||
numberFrom(evData.stop_duration_seconds) ??
|
numberFrom(evData.stop_duration_seconds) ??
|
||||||
|
(stopSecForReason != null ? stopSecForReason : null) ??
|
||||||
null;
|
null;
|
||||||
const episodeEndTsMs =
|
const episodeEndTsMs =
|
||||||
|
numberFrom(evData.end_ms) ??
|
||||||
numberFrom(evDowntime?.episodeEndTsMs) ??
|
numberFrom(evDowntime?.episodeEndTsMs) ??
|
||||||
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: 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({
|
await prisma.reasonEntry.upsert({
|
||||||
where: { reasonId },
|
where: { reasonId },
|
||||||
create: {
|
create: {
|
||||||
@@ -480,19 +691,19 @@ 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 {
|
||||||
const scrapEntryId =
|
const scrapEntryId =
|
||||||
clampText(evReason.scrapEntryId, 128) ??
|
clampText((reasonRaw as any).scrapEntryId, 128) ??
|
||||||
clampText(evRecord.id, 128) ??
|
clampText(evRecord.id, 128) ??
|
||||||
clampText(evRecord.eventId, 128) ??
|
clampText(evRecord.eventId, 128) ??
|
||||||
row.id;
|
row.id;
|
||||||
@@ -512,14 +723,14 @@ export async function POST(req: Request) {
|
|||||||
kind: "scrap",
|
kind: "scrap",
|
||||||
scrapEntryId,
|
scrapEntryId,
|
||||||
scrapQty,
|
scrapQty,
|
||||||
scrapUnit: clampText(evReason.scrapUnit, 16) ?? null,
|
scrapUnit: clampText((reasonRaw as any).scrapUnit, 16) ?? null,
|
||||||
...commonWrite,
|
...commonWrite,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
kind: "scrap",
|
kind: "scrap",
|
||||||
scrapEntryId,
|
scrapEntryId,
|
||||||
scrapQty,
|
scrapQty,
|
||||||
scrapUnit: clampText(evReason.scrapUnit, 16) ?? null,
|
scrapUnit: clampText((reasonRaw as any).scrapUnit, 16) ?? null,
|
||||||
...commonWrite,
|
...commonWrite,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
670
app/api/ingest/event/route.ts.bak
Normal file
670
app/api/ingest/event/route.ts.bak
Normal 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 });
|
||||||
|
}
|
||||||
@@ -97,26 +97,38 @@ export async function POST(req: Request) {
|
|||||||
// 5) Store heartbeat
|
// 5) Store heartbeat
|
||||||
// Keep your legacy fields, but store meta fields too.
|
// Keep your legacy fields, but store meta fields too.
|
||||||
const tsServerNow = new Date();
|
const tsServerNow = new Date();
|
||||||
const hb = await prisma.machineHeartbeat.create({
|
const hbRow = {
|
||||||
data: {
|
|
||||||
orgId,
|
orgId,
|
||||||
machineId: machine.id,
|
machineId: machine.id,
|
||||||
|
|
||||||
// Phase 0 meta
|
|
||||||
schemaVersion,
|
schemaVersion,
|
||||||
seq,
|
seq,
|
||||||
ts: tsDeviceDate,
|
ts: tsDeviceDate,
|
||||||
tsServer: tsServerNow,
|
tsServer: tsServerNow,
|
||||||
|
|
||||||
// Legacy payload compatibility
|
|
||||||
status: body.status ? String(body.status) : (body.online ? "RUN" : "STOP"),
|
status: body.status ? String(body.status) : (body.online ? "RUN" : "STOP"),
|
||||||
message: body.message ? String(body.message) : null,
|
message: body.message ? String(body.message) : null,
|
||||||
ip: body.ip ? String(body.ip) : null,
|
ip: body.ip ? String(body.ip) : null,
|
||||||
fwVersion: body.fwVersion ? String(body.fwVersion) : 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({
|
await prisma.machine.update({
|
||||||
where: { id: machine.id },
|
where: { id: machine.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -132,6 +144,7 @@ export async function POST(req: Request) {
|
|||||||
id: hb.id,
|
id: hb.id,
|
||||||
tsDevice: hb.ts,
|
tsDevice: hb.ts,
|
||||||
tsServer: hb.tsServer,
|
tsServer: hb.tsServer,
|
||||||
|
duplicate: insertHb.count === 0,
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
|||||||
@@ -27,6 +27,29 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|||||||
return value as Record<string, unknown>;
|
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 {
|
function readPath(root: unknown, path: string[]): unknown {
|
||||||
let current = root;
|
let current = root;
|
||||||
for (const key of path) {
|
for (const key of path) {
|
||||||
@@ -160,28 +183,37 @@ export async function POST(req: Request) {
|
|||||||
orgId = machine.orgId;
|
orgId = machine.orgId;
|
||||||
|
|
||||||
const woRecord = (body.activeWorkOrder ?? {}) as Record<string, unknown>;
|
const woRecord = (body.activeWorkOrder ?? {}) as Record<string, unknown>;
|
||||||
const good =
|
const activeWorkOrderId = woRecord.id != null ? String(woRecord.id).trim() : "";
|
||||||
typeof woRecord.good === "number"
|
const activeSku = woRecord.sku != null ? String(woRecord.sku).trim() : "";
|
||||||
? woRecord.good
|
const activeStatus = woRecord.status != null ? String(woRecord.status).trim() : "";
|
||||||
: typeof woRecord.goodParts === "number"
|
const activeTargetQty = toFiniteInt(woRecord.target);
|
||||||
? woRecord.goodParts
|
const activeCycleTime = toFiniteNumber(woRecord.cycleTime);
|
||||||
: typeof woRecord.good_parts === "number"
|
const good = pickFirstNumber(woRecord.good, woRecord.goodParts, woRecord.good_parts);
|
||||||
? woRecord.good_parts
|
const scrap = pickFirstNumber(woRecord.scrap, woRecord.scrapParts, woRecord.scrap_parts);
|
||||||
: null;
|
const activeGoodParts = Math.max(0, Math.trunc(good ?? 0));
|
||||||
const scrap =
|
const activeScrapParts = Math.max(0, Math.trunc(scrap ?? 0));
|
||||||
typeof woRecord.scrap === "number"
|
const activeCycleCount = Math.max(
|
||||||
? woRecord.scrap
|
0,
|
||||||
: typeof woRecord.scrapParts === "number"
|
toFiniteInt(woRecord.cycleCount ?? woRecord.cycle_count ?? body.cycle_count) ?? 0
|
||||||
? woRecord.scrapParts
|
);
|
||||||
: typeof woRecord.scrap_parts === "number"
|
const snapshotCycleCount =
|
||||||
? woRecord.scrap_parts
|
toFiniteInt(body.cycle_count) ??
|
||||||
: null;
|
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 k = body.kpis ?? {};
|
||||||
const safeCycleTime =
|
const safeCycleTime =
|
||||||
typeof body.cycleTime === "number" && body.cycleTime > 0
|
typeof body.cycleTime === "number" && body.cycleTime > 0
|
||||||
? body.cycleTime
|
? body.cycleTime
|
||||||
: typeof woRecord.cycleTime === "number" && woRecord.cycleTime > 0
|
: activeCycleTime != null && activeCycleTime > 0
|
||||||
? woRecord.cycleTime
|
? activeCycleTime
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const safeCavities =
|
const safeCavities =
|
||||||
@@ -190,44 +222,74 @@ export async function POST(req: Request) {
|
|||||||
: typeof woRecord.cavities === "number" && woRecord.cavities > 0
|
: typeof woRecord.cavities === "number" && woRecord.cavities > 0
|
||||||
? woRecord.cavities
|
? woRecord.cavities
|
||||||
: null;
|
: null;
|
||||||
// Write snapshot (ts = tsDevice; tsServer auto)
|
// Write snapshot (ts = tsDevice; tsServer auto). Idempotent on (org, machine, ts) to absorb retries.
|
||||||
const row = await prisma.machineKpiSnapshot.create({
|
const kpiData = {
|
||||||
data: {
|
|
||||||
orgId,
|
orgId,
|
||||||
machineId: machine.id,
|
machineId: machine.id,
|
||||||
|
|
||||||
// Phase 0 meta
|
|
||||||
schemaVersion,
|
schemaVersion,
|
||||||
seq,
|
seq,
|
||||||
ts: tsDeviceDate, // store device-time in ts; server-time goes to ts_server
|
ts: tsDeviceDate,
|
||||||
|
workOrderId: activeWorkOrderId || null,
|
||||||
// Work order fields
|
sku: activeSku || null,
|
||||||
workOrderId: woRecord.id != null ? String(woRecord.id) : null,
|
target: activeTargetQty,
|
||||||
sku: woRecord.sku != null ? String(woRecord.sku) : null,
|
|
||||||
target: typeof woRecord.target === "number" ? Math.trunc(woRecord.target) : null,
|
|
||||||
good: good != null ? Math.trunc(good) : null,
|
good: good != null ? Math.trunc(good) : null,
|
||||||
scrap: scrap != null ? Math.trunc(scrap) : null,
|
scrap: scrap != null ? Math.trunc(scrap) : null,
|
||||||
|
cycleCount: snapshotCycleCount,
|
||||||
// Counters
|
goodParts: snapshotGoodParts,
|
||||||
cycleCount: typeof body.cycle_count === "number" ? body.cycle_count : null,
|
scrapParts: snapshotScrapParts,
|
||||||
goodParts: typeof body.good_parts === "number" ? body.good_parts : null,
|
|
||||||
scrapParts: typeof body.scrap_parts === "number" ? body.scrap_parts : null,
|
|
||||||
cavities: safeCavities,
|
cavities: safeCavities,
|
||||||
|
|
||||||
// Cycle times
|
|
||||||
cycleTime: safeCycleTime,
|
cycleTime: safeCycleTime,
|
||||||
actualCycle: typeof body.actualCycleTime === "number" ? body.actualCycleTime : null,
|
actualCycle: typeof body.actualCycleTime === "number" ? body.actualCycleTime : null,
|
||||||
|
|
||||||
// KPIs (0..100)
|
|
||||||
availability: typeof k.availability === "number" ? k.availability : null,
|
availability: typeof k.availability === "number" ? k.availability : null,
|
||||||
performance: typeof k.performance === "number" ? k.performance : null,
|
performance: typeof k.performance === "number" ? k.performance : null,
|
||||||
quality: typeof k.quality === "number" ? k.quality : null,
|
quality: typeof k.quality === "number" ? k.quality : null,
|
||||||
oee: typeof k.oee === "number" ? k.oee : null,
|
oee: typeof k.oee === "number" ? k.oee : null,
|
||||||
|
|
||||||
trackingEnabled: typeof body.trackingEnabled === "boolean" ? body.trackingEnabled : null,
|
trackingEnabled: typeof body.trackingEnabled === "boolean" ? body.trackingEnabled : null,
|
||||||
productionStarted: typeof body.productionStarted === "boolean" ? body.productionStarted : 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
|
// Optional but useful: update machine "last seen" meta fields
|
||||||
await prisma.machine.update({
|
await prisma.machine.update({
|
||||||
@@ -266,6 +328,7 @@ export async function POST(req: Request) {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
tsDevice: row.ts,
|
tsDevice: row.ts,
|
||||||
tsServer: row.tsServer,
|
tsServer: row.tsServer,
|
||||||
|
duplicate: insertKpi.count === 0,
|
||||||
trace: traceEnabled ? trace : undefined,
|
trace: traceEnabled ? trace : undefined,
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
37
app/api/recap/[machineId]/route.ts
Normal file
37
app/api/recap/[machineId]/route.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
42
app/api/recap/[machineId]/timeline/route.ts
Normal file
42
app/api/recap/[machineId]/timeline/route.ts
Normal 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
29
app/api/recap/route.ts
Normal 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);
|
||||||
|
}
|
||||||
21
app/api/recap/summary/route.ts
Normal file
21
app/api/recap/summary/route.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
39
app/api/recap/timeline/route.ts
Normal file
39
app/api/recap/timeline/route.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
import { createHash } from "crypto";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireSession } from "@/lib/auth/requireSession";
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
import { logLine } from "@/lib/logger";
|
import { logLine } from "@/lib/logger";
|
||||||
@@ -42,6 +43,10 @@ function pickRange(req: NextRequest) {
|
|||||||
return { start: new Date(now.getTime() - ms), end: now };
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
const perfEnabled = PERF_LOGS_ENABLED;
|
const perfEnabled = PERF_LOGS_ENABLED;
|
||||||
const totalStart = nowMs();
|
const totalStart = nowMs();
|
||||||
@@ -67,6 +72,32 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
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 workOrdersStart = nowMs();
|
||||||
const workOrderRows = await prisma.machineCycle.findMany({
|
const workOrderRows = await prisma.machineCycle.findMany({
|
||||||
where: { ...baseWhere, workOrderId: { not: null } },
|
where: { ...baseWhere, workOrderId: { not: null } },
|
||||||
@@ -90,7 +121,6 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const payload = { ok: true, workOrders, skus };
|
const payload = { ok: true, workOrders, skus };
|
||||||
|
|
||||||
const responseHeaders = new Headers();
|
|
||||||
if (perfEnabled) {
|
if (perfEnabled) {
|
||||||
timings.postQuery = elapsedMs(postQueryStart);
|
timings.postQuery = elapsedMs(postQueryStart);
|
||||||
timings.total = elapsedMs(totalStart);
|
timings.total = elapsedMs(totalStart);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
import { createHash } from "crypto";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireSession } from "@/lib/auth/requireSession";
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
import { logLine } from "@/lib/logger";
|
import { logLine } from "@/lib/logger";
|
||||||
@@ -46,6 +47,14 @@ function safeNum(v: unknown) {
|
|||||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
const perfEnabled = PERF_LOGS_ENABLED;
|
const perfEnabled = PERF_LOGS_ENABLED;
|
||||||
const totalStart = nowMs();
|
const totalStart = nowMs();
|
||||||
@@ -73,6 +82,52 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
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 kpiStart = nowMs();
|
||||||
const kpiRows = await prisma.machineKpiSnapshot.findMany({
|
const kpiRows = await prisma.machineKpiSnapshot.findMany({
|
||||||
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||||
@@ -86,6 +141,8 @@ export async function GET(req: NextRequest) {
|
|||||||
good: true,
|
good: true,
|
||||||
scrap: true,
|
scrap: true,
|
||||||
target: true,
|
target: true,
|
||||||
|
trackingEnabled: true,
|
||||||
|
productionStarted: true,
|
||||||
machineId: true,
|
machineId: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -100,7 +157,9 @@ export async function GET(req: NextRequest) {
|
|||||||
let qualSum = 0;
|
let qualSum = 0;
|
||||||
let qualCount = 0;
|
let qualCount = 0;
|
||||||
|
|
||||||
|
// OEE-family summaries are production-only to avoid mixing downtime/off windows.
|
||||||
for (const k of kpiRows) {
|
for (const k of kpiRows) {
|
||||||
|
if (!isProductionSnapshot(k.trackingEnabled, k.productionStarted)) continue;
|
||||||
if (safeNum(k.oee) != null) {
|
if (safeNum(k.oee) != null) {
|
||||||
oeeSum += Number(k.oee);
|
oeeSum += Number(k.oee);
|
||||||
oeeCount += 1;
|
oeeCount += 1;
|
||||||
@@ -223,7 +282,7 @@ export async function GET(req: NextRequest) {
|
|||||||
else if (type === "oee-drop") oeeDropCount += 1;
|
else if (type === "oee-drop") oeeDropCount += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrendPoint = { t: string; v: number };
|
type TrendPoint = { t: string; v: number | null };
|
||||||
|
|
||||||
const trend: {
|
const trend: {
|
||||||
oee: TrendPoint[];
|
oee: TrendPoint[];
|
||||||
@@ -239,17 +298,68 @@ export async function GET(req: NextRequest) {
|
|||||||
scrapRate: [],
|
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) {
|
for (const k of kpiRows) {
|
||||||
const t = k.ts.toISOString();
|
const t = k.ts.toISOString();
|
||||||
if (safeNum(k.oee) != null) trend.oee.push({ t, v: Number(k.oee) });
|
let b = tsBuckets.get(t);
|
||||||
if (safeNum(k.availability) != null) trend.availability.push({ t, v: Number(k.availability) });
|
if (!b) {
|
||||||
if (safeNum(k.performance) != null) trend.performance.push({ t, v: Number(k.performance) });
|
b = {
|
||||||
if (safeNum(k.quality) != null) trend.quality.push({ t, v: Number(k.quality) });
|
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 good = safeNum(k.good);
|
||||||
const scrap = safeNum(k.scrap);
|
const scrap = safeNum(k.scrap);
|
||||||
if (good != null && scrap != null && good + scrap > 0) {
|
if (good != null) b.goodSum += Number(good);
|
||||||
trend.scrapRate.push({ t, v: (scrap / (good + scrap)) * 100 });
|
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();
|
const cycleRowsStart = nowMs();
|
||||||
@@ -405,7 +515,6 @@ export async function GET(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const responseHeaders = new Headers();
|
|
||||||
if (perfEnabled) {
|
if (perfEnabled) {
|
||||||
timings.postQuery = elapsedMs(postQueryStart);
|
timings.postQuery = elapsedMs(postQueryStart);
|
||||||
timings.total = elapsedMs(totalStart);
|
timings.total = elapsedMs(totalStart);
|
||||||
|
|||||||
@@ -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,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
redirect("/machines");
|
redirect("/recap");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ type ApiParetoRes = {
|
|||||||
total?: number;
|
total?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LegacyParetoItem = {
|
||||||
|
reasonCode?: string;
|
||||||
|
reasonLabel?: string;
|
||||||
|
value?: number; // minutes (downtime) or qty (scrap)
|
||||||
|
count?: number;
|
||||||
|
cumPct?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type ApiDowntimeEvent = {
|
type ApiDowntimeEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
episodeId: string | null;
|
episodeId: string | null;
|
||||||
@@ -104,18 +112,139 @@ function fmtDT(iso: string | null) {
|
|||||||
return d.toLocaleString("en-US", { hour12: true });
|
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;
|
||||||
|
|
||||||
type ApiCoverageRes = {
|
// Support a legacy envelope where the server returns `items[]` instead of `rows[]`.
|
||||||
ok: boolean;
|
const legacyItems = (input as any)?.items as unknown;
|
||||||
error?: string;
|
if (!Array.isArray(legacyItems) || legacyItems.length === 0) return input;
|
||||||
orgId?: string;
|
|
||||||
machineId?: string | null;
|
const items = legacyItems as LegacyParetoItem[];
|
||||||
range?: "24h" | "7d" | "30d";
|
const safeItems = items
|
||||||
start?: string;
|
.map((it) => ({
|
||||||
receivedEpisodes?: number;
|
reasonCode: String(it?.reasonCode ?? "").trim(),
|
||||||
receivedMinutes?: number;
|
reasonLabel: String(it?.reasonLabel ?? it?.reasonCode ?? "").trim(),
|
||||||
note?: string;
|
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 Range = "24h" | "7d" | "30d";
|
type Range = "24h" | "7d" | "30d";
|
||||||
type Metric = "minutes" | "count";
|
type Metric = "minutes" | "count";
|
||||||
@@ -1156,6 +1285,9 @@ export default function DowntimePageClient() {
|
|||||||
// client-only filters (shareable)
|
// client-only filters (shareable)
|
||||||
const metric = ((sp.get("metric") as Metric) || "minutes") as Metric;
|
const metric = ((sp.get("metric") as Metric) || "minutes") as Metric;
|
||||||
const reasonCode = sp.get("reasonCode") || null;
|
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 hmDay = sp.get("hmDay");
|
||||||
const hmHour = sp.get("hmHour");
|
const hmHour = sp.get("hmHour");
|
||||||
@@ -1167,7 +1299,6 @@ export default function DowntimePageClient() {
|
|||||||
|
|
||||||
|
|
||||||
const [pareto, setPareto] = useState<ApiParetoRes | null>(null);
|
const [pareto, setPareto] = useState<ApiParetoRes | null>(null);
|
||||||
const [coverage, setCoverage] = useState<ApiCoverageRes | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [eventsRes, setEventsRes] = useState<ApiDowntimeEventsRes | null>(null);
|
const [eventsRes, setEventsRes] = useState<ApiDowntimeEventsRes | null>(null);
|
||||||
@@ -1178,6 +1309,7 @@ export default function DowntimePageClient() {
|
|||||||
|
|
||||||
const [eventsLimit, setEventsLimit] = useState<number>(200);
|
const [eventsLimit, setEventsLimit] = useState<number>(200);
|
||||||
const [eventsBefore, setEventsBefore] = useState<string | null>(null);
|
const [eventsBefore, setEventsBefore] = useState<string | null>(null);
|
||||||
|
const debug = sp.get("debug") === "1";
|
||||||
|
|
||||||
// simple client filter (fast): text search on machine/reason/wo
|
// simple client filter (fast): text search on machine/reason/wo
|
||||||
const [eventSearch, setEventSearch] = useState("");
|
const [eventSearch, setEventSearch] = useState("");
|
||||||
@@ -1222,41 +1354,28 @@ export default function DowntimePageClient() {
|
|||||||
qs.set("kind", "downtime");
|
qs.set("kind", "downtime");
|
||||||
qs.set("range", range);
|
qs.set("range", range);
|
||||||
if (machineId) qs.set("machineId", machineId);
|
if (machineId) qs.set("machineId", machineId);
|
||||||
|
qs.set("shift", shift);
|
||||||
|
qs.set("planned", planned);
|
||||||
|
qs.set("microstopLtMin", microstopLtMin);
|
||||||
|
|
||||||
const [r1, r2] = await Promise.all([
|
const r1 = await fetch(`/api/analytics/pareto?${qs.toString()}`, {
|
||||||
fetch(`/api/analytics/pareto?${qs.toString()}`, {
|
|
||||||
cache: "no-cache",
|
cache: "no-cache",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
signal: ac.signal,
|
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 j1raw = (await r1.json().catch(() => ({}))) as ApiParetoRes;
|
||||||
const j2 = (await r2.json().catch(() => ({}))) as ApiCoverageRes;
|
|
||||||
|
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
|
|
||||||
if (!r1.ok || j1.ok === false) {
|
if (!r1.ok || j1raw.ok === false) {
|
||||||
setErr(j1?.error ?? "Failed to load pareto");
|
setErr(j1raw?.error ?? "Failed to load pareto");
|
||||||
setPareto(null);
|
setPareto(null);
|
||||||
setCoverage(null);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!r2.ok || j2.ok === false) {
|
setPareto(normalizeParetoRes(j1raw));
|
||||||
// coverage is “nice to have” — don’t kill the page
|
|
||||||
setCoverage(null);
|
|
||||||
} else {
|
|
||||||
setCoverage(j2);
|
|
||||||
}
|
|
||||||
|
|
||||||
setPareto(j1);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
@@ -1270,7 +1389,7 @@ export default function DowntimePageClient() {
|
|||||||
alive = false;
|
alive = false;
|
||||||
ac.abort();
|
ac.abort();
|
||||||
};
|
};
|
||||||
}, [range, machineId]);
|
}, [range, machineId, shift, planned, microstopLtMin]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -1320,6 +1439,9 @@ export default function DowntimePageClient() {
|
|||||||
qs.set("limit", String(eventsLimit));
|
qs.set("limit", String(eventsLimit));
|
||||||
if (machineId) qs.set("machineId", machineId);
|
if (machineId) qs.set("machineId", machineId);
|
||||||
if (reasonCode) qs.set("reasonCode", reasonCode);
|
if (reasonCode) qs.set("reasonCode", reasonCode);
|
||||||
|
qs.set("shift", shift);
|
||||||
|
qs.set("planned", planned);
|
||||||
|
qs.set("microstopLtMin", microstopLtMin);
|
||||||
if (eventsBefore) qs.set("before", eventsBefore);
|
if (eventsBefore) qs.set("before", eventsBefore);
|
||||||
|
|
||||||
const r = await fetch(`/api/analytics/downtime-events?${qs.toString()}`, {
|
const r = await fetch(`/api/analytics/downtime-events?${qs.toString()}`, {
|
||||||
@@ -1352,10 +1474,26 @@ export default function DowntimePageClient() {
|
|||||||
alive = false;
|
alive = false;
|
||||||
ac.abort();
|
ac.abort();
|
||||||
};
|
};
|
||||||
}, [range, machineId, reasonCode, eventsLimit, eventsBefore]);
|
}, [range, machineId, reasonCode, shift, planned, microstopLtMin, eventsLimit, eventsBefore]);
|
||||||
|
|
||||||
// Derived data
|
// 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 metricRowsAll = useMemo(() => computeMetricRows(baseRows, metric), [baseRows, metric]);
|
||||||
|
|
||||||
const metricRowsFiltered = useMemo(() => {
|
const metricRowsFiltered = useMemo(() => {
|
||||||
@@ -1386,7 +1524,7 @@ export default function DowntimePageClient() {
|
|||||||
}));
|
}));
|
||||||
}, [catalogRows]);
|
}, [catalogRows]);
|
||||||
|
|
||||||
const totalMinutes = pareto?.totalMinutesLost ?? 0;
|
const totalMinutes = paretoEffective?.totalMinutesLost ?? 0;
|
||||||
const totalStops = useMemo(
|
const totalStops = useMemo(
|
||||||
() => baseRows.reduce((acc, r) => acc + (r.count ?? 0), 0),
|
() => baseRows.reduce((acc, r) => acc + (r.count ?? 0), 0),
|
||||||
[baseRows]
|
[baseRows]
|
||||||
@@ -1401,10 +1539,10 @@ export default function DowntimePageClient() {
|
|||||||
|
|
||||||
const threshold80Index = useMemo(() => {
|
const threshold80Index = useMemo(() => {
|
||||||
// If API threshold80 exists, it’s based on minutes. For count metric, compute locally.
|
// If API threshold80 exists, it’s 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);
|
const idx = metricRowsAll.findIndex((r) => (r.cumulativePct ?? 0) >= 80);
|
||||||
return idx >= 0 ? idx : null;
|
return idx >= 0 ? idx : null;
|
||||||
}, [metric, pareto?.threshold80?.index, metricRowsAll]);
|
}, [metric, paretoEffective?.threshold80?.index, metricRowsAll]);
|
||||||
|
|
||||||
const heroData = useMemo(() => {
|
const heroData = useMemo(() => {
|
||||||
// Keep hero readable: top 12 (like your screenshot)
|
// Keep hero readable: top 12 (like your screenshot)
|
||||||
@@ -1420,12 +1558,11 @@ export default function DowntimePageClient() {
|
|||||||
}));
|
}));
|
||||||
}, [metricRowsAll]);
|
}, [metricRowsAll]);
|
||||||
|
|
||||||
const totalDowntimeMin = pareto?.totalMinutesLost ?? 0;
|
const totalDowntimeMin = paretoEffective?.totalMinutesLost ?? 0;
|
||||||
const events = eventsRes?.events ?? [];
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEventsBefore(null);
|
setEventsBefore(null);
|
||||||
}, [range, machineId, reasonCode]);
|
}, [range, machineId, reasonCode, shift, planned, microstopLtMin]);
|
||||||
|
|
||||||
const filteredEvents = useMemo(() => {
|
const filteredEvents = useMemo(() => {
|
||||||
let list = events;
|
let list = events;
|
||||||
@@ -1455,8 +1592,8 @@ const filteredEvents = useMemo(() => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Use distinct episodes as "stops" (best available now)
|
// Use filtered pareto totals so top filters always affect the KPI.
|
||||||
const stops = coverage?.receivedEpisodes ?? totalStops;
|
const stops = totalStops;
|
||||||
|
|
||||||
// Window minutes for MTBF/Availability
|
// Window minutes for MTBF/Availability
|
||||||
const windowMin =
|
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 = (
|
const filtersRow = (
|
||||||
<div className="mt-4 flex items-center justify-between gap-4">
|
<div className="mt-4 flex items-center justify-between gap-4">
|
||||||
{/* LEFT: range + metric + reset (never wrap) */}
|
{/* LEFT: range + metric + reset (never wrap) */}
|
||||||
@@ -1805,8 +1937,51 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 && (
|
{!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 */}
|
{/* KPI strip */}
|
||||||
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-8">
|
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-8">
|
||||||
<KPI
|
<KPI
|
||||||
@@ -1818,7 +1993,7 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
|
|||||||
<KPI
|
<KPI
|
||||||
label="Stops count"
|
label="Stops count"
|
||||||
value={fmtNum(stops, 0)}
|
value={fmtNum(stops, 0)}
|
||||||
sub="Distinct episodes (coverage)"
|
sub="Distinct episodes (filtered)"
|
||||||
accent="zinc"
|
accent="zinc"
|
||||||
/>
|
/>
|
||||||
<KPI
|
<KPI
|
||||||
@@ -2047,29 +2222,25 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
|
|||||||
|
|
||||||
{/* Coverage mini */}
|
{/* Coverage mini */}
|
||||||
<div className="mt-4 rounded-2xl border border-white/10 bg-white/5 p-4">
|
<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">
|
<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>
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
<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="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
<div className="text-[11px] text-zinc-400">Episodes</div>
|
<div className="text-[11px] text-zinc-400">Episodes</div>
|
||||||
<div className="mt-1 text-base font-semibold text-white">
|
<div className="mt-1 text-base font-semibold text-white">
|
||||||
{coverage?.receivedEpisodes != null ? fmtNum(coverage.receivedEpisodes, 0) : "—"}
|
{fmtNum(stops, 0)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
<div className="text-[11px] text-zinc-400">Minutes</div>
|
<div className="text-[11px] text-zinc-400">Minutes</div>
|
||||||
<div className="mt-1 text-base font-semibold text-white">
|
<div className="mt-1 text-base font-semibold text-white">
|
||||||
{coverage?.receivedMinutes != null ? fmtNum(coverage.receivedMinutes, 1) : "—"}
|
{fmtNum(totalDowntimeMin, 1)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{coverage?.note ? (
|
|
||||||
<div className="mt-3 text-[11px] text-zinc-500">{coverage.note}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,18 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
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 type { LucideIcon } from "lucide-react";
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||||
@@ -19,15 +30,15 @@ type NavItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const items: NavItem[] = [
|
const items: NavItem[] = [
|
||||||
|
{ href: "/recap", labelKey: "nav.recap", icon: Sunrise },
|
||||||
{ href: "/overview", labelKey: "nav.overview", icon: LayoutGrid },
|
{ href: "/overview", labelKey: "nav.overview", icon: LayoutGrid },
|
||||||
{ href: "/machines", labelKey: "nav.machines", icon: Wrench },
|
{ href: "/machines", labelKey: "nav.machines", icon: Wrench },
|
||||||
{ href: "/reports", labelKey: "nav.reports", icon: BarChart3 },
|
{ href: "/reports", labelKey: "nav.reports", icon: BarChart3 },
|
||||||
{ href: "/alerts", labelKey: "nav.alerts", icon: Bell },
|
{ href: "/alerts", labelKey: "nav.alerts", icon: Bell },
|
||||||
{ href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true },
|
{ href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true },
|
||||||
{ href: "/settings", labelKey: "nav.settings", icon: Settings },
|
|
||||||
{ href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 },
|
{ href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 },
|
||||||
|
|
||||||
];
|
];
|
||||||
|
const settingsItem: NavItem = { href: "/settings", labelKey: "nav.settings", icon: Settings };
|
||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
variant?: "desktop" | "drawer";
|
variant?: "desktop" | "drawer";
|
||||||
@@ -97,16 +108,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
|||||||
}
|
}
|
||||||
}, [screenlessMode, pathname, router]);
|
}, [screenlessMode, pathname, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
const markNavStart = (href: string, ts: number) => {
|
||||||
if (!pendingHref) return;
|
|
||||||
if (pathname === pendingHref || pathname.startsWith(`${pendingHref}/`)) {
|
|
||||||
setPendingHref(null);
|
|
||||||
} else if (!isPending) {
|
|
||||||
setPendingHref(null);
|
|
||||||
}
|
|
||||||
}, [pathname, pendingHref, isPending]);
|
|
||||||
|
|
||||||
const markNavStart = (href: string) => {
|
|
||||||
if (!PERF_ENABLED) return;
|
if (!PERF_ENABLED) return;
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
@@ -114,7 +116,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
href,
|
href,
|
||||||
from: pathname,
|
from: pathname,
|
||||||
ts: Date.now(),
|
ts,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch {
|
} 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",
|
"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]",
|
variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
const navLocked = isPending;
|
||||||
|
|
||||||
return (
|
const renderNavItem = (it: NavItem) => {
|
||||||
<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 isCurrent = pathname === it.href;
|
const isCurrent = pathname === it.href;
|
||||||
const active = isCurrent || pathname.startsWith(it.href + "/");
|
const active = isCurrent || pathname.startsWith(it.href + "/");
|
||||||
const isPendingItem = isPending && pendingHref === it.href;
|
const isPendingItem = isPending && pendingHref === it.href;
|
||||||
const navLocked = isPending;
|
|
||||||
const Icon = it.icon;
|
const Icon = it.icon;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -178,7 +160,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
markNavStart(it.href);
|
markNavStart(it.href, Math.round(performance.timeOrigin + event.timeStamp));
|
||||||
setPendingHref(it.href);
|
setPendingHref(it.href);
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(it.href);
|
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}
|
{isPendingItem ? <Loader2 className="ml-auto h-4 w-4 animate-spin text-emerald-300" /> : null}
|
||||||
</Link>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<div className="px-5 py-4 border-t border-white/10 space-y-3">
|
<div className="px-5 py-4 border-t border-white/10 space-y-3">
|
||||||
|
|||||||
46
components/recap/RecapBanners.tsx
Normal file
46
components/recap/RecapBanners.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
components/recap/RecapDowntimeTop.tsx
Normal file
33
components/recap/RecapDowntimeTop.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
components/recap/RecapFullTimeline.tsx
Normal file
114
components/recap/RecapFullTimeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
components/recap/RecapKpiRow.tsx
Normal file
50
components/recap/RecapKpiRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
components/recap/RecapMachineCard.tsx
Normal file
142
components/recap/RecapMachineCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
components/recap/RecapMachineStatus.tsx
Normal file
34
components/recap/RecapMachineStatus.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
components/recap/RecapMiniTimeline.tsx
Normal file
82
components/recap/RecapMiniTimeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
components/recap/RecapProductionBySku.tsx
Normal file
43
components/recap/RecapProductionBySku.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
components/recap/RecapTimeline.tsx
Normal file
173
components/recap/RecapTimeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
components/recap/RecapWorkOrderStatus.tsx
Normal file
61
components/recap/RecapWorkOrderStatus.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
components/recap/RecapWorkOrders.tsx
Normal file
64
components/recap/RecapWorkOrders.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
components/recap/timelineRender.ts
Normal file
151
components/recap/timelineRender.ts
Normal 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
106
fix.md
Normal 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
42
fix2.md
Normal 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
54
fix3.md
Normal 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
244
fix4.md
Normal 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
252
fix5.md
Normal 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 ~642–678 (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 365–373):
|
||||||
|
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 1–22):
|
||||||
|
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 24–42):
|
||||||
|
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 215–222):
|
||||||
|
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
107
fix6.md
Normal 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 ~58–76 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
1
flows (61) (1).json
Normal file
File diff suppressed because one or more lines are too long
3867
flows (63).json
Normal file
3867
flows (63).json
Normal file
File diff suppressed because one or more lines are too long
4246
flows (64).json
Normal file
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
204
lib/analytics/downtimeFilters.ts
Normal file
204
lib/analytics/downtimeFilters.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -244,6 +244,7 @@ export async function computeFinancialImpact(params: FinancialImpactParams): Pro
|
|||||||
|
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
const eventType = String(ev.eventType ?? "").toLowerCase();
|
const eventType = String(ev.eventType ?? "").toLowerCase();
|
||||||
|
if (eventType === "mold-change") continue;
|
||||||
const { blob, inner } = parseBlob(ev.data);
|
const { blob, inner } = parseBlob(ev.data);
|
||||||
const status = String(blob?.status ?? inner?.status ?? "").toLowerCase();
|
const status = String(blob?.status ?? inner?.status ?? "").toLowerCase();
|
||||||
const severity = String(ev.severity ?? "").toLowerCase();
|
const severity = String(ev.severity ?? "").toLowerCase();
|
||||||
|
|||||||
104
lib/i18n/en.json
104
lib/i18n/en.json
@@ -9,6 +9,8 @@
|
|||||||
"common.close": "Close",
|
"common.close": "Close",
|
||||||
"common.save": "Save",
|
"common.save": "Save",
|
||||||
"common.copy": "Copy",
|
"common.copy": "Copy",
|
||||||
|
"common.yes": "Yes",
|
||||||
|
"common.no": "No",
|
||||||
"nav.overview": "Overview",
|
"nav.overview": "Overview",
|
||||||
"nav.machines": "Machines",
|
"nav.machines": "Machines",
|
||||||
"nav.reports": "Reports",
|
"nav.reports": "Reports",
|
||||||
@@ -104,6 +106,91 @@
|
|||||||
"overview.event.slow-cycle": "slow-cycle",
|
"overview.event.slow-cycle": "slow-cycle",
|
||||||
"overview.status.offline": "OFFLINE",
|
"overview.status.offline": "OFFLINE",
|
||||||
"overview.status.online": "ONLINE",
|
"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.title": "Machines",
|
||||||
"machines.subtitle": "Select a machine to view live KPIs.",
|
"machines.subtitle": "Select a machine to view live KPIs.",
|
||||||
"machines.cancel": "Cancel",
|
"machines.cancel": "Cancel",
|
||||||
@@ -147,9 +234,10 @@
|
|||||||
"machine.detail.error.network": "Network error",
|
"machine.detail.error.network": "Network error",
|
||||||
"machine.detail.back": "Back",
|
"machine.detail.back": "Back",
|
||||||
"machine.detail.workOrders.upload": "Upload Work Orders",
|
"machine.detail.workOrders.upload": "Upload Work Orders",
|
||||||
|
"machine.detail.workOrders.downloadTemplate": "Download Template",
|
||||||
"machine.detail.workOrders.uploading": "Uploading...",
|
"machine.detail.workOrders.uploading": "Uploading...",
|
||||||
"machine.detail.workOrders.uploadParsing": "Parsing file...",
|
"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.uploadSuccess": "Uploaded {count} work orders",
|
||||||
"machine.detail.workOrders.uploadError": "Upload failed",
|
"machine.detail.workOrders.uploadError": "Upload failed",
|
||||||
"machine.detail.workOrders.uploadInvalid": "No valid work orders found",
|
"machine.detail.workOrders.uploadInvalid": "No valid work orders found",
|
||||||
@@ -167,11 +255,15 @@
|
|||||||
"machine.detail.bucket.unknown": "Unknown",
|
"machine.detail.bucket.unknown": "Unknown",
|
||||||
"machine.detail.activity.title": "Machine Activity Timeline",
|
"machine.detail.activity.title": "Machine Activity Timeline",
|
||||||
"machine.detail.activity.subtitle": "Real-time analysis of production cycles",
|
"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.activity.noData": "No timeline data yet.",
|
||||||
"machine.detail.tooltip.cycle": "Cycle: {label}",
|
"machine.detail.tooltip.cycle": "Cycle: {label}",
|
||||||
"machine.detail.tooltip.duration": "Duration",
|
"machine.detail.tooltip.duration": "Duration",
|
||||||
"machine.detail.tooltip.ideal": "Ideal",
|
"machine.detail.tooltip.ideal": "Ideal",
|
||||||
"machine.detail.tooltip.deviation": "Deviation",
|
"machine.detail.tooltip.deviation": "Deviation",
|
||||||
|
"machine.detail.kpi.oeeCurrent": "Current OEE",
|
||||||
"machine.detail.kpi.updated": "Updated {time}",
|
"machine.detail.kpi.updated": "Updated {time}",
|
||||||
"machine.detail.currentWorkOrder": "Current Work Order",
|
"machine.detail.currentWorkOrder": "Current Work Order",
|
||||||
"machine.detail.recentEvents": "Critical Events",
|
"machine.detail.recentEvents": "Critical Events",
|
||||||
@@ -526,10 +618,18 @@
|
|||||||
"financial.field.scrapCostPerUnit": "Scrap cost / unit",
|
"financial.field.scrapCostPerUnit": "Scrap cost / unit",
|
||||||
"financial.field.rawMaterialCostPerUnit": "Raw material / unit",
|
"financial.field.rawMaterialCostPerUnit": "Raw material / unit",
|
||||||
"nav.downtime": "Downtime",
|
"nav.downtime": "Downtime",
|
||||||
|
"nav.recap": "Daily recap",
|
||||||
"settings.tabs.modules": "Modules",
|
"settings.tabs.modules": "Modules",
|
||||||
"settings.modules.title": "Modules",
|
"settings.modules.title": "Modules",
|
||||||
"settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.",
|
"settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.",
|
||||||
"settings.modules.screenless.title": "Screenless mode",
|
"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.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}%"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"common.close": "Cerrar",
|
"common.close": "Cerrar",
|
||||||
"common.save": "Guardar",
|
"common.save": "Guardar",
|
||||||
"common.copy": "Copiar",
|
"common.copy": "Copiar",
|
||||||
|
"common.yes": "Sí",
|
||||||
|
"common.no": "No",
|
||||||
"nav.overview": "Resumen",
|
"nav.overview": "Resumen",
|
||||||
"nav.machines": "Máquinas",
|
"nav.machines": "Máquinas",
|
||||||
"nav.reports": "Reportes",
|
"nav.reports": "Reportes",
|
||||||
@@ -102,8 +104,100 @@
|
|||||||
"overview.event.macrostop": "macroparo",
|
"overview.event.macrostop": "macroparo",
|
||||||
"overview.event.microstop": "microparo",
|
"overview.event.microstop": "microparo",
|
||||||
"overview.event.slow-cycle": "ciclo lento",
|
"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.offline": "FUERA DE LÍNEA",
|
||||||
"overview.status.online": "EN 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.title": "Máquinas",
|
||||||
"machines.subtitle": "Selecciona una máquina para ver KPIs en vivo.",
|
"machines.subtitle": "Selecciona una máquina para ver KPIs en vivo.",
|
||||||
"machines.cancel": "Cancelar",
|
"machines.cancel": "Cancelar",
|
||||||
@@ -147,9 +241,10 @@
|
|||||||
"machine.detail.error.network": "Error de red",
|
"machine.detail.error.network": "Error de red",
|
||||||
"machine.detail.back": "Volver",
|
"machine.detail.back": "Volver",
|
||||||
"machine.detail.workOrders.upload": "Subir ordenes de trabajo",
|
"machine.detail.workOrders.upload": "Subir ordenes de trabajo",
|
||||||
|
"machine.detail.workOrders.downloadTemplate": "Descargar plantilla",
|
||||||
"machine.detail.workOrders.uploading": "Subiendo...",
|
"machine.detail.workOrders.uploading": "Subiendo...",
|
||||||
"machine.detail.workOrders.uploadParsing": "Leyendo archivo...",
|
"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.uploadSuccess": "Se cargaron {count} ordenes de trabajo",
|
||||||
"machine.detail.workOrders.uploadError": "No se pudo cargar",
|
"machine.detail.workOrders.uploadError": "No se pudo cargar",
|
||||||
"machine.detail.workOrders.uploadInvalid": "No se encontraron ordenes de trabajo validas",
|
"machine.detail.workOrders.uploadInvalid": "No se encontraron ordenes de trabajo validas",
|
||||||
@@ -167,11 +262,15 @@
|
|||||||
"machine.detail.bucket.unknown": "Desconocido",
|
"machine.detail.bucket.unknown": "Desconocido",
|
||||||
"machine.detail.activity.title": "Línea de tiempo de actividad",
|
"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.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.activity.noData": "Sin datos de línea de tiempo.",
|
||||||
"machine.detail.tooltip.cycle": "Ciclo: {label}",
|
"machine.detail.tooltip.cycle": "Ciclo: {label}",
|
||||||
"machine.detail.tooltip.duration": "Duración",
|
"machine.detail.tooltip.duration": "Duración",
|
||||||
"machine.detail.tooltip.ideal": "Ideal",
|
"machine.detail.tooltip.ideal": "Ideal",
|
||||||
"machine.detail.tooltip.deviation": "Desviación",
|
"machine.detail.tooltip.deviation": "Desviación",
|
||||||
|
"machine.detail.kpi.oeeCurrent": "OEE actual",
|
||||||
"machine.detail.kpi.updated": "Actualizado {time}",
|
"machine.detail.kpi.updated": "Actualizado {time}",
|
||||||
"machine.detail.currentWorkOrder": "Orden de trabajo actual",
|
"machine.detail.currentWorkOrder": "Orden de trabajo actual",
|
||||||
"machine.detail.recentEvents": "Eventos críticos",
|
"machine.detail.recentEvents": "Eventos críticos",
|
||||||
@@ -526,6 +625,7 @@
|
|||||||
"financial.field.scrapCostPerUnit": "Costo scrap / unidad",
|
"financial.field.scrapCostPerUnit": "Costo scrap / unidad",
|
||||||
"financial.field.rawMaterialCostPerUnit": "Costo materia prima / unidad",
|
"financial.field.rawMaterialCostPerUnit": "Costo materia prima / unidad",
|
||||||
"nav.downtime": "Downtime",
|
"nav.downtime": "Downtime",
|
||||||
|
"nav.recap": "Resumen diario",
|
||||||
"settings.tabs.modules": "Módulos",
|
"settings.tabs.modules": "Módulos",
|
||||||
"settings.modules.title": "Módulos",
|
"settings.modules.title": "Módulos",
|
||||||
"settings.modules.subtitle": "Activa/desactiva módulos según cómo opera la planta.",
|
"settings.modules.subtitle": "Activa/desactiva módulos según cómo opera la planta.",
|
||||||
|
|||||||
907
lib/recap/getRecapData.ts
Normal file
907
lib/recap/getRecapData.ts
Normal 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();
|
||||||
|
}
|
||||||
27
lib/recap/progressDisplay.ts
Normal file
27
lib/recap/progressDisplay.ts
Normal 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);
|
||||||
|
}
|
||||||
4
lib/recap/recapUiConstants.ts
Normal file
4
lib/recap/recapUiConstants.ts
Normal 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
776
lib/recap/redesign.ts
Normal 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
789
lib/recap/timeline.ts
Normal 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
193
lib/recap/timelineApi.ts
Normal 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
222
lib/recap/types.ts
Normal 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
0
mis-control-tower@0.1.0
Normal file
2394
package-lock.json
generated
2394
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,11 @@
|
|||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"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:migrate:deploy": "prisma migrate deploy"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.19.1",
|
"@prisma/client": "^6.19.1",
|
||||||
@@ -29,6 +33,7 @@
|
|||||||
"@types/nodemailer": "^7.0.4",
|
"@types/nodemailer": "^7.0.4",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"baseline-browser-mapping": "^2.10.22",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.10",
|
"eslint-config-next": "16.0.10",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
@@ -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");
|
||||||
@@ -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;
|
||||||
@@ -12,59 +12,55 @@ model Org {
|
|||||||
name String
|
name String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
members OrgUser[]
|
|
||||||
sessions Session[]
|
|
||||||
machines Machine[]
|
machines Machine[]
|
||||||
|
events MachineEvent[]
|
||||||
heartbeats MachineHeartbeat[]
|
heartbeats MachineHeartbeat[]
|
||||||
kpiSnapshots MachineKpiSnapshot[]
|
kpiSnapshots MachineKpiSnapshot[]
|
||||||
events MachineEvent[]
|
members OrgUser[]
|
||||||
workOrders MachineWorkOrder[]
|
reasonEntries ReasonEntry[]
|
||||||
settings OrgSettings?
|
sessions Session[]
|
||||||
shifts OrgShift[]
|
|
||||||
machineSettings MachineSettings[]
|
|
||||||
settingsAudits SettingsAudit[]
|
|
||||||
invites OrgInvite[]
|
|
||||||
alertPolicies AlertPolicy[]
|
|
||||||
alertContacts AlertContact[]
|
alertContacts AlertContact[]
|
||||||
alertNotifications AlertNotification[]
|
alertNotifications AlertNotification[]
|
||||||
financialProfile OrgFinancialProfile?
|
alertPolicies AlertPolicy?
|
||||||
|
downtimeActions DowntimeAction[]
|
||||||
locationFinancialOverrides LocationFinancialOverride[]
|
locationFinancialOverrides LocationFinancialOverride[]
|
||||||
machineFinancialOverrides MachineFinancialOverride[]
|
machineFinancialOverrides MachineFinancialOverride[]
|
||||||
|
machineSettings MachineSettings[]
|
||||||
|
workOrders MachineWorkOrder[]
|
||||||
|
financialProfile OrgFinancialProfile?
|
||||||
|
invites OrgInvite[]
|
||||||
|
settings OrgSettings?
|
||||||
|
shifts OrgShift[]
|
||||||
productCostOverrides ProductCostOverride[]
|
productCostOverrides ProductCostOverride[]
|
||||||
reasonEntries ReasonEntry[]
|
settingsAudits SettingsAudit[]
|
||||||
downtimeActions DowntimeAction[]
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
name String?
|
name String?
|
||||||
phone String? @map("phone")
|
|
||||||
passwordHash String
|
passwordHash String
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
emailVerifiedAt DateTime? @map("email_verified_at")
|
|
||||||
emailVerificationToken String? @unique @map("email_verification_token")
|
|
||||||
emailVerificationExpiresAt DateTime? @map("email_verification_expires_at")
|
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[]
|
orgs OrgUser[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
sentInvites OrgInvite[] @relation("OrgInviteInviter")
|
|
||||||
alertContacts AlertContact[]
|
alertContacts AlertContact[]
|
||||||
alertNotifications AlertNotification[]
|
alertNotifications AlertNotification[]
|
||||||
downtimeActionsOwned DowntimeAction[] @relation("DowntimeActionOwner")
|
|
||||||
downtimeActionsCreated DowntimeAction[] @relation("DowntimeActionCreator")
|
downtimeActionsCreated DowntimeAction[] @relation("DowntimeActionCreator")
|
||||||
|
downtimeActionsOwned DowntimeAction[] @relation("DowntimeActionOwner")
|
||||||
|
sentInvites OrgInvite[] @relation("OrgInviteInviter")
|
||||||
}
|
}
|
||||||
|
|
||||||
model OrgUser {
|
model OrgUser {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
orgId String
|
orgId String
|
||||||
userId String
|
userId String
|
||||||
role String @default("MEMBER") // OWNER | ADMIN | MEMBER
|
role String @default("MEMBER")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@ -77,16 +73,15 @@ model OrgInvite {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
orgId String @map("org_id")
|
orgId String @map("org_id")
|
||||||
email String
|
email String
|
||||||
role String @default("MEMBER") // OWNER | ADMIN | MEMBER
|
role String @default("MEMBER")
|
||||||
token String @unique
|
token String @unique
|
||||||
invitedBy String? @map("invited_by")
|
invitedBy String? @map("invited_by")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
expiresAt DateTime @map("expires_at")
|
expiresAt DateTime @map("expires_at")
|
||||||
acceptedAt DateTime? @map("accepted_at")
|
acceptedAt DateTime? @map("accepted_at")
|
||||||
revokedAt DateTime? @map("revoked_at")
|
revokedAt DateTime? @map("revoked_at")
|
||||||
|
inviter User? @relation("OrgInviteInviter", fields: [invitedBy], references: [id])
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
inviter User? @relation("OrgInviteInviter", fields: [invitedBy], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([orgId])
|
@@index([orgId])
|
||||||
@@index([orgId, email])
|
@@index([orgId, email])
|
||||||
@@ -95,7 +90,7 @@ model OrgInvite {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id @default(uuid()) // cookie value
|
id String @id @default(uuid())
|
||||||
orgId String
|
orgId String
|
||||||
userId String
|
userId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -104,7 +99,6 @@ model Session {
|
|||||||
revokedAt DateTime?
|
revokedAt DateTime?
|
||||||
ip String?
|
ip String?
|
||||||
userAgent String?
|
userAgent String?
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@ -117,36 +111,33 @@ model Machine {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
orgId String
|
orgId String
|
||||||
name String
|
name String
|
||||||
apiKey String? @unique
|
|
||||||
code String?
|
code String?
|
||||||
location String?
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
location String?
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
tsDevice DateTime @default(now()) @map("ts")
|
apiKey String? @unique
|
||||||
tsServer DateTime @default(now()) @map("ts_server")
|
|
||||||
schemaVersion String? @map("schema_version")
|
schemaVersion String? @map("schema_version")
|
||||||
seq BigInt? @map("seq")
|
seq BigInt? @map("seq")
|
||||||
|
tsDevice DateTime @default(now()) @map("ts")
|
||||||
|
tsServer DateTime @default(now()) @map("ts_server")
|
||||||
pairingCode String? @unique @map("pairing_code")
|
pairingCode String? @unique @map("pairing_code")
|
||||||
pairingCodeExpiresAt DateTime? @map("pairing_code_expires_at")
|
pairingCodeExpiresAt DateTime? @map("pairing_code_expires_at")
|
||||||
pairingCodeUsedAt DateTime? @map("pairing_code_used_at")
|
pairingCodeUsedAt DateTime? @map("pairing_code_used_at")
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
cycles MachineCycle[]
|
||||||
|
events MachineEvent[]
|
||||||
heartbeats MachineHeartbeat[]
|
heartbeats MachineHeartbeat[]
|
||||||
kpiSnapshots MachineKpiSnapshot[]
|
kpiSnapshots MachineKpiSnapshot[]
|
||||||
events MachineEvent[]
|
|
||||||
cycles MachineCycle[]
|
|
||||||
workOrders MachineWorkOrder[]
|
|
||||||
settings MachineSettings?
|
|
||||||
settingsAudits SettingsAudit[]
|
|
||||||
alertNotifications AlertNotification[]
|
|
||||||
financialOverrides MachineFinancialOverride[]
|
|
||||||
reasonEntries ReasonEntry[]
|
reasonEntries ReasonEntry[]
|
||||||
|
alertNotifications AlertNotification[]
|
||||||
downtimeActions DowntimeAction[]
|
downtimeActions DowntimeAction[]
|
||||||
|
financialOverrides MachineFinancialOverride[]
|
||||||
|
settings MachineSettings?
|
||||||
|
workOrders MachineWorkOrder[]
|
||||||
|
settingsAudits SettingsAudit[]
|
||||||
|
|
||||||
@@unique([orgId, name])
|
@@unique([orgId, name])
|
||||||
@@index([orgId])
|
@@index([orgId])
|
||||||
@@index([orgId, createdAt])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model MachineHeartbeat {
|
model MachineHeartbeat {
|
||||||
@@ -154,20 +145,17 @@ model MachineHeartbeat {
|
|||||||
orgId String
|
orgId String
|
||||||
machineId String
|
machineId String
|
||||||
ts DateTime @default(now())
|
ts DateTime @default(now())
|
||||||
tsServer DateTime @default(now()) @map("ts_server")
|
|
||||||
schemaVersion String? @map("schema_version")
|
|
||||||
seq BigInt? @map("seq")
|
|
||||||
|
|
||||||
status String
|
status String
|
||||||
message String?
|
message String?
|
||||||
ip String?
|
ip String?
|
||||||
fwVersion String?
|
fwVersion String?
|
||||||
|
schemaVersion String? @map("schema_version")
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
seq BigInt? @map("seq")
|
||||||
|
tsServer DateTime @default(now()) @map("ts_server")
|
||||||
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)
|
||||||
|
|
||||||
@@index([orgId, machineId, ts])
|
@@index([orgId, machineId, ts])
|
||||||
@@index([orgId, machineId, tsServer])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model MachineKpiSnapshot {
|
model MachineKpiSnapshot {
|
||||||
@@ -175,10 +163,8 @@ model MachineKpiSnapshot {
|
|||||||
orgId String
|
orgId String
|
||||||
machineId String
|
machineId String
|
||||||
ts DateTime @default(now())
|
ts DateTime @default(now())
|
||||||
|
|
||||||
workOrderId String?
|
workOrderId String?
|
||||||
sku String?
|
sku String?
|
||||||
|
|
||||||
target Int?
|
target Int?
|
||||||
good Int?
|
good Int?
|
||||||
scrap Int?
|
scrap Int?
|
||||||
@@ -186,23 +172,21 @@ model MachineKpiSnapshot {
|
|||||||
goodParts Int?
|
goodParts Int?
|
||||||
scrapParts Int?
|
scrapParts Int?
|
||||||
cavities Int?
|
cavities Int?
|
||||||
cycleTime Float? // theoretical/target
|
cycleTime Float?
|
||||||
actualCycle Float? // if you want (optional)
|
actualCycle Float?
|
||||||
|
|
||||||
availability Float?
|
availability Float?
|
||||||
performance Float?
|
performance Float?
|
||||||
quality Float?
|
quality Float?
|
||||||
oee Float?
|
oee Float?
|
||||||
|
|
||||||
trackingEnabled Boolean?
|
trackingEnabled Boolean?
|
||||||
productionStarted Boolean?
|
productionStarted Boolean?
|
||||||
tsServer DateTime @default(now()) @map("ts_server")
|
|
||||||
schemaVersion String? @map("schema_version")
|
schemaVersion String? @map("schema_version")
|
||||||
seq BigInt? @map("seq")
|
seq BigInt? @map("seq")
|
||||||
|
tsServer DateTime @default(now()) @map("ts_server")
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
|
||||||
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)
|
||||||
|
|
||||||
|
@@unique([orgId, machineId, seq], map: "uq_kpi_org_machine_seq")
|
||||||
@@index([orgId, machineId, ts])
|
@@index([orgId, machineId, ts])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,26 +195,22 @@ model MachineEvent {
|
|||||||
orgId String
|
orgId String
|
||||||
machineId String
|
machineId String
|
||||||
ts DateTime @default(now())
|
ts DateTime @default(now())
|
||||||
|
topic String
|
||||||
topic String // "anomaly-detected"
|
eventType String
|
||||||
eventType String // "slow-cycle"
|
severity String
|
||||||
severity String // "critical"
|
|
||||||
requiresAck Boolean @default(false)
|
requiresAck Boolean @default(false)
|
||||||
title String
|
title String
|
||||||
description 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?
|
data Json?
|
||||||
|
|
||||||
workOrderId String?
|
workOrderId String?
|
||||||
sku String?
|
sku String?
|
||||||
|
schemaVersion String? @map("schema_version")
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
seq BigInt? @map("seq")
|
||||||
|
tsServer DateTime @default(now()) @map("ts_server")
|
||||||
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)
|
||||||
|
|
||||||
|
@@unique([orgId, machineId, seq], map: "uq_event_org_machine_seq")
|
||||||
@@index([orgId, machineId, ts])
|
@@index([orgId, machineId, ts])
|
||||||
@@index([orgId, machineId, eventType, ts])
|
@@index([orgId, machineId, eventType, ts])
|
||||||
}
|
}
|
||||||
@@ -240,25 +220,22 @@ model MachineCycle {
|
|||||||
orgId String
|
orgId String
|
||||||
machineId String
|
machineId String
|
||||||
ts DateTime @default(now())
|
ts DateTime @default(now())
|
||||||
|
|
||||||
cycleCount Int?
|
cycleCount Int?
|
||||||
actualCycleTime Float
|
actualCycleTime Float
|
||||||
theoreticalCycleTime Float?
|
theoreticalCycleTime Float?
|
||||||
|
|
||||||
workOrderId String?
|
workOrderId String?
|
||||||
sku String?
|
sku String?
|
||||||
|
|
||||||
cavities Int?
|
cavities Int?
|
||||||
goodDelta Int?
|
goodDelta Int?
|
||||||
scrapDelta Int?
|
scrapDelta Int?
|
||||||
tsServer DateTime @default(now()) @map("ts_server")
|
createdAt DateTime @default(now())
|
||||||
schemaVersion String? @map("schema_version")
|
schemaVersion String? @map("schema_version")
|
||||||
seq BigInt? @map("seq")
|
seq BigInt? @map("seq")
|
||||||
|
tsServer DateTime @default(now()) @map("ts_server")
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
machine Machine @relation(fields: [machineId], references: [id])
|
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, ts])
|
||||||
@@index([orgId, machineId, cycleCount])
|
@@index([orgId, machineId, cycleCount])
|
||||||
}
|
}
|
||||||
@@ -274,9 +251,14 @@ model MachineWorkOrder {
|
|||||||
status String @default("PENDING")
|
status String @default("PENDING")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
goodParts Int @default(0) @map("good_parts")
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
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)
|
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
|
||||||
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([machineId, workOrderId])
|
@@unique([machineId, workOrderId])
|
||||||
@@index([orgId, machineId])
|
@@index([orgId, machineId])
|
||||||
@@ -293,7 +275,6 @@ model IngestLog {
|
|||||||
seq BigInt?
|
seq BigInt?
|
||||||
tsDevice DateTime?
|
tsDevice DateTime?
|
||||||
tsServer DateTime @default(now())
|
tsServer DateTime @default(now())
|
||||||
|
|
||||||
ok Boolean
|
ok Boolean
|
||||||
status Int
|
status Int
|
||||||
errorCode String?
|
errorCode String?
|
||||||
@@ -312,7 +293,6 @@ model OrgSettings {
|
|||||||
timezone String @default("UTC")
|
timezone String @default("UTC")
|
||||||
shiftChangeCompMin Int @default(10) @map("shift_change_comp_min")
|
shiftChangeCompMin Int @default(10) @map("shift_change_comp_min")
|
||||||
lunchBreakMin Int @default(30) @map("lunch_break_min")
|
lunchBreakMin Int @default(30) @map("lunch_break_min")
|
||||||
shiftScheduleOverridesJson Json? @map("shift_schedule_overrides_json")
|
|
||||||
stoppageMultiplier Float @default(1.5) @map("stoppage_multiplier")
|
stoppageMultiplier Float @default(1.5) @map("stoppage_multiplier")
|
||||||
oeeAlertThresholdPct Float @default(90) @map("oee_alert_threshold_pct")
|
oeeAlertThresholdPct Float @default(90) @map("oee_alert_threshold_pct")
|
||||||
macroStoppageMultiplier Float @default(5) @map("macro_stoppage_multiplier")
|
macroStoppageMultiplier Float @default(5) @map("macro_stoppage_multiplier")
|
||||||
@@ -323,7 +303,7 @@ model OrgSettings {
|
|||||||
version Int @default(1)
|
version Int @default(1)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
updatedBy String? @map("updated_by")
|
updatedBy String? @map("updated_by")
|
||||||
|
shiftScheduleOverridesJson Json? @map("shift_schedule_overrides_json")
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@map("org_settings")
|
@@map("org_settings")
|
||||||
@@ -344,7 +324,6 @@ model OrgFinancialProfile {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
updatedBy String? @map("updated_by")
|
updatedBy String? @map("updated_by")
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@map("org_financial_profiles")
|
@@map("org_financial_profiles")
|
||||||
@@ -367,7 +346,6 @@ model LocationFinancialOverride {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
updatedBy String? @map("updated_by")
|
updatedBy String? @map("updated_by")
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([orgId, location])
|
@@unique([orgId, location])
|
||||||
@@ -392,9 +370,8 @@ model MachineFinancialOverride {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
updatedBy String? @map("updated_by")
|
updatedBy String? @map("updated_by")
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
|
||||||
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)
|
||||||
|
|
||||||
@@unique([orgId, machineId])
|
@@unique([orgId, machineId])
|
||||||
@@index([orgId])
|
@@index([orgId])
|
||||||
@@ -410,7 +387,6 @@ model ProductCostOverride {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
updatedBy String? @map("updated_by")
|
updatedBy String? @map("updated_by")
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([orgId, sku])
|
@@unique([orgId, sku])
|
||||||
@@ -420,14 +396,12 @@ model ProductCostOverride {
|
|||||||
|
|
||||||
model AlertPolicy {
|
model AlertPolicy {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
orgId String @map("org_id")
|
orgId String @unique @map("org_id")
|
||||||
policyJson Json @map("policy_json")
|
policyJson Json @map("policy_json")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
updatedBy String? @map("updated_by")
|
updatedBy String? @map("updated_by")
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([orgId])
|
|
||||||
@@index([orgId])
|
@@index([orgId])
|
||||||
@@map("alert_policies")
|
@@map("alert_policies")
|
||||||
}
|
}
|
||||||
@@ -437,16 +411,15 @@ model AlertContact {
|
|||||||
orgId String @map("org_id")
|
orgId String @map("org_id")
|
||||||
userId String? @map("user_id")
|
userId String? @map("user_id")
|
||||||
name String
|
name String
|
||||||
roleScope String @map("role_scope") // MEMBER | ADMIN | OWNER | CUSTOM
|
roleScope String @map("role_scope")
|
||||||
email String?
|
email String?
|
||||||
phone 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")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
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[]
|
notifications AlertNotification[]
|
||||||
|
|
||||||
@@unique([orgId, userId])
|
@@unique([orgId, userId])
|
||||||
@@ -469,11 +442,10 @@ model AlertNotification {
|
|||||||
sentAt DateTime @default(now()) @map("sent_at")
|
sentAt DateTime @default(now()) @map("sent_at")
|
||||||
status String
|
status String
|
||||||
error String?
|
error String?
|
||||||
|
contact AlertContact? @relation(fields: [contactId], references: [id])
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
|
||||||
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
|
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
|
||||||
contact AlertContact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
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])
|
||||||
|
|
||||||
@@index([orgId, machineId, sentAt])
|
@@index([orgId, machineId, sentAt])
|
||||||
@@index([orgId, eventId, role, channel])
|
@@index([orgId, eventId, role, channel])
|
||||||
@@ -490,7 +462,6 @@ model OrgShift {
|
|||||||
endTime String @map("end_time")
|
endTime String @map("end_time")
|
||||||
sortOrder Int @map("sort_order")
|
sortOrder Int @map("sort_order")
|
||||||
enabled Boolean @default(true)
|
enabled Boolean @default(true)
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([orgId])
|
@@index([orgId])
|
||||||
@@ -504,9 +475,8 @@ model MachineSettings {
|
|||||||
overridesJson Json? @map("overrides_json")
|
overridesJson Json? @map("overrides_json")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
updatedBy String? @map("updated_by")
|
updatedBy String? @map("updated_by")
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
|
||||||
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)
|
||||||
|
|
||||||
@@index([orgId])
|
@@index([orgId])
|
||||||
@@map("machine_settings")
|
@@map("machine_settings")
|
||||||
@@ -520,9 +490,8 @@ model SettingsAudit {
|
|||||||
source String
|
source String
|
||||||
payloadJson Json @map("payload_json")
|
payloadJson Json @map("payload_json")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
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)
|
machine Machine? @relation(fields: [machineId], references: [id], onDelete: Cascade)
|
||||||
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([orgId, createdAt])
|
@@index([orgId, createdAt])
|
||||||
@@index([machineId, createdAt])
|
@@index([machineId, createdAt])
|
||||||
@@ -533,42 +502,29 @@ model ReasonEntry {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
orgId String
|
orgId String
|
||||||
machineId String
|
machineId String
|
||||||
|
|
||||||
// idempotency key from Edge (rsn_<ulid>)
|
|
||||||
reasonId String @unique
|
reasonId String @unique
|
||||||
|
|
||||||
// "downtime" | "scrap"
|
|
||||||
kind String
|
kind String
|
||||||
|
|
||||||
// For downtime reasons
|
|
||||||
episodeId String?
|
episodeId String?
|
||||||
durationSeconds Int?
|
durationSeconds Int?
|
||||||
episodeEndTs DateTime?
|
episodeEndTs DateTime?
|
||||||
|
|
||||||
// For scrap reasons
|
|
||||||
scrapEntryId String?
|
scrapEntryId String?
|
||||||
scrapQty Int?
|
scrapQty Int?
|
||||||
scrapUnit String?
|
scrapUnit String?
|
||||||
|
|
||||||
// Required reason
|
|
||||||
reasonCode String
|
reasonCode String
|
||||||
reasonLabel String?
|
reasonLabel String?
|
||||||
reasonText String?
|
reasonText String?
|
||||||
|
|
||||||
capturedAt DateTime
|
capturedAt DateTime
|
||||||
workOrderId String?
|
workOrderId String?
|
||||||
meta Json?
|
meta Json?
|
||||||
schemaVersion Int @default(1)
|
schemaVersion Int @default(1)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
|
||||||
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)
|
||||||
|
|
||||||
@@index([orgId, machineId, capturedAt])
|
|
||||||
@@index([orgId, kind, capturedAt])
|
|
||||||
@@unique([orgId, kind, episodeId])
|
@@unique([orgId, kind, episodeId])
|
||||||
@@unique([orgId, kind, scrapEntryId])
|
@@unique([orgId, kind, scrapEntryId])
|
||||||
|
@@index([orgId, machineId, capturedAt])
|
||||||
|
@@index([orgId, kind, capturedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model DowntimeAction {
|
model DowntimeAction {
|
||||||
@@ -578,7 +534,6 @@ model DowntimeAction {
|
|||||||
reasonCode String? @map("reason_code")
|
reasonCode String? @map("reason_code")
|
||||||
hmDay Int? @map("hm_day")
|
hmDay Int? @map("hm_day")
|
||||||
hmHour Int? @map("hm_hour")
|
hmHour Int? @map("hm_hour")
|
||||||
|
|
||||||
title String
|
title String
|
||||||
notes String?
|
notes String?
|
||||||
status String @default("open")
|
status String @default("open")
|
||||||
@@ -586,19 +541,16 @@ model DowntimeAction {
|
|||||||
dueDate DateTime? @map("due_date")
|
dueDate DateTime? @map("due_date")
|
||||||
reminderAt DateTime? @map("reminder_at")
|
reminderAt DateTime? @map("reminder_at")
|
||||||
lastReminderAt DateTime? @map("last_reminder_at")
|
lastReminderAt DateTime? @map("last_reminder_at")
|
||||||
reminderStage String? @map("reminder_stage")
|
|
||||||
completedAt DateTime? @map("completed_at")
|
completedAt DateTime? @map("completed_at")
|
||||||
|
|
||||||
ownerUserId String? @map("owner_user_id")
|
ownerUserId String? @map("owner_user_id")
|
||||||
createdBy String? @map("created_by")
|
createdBy String? @map("created_by")
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_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)
|
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])
|
||||||
ownerUser User? @relation("DowntimeActionOwner", fields: [ownerUserId], references: [id], onDelete: SetNull)
|
|
||||||
creator User? @relation("DowntimeActionCreator", fields: [createdBy], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([orgId])
|
@@index([orgId])
|
||||||
@@index([orgId, machineId])
|
@@index([orgId, machineId])
|
||||||
|
|||||||
144
recap_fix.md
Normal file
144
recap_fix.md
Normal 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
|
||||||
308
scripts/backfill-downtime-reasons.mjs
Normal file
308
scripts/backfill-downtime-reasons.mjs
Normal 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();
|
||||||
|
});
|
||||||
|
|
||||||
8
scripts/pi-work_orders_add_mold_cavities.sql
Normal file
8
scripts/pi-work_orders_add_mold_cavities.sql
Normal 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.
|
||||||
68
scripts/test-downtime-reason-guard.mjs
Normal file
68
scripts/test-downtime-reason-guard.mjs
Normal 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();
|
||||||
|
|
||||||
Reference in New Issue
Block a user