From ffc39a5c90c4890e987408108e6dcb4112c50dcf Mon Sep 17 00:00:00 2001 From: Marcelo Date: Thu, 18 Dec 2025 20:17:20 +0000 Subject: [PATCH] MVP --- .../[machineId]/MachineDetailClient.tsx | 521 +++++++++++++++++- app/api/ingest/cycle/route.ts | 46 ++ app/api/ingest/event/route.ts | 104 ++-- app/api/machines/[machineId]/route.ts | 185 ++++++- app/globals.css | 5 + package-lock.json | 420 +++++++++++++- package.json | 3 +- .../migration.sql | 27 + prisma/schema.prisma | 26 + 9 files changed, 1268 insertions(+), 69 deletions(-) create mode 100644 app/api/ingest/cycle/route.ts create mode 100644 prisma/migrations/20251218153109_add_machine_cycles/migration.sql diff --git a/app/(app)/machines/[machineId]/MachineDetailClient.tsx b/app/(app)/machines/[machineId]/MachineDetailClient.tsx index 27d2808..a368533 100644 --- a/app/(app)/machines/[machineId]/MachineDetailClient.tsx +++ b/app/(app)/machines/[machineId]/MachineDetailClient.tsx @@ -3,6 +3,24 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; +import { ComposedChart } from "recharts"; +import { Cell } from "recharts"; + + +import { + ResponsiveContainer, + ScatterChart, + Scatter, + LineChart, + Line, + CartesianGrid, + XAxis, + YAxis, + Tooltip, + ReferenceLine, + BarChart, + Bar, +} from "recharts"; type Heartbeat = { @@ -38,6 +56,21 @@ type EventRow = { requiresAck: boolean; }; +type CycleRow = { + ts: string; // ISO + t: number; // epoch ms + cycleCount: number | null; + actual: number; // seconds + ideal: number | null; + workOrderId: string | null; + sku: string | null; +}; + +type CycleDerivedRow = CycleRow & { + extra: number | null; + bucket: "normal" | "slow" | "microstop" | "macrostop" | "unknown"; +}; + type MachineDetail = { id: string; name: string; @@ -55,6 +88,20 @@ export default function MachineDetailClient() { const [machine, setMachine] = useState(null); const [events, setEvents] = useState([]); const [error, setError] = useState(null); + const [cycles, setCycles] = useState([]); + const [open, setOpen] = useState(null); + + + const BUCKET = { + normal: { label: "Ciclo Normal", dot: "#12D18E", glow: "rgba(18,209,142,.35)", chip: "bg-emerald-500/15 text-emerald-300 border-emerald-500/20" }, + slow: { label: "Ciclo Lento", dot: "#F7B500", glow: "rgba(247,181,0,.35)", chip: "bg-yellow-500/15 text-yellow-300 border-yellow-500/20" }, + microstop:{ label: "Microparo", dot: "#FF7A00", glow: "rgba(255,122,0,.35)", chip: "bg-orange-500/15 text-orange-300 border-orange-500/20" }, + macrostop:{ label: "Macroparo", dot: "#FF3B5C", glow: "rgba(255,59,92,.35)", chip: "bg-rose-500/15 text-rose-300 border-rose-500/20" }, + unknown: { label: "Desconocido", dot: "#A1A1AA", glow: "rgba(161,161,170,.25)", chip: "bg-white/10 text-zinc-200 border-white/10" }, + } as const; + + + useEffect(() => { if (!machineId) return; // <-- IMPORTANT guard @@ -68,6 +115,9 @@ export default function MachineDetailClient() { credentials: "include", }); const json = await res.json(); + + + if (!alive) return; @@ -79,6 +129,7 @@ export default function MachineDetailClient() { setMachine(json.machine ?? null); setEvents(json.events ?? []); + setCycles(json.cycles ?? []); setError(null); setLoading(false); } catch { @@ -86,6 +137,7 @@ export default function MachineDetailClient() { setError("Network error"); setLoading(false); } + } load(); @@ -96,6 +148,8 @@ export default function MachineDetailClient() { }; }, [machineId]); + + function fmtPct(v?: number | null) { if (v === null || v === undefined || Number.isNaN(v)) return "—"; return `${v.toFixed(1)}%`; @@ -139,6 +193,222 @@ export default function MachineDetailClient() { const kpi = machine?.latestKpi ?? null; const offline = useMemo(() => isOffline(hb?.ts), [hb?.ts]); const statusLabel = offline ? "OFFLINE" : (hb?.status ?? "UNKNOWN"); + const cycleTarget = (machine as any)?.effectiveCycleTime ?? kpi?.cycleTime ?? null; + + const ActiveRing = (props: any) => { + const { cx, cy, fill } = props; + if (cx == null || cy == null) return null; + return ( + + + + + ); + }; + + function MiniCard({ + title, + subtitle, + value, + onClick, + }: { + title: string; + subtitle: string; + value: string; + onClick?: () => void; + }) { + const clickable = typeof onClick === "function"; + + if (clickable) { + return ( + + ); + } + + return ( +
+
{title}
+
{subtitle}
+
{value}
+
+ ); + } + + function Modal({ + open, + onClose, + title, + children, + }: { + open: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + }) { + if (!open) return null; + + return ( +
+ {/* overlay */} +
+ + {/* panel */} +
+ {/* gradient wash (Step 2) */} +
+ + {/* content */} +
+
+
{title}
+ +
+ + {children} +
+
+
+ ); + } + + + function CycleTooltip({ active, payload, label }: any) { + if (!active || !payload?.length) return null; + + const p = payload[0]?.payload; + if (!p) return null; + + const ideal = p.ideal ?? null; + const actual = p.actual ?? null; + const deltaPct = p.deltaPct ?? null; + + return ( +
+
Ciclo: {label}
+
+
Duración: {actual?.toFixed(2)}s
+
Ideal: {ideal != null ? `${ideal.toFixed(2)}s` : "—"}
+
Desviación: {deltaPct != null ? `${deltaPct.toFixed(1)}%` : "—"}
+
+
+ ); + } + + + + + const TOL = 0.10; + function hasIdealAndActual(r: CycleDerivedRow): r is CycleDerivedRow & { ideal: number; actual: number } { + return r.ideal != null && r.actual != null && r.ideal > 0; + } + const cycleDerived = useMemo(() => { + const rows = cycles ?? []; + + const mapped: CycleDerivedRow[] = rows.map((c) => { + const ideal = c.ideal ?? null; + const actual = c.actual ?? null; + const extra = ideal != null && actual != null ? actual - ideal : null; + + let bucket: CycleDerivedRow["bucket"] = "unknown"; + if (ideal != null && actual != null) { + if (actual <= ideal * (1 + TOL)) bucket = "normal"; + else if (extra != null && extra <= 1) bucket = "slow"; + else if (extra != null && extra <= 10) bucket = "microstop"; + else bucket = "macrostop"; + } + + return { ...c, ideal, actual, extra, bucket }; + }); + + const counts = mapped.reduce( + (acc, r) => { + acc.total += 1; + acc[r.bucket] += 1; + if (r.extra != null && r.extra > 0) acc.extraTotal += r.extra; + return acc; + }, + { total: 0, normal: 0, slow: 0, microstop: 0, macrostop: 0, unknown: 0, extraTotal: 0 } + ); + + const deltas = mapped + .filter(hasIdealAndActual) + .map((r) => ((r.actual - r.ideal) / r.ideal) * 100); + + const avgDeltaPct = deltas.length ? deltas.reduce((a, b) => a + b, 0) / deltas.length : null; + + return { mapped, counts, avgDeltaPct }; + }, [cycles]); + const deviationSeries = useMemo(() => { + // use last N cycles to keep chart readable + const last = cycleDerived.mapped.slice(-100); + + return last + .map((r, idx) => { + const ideal = r.ideal; + const actual = r.actual; + if (ideal == null || actual == null || ideal <= 0) return null; + + const deltaPct = ((actual - ideal) / ideal) * 100; + + return { + i: idx + 1, // x-axis index (cycle order) + actual, + ideal, + deltaPct, + bucket: r.bucket, + }; + }) + .filter(Boolean) as Array<{ + i: number; + actual: number; + ideal: number; + deltaPct: number; + bucket: string; + }>; + }, [cycleDerived.mapped]); + + const impactAgg = useMemo(() => { + // sum extra seconds by bucket + const buckets = { slow: 0, microstop: 0, macrostop: 0 } as Record; + + for (const r of cycleDerived.mapped) { + if (!r.extra || r.extra <= 0) continue; + if (r.bucket === "slow" || r.bucket === "microstop" || r.bucket === "macrostop") { + buckets[r.bucket] += r.extra; + } + } + + const rows = [ + { name: "Slow", seconds: Math.round(buckets.slow * 10) / 10 }, + { name: "Microstop", seconds: Math.round(buckets.microstop * 10) / 10 }, + { name: "Macrostop", seconds: Math.round(buckets.macrostop * 10) / 10 }, + ]; + + const total = rows.reduce((a, b) => a + b.seconds, 0); + return { rows, total }; + }, [cycleDerived.mapped]); + return (
@@ -228,11 +498,11 @@ export default function MachineDetailClient() {
- Cycle target: {kpi?.cycleTime ? `${kpi.cycleTime}s` : "—"} + Cycle target: {cycleTarget ? `${cycleTarget}s` : "—"}
-
+
Recent Events
{events.length} shown
@@ -240,8 +510,8 @@ export default function MachineDetailClient() { {events.length === 0 ? (
No events yet.
- ) : ( -
+ ) : ( +
{events.map((e) => (
@@ -274,7 +544,250 @@ export default function MachineDetailClient() { )}
+ {/* Mini analysis cards */} +
+ setOpen("events")} + /> + setOpen("deviation")} + /> + setOpen("impact")} + /> +
+ setOpen(null)} + title="Eventos Detectados" + > +
+ {cycleDerived.mapped + .filter((r) => r.bucket !== "normal" && r.bucket !== "unknown") + .slice() + .reverse() + .map((r, idx) => { + const meta = BUCKET[r.bucket as keyof typeof BUCKET]; + + return ( +
+
+ {/* left accent dot */} + +
+
+ {/* colored chip */} + + {meta.label} + + + + {r.actual?.toFixed(2)}s + {r.ideal != null ? ` (ideal ${r.ideal.toFixed(2)}s)` : ""} + +
+
+
+ +
{timeAgo(r.ts)}
+
+ ); + })} +
+
+ setOpen(null)} + title="Ciclo Real vs Estándar" + > +
+ {/* Summary cards */} +
+
+
Ciclo estándar (ideal)
+
+ {cycleTarget ? `${Number(cycleTarget).toFixed(1)}s` : "—"} +
+
+ +
+
Desviación promedio
+
+ {cycleDerived.avgDeltaPct == null ? "—" : `${cycleDerived.avgDeltaPct.toFixed(1)}%`} +
+
+ +
+
Muestra
+
+ {deviationSeries.length} ciclos +
+
+
+ + {/* Chart */} +
+ + + + + + + + + } cursor={{ stroke: "rgba(255,255,255,0.15)" }} /> + + {/* Ideal center line */} + {kpi?.cycleTime ? ( + <> + + + {/* ±10% tolerance band lines */} + + + + ) : null} + + {/* Optional: ideal line from series */} + + + + {/* ONE scatter so hover always matches */} + } + shape={(props: any) => { + const { cx, cy, payload } = props; + const meta = BUCKET[payload.bucket as keyof typeof BUCKET] ?? BUCKET.unknown; + + return ( + + ); + }} + /> + + +
+ +
+ Tip: la línea tenue es el ideal. Cada punto es un ciclo real. +
+
+
+ setOpen(null)} + title="Impacto en Producción" + > +
+
+
+
Tiempo extra total
+
+ {Math.round(impactAgg.total)}s +
+
+ +
+
Microstops
+
+ {Math.round((impactAgg.rows.find(r => r.name === "Microstop")?.seconds ?? 0))}s +
+
+ +
+
Macroparos
+
+ {Math.round((impactAgg.rows.find(r => r.name === "Macrostop")?.seconds ?? 0))}s +
+
+
+ +
+ + + + + + [`${Number(val).toFixed(1)}s`, "Tiempo extra"]} + /> + + {impactAgg.rows.map((row, idx) => { + const key = + row.name === "Slow" ? "slow" : + row.name === "Microstop" ? "microstop" : + "macrostop"; + + return ; + })} + + + +
+ +
+ Esto es “tiempo perdido” vs ideal, distribuido por tipo de evento. +
+
+
+ + )}
); diff --git a/app/api/ingest/cycle/route.ts b/app/api/ingest/cycle/route.ts new file mode 100644 index 0000000..91f7c75 --- /dev/null +++ b/app/api/ingest/cycle/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +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 }); + + const body = await req.json().catch(() => null); + if (!body?.machineId || !body?.cycle) { + return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 }); + } + + const machine = await prisma.machine.findFirst({ + where: { id: String(body.machineId), apiKey }, + select: { id: true, orgId: true }, + }); + if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + + const c = body.cycle; + + const tsMs = + (typeof c.timestamp === "number" && c.timestamp) || + (typeof c.ts === "number" && c.ts) || + (typeof c.event_timestamp === "number" && c.event_timestamp) || + undefined; + + const ts = tsMs ? new Date(tsMs) : new Date(); + + const row = await prisma.machineCycle.create({ + data: { + orgId: machine.orgId, + machineId: machine.id, + ts, + cycleCount: typeof c.cycle_count === "number" ? c.cycle_count : null, + actualCycleTime: Number(c.actual_cycle_time), + theoreticalCycleTime: c.theoretical_cycle_time != null ? Number(c.theoretical_cycle_time) : null, + workOrderId: c.work_order_id ? String(c.work_order_id) : null, + sku: c.sku ? String(c.sku) : null, + cavities: typeof c.cavities === "number" ? c.cavities : null, + goodDelta: typeof c.good_delta === "number" ? c.good_delta : null, + scrapDelta: typeof c.scrap_delta === "number" ? c.scrap_delta : null, + }, + }); + + return NextResponse.json({ ok: true, id: row.id, ts: row.ts }); +} diff --git a/app/api/ingest/event/route.ts b/app/api/ingest/event/route.ts index b0e8681..cc14edd 100644 --- a/app/api/ingest/event/route.ts +++ b/app/api/ingest/event/route.ts @@ -22,18 +22,25 @@ export async function POST(req: Request) { -const e = body.event; +const rawEvent = body.event; +const e = Array.isArray(rawEvent) ? rawEvent[0] : rawEvent; -const ts = - typeof e?.data?.timestamp === "number" - ? new Date(e.data.timestamp) - : undefined; +if (!e || typeof e !== "object") { + return NextResponse.json({ ok: false, error: "Invalid event object" }, { status: 400 }); +} +const rawType = + e.eventType ?? e.anomaly_type ?? e.topic ?? body.topic ?? ""; -// normalize inputs from event -const sev = String(e.severity ?? "").toLowerCase(); -const typ = String(e.eventType ?? e.anomaly_type ?? "").toLowerCase(); -const title = String(e.title ?? "").trim(); +const normalizeType = (t: string) => + String(t) + .trim() + .toLowerCase() + .replace(/_/g, "-"); +const typ = normalizeType(rawType); +const sev = String(e.severity ?? "").trim().toLowerCase(); + +// accept these types const ALLOWED_TYPES = new Set([ "slow-cycle", "anomaly-detected", @@ -43,35 +50,58 @@ const ALLOWED_TYPES = new Set([ "microstop", ]); -const ALLOWED_SEVERITIES = new Set(["warning", "critical"]); - -// Drop generic/noise -if (!ALLOWED_SEVERITIES.has(sev) || !ALLOWED_TYPES.has(typ)) { - return NextResponse.json({ ok: true, skipped: true }, { status: 200 }); +if (!ALLOWED_TYPES.has(typ)) { + return NextResponse.json({ ok: true, skipped: true, reason: "type_not_allowed", typ, sev }, { status: 200 }); } -if (!title) return NextResponse.json({ ok: true, skipped: true }, { status: 200 }); +// optional: severity enforcement only for SOME types (not slow-cycle) +const NEEDS_HIGH_SEV = new Set(["down", "scrap-spike"]); +const ALLOWED_SEVERITIES = new Set(["warning", "critical", "error"]); + +if (NEEDS_HIGH_SEV.has(typ) && !ALLOWED_SEVERITIES.has(sev)) { + return NextResponse.json({ ok: true, skipped: true, reason: "severity_too_low", typ, sev }, { status: 200 }); +} + +// timestamp handling (support multiple field names) +const tsMs = + (typeof (e as any)?.timestamp === "number" && (e as any).timestamp) || + (typeof e?.data?.timestamp === "number" && e.data.timestamp) || + (typeof e?.data?.event_timestamp === "number" && e.data.event_timestamp) || + (typeof e?.data?.ts === "number" && e.data.ts) || + undefined; + +const ts = tsMs ? new Date(tsMs) : new Date(); // default to now if missing + +const title = + String(e.title ?? "").trim() || + (typ === "slow-cycle" ? "Slow Cycle Detected" : "Event"); + +const description = e.description + ? String(e.description) + : null; + +const row = await prisma.machineEvent.create({ + data: { + orgId: machine.orgId, + machineId: machine.id, + ts, + + topic: String(e.topic ?? typ), + eventType: typ, // ✅ store normalized type + severity: sev || "info", // ✅ store normalized severity + requiresAck: !!e.requires_ack, + title, + description, + + data: e.data ?? e, + + workOrderId: + (e as any)?.work_order_id ? String((e as any).work_order_id) + : e?.data?.work_order_id ? String(e.data.work_order_id) + : null, + }, +}); + +return NextResponse.json({ ok: true, id: row.id, ts: row.ts }); - - - const row = await prisma.machineEvent.create({ - data: { - orgId: machine.orgId, - machineId: machine.id, - ts: ts ?? undefined, - - topic: e.topic ? String(e.topic) : "event", - eventType: e.anomaly_type ? String(e.anomaly_type) : "unknown", - severity: e.severity ? String(e.severity) : "info", - requiresAck: !!e.requires_ack, - title: e.title ? String(e.title) : "Event", - description: e.description ? String(e.description) : null, - - data: e.data ?? e, // store full blob - - workOrderId: e?.data?.work_order_id ? String(e.data.work_order_id) : null, - }, - }); - - return NextResponse.json({ ok: true, id: row.id, ts: row.ts }); } diff --git a/app/api/machines/[machineId]/route.ts b/app/api/machines/[machineId]/route.ts index b0837df..bd345fb 100644 --- a/app/api/machines/[machineId]/route.ts +++ b/app/api/machines/[machineId]/route.ts @@ -3,17 +3,102 @@ import type { NextRequest } from "next/server"; import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; +function normalizeEvent(row: any) { + // data can be object OR [object] + const raw = row.data; + const blob = Array.isArray(raw) ? raw[0] : raw; + + // some payloads nest details under blob.data + const inner = blob?.data ?? blob ?? {}; + + const normalizeType = (t: any) => + String(t ?? "") + .trim() + .toLowerCase() + .replace(/_/g, "-"); + + // Prefer the DB columns if they are meaningful + const fromDbType = row.eventType && row.eventType !== "unknown" ? row.eventType : null; + const fromBlobType = blob?.anomaly_type ?? blob?.eventType ?? blob?.topic ?? inner?.anomaly_type ?? inner?.eventType ?? null; + + // infer slow-cycle if the signature exists + const inferredType = + fromDbType ?? + fromBlobType ?? + ((inner?.actual_cycle_time && inner?.theoretical_cycle_time) || (blob?.actual_cycle_time && blob?.theoretical_cycle_time) + ? "slow-cycle" + : "unknown"); + + + const eventType = normalizeType(inferredType); + + const severity = + String( + (row.severity && row.severity !== "info" ? row.severity : null) ?? + blob?.severity ?? + inner?.severity ?? + "info" + ) + .trim() + .toLowerCase(); + + const title = + String( + (row.title && row.title !== "Event" ? row.title : null) ?? + blob?.title ?? + inner?.title ?? + (eventType === "slow-cycle" ? "Slow Cycle Detected" : "Event") + ).trim(); + + const description = + row.description ?? + blob?.description ?? + inner?.description ?? + (eventType === "slow-cycle" && + inner?.actual_cycle_time && + inner?.theoretical_cycle_time && + inner?.delta_percent != null + ? `Cycle took ${Number(inner.actual_cycle_time).toFixed(1)}s (+${inner.delta_percent}% vs ${Number(inner.theoretical_cycle_time).toFixed(1)}s objetivo)` + : null); + + const ts = + row.ts ?? + (typeof blob?.timestamp === "number" ? new Date(blob.timestamp) : null) ?? + (typeof inner?.timestamp === "number" ? new Date(inner.timestamp) : null) ?? + null; + + const workOrderId = + row.workOrderId ?? + blob?.work_order_id ?? + inner?.work_order_id ?? + null; + + return { + id: row.id, + ts, + topic: String(row.topic ?? blob?.topic ?? eventType), + eventType, + severity, + title, + description, + requiresAck: !!row.requiresAck, + workOrderId, + }; +} + + + + export async function GET( _req: NextRequest, - { params }: { params: { machineId: string } } + { params }: { params: Promise<{ machineId: string }> } ) { const session = await requireSession(); if (!session) { return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); } - const { machineId } = params; - + const { machineId } = await params; const machine = await prisma.machine.findFirst({ where: { id: machineId, orgId: session.orgId }, @@ -51,18 +136,85 @@ export async function GET( return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 }); } - const events = await prisma.machineEvent.findMany({ - where: { - orgId: session.orgId, - machineId, - severity: { in: ["warning", "critical"] }, - eventType: { in: ["slow-cycle", "anomaly-detected", "performance-degradation", "scrap-spike", "down", "microstop"] }, - }, - orderBy: { ts: "desc" }, - take: 30, - select: { /* same as now */ }, + const rawEvents = await prisma.machineEvent.findMany({ + where: { + orgId: session.orgId, + machineId, + }, + orderBy: { ts: "desc" }, + take: 100, // pull more, we'll filter after normalization + select: { + id: true, + ts: true, + topic: true, + eventType: true, + severity: true, + title: true, + description: true, + requiresAck: true, + data: true, + workOrderId: true, + }, }); + const normalized = rawEvents.map(normalizeEvent); + +const ALLOWED_TYPES = new Set([ + "slow-cycle", + "anomaly-detected", + "performance-degradation", + "scrap-spike", + "down", + "microstop", +]); + +const events = normalized + .filter((e) => ALLOWED_TYPES.has(e.eventType)) + // keep slow-cycle even if severity is info, otherwise require warning/critical/error + .filter((e) => e.eventType === "slow-cycle" || ["warning", "critical", "error"].includes(e.severity)) + .slice(0, 30); + + +const rawCycles = await prisma.machineCycle.findMany({ + where: { orgId: session.orgId, machineId }, + orderBy: { ts: "desc" }, + take: 200, + select: { + ts: true, + cycleCount: true, + actualCycleTime: true, + theoreticalCycleTime: true, + workOrderId: true, + sku: true, + }, +}); + +// chart-friendly: oldest -> newest + numeric timestamps +const cycles = rawCycles + .slice() + .reverse() + .map((c) => ({ + ts: c.ts, // keep Date for “time ago” UI + t: c.ts.getTime(), // numeric x-axis for charts + cycleCount: c.cycleCount ?? null, + actual: c.actualCycleTime, // rename to what chart expects + ideal: c.theoreticalCycleTime ?? null, + workOrderId: c.workOrderId ?? null, + sku: c.sku ?? null, + } +)); + +const latestKpi = machine.kpiSnapshots[0] ?? null; + +// rawCycles is ordered DESC, so [0] is the most recent cycle row +const latestCycleIdeal = rawCycles[0]?.theoreticalCycleTime ?? null; + +// REAL effective value (not mock): prefer KPI if present, else fallback to cycles table +const effectiveCycleTime = latestKpi?.cycleTime ?? latestCycleIdeal ?? null; + + + + return NextResponse.json({ ok: true, machine: { @@ -72,7 +224,14 @@ export async function GET( location: machine.location, latestHeartbeat: machine.heartbeats[0] ?? null, latestKpi: machine.kpiSnapshots[0] ?? null, + effectiveCycleTime + }, events, + cycles }); + } + + + diff --git a/app/globals.css b/app/globals.css index a2dc41e..697ca12 100644 --- a/app/globals.css +++ b/app/globals.css @@ -24,3 +24,8 @@ body { color: var(--foreground); font-family: Arial, Helvetica, sans-serif; } + +/* Hide scrollbar but keep scrolling */ +.no-scrollbar::-webkit-scrollbar { display: none; } +.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } + diff --git a/package-lock.json b/package-lock.json index ae5d943..0e21818 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "lucide-react": "^0.561.0", "next": "16.0.10", "react": "19.2.1", - "react-dom": "19.2.1" + "react-dom": "19.2.1", + "recharts": "^3.6.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1316,6 +1317,42 @@ "@prisma/debug": "6.19.1" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz", + "integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1327,7 +1364,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@swc/helpers": { @@ -1631,6 +1673,69 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1666,7 +1771,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1682,6 +1787,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.50.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz", @@ -2514,9 +2625,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.7", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz", - "integrity": "sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==", + "version": "2.9.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.9.tgz", + "integrity": "sha512-V8fbOCSeOFvlDj7LLChUcqbZrdKD9RU/VR260piF1790vT0mfLSwGc/Qzxv3IqiTukOpNtItePa0HBpMAj7MDg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2753,6 +2864,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2823,9 +2943,130 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2905,6 +3146,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3249,6 +3496,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz", + "integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3696,6 +3953,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -4200,6 +4463,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4242,6 +4515,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5845,9 +6127,31 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -5862,6 +6166,51 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/recharts": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz", + "integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5906,6 +6255,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6495,6 +6850,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -6802,9 +7163,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", - "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -6842,6 +7203,37 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6978,9 +7370,9 @@ } }, "node_modules/zod": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.0.tgz", - "integrity": "sha512-Bd5fw9wlIhtqCCxotZgdTOMwGm1a0u75wARVEY9HMs1X17trvA/lMi4+MGK5EUfYkXVTbX8UDiDKW4OgzHVUZw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", "funding": { diff --git a/package.json b/package.json index f30a4a2..0d0f9db 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "lucide-react": "^0.561.0", "next": "16.0.10", "react": "19.2.1", - "react-dom": "19.2.1" + "react-dom": "19.2.1", + "recharts": "^3.6.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/prisma/migrations/20251218153109_add_machine_cycles/migration.sql b/prisma/migrations/20251218153109_add_machine_cycles/migration.sql new file mode 100644 index 0000000..ae4404e --- /dev/null +++ b/prisma/migrations/20251218153109_add_machine_cycles/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable +CREATE TABLE "MachineCycle" ( + "id" TEXT NOT NULL, + "orgId" TEXT NOT NULL, + "machineId" TEXT NOT NULL, + "ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "cycleCount" INTEGER, + "actualCycleTime" DOUBLE PRECISION NOT NULL, + "theoreticalCycleTime" DOUBLE PRECISION, + "workOrderId" TEXT, + "sku" TEXT, + "cavities" INTEGER, + "goodDelta" INTEGER, + "scrapDelta" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MachineCycle_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "MachineCycle_orgId_machineId_ts_idx" ON "MachineCycle"("orgId", "machineId", "ts"); + +-- CreateIndex +CREATE INDEX "MachineCycle_orgId_machineId_cycleCount_idx" ON "MachineCycle"("orgId", "machineId", "cycleCount"); + +-- AddForeignKey +ALTER TABLE "MachineCycle" ADD CONSTRAINT "MachineCycle_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c17c1ee..84d4369 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -81,6 +81,8 @@ model Machine { heartbeats MachineHeartbeat[] kpiSnapshots MachineKpiSnapshot[] events MachineEvent[] + cycles MachineCycle[] + @@unique([orgId, name]) @@index([orgId]) @@ -161,3 +163,27 @@ model MachineEvent { @@index([orgId, machineId, ts]) @@index([orgId, machineId, eventType, ts]) } +model MachineCycle { + id String @id @default(uuid()) + orgId String + machineId String + ts DateTime @default(now()) + + cycleCount Int? + actualCycleTime Float + theoreticalCycleTime Float? + + workOrderId String? + sku String? + + cavities Int? + goodDelta Int? + scrapDelta Int? + + createdAt DateTime @default(now()) + + machine Machine @relation(fields: [machineId], references: [id]) + @@index([orgId, machineId, ts]) + @@index([orgId, machineId, cycleCount]) +} +