All pages active
This commit is contained in:
533
app/(app)/overview/page.tsx
Normal file
533
app/(app)/overview/page.tsx
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
type Heartbeat = {
|
||||||
|
ts: string;
|
||||||
|
status: string;
|
||||||
|
message?: string | null;
|
||||||
|
ip?: string | null;
|
||||||
|
fwVersion?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Kpi = {
|
||||||
|
ts: string;
|
||||||
|
oee?: number | null;
|
||||||
|
availability?: number | null;
|
||||||
|
performance?: number | null;
|
||||||
|
quality?: number | null;
|
||||||
|
workOrderId?: string | null;
|
||||||
|
sku?: string | null;
|
||||||
|
good?: number | null;
|
||||||
|
scrap?: number | null;
|
||||||
|
target?: number | null;
|
||||||
|
cycleTime?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MachineRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
latestHeartbeat: Heartbeat | null;
|
||||||
|
latestKpi?: Kpi | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EventRow = {
|
||||||
|
id: string;
|
||||||
|
ts: string;
|
||||||
|
topic?: string;
|
||||||
|
eventType: string;
|
||||||
|
severity: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
requiresAck: boolean;
|
||||||
|
machineId?: string;
|
||||||
|
machineName?: string;
|
||||||
|
source: "ingested" | "derived";
|
||||||
|
};
|
||||||
|
|
||||||
|
type CycleRow = {
|
||||||
|
ts: string;
|
||||||
|
t: number;
|
||||||
|
cycleCount: number | null;
|
||||||
|
actual: number;
|
||||||
|
ideal: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OFFLINE_MS = 15000;
|
||||||
|
const EVENT_WINDOW_SEC = 1800;
|
||||||
|
const MAX_EVENT_MACHINES = 6;
|
||||||
|
const TOL = 0.10;
|
||||||
|
|
||||||
|
function secondsAgo(ts?: string) {
|
||||||
|
if (!ts) return "never";
|
||||||
|
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
|
||||||
|
if (diff < 60) return `${diff}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOffline(ts?: string) {
|
||||||
|
if (!ts) return true;
|
||||||
|
return Date.now() - new Date(ts).getTime() > OFFLINE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 severityClass(sev?: string) {
|
||||||
|
const s = (sev ?? "").toLowerCase();
|
||||||
|
if (s === "critical") return "bg-red-500/15 text-red-300";
|
||||||
|
if (s === "warning") return "bg-yellow-500/15 text-yellow-300";
|
||||||
|
if (s === "info") return "bg-blue-500/15 text-blue-300";
|
||||||
|
return "bg-white/10 text-zinc-200";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceClass(src: EventRow["source"]) {
|
||||||
|
return src === "ingested"
|
||||||
|
? "bg-white/10 text-zinc-200"
|
||||||
|
: "bg-emerald-500/15 text-emerald-300";
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyDerivedEvent(c: CycleRow) {
|
||||||
|
if (c.ideal == null || c.ideal <= 0 || c.actual <= 0) return null;
|
||||||
|
if (c.actual <= c.ideal * (1 + TOL)) return null;
|
||||||
|
const extra = c.actual - c.ideal;
|
||||||
|
let eventType = "slow-cycle";
|
||||||
|
let severity = "warning";
|
||||||
|
if (extra <= 1) {
|
||||||
|
eventType = "slow-cycle";
|
||||||
|
severity = "info";
|
||||||
|
} else if (extra <= 10) {
|
||||||
|
eventType = "microstop";
|
||||||
|
severity = "warning";
|
||||||
|
} else {
|
||||||
|
eventType = "macrostop";
|
||||||
|
severity = "critical";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventType,
|
||||||
|
severity,
|
||||||
|
title:
|
||||||
|
eventType === "macrostop"
|
||||||
|
? "Macrostop Detected"
|
||||||
|
: eventType === "microstop"
|
||||||
|
? "Microstop Detected"
|
||||||
|
: "Slow Cycle Detected",
|
||||||
|
description: `Cycle ${c.actual.toFixed(2)}s (ideal ${c.ideal.toFixed(2)}s)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OverviewPage() {
|
||||||
|
const [machines, setMachines] = useState<MachineRow[]>([]);
|
||||||
|
const [events, setEvents] = useState<EventRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [eventsLoading, setEventsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/machines", { cache: "no-store" });
|
||||||
|
const json = await res.json();
|
||||||
|
if (!alive) return;
|
||||||
|
setMachines(json.machines ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
} catch {
|
||||||
|
if (!alive) return;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
const t = setInterval(load, 15000);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(t);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!machines.length) {
|
||||||
|
setEvents([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let alive = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function loadEvents() {
|
||||||
|
setEventsLoading(true);
|
||||||
|
|
||||||
|
const sorted = [...machines].sort((a, b) => {
|
||||||
|
const at = a.latestHeartbeat?.ts ? new Date(a.latestHeartbeat.ts).getTime() : 0;
|
||||||
|
const bt = b.latestHeartbeat?.ts ? new Date(b.latestHeartbeat.ts).getTime() : 0;
|
||||||
|
return bt - at;
|
||||||
|
});
|
||||||
|
|
||||||
|
const targets = sorted.slice(0, MAX_EVENT_MACHINES);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
targets.map(async (m) => {
|
||||||
|
const res = await fetch(`/api/machines/${m.id}?windowSec=${EVENT_WINDOW_SEC}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
return { machine: m, payload: json };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!alive) return;
|
||||||
|
|
||||||
|
const combined: EventRow[] = [];
|
||||||
|
for (const { machine, payload } of results) {
|
||||||
|
const ingested = Array.isArray(payload?.events) ? payload.events : [];
|
||||||
|
for (const e of ingested) {
|
||||||
|
if (!e?.ts) continue;
|
||||||
|
combined.push({
|
||||||
|
...e,
|
||||||
|
machineId: machine.id,
|
||||||
|
machineName: machine.name,
|
||||||
|
source: "ingested",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycles: CycleRow[] = Array.isArray(payload?.cycles) ? payload.cycles : [];
|
||||||
|
for (const c of cycles.slice(-120)) {
|
||||||
|
const derived = classifyDerivedEvent(c);
|
||||||
|
if (!derived) continue;
|
||||||
|
combined.push({
|
||||||
|
id: `derived-${machine.id}-${c.t}`,
|
||||||
|
ts: c.ts,
|
||||||
|
topic: derived.eventType,
|
||||||
|
eventType: derived.eventType,
|
||||||
|
severity: derived.severity,
|
||||||
|
title: derived.title,
|
||||||
|
description: derived.description,
|
||||||
|
requiresAck: false,
|
||||||
|
machineId: machine.id,
|
||||||
|
machineName: machine.name,
|
||||||
|
source: "derived",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped = combined.filter((e) => {
|
||||||
|
const key = `${e.machineId ?? ""}-${e.eventType}-${e.ts}-${e.title}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
deduped.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
|
||||||
|
setEvents(deduped.slice(0, 30));
|
||||||
|
} catch {
|
||||||
|
if (!alive) return;
|
||||||
|
setEvents([]);
|
||||||
|
} finally {
|
||||||
|
if (alive) setEventsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEvents();
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [machines]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
for (const m of machines) {
|
||||||
|
const hb = m.latestHeartbeat;
|
||||||
|
const offline = isOffline(hb?.ts);
|
||||||
|
if (!offline) online += 1;
|
||||||
|
|
||||||
|
const status = (hb?.status ?? "").toUpperCase();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (k?.availability != null) {
|
||||||
|
availSum += Number(k.availability);
|
||||||
|
availCount += 1;
|
||||||
|
}
|
||||||
|
if (k?.performance != null) {
|
||||||
|
perfSum += Number(k.performance);
|
||||||
|
perfCount += 1;
|
||||||
|
}
|
||||||
|
if (k?.quality != null) {
|
||||||
|
qualSum += Number(k.quality);
|
||||||
|
qualCount += 1;
|
||||||
|
}
|
||||||
|
if (k?.good != null) goodSum += Number(k.good);
|
||||||
|
if (k?.scrap != null) scrapSum += Number(k.scrap);
|
||||||
|
if (k?.target != null) targetSum += Number(k.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
scrapSum,
|
||||||
|
targetSum,
|
||||||
|
};
|
||||||
|
}, [machines]);
|
||||||
|
|
||||||
|
const attention = useMemo(() => {
|
||||||
|
const list = machines
|
||||||
|
.map((m) => {
|
||||||
|
const hb = m.latestHeartbeat;
|
||||||
|
const offline = isOffline(hb?.ts);
|
||||||
|
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-6">
|
||||||
|
<div className="mb-6 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-white">Overview</h1>
|
||||||
|
<p className="text-sm text-zinc-400">Fleet pulse, alerts, and top attention items.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/machines"
|
||||||
|
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
View Machines
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="mb-4 text-sm text-zinc-400">Loading overview...</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">Fleet Health</div>
|
||||||
|
<div className="mt-2 text-3xl font-semibold text-white">{stats.total}</div>
|
||||||
|
<div className="mt-2 text-xs text-zinc-400">Machines total</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">
|
||||||
|
Online {stats.online}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-white/10 px-2 py-0.5 text-zinc-300">
|
||||||
|
Offline {stats.offline}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-emerald-200">
|
||||||
|
Run {stats.running}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
|
||||||
|
Idle {stats.idle}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-red-500/15 px-2 py-0.5 text-red-300">
|
||||||
|
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">Production Totals</div>
|
||||||
|
<div className="mt-2 grid grid-cols-3 gap-3">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="text-[11px] text-zinc-400">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">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">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">Sum of latest KPIs across machines.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="text-xs text-zinc-400">Activity Feed</div>
|
||||||
|
<div className="mt-2 text-3xl font-semibold text-white">{events.length}</div>
|
||||||
|
<div className="mt-2 text-xs text-zinc-400">
|
||||||
|
{eventsLoading ? "Refreshing recent events..." : "Last 30 merged events"}
|
||||||
|
</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)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{events.length === 0 && !eventsLoading ? (
|
||||||
|
<div className="text-xs text-zinc-500">No recent events.</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">OEE (avg)</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">Availability (avg)</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">Performance (avg)</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">Quality (avg)</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">Attention List</div>
|
||||||
|
<div className="text-xs text-zinc-400">{attention.length} shown</div>
|
||||||
|
</div>
|
||||||
|
{attention.length === 0 ? (
|
||||||
|
<div className="text-sm text-zinc-400">No urgent issues detected.</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(machine.latestHeartbeat?.ts)}</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 ? "OFFLINE" : "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>
|
||||||
|
|
||||||
|
<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="text-sm font-semibold text-white">Unified Timeline</div>
|
||||||
|
<div className="text-xs text-zinc-400">{events.length} items</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{events.length === 0 && !eventsLoading ? (
|
||||||
|
<div className="text-sm text-zinc-400">No events yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-[360px] space-y-3 overflow-y-auto no-scrollbar">
|
||||||
|
{events.map((e) => (
|
||||||
|
<div key={`${e.id}-${e.source}`} className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-xs ${severityClass(e.severity)}`}>
|
||||||
|
{e.severity.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-zinc-200">
|
||||||
|
{e.eventType}
|
||||||
|
</span>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-xs ${sourceClass(e.source)}`}>
|
||||||
|
{e.source}
|
||||||
|
</span>
|
||||||
|
{e.requiresAck ? (
|
||||||
|
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">ACK</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 truncate text-sm font-semibold text-white">
|
||||||
|
{e.machineName ? `${e.machineName}: ` : ""}
|
||||||
|
{e.title}
|
||||||
|
</div>
|
||||||
|
{e.description ? (
|
||||||
|
<div className="mt-1 text-sm text-zinc-300">{e.description}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-xs text-zinc-400">{secondsAgo(e.ts)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
809
app/(app)/reports/page.tsx
Normal file
809
app/(app)/reports/page.tsx
Normal file
@@ -0,0 +1,809 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
type RangeKey = "24h" | "7d" | "30d" | "custom";
|
||||||
|
|
||||||
|
type ReportSummary = {
|
||||||
|
oeeAvg: number | null;
|
||||||
|
availabilityAvg: number | null;
|
||||||
|
performanceAvg: number | null;
|
||||||
|
qualityAvg: number | null;
|
||||||
|
goodTotal: number | null;
|
||||||
|
scrapTotal: number | null;
|
||||||
|
targetTotal: number | null;
|
||||||
|
scrapRate: number | null;
|
||||||
|
topScrapSku?: string | null;
|
||||||
|
topScrapWorkOrder?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReportDowntime = {
|
||||||
|
macrostopSec: number;
|
||||||
|
microstopSec: number;
|
||||||
|
slowCycleCount: number;
|
||||||
|
qualitySpikeCount: number;
|
||||||
|
performanceDegradationCount: number;
|
||||||
|
oeeDropCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReportTrendPoint = { t: string; v: number };
|
||||||
|
|
||||||
|
type ReportPayload = {
|
||||||
|
summary: ReportSummary;
|
||||||
|
downtime: ReportDowntime;
|
||||||
|
trend: {
|
||||||
|
oee: ReportTrendPoint[];
|
||||||
|
availability: ReportTrendPoint[];
|
||||||
|
performance: ReportTrendPoint[];
|
||||||
|
quality: ReportTrendPoint[];
|
||||||
|
scrapRate: ReportTrendPoint[];
|
||||||
|
};
|
||||||
|
distribution: {
|
||||||
|
cycleTime: {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
rangeStart?: number;
|
||||||
|
rangeEnd?: number;
|
||||||
|
overflow?: "low" | "high";
|
||||||
|
minValue?: number;
|
||||||
|
maxValue?: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
insights?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MachineOption = { id: string; name: string };
|
||||||
|
type FilterOptions = { workOrders: string[]; skus: string[] };
|
||||||
|
|
||||||
|
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 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 CycleTooltip({ active, payload }: any) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const p = payload[0]?.payload;
|
||||||
|
if (!p) return null;
|
||||||
|
|
||||||
|
let detail = "";
|
||||||
|
if (p.overflow === "low") {
|
||||||
|
detail = `Below ${p.rangeEnd?.toFixed(1)}s`;
|
||||||
|
} else if (p.overflow === "high") {
|
||||||
|
detail = `Above ${p.rangeStart?.toFixed(1)}s`;
|
||||||
|
} else if (p.rangeStart != null && p.rangeEnd != null) {
|
||||||
|
detail = `${p.rangeStart.toFixed(1)}s - ${p.rangeEnd.toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extreme =
|
||||||
|
p.overflow && (p.minValue != null || p.maxValue != null)
|
||||||
|
? `Extremes: ${p.minValue?.toFixed(1) ?? "--"}s - ${p.maxValue?.toFixed(1) ?? "--"}s`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-zinc-950/95 px-4 py-3 shadow-lg">
|
||||||
|
<div className="text-sm font-semibold text-white">{p.label}</div>
|
||||||
|
<div className="mt-2 space-y-1 text-xs text-zinc-300">
|
||||||
|
<div>
|
||||||
|
Cycles: <span className="text-white">{p.count}</span>
|
||||||
|
</div>
|
||||||
|
{detail ? (
|
||||||
|
<div>
|
||||||
|
Range: <span className="text-white">{detail}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{extreme ? <div className="text-zinc-400">{extreme}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCsv(report: ReportPayload) {
|
||||||
|
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(",");
|
||||||
|
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("section,key,value");
|
||||||
|
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 }
|
||||||
|
) {
|
||||||
|
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>Report Export</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>Reports</h1>
|
||||||
|
<div class="meta">Range: ${rangeLabel} | Machine: ${filters.machine} | Work Order: ${filters.workOrder} | 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">Top Loss Drivers</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Metric</th><th>Value</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Macrostop (sec)</td><td>${downtime.macrostopSec}</td></tr>
|
||||||
|
<tr><td>Microstop (sec)</td><td>${downtime.microstopSec}</td></tr>
|
||||||
|
<tr><td>Slow Cycles</td><td>${downtime.slowCycleCount}</td></tr>
|
||||||
|
<tr><td>Quality Spikes</td><td>${downtime.qualitySpikeCount}</td></tr>
|
||||||
|
<tr><td>Performance Degradation</td><td>${downtime.performanceDegradationCount}</td></tr>
|
||||||
|
<tr><td>OEE Drops</td><td>${downtime.oeeDropCount}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top: 16px;">
|
||||||
|
<div class="label">Quality Summary</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Metric</th><th>Value</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Scrap Rate</td><td>${summary.scrapRate != null ? summary.scrapRate.toFixed(1) + "%" : "--"}</td></tr>
|
||||||
|
<tr><td>Good Total</td><td>${summary.goodTotal ?? "--"}</td></tr>
|
||||||
|
<tr><td>Scrap Total</td><td>${summary.scrapTotal ?? "--"}</td></tr>
|
||||||
|
<tr><td>Target Total</td><td>${summary.targetTotal ?? "--"}</td></tr>
|
||||||
|
<tr><td>Top Scrap SKU</td><td>${summary.topScrapSku ?? "--"}</td></tr>
|
||||||
|
<tr><td>Top Scrap Work Order</td><td>${summary.topScrapWorkOrder ?? "--"}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top: 16px;">
|
||||||
|
<div class="label">Cycle Time Distribution</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Bin</th><th>Count</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">Notes for Ops</div>
|
||||||
|
${insights.length ? `<ul>${insights.map((n) => `<li>${n}</li>`).join("")}</ul>` : "<div>None</div>"}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportsPage() {
|
||||||
|
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 "Last 24 hours";
|
||||||
|
if (range === "7d") return "Last 7 days";
|
||||||
|
if (range === "30d") return "Last 30 days";
|
||||||
|
return "Custom range";
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
setMachines((json?.machines ?? []).map((m: any) => ({ id: m.id, name: m.name })));
|
||||||
|
} catch {
|
||||||
|
if (!alive) return;
|
||||||
|
setMachines([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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" });
|
||||||
|
const json = await res.json();
|
||||||
|
if (!alive) return;
|
||||||
|
if (!res.ok || json?.ok === false) {
|
||||||
|
setError(json?.error ?? "Failed to load reports");
|
||||||
|
setReport(null);
|
||||||
|
} else {
|
||||||
|
setReport(json);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!alive) return;
|
||||||
|
setError("Network error");
|
||||||
|
setReport(null);
|
||||||
|
} finally {
|
||||||
|
if (alive) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMachines();
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [range, machineId, workOrderId, sku]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
|
||||||
|
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" });
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}, [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 machineLabel = useMemo(() => {
|
||||||
|
if (!machineId) return "All machines";
|
||||||
|
return machines.find((m) => m.id === machineId)?.name ?? machineId;
|
||||||
|
}, [machineId, machines]);
|
||||||
|
|
||||||
|
const workOrderLabel = workOrderId || "All work orders";
|
||||||
|
const skuLabel = sku || "All SKUs";
|
||||||
|
|
||||||
|
const handleExportCsv = () => {
|
||||||
|
if (!report) return;
|
||||||
|
const csv = buildCsv(report);
|
||||||
|
downloadText("reports.csv", csv);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportPdf = () => {
|
||||||
|
if (!report) return;
|
||||||
|
const html = buildPdfHtml(report, rangeLabel, {
|
||||||
|
machine: machineLabel,
|
||||||
|
workOrder: workOrderLabel,
|
||||||
|
sku: skuLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
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-6">
|
||||||
|
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-white">Reports</h1>
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
Trends, downtime, and quality analytics across machines.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExportPdf}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Export PDF
|
||||||
|
</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">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">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">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="">All machines</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">Work Order</div>
|
||||||
|
<input
|
||||||
|
list="work-order-list"
|
||||||
|
value={workOrderId}
|
||||||
|
onChange={(e) => setWorkOrderId(e.target.value)}
|
||||||
|
placeholder="All work orders"
|
||||||
|
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">SKU</div>
|
||||||
|
<input
|
||||||
|
list="sku-list"
|
||||||
|
value={sku}
|
||||||
|
onChange={(e) => setSku(e.target.value)}
|
||||||
|
placeholder="All SKUs"
|
||||||
|
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">Loading reports...</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 ? "Computed from KPI snapshots." : "No data in selected range."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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-2 text-sm font-semibold text-white">OEE Trend</div>
|
||||||
|
<div className="h-[260px] rounded-2xl border border-white/10 bg-black/25 p-4">
|
||||||
|
{oeeSeries.length ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={oeeSeries}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" />
|
||||||
|
<XAxis dataKey="label" tick={{ fill: "#a1a1aa" }} />
|
||||||
|
<YAxis domain={[0, 100]} tick={{ fill: "#a1a1aa" }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: "rgba(0,0,0,0.85)", border: "1px solid rgba(255,255,255,0.1)" }}
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const row = payload?.[0]?.payload;
|
||||||
|
return row?.ts ? new Date(row.ts).toLocaleString() : "";
|
||||||
|
}}
|
||||||
|
formatter={(val: any) => [`${Number(val).toFixed(1)}%`, "OEE"]}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="value" stroke="#34d399" dot={false} strokeWidth={2} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||||
|
No trend data yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="mb-2 text-sm font-semibold text-white">Downtime Pareto</div>
|
||||||
|
<div className="h-[260px] rounded-2xl border border-white/10 bg-black/25 p-4">
|
||||||
|
{downtimeSeries.length ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={downtimeSeries}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" />
|
||||||
|
<XAxis dataKey="name" tick={{ fill: "#a1a1aa" }} />
|
||||||
|
<YAxis tick={{ fill: "#a1a1aa" }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: "rgba(0,0,0,0.85)", border: "1px solid rgba(255,255,255,0.1)" }}
|
||||||
|
formatter={(val: any) => [`${Number(val)} min`, "Downtime"]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="value" radius={[10, 10, 0, 0]} isAnimationActive={false}>
|
||||||
|
{downtimeSeries.map((row, idx) => (
|
||||||
|
<Cell key={`${row.name}-${idx}`} fill={downtimeColors[row.name] ?? "#94a3b8"} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||||
|
No downtime data yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
<div className="mb-2 text-sm font-semibold text-white">Cycle Time Distribution</div>
|
||||||
|
<div className="h-[220px] rounded-2xl border border-white/10 bg-black/25 p-4">
|
||||||
|
{cycleHistogram.length ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={cycleHistogram}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" />
|
||||||
|
<XAxis dataKey="label" tick={{ fill: "#a1a1aa", fontSize: 10 }} />
|
||||||
|
<YAxis tick={{ fill: "#a1a1aa" }} />
|
||||||
|
<Tooltip content={<CycleTooltip />} />
|
||||||
|
<Bar dataKey="count" radius={[8, 8, 0, 0]} fill="#60a5fa" isAnimationActive={false} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||||
|
No cycle data yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="mb-2 text-sm font-semibold text-white">Scrap Trend</div>
|
||||||
|
<div className="h-[220px] rounded-2xl border border-white/10 bg-black/25 p-4">
|
||||||
|
{scrapSeries.length ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={scrapSeries}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" />
|
||||||
|
<XAxis dataKey="label" tick={{ fill: "#a1a1aa" }} />
|
||||||
|
<YAxis domain={[0, 100]} tick={{ fill: "#a1a1aa" }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: "rgba(0,0,0,0.85)", border: "1px solid rgba(255,255,255,0.1)" }}
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const row = payload?.[0]?.payload;
|
||||||
|
return row?.ts ? new Date(row.ts).toLocaleString() : "";
|
||||||
|
}}
|
||||||
|
formatter={(val: any) => [`${Number(val).toFixed(1)}%`, "Scrap Rate"]}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="value" stroke="#f97316" dot={false} strokeWidth={2} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||||
|
No scrap data yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="mb-2 text-sm font-semibold text-white">Top Loss Drivers</div>
|
||||||
|
<div className="space-y-3 text-sm text-zinc-300">
|
||||||
|
{[
|
||||||
|
{ label: "Macrostop", value: fmtDuration(downtime?.macrostopSec) },
|
||||||
|
{ label: "Microstop", value: fmtDuration(downtime?.microstopSec) },
|
||||||
|
{ label: "Slow Cycle", value: downtime ? `${downtime.slowCycleCount}` : "--" },
|
||||||
|
{ label: "Quality Spike", value: downtime ? `${downtime.qualitySpikeCount}` : "--" },
|
||||||
|
{ label: "OEE Drop", value: downtime ? `${downtime.oeeDropCount}` : "--" },
|
||||||
|
{
|
||||||
|
label: "Perf Degradation",
|
||||||
|
value: downtime ? `${downtime.performanceDegradationCount}` : "--",
|
||||||
|
},
|
||||||
|
].map((row) => (
|
||||||
|
<div key={row.label} className="flex items-center justify-between rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<span>{row.label}</span>
|
||||||
|
<span className="text-xs text-zinc-400">{row.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">Quality Summary</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">Scrap Rate</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">Top Scrap SKU</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">Top Scrap Work Order</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">Notes for Ops</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">Suggested actions</div>
|
||||||
|
{report?.insights && report.insights.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{report.insights.map((note, idx) => (
|
||||||
|
<div key={idx}>{note}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>No insights yet. Generate reports after data collection.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
app/(app)/settings/page.tsx
Normal file
200
app/(app)/settings/page.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
function Toggle({
|
||||||
|
label,
|
||||||
|
helper,
|
||||||
|
enabled,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
helper: string;
|
||||||
|
enabled: boolean;
|
||||||
|
onChange: (next: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(!enabled)}
|
||||||
|
className="flex w-full items-center justify-between gap-4 rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-left hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-white">{label}</div>
|
||||||
|
<div className="text-xs text-zinc-400">{helper}</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`h-6 w-12 rounded-full border border-white/10 p-0.5 transition ${
|
||||||
|
enabled ? "bg-emerald-500/20" : "bg-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`block h-5 w-5 rounded-full transition ${
|
||||||
|
enabled ? "translate-x-6 bg-emerald-400" : "bg-zinc-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [emailEnabled, setEmailEnabled] = useState(true);
|
||||||
|
const [smsEnabled, setSmsEnabled] = useState(false);
|
||||||
|
const [webhookEnabled, setWebhookEnabled] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold text-white">Settings</h1>
|
||||||
|
<p className="text-sm text-zinc-400">Configure alerts, shifts, and integrations.</p>
|
||||||
|
</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 xl:col-span-1">
|
||||||
|
<div className="text-sm font-semibold text-white">Organization</div>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="text-xs text-zinc-400">Plant Name</div>
|
||||||
|
<div className="mt-1 text-sm text-zinc-300">MIS Plant</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="text-xs text-zinc-400">Time Zone</div>
|
||||||
|
<div className="mt-1 text-sm text-zinc-300">America/Mexico_City</div>
|
||||||
|
</div>
|
||||||
|
<button className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10">
|
||||||
|
Edit Organization
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 gap-4">
|
||||||
|
<div className="text-sm font-semibold text-white">Alert Thresholds</div>
|
||||||
|
<div className="text-xs text-zinc-400">Applies to all machines</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
{[
|
||||||
|
{ label: "OEE Alert", value: "85%", helper: "Trigger when OEE drops below this" },
|
||||||
|
{ label: "Availability Alert", value: "85%", helper: "Low run time detection" },
|
||||||
|
{ label: "Performance Alert", value: "85%", helper: "Slow cycle detection" },
|
||||||
|
{ label: "Quality Alert", value: "95%", helper: "Scrap spike detection" },
|
||||||
|
{ label: "Microstop (sec)", value: "60s", helper: "Stop longer than this" },
|
||||||
|
{ label: "Macrostop (sec)", value: "300s", helper: "Major stop threshold" },
|
||||||
|
].map((row) => (
|
||||||
|
<div key={row.label} className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="text-xs text-zinc-400">{row.label}</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-white">{row.value}</div>
|
||||||
|
<button className="rounded-lg border border-white/10 bg-white/5 px-3 py-1 text-xs text-white hover:bg-white/10">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-zinc-500">{row.helper}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</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-2">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-4">
|
||||||
|
<div className="text-sm font-semibold text-white">Shift Schedule</div>
|
||||||
|
<div className="text-xs text-zinc-400">Used for Availability calculations</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{ label: "Shift A", time: "06:00 - 14:00", days: "Mon - Fri" },
|
||||||
|
{ label: "Shift B", time: "14:00 - 22:00", days: "Mon - Fri" },
|
||||||
|
{ label: "Shift C", time: "22:00 - 06:00", days: "Mon - Fri" },
|
||||||
|
].map((shift) => (
|
||||||
|
<div key={shift.label} className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="text-sm font-semibold text-white">{shift.label}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">{shift.time}</div>
|
||||||
|
<div className="mt-2 text-xs text-zinc-500">{shift.days}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<button className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10">
|
||||||
|
Edit Shifts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="text-sm font-semibold text-white">Notification Channels</div>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<Toggle
|
||||||
|
label="Email Alerts"
|
||||||
|
helper="Send alerts to supervisors and managers"
|
||||||
|
enabled={emailEnabled}
|
||||||
|
onChange={setEmailEnabled}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="SMS Alerts"
|
||||||
|
helper="Send critical alerts to on-call staff"
|
||||||
|
enabled={smsEnabled}
|
||||||
|
onChange={setSmsEnabled}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Webhook"
|
||||||
|
helper="POST events to external systems"
|
||||||
|
enabled={webhookEnabled}
|
||||||
|
onChange={setWebhookEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 flex items-center justify-between">
|
||||||
|
<div className="text-sm font-semibold text-white">Integrations</div>
|
||||||
|
<div className="text-xs text-zinc-400">Live endpoints</div>
|
||||||
|
</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">Webhook URL</div>
|
||||||
|
<div className="mt-1 text-sm text-white">https://hooks.example.com/iiot</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="text-xs text-zinc-400">ERP Sync</div>
|
||||||
|
<div className="mt-1 text-sm text-zinc-300">Not configured</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">Users & Roles</div>
|
||||||
|
<div className="space-y-3 text-sm text-zinc-300">
|
||||||
|
{[
|
||||||
|
{ name: "Juan Perez", role: "Plant Manager" },
|
||||||
|
{ name: "Sandra Rivera", role: "Supervisor" },
|
||||||
|
{ name: "Maintenance", role: "Technician" },
|
||||||
|
].map((user) => (
|
||||||
|
<div key={user.name} className="flex items-center justify-between rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-white">{user.name}</div>
|
||||||
|
<div className="text-xs text-zinc-400">{user.role}</div>
|
||||||
|
</div>
|
||||||
|
<button className="rounded-lg border border-white/10 bg-white/5 px-3 py-1 text-xs text-white hover:bg-white/10">
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<button className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10">
|
||||||
|
Invite User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,14 +33,34 @@ export async function GET() {
|
|||||||
take: 1,
|
take: 1,
|
||||||
select: { ts: true, status: true, message: true, ip: true, fwVersion: true },
|
select: { ts: true, status: true, message: true, ip: true, fwVersion: true },
|
||||||
},
|
},
|
||||||
|
kpiSnapshots: {
|
||||||
|
orderBy: { ts: "desc" },
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
ts: true,
|
||||||
|
oee: true,
|
||||||
|
availability: true,
|
||||||
|
performance: true,
|
||||||
|
quality: true,
|
||||||
|
workOrderId: true,
|
||||||
|
sku: true,
|
||||||
|
good: true,
|
||||||
|
scrap: true,
|
||||||
|
target: true,
|
||||||
|
cycleTime: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// flatten latest heartbeat for UI convenience
|
// flatten latest heartbeat for UI convenience
|
||||||
const out = machines.map((m) => ({
|
const out = machines.map((m) => ({
|
||||||
...m,
|
...m,
|
||||||
latestHeartbeat: m.heartbeats[0] ?? null,
|
latestHeartbeat: m.heartbeats[0] ?? null,
|
||||||
|
latestKpi: m.kpiSnapshots[0] ?? null,
|
||||||
heartbeats: undefined,
|
heartbeats: undefined,
|
||||||
|
kpiSnapshots: undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, machines: out });
|
return NextResponse.json({ ok: true, machines: out });
|
||||||
|
|||||||
65
app/api/reports/filters/route.ts
Normal file
65
app/api/reports/filters/route.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
|
|
||||||
|
const RANGE_MS: Record<string, number> = {
|
||||||
|
"24h": 24 * 60 * 60 * 1000,
|
||||||
|
"7d": 7 * 24 * 60 * 60 * 1000,
|
||||||
|
"30d": 30 * 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 pickRange(req: NextRequest) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const range = url.searchParams.get("range") ?? "24h";
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (range === "custom") {
|
||||||
|
const start = parseDate(url.searchParams.get("start")) ?? new Date(now.getTime() - RANGE_MS["24h"]);
|
||||||
|
const end = parseDate(url.searchParams.get("end")) ?? now;
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ms = RANGE_MS[range] ?? RANGE_MS["24h"];
|
||||||
|
return { start: new Date(now.getTime() - ms), end: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 machineId = url.searchParams.get("machineId") ?? undefined;
|
||||||
|
const { start, end } = pickRange(req);
|
||||||
|
|
||||||
|
const baseWhere = {
|
||||||
|
orgId: session.orgId,
|
||||||
|
...(machineId ? { machineId } : {}),
|
||||||
|
ts: { gte: start, lte: end },
|
||||||
|
};
|
||||||
|
|
||||||
|
const workOrderRows = await prisma.machineCycle.findMany({
|
||||||
|
where: { ...baseWhere, workOrderId: { not: null } },
|
||||||
|
distinct: ["workOrderId"],
|
||||||
|
select: { workOrderId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const skuRows = await prisma.machineCycle.findMany({
|
||||||
|
where: { ...baseWhere, sku: { not: null } },
|
||||||
|
distinct: ["sku"],
|
||||||
|
select: { sku: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const workOrders = workOrderRows.map((r) => r.workOrderId).filter(Boolean) as string[];
|
||||||
|
const skus = skuRows.map((r) => r.sku).filter(Boolean) as string[];
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, workOrders, skus });
|
||||||
|
}
|
||||||
368
app/api/reports/route.ts
Normal file
368
app/api/reports/route.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
|
|
||||||
|
const RANGE_MS: Record<string, number> = {
|
||||||
|
"24h": 24 * 60 * 60 * 1000,
|
||||||
|
"7d": 7 * 24 * 60 * 60 * 1000,
|
||||||
|
"30d": 30 * 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 pickRange(req: NextRequest) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const range = url.searchParams.get("range") ?? "24h";
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (range === "custom") {
|
||||||
|
const start = parseDate(url.searchParams.get("start")) ?? new Date(now.getTime() - RANGE_MS["24h"]);
|
||||||
|
const end = parseDate(url.searchParams.get("end")) ?? now;
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ms = RANGE_MS[range] ?? RANGE_MS["24h"];
|
||||||
|
return { start: new Date(now.getTime() - ms), end: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeNum(v: unknown) {
|
||||||
|
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 machineId = url.searchParams.get("machineId") ?? undefined;
|
||||||
|
const { start, end } = pickRange(req);
|
||||||
|
const workOrderId = url.searchParams.get("workOrderId") ?? undefined;
|
||||||
|
const sku = url.searchParams.get("sku") ?? undefined;
|
||||||
|
const baseWhere = {
|
||||||
|
orgId: session.orgId,
|
||||||
|
...(machineId ? { machineId } : {}),
|
||||||
|
...(workOrderId ? { workOrderId } : {}),
|
||||||
|
...(sku ? { sku } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const kpiRows = await prisma.machineKpiSnapshot.findMany({
|
||||||
|
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||||
|
orderBy: { ts: "asc" },
|
||||||
|
select: {
|
||||||
|
ts: true,
|
||||||
|
oee: true,
|
||||||
|
availability: true,
|
||||||
|
performance: true,
|
||||||
|
quality: true,
|
||||||
|
good: true,
|
||||||
|
scrap: true,
|
||||||
|
target: true,
|
||||||
|
machineId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let oeeSum = 0;
|
||||||
|
let oeeCount = 0;
|
||||||
|
let availSum = 0;
|
||||||
|
let availCount = 0;
|
||||||
|
let perfSum = 0;
|
||||||
|
let perfCount = 0;
|
||||||
|
let qualSum = 0;
|
||||||
|
let qualCount = 0;
|
||||||
|
|
||||||
|
for (const k of kpiRows) {
|
||||||
|
if (safeNum(k.oee) != null) {
|
||||||
|
oeeSum += Number(k.oee);
|
||||||
|
oeeCount += 1;
|
||||||
|
}
|
||||||
|
if (safeNum(k.availability) != null) {
|
||||||
|
availSum += Number(k.availability);
|
||||||
|
availCount += 1;
|
||||||
|
}
|
||||||
|
if (safeNum(k.performance) != null) {
|
||||||
|
perfSum += Number(k.performance);
|
||||||
|
perfCount += 1;
|
||||||
|
}
|
||||||
|
if (safeNum(k.quality) != null) {
|
||||||
|
qualSum += Number(k.quality);
|
||||||
|
qualCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycles = await prisma.machineCycle.findMany({
|
||||||
|
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||||
|
select: { goodDelta: true, scrapDelta: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let goodTotal = 0;
|
||||||
|
let scrapTotal = 0;
|
||||||
|
|
||||||
|
for (const c of cycles) {
|
||||||
|
if (safeNum(c.goodDelta) != null) goodTotal += Number(c.goodDelta);
|
||||||
|
if (safeNum(c.scrapDelta) != null) scrapTotal += Number(c.scrapDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpiAgg = await prisma.machineKpiSnapshot.groupBy({
|
||||||
|
by: ["machineId"],
|
||||||
|
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||||
|
_max: { good: true, scrap: true, target: true },
|
||||||
|
_min: { good: true, scrap: true },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let targetTotal = 0;
|
||||||
|
if (goodTotal === 0 && scrapTotal === 0) {
|
||||||
|
let goodFallback = 0;
|
||||||
|
let scrapFallback = 0;
|
||||||
|
|
||||||
|
for (const row of kpiAgg) {
|
||||||
|
const count = row._count._all ?? 0;
|
||||||
|
const maxGood = safeNum(row._max.good);
|
||||||
|
const minGood = safeNum(row._min.good);
|
||||||
|
const maxScrap = safeNum(row._max.scrap);
|
||||||
|
const minScrap = safeNum(row._min.scrap);
|
||||||
|
|
||||||
|
if (count > 1 && maxGood != null && minGood != null) {
|
||||||
|
goodFallback += Math.max(0, maxGood - minGood);
|
||||||
|
} else if (maxGood != null) {
|
||||||
|
goodFallback += maxGood;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 1 && maxScrap != null && minScrap != null) {
|
||||||
|
scrapFallback += Math.max(0, maxScrap - minScrap);
|
||||||
|
} else if (maxScrap != null) {
|
||||||
|
scrapFallback += maxScrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
goodTotal = goodFallback;
|
||||||
|
scrapTotal = scrapFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of kpiAgg) {
|
||||||
|
const maxTarget = safeNum(row._max.target);
|
||||||
|
if (maxTarget != null) targetTotal += maxTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await prisma.machineEvent.findMany({
|
||||||
|
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||||
|
select: { eventType: true, data: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let macrostopSec = 0;
|
||||||
|
let microstopSec = 0;
|
||||||
|
let slowCycleCount = 0;
|
||||||
|
let qualitySpikeCount = 0;
|
||||||
|
let performanceDegradationCount = 0;
|
||||||
|
let oeeDropCount = 0;
|
||||||
|
|
||||||
|
for (const e of events) {
|
||||||
|
const type = String(e.eventType ?? "").toLowerCase();
|
||||||
|
let blob: any = e.data;
|
||||||
|
|
||||||
|
if (typeof blob === "string") {
|
||||||
|
try {
|
||||||
|
blob = JSON.parse(blob);
|
||||||
|
} catch {
|
||||||
|
blob = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner = blob?.data ?? blob ?? {};
|
||||||
|
const stopSec =
|
||||||
|
(typeof inner?.stoppage_duration_seconds === "number" && inner.stoppage_duration_seconds) ||
|
||||||
|
(typeof inner?.stop_duration_seconds === "number" && inner.stop_duration_seconds) ||
|
||||||
|
0;
|
||||||
|
|
||||||
|
if (type === "macrostop") macrostopSec += Number(stopSec) || 0;
|
||||||
|
else if (type === "microstop") microstopSec += Number(stopSec) || 0;
|
||||||
|
else if (type === "slow-cycle") slowCycleCount += 1;
|
||||||
|
else if (type === "quality-spike") qualitySpikeCount += 1;
|
||||||
|
else if (type === "performance-degradation") performanceDegradationCount += 1;
|
||||||
|
else if (type === "oee-drop") oeeDropCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrendPoint = { t: string; v: number };
|
||||||
|
|
||||||
|
const trend: {
|
||||||
|
oee: TrendPoint[];
|
||||||
|
availability: TrendPoint[];
|
||||||
|
performance: TrendPoint[];
|
||||||
|
quality: TrendPoint[];
|
||||||
|
scrapRate: TrendPoint[];
|
||||||
|
} = {
|
||||||
|
oee: [],
|
||||||
|
availability: [],
|
||||||
|
performance: [],
|
||||||
|
quality: [],
|
||||||
|
scrapRate: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const k of kpiRows) {
|
||||||
|
const t = k.ts.toISOString();
|
||||||
|
if (safeNum(k.oee) != null) trend.oee.push({ t, v: Number(k.oee) });
|
||||||
|
if (safeNum(k.availability) != null) trend.availability.push({ t, v: Number(k.availability) });
|
||||||
|
if (safeNum(k.performance) != null) trend.performance.push({ t, v: Number(k.performance) });
|
||||||
|
if (safeNum(k.quality) != null) trend.quality.push({ t, v: Number(k.quality) });
|
||||||
|
|
||||||
|
const good = safeNum(k.good);
|
||||||
|
const scrap = safeNum(k.scrap);
|
||||||
|
if (good != null && scrap != null && good + scrap > 0) {
|
||||||
|
trend.scrapRate.push({ t, v: (scrap / (good + scrap)) * 100 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cycleRows = await prisma.machineCycle.findMany({
|
||||||
|
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||||
|
select: { actualCycleTime: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const values = cycleRows
|
||||||
|
.map((c) => Number(c.actualCycleTime))
|
||||||
|
.filter((v) => Number.isFinite(v) && v > 0)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
let cycleTimeBins: {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
rangeStart?: number;
|
||||||
|
rangeEnd?: number;
|
||||||
|
overflow?: "low" | "high";
|
||||||
|
minValue?: number;
|
||||||
|
maxValue?: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
if (values.length) {
|
||||||
|
const pct = (p: number) => {
|
||||||
|
const idx = Math.max(0, Math.min(values.length - 1, Math.floor(p * (values.length - 1))));
|
||||||
|
return values[idx];
|
||||||
|
};
|
||||||
|
|
||||||
|
const p5 = pct(0.05);
|
||||||
|
const p95 = pct(0.95);
|
||||||
|
|
||||||
|
const inRange = values.filter((v) => v >= p5 && v <= p95);
|
||||||
|
const low = values.filter((v) => v < p5);
|
||||||
|
const high = values.filter((v) => v > p95);
|
||||||
|
|
||||||
|
const binCount = 10;
|
||||||
|
const span = Math.max(0.1, p95 - p5);
|
||||||
|
const step = span / binCount;
|
||||||
|
|
||||||
|
const counts = new Array(binCount).fill(0);
|
||||||
|
for (const v of inRange) {
|
||||||
|
const idx = Math.min(binCount - 1, Math.floor((v - p5) / step));
|
||||||
|
counts[idx] += 1;
|
||||||
|
}
|
||||||
|
const decimals = step < 0.1 ? 2 : step < 1 ? 1 : 0;
|
||||||
|
|
||||||
|
cycleTimeBins = counts.map((count, i) => {
|
||||||
|
const a = p5 + step * i;
|
||||||
|
const b = p5 + step * (i + 1);
|
||||||
|
return {
|
||||||
|
label: `${a.toFixed(decimals)}-${b.toFixed(decimals)}s`,
|
||||||
|
count,
|
||||||
|
rangeStart: a,
|
||||||
|
rangeEnd: b,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
if (low.length) {
|
||||||
|
cycleTimeBins.unshift({
|
||||||
|
label: `< ${p5.toFixed(1)}s`,
|
||||||
|
count: low.length,
|
||||||
|
rangeEnd: p5,
|
||||||
|
overflow: "low",
|
||||||
|
minValue: low[0],
|
||||||
|
maxValue: low[low.length - 1],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (high.length) {
|
||||||
|
cycleTimeBins.push({
|
||||||
|
label: `> ${p95.toFixed(1)}s`,
|
||||||
|
count: high.length,
|
||||||
|
rangeStart: p95,
|
||||||
|
overflow: "high",
|
||||||
|
minValue: high[0],
|
||||||
|
maxValue: high[high.length - 1],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const scrapRate =
|
||||||
|
goodTotal + scrapTotal > 0 ? (scrapTotal / (goodTotal + scrapTotal)) * 100 : null;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// top scrap SKU / work order (from cycles)
|
||||||
|
const scrapBySku = new Map<string, number>();
|
||||||
|
const scrapByWo = new Map<string, number>();
|
||||||
|
|
||||||
|
const scrapRows = await prisma.machineCycle.findMany({
|
||||||
|
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||||
|
select: { sku: true, workOrderId: true, scrapDelta: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const row of scrapRows) {
|
||||||
|
const scrap = safeNum(row.scrapDelta);
|
||||||
|
if (scrap == null || scrap <= 0) continue;
|
||||||
|
if (row.sku) scrapBySku.set(row.sku, (scrapBySku.get(row.sku) ?? 0) + scrap);
|
||||||
|
if (row.workOrderId) scrapByWo.set(row.workOrderId, (scrapByWo.get(row.workOrderId) ?? 0) + scrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
const topScrapSku = [...scrapBySku.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
||||||
|
const topScrapWorkOrder = [...scrapByWo.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
||||||
|
|
||||||
|
const oeeAvg = oeeCount ? oeeSum / oeeCount : null;
|
||||||
|
const availabilityAvg = availCount ? availSum / availCount : null;
|
||||||
|
const performanceAvg = perfCount ? perfSum / perfCount : null;
|
||||||
|
const qualityAvg = qualCount ? qualSum / qualCount : null;
|
||||||
|
|
||||||
|
// insights
|
||||||
|
const insights: string[] = [];
|
||||||
|
if (scrapRate != null && scrapRate > 5) insights.push(`Scrap rate is ${scrapRate.toFixed(1)}% (above 5%).`);
|
||||||
|
if (performanceAvg != null && performanceAvg < 85) insights.push("Performance below 85%.");
|
||||||
|
if (availabilityAvg != null && availabilityAvg < 85) insights.push("Availability below 85%.");
|
||||||
|
if (oeeAvg != null && oeeAvg < 85) insights.push("OEE below 85%.");
|
||||||
|
if (macrostopSec > 1800) insights.push("Macrostop time exceeds 30 minutes in this range.");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
summary: {
|
||||||
|
oeeAvg,
|
||||||
|
availabilityAvg,
|
||||||
|
performanceAvg,
|
||||||
|
qualityAvg,
|
||||||
|
goodTotal,
|
||||||
|
scrapTotal,
|
||||||
|
targetTotal,
|
||||||
|
scrapRate,
|
||||||
|
topScrapSku,
|
||||||
|
topScrapWorkOrder,
|
||||||
|
},
|
||||||
|
|
||||||
|
downtime: {
|
||||||
|
macrostopSec,
|
||||||
|
microstopSec,
|
||||||
|
slowCycleCount,
|
||||||
|
qualitySpikeCount,
|
||||||
|
performanceDegradationCount,
|
||||||
|
oeeDropCount,
|
||||||
|
},
|
||||||
|
trend,
|
||||||
|
insights,
|
||||||
|
distribution: {
|
||||||
|
cycleTime: cycleTimeBins
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
331
app/api/settings/machines/[machineId]/route.ts
Normal file
331
app/api/settings/machines/[machineId]/route.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
|
import {
|
||||||
|
DEFAULT_ALERTS,
|
||||||
|
DEFAULT_DEFAULTS,
|
||||||
|
DEFAULT_SHIFT,
|
||||||
|
applyOverridePatch,
|
||||||
|
buildSettingsPayload,
|
||||||
|
deepMerge,
|
||||||
|
validateDefaults,
|
||||||
|
validateShiftFields,
|
||||||
|
validateShiftSchedule,
|
||||||
|
validateThresholds,
|
||||||
|
} from "@/lib/settings";
|
||||||
|
|
||||||
|
function isPlainObject(value: any): value is Record<string, any> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAllowedOverrides(raw: any) {
|
||||||
|
if (!isPlainObject(raw)) return {};
|
||||||
|
const out: Record<string, any> = {};
|
||||||
|
for (const key of ["shiftSchedule", "thresholds", "alerts", "defaults"]) {
|
||||||
|
if (raw[key] !== undefined) out[key] = raw[key];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, userId: string) {
|
||||||
|
let settings = await tx.orgSettings.findUnique({
|
||||||
|
where: { orgId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (settings) {
|
||||||
|
let shifts = await tx.orgShift.findMany({
|
||||||
|
where: { orgId },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
});
|
||||||
|
if (!shifts.length) {
|
||||||
|
await tx.orgShift.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
name: DEFAULT_SHIFT.name,
|
||||||
|
startTime: DEFAULT_SHIFT.start,
|
||||||
|
endTime: DEFAULT_SHIFT.end,
|
||||||
|
sortOrder: 1,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
shifts = await tx.orgShift.findMany({
|
||||||
|
where: { orgId },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { settings, shifts };
|
||||||
|
}
|
||||||
|
|
||||||
|
settings = await tx.orgSettings.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
timezone: "UTC",
|
||||||
|
shiftChangeCompMin: 10,
|
||||||
|
lunchBreakMin: 30,
|
||||||
|
stoppageMultiplier: 1.5,
|
||||||
|
oeeAlertThresholdPct: 90,
|
||||||
|
performanceThresholdPct: 85,
|
||||||
|
qualitySpikeDeltaPct: 5,
|
||||||
|
alertsJson: DEFAULT_ALERTS,
|
||||||
|
defaultsJson: DEFAULT_DEFAULTS,
|
||||||
|
updatedBy: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.orgShift.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
name: DEFAULT_SHIFT.name,
|
||||||
|
startTime: DEFAULT_SHIFT.start,
|
||||||
|
endTime: DEFAULT_SHIFT.end,
|
||||||
|
sortOrder: 1,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const shifts = await tx.orgShift.findMany({
|
||||||
|
where: { orgId },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
});
|
||||||
|
return { settings, shifts };
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const machine = await prisma.machine.findFirst({
|
||||||
|
where: { id: machineId, orgId: session.orgId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!machine) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const { settings, overrides } = await prisma.$transaction(async (tx) => {
|
||||||
|
const orgSettings = await ensureOrgSettings(tx, session.orgId, session.userId);
|
||||||
|
if (!orgSettings?.settings) throw new Error("SETTINGS_NOT_FOUND");
|
||||||
|
|
||||||
|
const machineSettings = await tx.machineSettings.findUnique({
|
||||||
|
where: { machineId },
|
||||||
|
select: { overridesJson: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const orgPayload = buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []);
|
||||||
|
const rawOverrides = pickAllowedOverrides(machineSettings?.overridesJson ?? {});
|
||||||
|
const effective = deepMerge(orgPayload, rawOverrides);
|
||||||
|
|
||||||
|
return { settings: { org: orgPayload, effective }, overrides: rawOverrides };
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
machineId,
|
||||||
|
orgSettings: settings.org,
|
||||||
|
effectiveSettings: settings.effective,
|
||||||
|
overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
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;
|
||||||
|
|
||||||
|
const machine = await prisma.machine.findFirst({
|
||||||
|
where: { id: machineId, orgId: session.orgId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!machine) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const source = String(body.source ?? "control_tower");
|
||||||
|
|
||||||
|
let patch = body.overrides ?? body;
|
||||||
|
if (patch === null) {
|
||||||
|
patch = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch && !isPlainObject(patch)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "overrides must be an object or null" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch && Object.keys(patch).length === 0) {
|
||||||
|
return NextResponse.json({ ok: false, error: "No overrides provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch && Object.keys(pickAllowedOverrides(patch)).length !== Object.keys(patch).length) {
|
||||||
|
return NextResponse.json({ ok: false, error: "overrides contain unsupported keys" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch?.shiftSchedule && !isPlainObject(patch.shiftSchedule)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "shiftSchedule must be an object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (patch?.thresholds !== undefined && patch.thresholds !== null && !isPlainObject(patch.thresholds)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "thresholds must be an object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (patch?.alerts !== undefined && patch.alerts !== null && !isPlainObject(patch.alerts)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "alerts must be an object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (patch?.defaults !== undefined && patch.defaults !== null && !isPlainObject(patch.defaults)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const shiftValidation = validateShiftFields(
|
||||||
|
patch?.shiftSchedule?.shiftChangeCompensationMin,
|
||||||
|
patch?.shiftSchedule?.lunchBreakMin
|
||||||
|
);
|
||||||
|
if (!shiftValidation.ok) {
|
||||||
|
return NextResponse.json({ ok: false, error: shiftValidation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const thresholdsValidation = validateThresholds(patch?.thresholds);
|
||||||
|
if (!thresholdsValidation.ok) {
|
||||||
|
return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultsValidation = validateDefaults(patch?.defaults);
|
||||||
|
if (!defaultsValidation.ok) {
|
||||||
|
return NextResponse.json({ ok: false, error: defaultsValidation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch?.shiftSchedule?.shifts !== undefined) {
|
||||||
|
const shiftResult = validateShiftSchedule(patch.shiftSchedule.shifts);
|
||||||
|
if (!shiftResult.ok) {
|
||||||
|
return NextResponse.json({ ok: false, error: shiftResult.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
patch = {
|
||||||
|
...patch,
|
||||||
|
shiftSchedule: {
|
||||||
|
...patch.shiftSchedule,
|
||||||
|
shifts: shiftResult.shifts?.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
start: s.startTime,
|
||||||
|
end: s.endTime,
|
||||||
|
enabled: s.enabled !== false,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (patch?.shiftSchedule) {
|
||||||
|
patch = {
|
||||||
|
...patch,
|
||||||
|
shiftSchedule: {
|
||||||
|
...patch.shiftSchedule,
|
||||||
|
shiftChangeCompensationMin:
|
||||||
|
patch.shiftSchedule.shiftChangeCompensationMin !== undefined
|
||||||
|
? Number(patch.shiftSchedule.shiftChangeCompensationMin)
|
||||||
|
: patch.shiftSchedule.shiftChangeCompensationMin,
|
||||||
|
lunchBreakMin:
|
||||||
|
patch.shiftSchedule.lunchBreakMin !== undefined
|
||||||
|
? Number(patch.shiftSchedule.lunchBreakMin)
|
||||||
|
: patch.shiftSchedule.lunchBreakMin,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch?.thresholds) {
|
||||||
|
patch = {
|
||||||
|
...patch,
|
||||||
|
thresholds: {
|
||||||
|
...patch.thresholds,
|
||||||
|
stoppageMultiplier:
|
||||||
|
patch.thresholds.stoppageMultiplier !== undefined
|
||||||
|
? Number(patch.thresholds.stoppageMultiplier)
|
||||||
|
: patch.thresholds.stoppageMultiplier,
|
||||||
|
oeeAlertThresholdPct:
|
||||||
|
patch.thresholds.oeeAlertThresholdPct !== undefined
|
||||||
|
? Number(patch.thresholds.oeeAlertThresholdPct)
|
||||||
|
: patch.thresholds.oeeAlertThresholdPct,
|
||||||
|
performanceThresholdPct:
|
||||||
|
patch.thresholds.performanceThresholdPct !== undefined
|
||||||
|
? Number(patch.thresholds.performanceThresholdPct)
|
||||||
|
: patch.thresholds.performanceThresholdPct,
|
||||||
|
qualitySpikeDeltaPct:
|
||||||
|
patch.thresholds.qualitySpikeDeltaPct !== undefined
|
||||||
|
? Number(patch.thresholds.qualitySpikeDeltaPct)
|
||||||
|
: patch.thresholds.qualitySpikeDeltaPct,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch?.defaults) {
|
||||||
|
patch = {
|
||||||
|
...patch,
|
||||||
|
defaults: {
|
||||||
|
...patch.defaults,
|
||||||
|
moldTotal:
|
||||||
|
patch.defaults.moldTotal !== undefined ? Number(patch.defaults.moldTotal) : patch.defaults.moldTotal,
|
||||||
|
moldActive:
|
||||||
|
patch.defaults.moldActive !== undefined ? Number(patch.defaults.moldActive) : patch.defaults.moldActive,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const orgSettings = await ensureOrgSettings(tx, session.orgId, session.userId);
|
||||||
|
if (!orgSettings?.settings) throw new Error("SETTINGS_NOT_FOUND");
|
||||||
|
|
||||||
|
const existing = await tx.machineSettings.findUnique({
|
||||||
|
where: { machineId },
|
||||||
|
select: { overridesJson: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextOverrides: any = null;
|
||||||
|
if (patch === null) {
|
||||||
|
nextOverrides = null;
|
||||||
|
} else {
|
||||||
|
const merged = applyOverridePatch(existing?.overridesJson ?? {}, patch);
|
||||||
|
nextOverrides = Object.keys(merged).length ? merged : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await tx.machineSettings.upsert({
|
||||||
|
where: { machineId },
|
||||||
|
update: {
|
||||||
|
overridesJson: nextOverrides,
|
||||||
|
updatedBy: session.userId,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
machineId,
|
||||||
|
orgId: session.orgId,
|
||||||
|
overridesJson: nextOverrides,
|
||||||
|
updatedBy: session.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.settingsAudit.create({
|
||||||
|
data: {
|
||||||
|
orgId: session.orgId,
|
||||||
|
machineId,
|
||||||
|
actorId: session.userId,
|
||||||
|
source,
|
||||||
|
payloadJson: body,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const orgPayload = buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []);
|
||||||
|
const overrides = pickAllowedOverrides(saved.overridesJson ?? {});
|
||||||
|
const effective = deepMerge(orgPayload, overrides);
|
||||||
|
|
||||||
|
return { orgPayload, overrides, effective };
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
machineId,
|
||||||
|
orgSettings: result.orgPayload,
|
||||||
|
effectiveSettings: result.effective,
|
||||||
|
overrides: result.overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
263
app/api/settings/route.ts
Normal file
263
app/api/settings/route.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
|
import {
|
||||||
|
DEFAULT_ALERTS,
|
||||||
|
DEFAULT_DEFAULTS,
|
||||||
|
DEFAULT_SHIFT,
|
||||||
|
buildSettingsPayload,
|
||||||
|
normalizeAlerts,
|
||||||
|
normalizeDefaults,
|
||||||
|
stripUndefined,
|
||||||
|
validateDefaults,
|
||||||
|
validateShiftFields,
|
||||||
|
validateShiftSchedule,
|
||||||
|
validateThresholds,
|
||||||
|
} from "@/lib/settings";
|
||||||
|
|
||||||
|
function isPlainObject(value: any): value is Record<string, any> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, userId: string) {
|
||||||
|
let settings = await tx.orgSettings.findUnique({
|
||||||
|
where: { orgId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (settings) {
|
||||||
|
let shifts = await tx.orgShift.findMany({
|
||||||
|
where: { orgId },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
});
|
||||||
|
if (!shifts.length) {
|
||||||
|
await tx.orgShift.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
name: DEFAULT_SHIFT.name,
|
||||||
|
startTime: DEFAULT_SHIFT.start,
|
||||||
|
endTime: DEFAULT_SHIFT.end,
|
||||||
|
sortOrder: 1,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
shifts = await tx.orgShift.findMany({
|
||||||
|
where: { orgId },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { settings, shifts };
|
||||||
|
}
|
||||||
|
|
||||||
|
settings = await tx.orgSettings.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
timezone: "UTC",
|
||||||
|
shiftChangeCompMin: 10,
|
||||||
|
lunchBreakMin: 30,
|
||||||
|
stoppageMultiplier: 1.5,
|
||||||
|
oeeAlertThresholdPct: 90,
|
||||||
|
performanceThresholdPct: 85,
|
||||||
|
qualitySpikeDeltaPct: 5,
|
||||||
|
alertsJson: DEFAULT_ALERTS,
|
||||||
|
defaultsJson: DEFAULT_DEFAULTS,
|
||||||
|
updatedBy: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.orgShift.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
name: DEFAULT_SHIFT.name,
|
||||||
|
startTime: DEFAULT_SHIFT.start,
|
||||||
|
endTime: DEFAULT_SHIFT.end,
|
||||||
|
sortOrder: 1,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const shifts = await tx.orgShift.findMany({
|
||||||
|
where: { orgId },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
});
|
||||||
|
return { settings, shifts };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await requireSession();
|
||||||
|
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const loaded = await prisma.$transaction(async (tx) => {
|
||||||
|
const found = await ensureOrgSettings(tx, session.orgId, session.userId);
|
||||||
|
if (!found?.settings) throw new Error("SETTINGS_NOT_FOUND");
|
||||||
|
return found;
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = buildSettingsPayload(loaded.settings, loaded.shifts ?? []);
|
||||||
|
return NextResponse.json({ ok: true, settings: payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: Request) {
|
||||||
|
const session = await requireSession();
|
||||||
|
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const source = String(body.source ?? "control_tower");
|
||||||
|
const timezone = body.timezone;
|
||||||
|
const shiftSchedule = body.shiftSchedule;
|
||||||
|
const thresholds = body.thresholds;
|
||||||
|
const alerts = body.alerts;
|
||||||
|
const defaults = body.defaults;
|
||||||
|
const expectedVersion = body.version;
|
||||||
|
|
||||||
|
if (
|
||||||
|
timezone === undefined &&
|
||||||
|
shiftSchedule === undefined &&
|
||||||
|
thresholds === undefined &&
|
||||||
|
alerts === undefined &&
|
||||||
|
defaults === undefined
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ ok: false, error: "No settings provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shiftSchedule && !isPlainObject(shiftSchedule)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "shiftSchedule must be an object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (thresholds !== undefined && !isPlainObject(thresholds)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "thresholds must be an object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (alerts !== undefined && !isPlainObject(alerts)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "alerts must be an object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (defaults !== undefined && !isPlainObject(defaults)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const shiftValidation = validateShiftFields(
|
||||||
|
shiftSchedule?.shiftChangeCompensationMin,
|
||||||
|
shiftSchedule?.lunchBreakMin
|
||||||
|
);
|
||||||
|
if (!shiftValidation.ok) {
|
||||||
|
return NextResponse.json({ ok: false, error: shiftValidation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const thresholdsValidation = validateThresholds(thresholds);
|
||||||
|
if (!thresholdsValidation.ok) {
|
||||||
|
return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultsValidation = validateDefaults(defaults);
|
||||||
|
if (!defaultsValidation.ok) {
|
||||||
|
return NextResponse.json({ ok: false, error: defaultsValidation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let shiftRows: any[] = [];
|
||||||
|
let hasShiftUpdate = false;
|
||||||
|
if (shiftSchedule?.shifts !== undefined) {
|
||||||
|
const shiftResult = validateShiftSchedule(shiftSchedule.shifts);
|
||||||
|
if (!shiftResult.ok) {
|
||||||
|
return NextResponse.json({ ok: false, error: shiftResult.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
shiftRows = shiftResult.shifts ?? [];
|
||||||
|
hasShiftUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
const current = await ensureOrgSettings(tx, session.orgId, session.userId);
|
||||||
|
if (!current?.settings) throw new Error("SETTINGS_NOT_FOUND");
|
||||||
|
|
||||||
|
if (expectedVersion != null && Number(expectedVersion) !== Number(current.settings.version)) {
|
||||||
|
return { error: "VERSION_MISMATCH", currentVersion: current.settings.version } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAlerts =
|
||||||
|
alerts !== undefined ? { ...normalizeAlerts(current.settings.alertsJson), ...alerts } : undefined;
|
||||||
|
const nextDefaults =
|
||||||
|
defaults !== undefined ? { ...normalizeDefaults(current.settings.defaultsJson), ...defaults } : undefined;
|
||||||
|
|
||||||
|
const updateData = stripUndefined({
|
||||||
|
timezone: timezone !== undefined ? String(timezone) : undefined,
|
||||||
|
shiftChangeCompMin:
|
||||||
|
shiftSchedule?.shiftChangeCompensationMin !== undefined
|
||||||
|
? Number(shiftSchedule.shiftChangeCompensationMin)
|
||||||
|
: undefined,
|
||||||
|
lunchBreakMin:
|
||||||
|
shiftSchedule?.lunchBreakMin !== undefined ? Number(shiftSchedule.lunchBreakMin) : undefined,
|
||||||
|
stoppageMultiplier:
|
||||||
|
thresholds?.stoppageMultiplier !== undefined ? Number(thresholds.stoppageMultiplier) : undefined,
|
||||||
|
oeeAlertThresholdPct:
|
||||||
|
thresholds?.oeeAlertThresholdPct !== undefined ? Number(thresholds.oeeAlertThresholdPct) : undefined,
|
||||||
|
performanceThresholdPct:
|
||||||
|
thresholds?.performanceThresholdPct !== undefined
|
||||||
|
? Number(thresholds.performanceThresholdPct)
|
||||||
|
: undefined,
|
||||||
|
qualitySpikeDeltaPct:
|
||||||
|
thresholds?.qualitySpikeDeltaPct !== undefined ? Number(thresholds.qualitySpikeDeltaPct) : undefined,
|
||||||
|
alertsJson: nextAlerts,
|
||||||
|
defaultsJson: nextDefaults,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasSettingsUpdate = Object.keys(updateData).length > 0;
|
||||||
|
|
||||||
|
if (!hasShiftUpdate && !hasSettingsUpdate) {
|
||||||
|
return { error: "No settings provided" } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateWithMeta = {
|
||||||
|
...updateData,
|
||||||
|
version: current.settings.version + 1,
|
||||||
|
updatedBy: session.userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
await tx.orgSettings.update({
|
||||||
|
where: { orgId: session.orgId },
|
||||||
|
data: updateWithMeta,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasShiftUpdate) {
|
||||||
|
await tx.orgShift.deleteMany({ where: { orgId: session.orgId } });
|
||||||
|
if (shiftRows.length) {
|
||||||
|
await tx.orgShift.createMany({
|
||||||
|
data: shiftRows.map((s) => ({
|
||||||
|
...s,
|
||||||
|
orgId: session.orgId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshed = await tx.orgSettings.findUnique({
|
||||||
|
where: { orgId: session.orgId },
|
||||||
|
});
|
||||||
|
if (!refreshed) throw new Error("SETTINGS_NOT_FOUND");
|
||||||
|
const refreshedShifts = await tx.orgShift.findMany({
|
||||||
|
where: { orgId: session.orgId },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.settingsAudit.create({
|
||||||
|
data: {
|
||||||
|
orgId: session.orgId,
|
||||||
|
actorId: session.userId,
|
||||||
|
source,
|
||||||
|
payloadJson: body,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { settings: refreshed, shifts: refreshedShifts };
|
||||||
|
});
|
||||||
|
|
||||||
|
if ((updated as any)?.error === "VERSION_MISMATCH") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "Version mismatch", currentVersion: (updated as any).currentVersion },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((updated as any)?.error) {
|
||||||
|
return NextResponse.json({ ok: false, error: (updated as any).error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildSettingsPayload(updated.settings, updated.shifts ?? []);
|
||||||
|
return NextResponse.json({ ok: true, settings: payload });
|
||||||
|
}
|
||||||
220
lib/settings.ts
Normal file
220
lib/settings.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
|
|
||||||
|
export const DEFAULT_ALERTS = {
|
||||||
|
oeeDropEnabled: true,
|
||||||
|
performanceDegradationEnabled: true,
|
||||||
|
qualitySpikeEnabled: true,
|
||||||
|
predictiveOeeDeclineEnabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_DEFAULTS = {
|
||||||
|
moldTotal: 1,
|
||||||
|
moldActive: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_SHIFT = {
|
||||||
|
name: "Shift 1",
|
||||||
|
start: "06:00",
|
||||||
|
end: "15:00",
|
||||||
|
};
|
||||||
|
|
||||||
|
type AnyRecord = Record<string, any>;
|
||||||
|
|
||||||
|
function isPlainObject(value: any): value is AnyRecord {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAlerts(raw: any) {
|
||||||
|
if (!isPlainObject(raw)) return { ...DEFAULT_ALERTS };
|
||||||
|
return { ...DEFAULT_ALERTS, ...raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDefaults(raw: any) {
|
||||||
|
if (!isPlainObject(raw)) return { ...DEFAULT_DEFAULTS };
|
||||||
|
return { ...DEFAULT_DEFAULTS, ...raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSettingsPayload(settings: any, shifts: any[]) {
|
||||||
|
const ordered = [...(shifts ?? [])].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||||
|
const mappedShifts = ordered.map((s, idx) => ({
|
||||||
|
name: s.name || `Shift ${idx + 1}`,
|
||||||
|
start: s.startTime,
|
||||||
|
end: s.endTime,
|
||||||
|
enabled: s.enabled !== false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
orgId: settings.orgId,
|
||||||
|
version: settings.version,
|
||||||
|
timezone: settings.timezone,
|
||||||
|
shiftSchedule: {
|
||||||
|
shifts: mappedShifts,
|
||||||
|
shiftChangeCompensationMin: settings.shiftChangeCompMin,
|
||||||
|
lunchBreakMin: settings.lunchBreakMin,
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
stoppageMultiplier: settings.stoppageMultiplier,
|
||||||
|
oeeAlertThresholdPct: settings.oeeAlertThresholdPct,
|
||||||
|
performanceThresholdPct: settings.performanceThresholdPct,
|
||||||
|
qualitySpikeDeltaPct: settings.qualitySpikeDeltaPct,
|
||||||
|
},
|
||||||
|
alerts: normalizeAlerts(settings.alertsJson),
|
||||||
|
defaults: normalizeDefaults(settings.defaultsJson),
|
||||||
|
updatedAt: settings.updatedAt,
|
||||||
|
updatedBy: settings.updatedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deepMerge(base: any, override: any): any {
|
||||||
|
if (!isPlainObject(base) || !isPlainObject(override)) return override;
|
||||||
|
const out: AnyRecord = { ...base };
|
||||||
|
for (const [key, value] of Object.entries(override)) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
if (isPlainObject(value) && isPlainObject(out[key])) {
|
||||||
|
out[key] = deepMerge(out[key], value);
|
||||||
|
} else {
|
||||||
|
out[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOverridePatch(existing: any, patch: any) {
|
||||||
|
const base: AnyRecord = isPlainObject(existing) ? { ...existing } : {};
|
||||||
|
if (!isPlainObject(patch)) return base;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (value === null) {
|
||||||
|
delete base[key];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(value)) {
|
||||||
|
const merged = applyOverridePatch(isPlainObject(base[key]) ? base[key] : {}, value);
|
||||||
|
if (Object.keys(merged).length === 0) {
|
||||||
|
delete base[key];
|
||||||
|
} else {
|
||||||
|
base[key] = merged;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
base[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateShiftSchedule(shifts: any[]) {
|
||||||
|
if (!Array.isArray(shifts)) return { ok: false, error: "shifts must be an array" };
|
||||||
|
if (shifts.length > 3) return { ok: false, error: "shifts max is 3" };
|
||||||
|
|
||||||
|
const normalized = shifts.map((raw, idx) => {
|
||||||
|
const start = String(raw?.start ?? "").trim();
|
||||||
|
const end = String(raw?.end ?? "").trim();
|
||||||
|
if (!TIME_RE.test(start) || !TIME_RE.test(end)) {
|
||||||
|
return { error: `shift ${idx + 1} start/end must be HH:mm` };
|
||||||
|
}
|
||||||
|
const name = String(raw?.name ?? `Shift ${idx + 1}`).trim() || `Shift ${idx + 1}`;
|
||||||
|
const enabled = raw?.enabled !== false;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
sortOrder: idx + 1,
|
||||||
|
enabled,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstError = normalized.find((s: any) => s?.error);
|
||||||
|
if (firstError) return { ok: false, error: firstError.error };
|
||||||
|
|
||||||
|
return { ok: true, shifts: normalized as any[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateShiftFields(shiftChangeCompensationMin?: any, lunchBreakMin?: any) {
|
||||||
|
if (shiftChangeCompensationMin != null) {
|
||||||
|
const v = Number(shiftChangeCompensationMin);
|
||||||
|
if (!Number.isFinite(v) || v < 0 || v > 480) {
|
||||||
|
return { ok: false, error: "shiftChangeCompensationMin must be 0-480" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lunchBreakMin != null) {
|
||||||
|
const v = Number(lunchBreakMin);
|
||||||
|
if (!Number.isFinite(v) || v < 0 || v > 480) {
|
||||||
|
return { ok: false, error: "lunchBreakMin must be 0-480" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateThresholds(thresholds: any) {
|
||||||
|
if (!isPlainObject(thresholds)) return { ok: true };
|
||||||
|
|
||||||
|
const stoppage = thresholds.stoppageMultiplier;
|
||||||
|
if (stoppage != null) {
|
||||||
|
const v = Number(stoppage);
|
||||||
|
if (!Number.isFinite(v) || v < 1.1 || v > 5.0) {
|
||||||
|
return { ok: false, error: "stoppageMultiplier must be 1.1-5.0" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const oee = thresholds.oeeAlertThresholdPct;
|
||||||
|
if (oee != null) {
|
||||||
|
const v = Number(oee);
|
||||||
|
if (!Number.isFinite(v) || v < 50 || v > 100) {
|
||||||
|
return { ok: false, error: "oeeAlertThresholdPct must be 50-100" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const perf = thresholds.performanceThresholdPct;
|
||||||
|
if (perf != null) {
|
||||||
|
const v = Number(perf);
|
||||||
|
if (!Number.isFinite(v) || v < 50 || v > 100) {
|
||||||
|
return { ok: false, error: "performanceThresholdPct must be 50-100" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const quality = thresholds.qualitySpikeDeltaPct;
|
||||||
|
if (quality != null) {
|
||||||
|
const v = Number(quality);
|
||||||
|
if (!Number.isFinite(v) || v < 0 || v > 100) {
|
||||||
|
return { ok: false, error: "qualitySpikeDeltaPct must be 0-100" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDefaults(defaults: any) {
|
||||||
|
if (!isPlainObject(defaults)) return { ok: true };
|
||||||
|
|
||||||
|
const moldTotal = defaults.moldTotal != null ? Number(defaults.moldTotal) : null;
|
||||||
|
const moldActive = defaults.moldActive != null ? Number(defaults.moldActive) : null;
|
||||||
|
|
||||||
|
if (moldTotal != null && (!Number.isFinite(moldTotal) || moldTotal < 0)) {
|
||||||
|
return { ok: false, error: "moldTotal must be >= 0" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moldActive != null && (!Number.isFinite(moldActive) || moldActive < 0)) {
|
||||||
|
return { ok: false, error: "moldActive must be >= 0" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moldTotal != null && moldActive != null && moldActive > moldTotal) {
|
||||||
|
return { ok: false, error: "moldActive must be <= moldTotal" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickUpdateValue(input: any) {
|
||||||
|
return input === undefined ? undefined : input;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripUndefined(obj: AnyRecord) {
|
||||||
|
const out: AnyRecord = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (value !== undefined) out[key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -13,12 +13,16 @@ model Org {
|
|||||||
slug String @unique
|
slug String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
members OrgUser[]
|
members OrgUser[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
machines Machine[]
|
machines Machine[]
|
||||||
heartbeats MachineHeartbeat[]
|
heartbeats MachineHeartbeat[]
|
||||||
kpiSnapshots MachineKpiSnapshot[]
|
kpiSnapshots MachineKpiSnapshot[]
|
||||||
events MachineEvent[]
|
events MachineEvent[]
|
||||||
|
settings OrgSettings?
|
||||||
|
shifts OrgShift[]
|
||||||
|
machineSettings MachineSettings[]
|
||||||
|
settingsAudits SettingsAudit[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -68,38 +72,39 @@ model Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Machine {
|
model Machine {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
orgId String
|
orgId String
|
||||||
name String
|
name String
|
||||||
apiKey String? @unique
|
apiKey String? @unique
|
||||||
code String?
|
code String?
|
||||||
location String?
|
location String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
tsDevice DateTime @default(now()) @map("ts")
|
tsDevice DateTime @default(now()) @map("ts")
|
||||||
tsServer DateTime @default(now()) @map("ts_server")
|
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")
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
|
||||||
heartbeats MachineHeartbeat[]
|
|
||||||
kpiSnapshots MachineKpiSnapshot[]
|
|
||||||
events MachineEvent[]
|
|
||||||
cycles MachineCycle[]
|
|
||||||
|
|
||||||
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
heartbeats MachineHeartbeat[]
|
||||||
|
kpiSnapshots MachineKpiSnapshot[]
|
||||||
|
events MachineEvent[]
|
||||||
|
cycles MachineCycle[]
|
||||||
|
settings MachineSettings?
|
||||||
|
settingsAudits SettingsAudit[]
|
||||||
|
|
||||||
@@unique([orgId, name])
|
@@unique([orgId, name])
|
||||||
@@index([orgId])
|
@@index([orgId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model MachineHeartbeat {
|
model MachineHeartbeat {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
orgId String
|
orgId String
|
||||||
machineId String
|
machineId String
|
||||||
ts DateTime @default(now())
|
ts DateTime @default(now())
|
||||||
tsServer DateTime @default(now()) @map("ts_server")
|
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")
|
||||||
|
|
||||||
status String
|
status String
|
||||||
message String?
|
message String?
|
||||||
@@ -138,9 +143,9 @@ model MachineKpiSnapshot {
|
|||||||
|
|
||||||
trackingEnabled Boolean?
|
trackingEnabled Boolean?
|
||||||
productionStarted Boolean?
|
productionStarted Boolean?
|
||||||
tsServer DateTime @default(now()) @map("ts_server")
|
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")
|
||||||
|
|
||||||
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: Cascade)
|
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
|
||||||
@@ -154,15 +159,15 @@ model MachineEvent {
|
|||||||
machineId String
|
machineId String
|
||||||
ts DateTime @default(now())
|
ts DateTime @default(now())
|
||||||
|
|
||||||
topic String // "anomaly-detected"
|
topic String // "anomaly-detected"
|
||||||
eventType String // "slow-cycle"
|
eventType String // "slow-cycle"
|
||||||
severity String // "critical"
|
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")
|
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")
|
||||||
|
|
||||||
// store the raw data blob so we don't lose fields
|
// store the raw data blob so we don't lose fields
|
||||||
data Json?
|
data Json?
|
||||||
@@ -176,42 +181,44 @@ model MachineEvent {
|
|||||||
@@index([orgId, machineId, ts])
|
@@index([orgId, machineId, ts])
|
||||||
@@index([orgId, machineId, eventType, ts])
|
@@index([orgId, machineId, eventType, ts])
|
||||||
}
|
}
|
||||||
model MachineCycle {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
orgId String
|
|
||||||
machineId String
|
|
||||||
ts DateTime @default(now())
|
|
||||||
|
|
||||||
cycleCount Int?
|
model MachineCycle {
|
||||||
actualCycleTime Float
|
id String @id @default(uuid())
|
||||||
|
orgId String
|
||||||
|
machineId String
|
||||||
|
ts DateTime @default(now())
|
||||||
|
|
||||||
|
cycleCount Int?
|
||||||
|
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")
|
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")
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
machine Machine @relation(fields: [machineId], references: [id])
|
||||||
|
|
||||||
machine Machine @relation(fields: [machineId], references: [id])
|
|
||||||
@@index([orgId, machineId, ts])
|
@@index([orgId, machineId, ts])
|
||||||
@@index([orgId, machineId, cycleCount])
|
@@index([orgId, machineId, cycleCount])
|
||||||
}
|
}
|
||||||
|
|
||||||
model IngestLog {
|
model IngestLog {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
orgId String?
|
orgId String?
|
||||||
machineId String?
|
machineId String?
|
||||||
endpoint String
|
endpoint String
|
||||||
schemaVersion String?
|
schemaVersion String?
|
||||||
seq BigInt?
|
seq BigInt?
|
||||||
tsDevice DateTime?
|
tsDevice DateTime?
|
||||||
tsServer DateTime @default(now())
|
tsServer DateTime @default(now())
|
||||||
|
|
||||||
ok Boolean
|
ok Boolean
|
||||||
status Int
|
status Int
|
||||||
@@ -226,4 +233,69 @@ model IngestLog {
|
|||||||
@@index([machineId, seq])
|
@@index([machineId, seq])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model OrgSettings {
|
||||||
|
orgId String @id @map("org_id")
|
||||||
|
timezone String @default("UTC")
|
||||||
|
shiftChangeCompMin Int @default(10) @map("shift_change_comp_min")
|
||||||
|
lunchBreakMin Int @default(30) @map("lunch_break_min")
|
||||||
|
stoppageMultiplier Float @default(1.5) @map("stoppage_multiplier")
|
||||||
|
oeeAlertThresholdPct Float @default(90) @map("oee_alert_threshold_pct")
|
||||||
|
performanceThresholdPct Float @default(85) @map("performance_threshold_pct")
|
||||||
|
qualitySpikeDeltaPct Float @default(5) @map("quality_spike_delta_pct")
|
||||||
|
alertsJson Json? @map("alerts_json")
|
||||||
|
defaultsJson Json? @map("defaults_json")
|
||||||
|
version Int @default(1)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
updatedBy String? @map("updated_by")
|
||||||
|
|
||||||
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("org_settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
model OrgShift {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
orgId String @map("org_id")
|
||||||
|
name String
|
||||||
|
startTime String @map("start_time")
|
||||||
|
endTime String @map("end_time")
|
||||||
|
sortOrder Int @map("sort_order")
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
|
||||||
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([orgId])
|
||||||
|
@@index([orgId, sortOrder])
|
||||||
|
@@map("org_shifts")
|
||||||
|
}
|
||||||
|
|
||||||
|
model MachineSettings {
|
||||||
|
machineId String @id @map("machine_id")
|
||||||
|
orgId String @map("org_id")
|
||||||
|
overridesJson Json? @map("overrides_json")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
updatedBy String? @map("updated_by")
|
||||||
|
|
||||||
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([orgId])
|
||||||
|
@@map("machine_settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SettingsAudit {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
orgId String @map("org_id")
|
||||||
|
machineId String? @map("machine_id")
|
||||||
|
actorId String? @map("actor_id")
|
||||||
|
source String
|
||||||
|
payloadJson Json @map("payload_json")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
machine Machine? @relation(fields: [machineId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([orgId, createdAt])
|
||||||
|
@@index([machineId, createdAt])
|
||||||
|
@@map("settings_audit")
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,48 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await prisma.orgSettings.upsert({
|
||||||
|
where: { orgId: org.id },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
orgId: org.id,
|
||||||
|
timezone: "UTC",
|
||||||
|
shiftChangeCompMin: 10,
|
||||||
|
lunchBreakMin: 30,
|
||||||
|
stoppageMultiplier: 1.5,
|
||||||
|
oeeAlertThresholdPct: 90,
|
||||||
|
performanceThresholdPct: 85,
|
||||||
|
qualitySpikeDeltaPct: 5,
|
||||||
|
alertsJson: {
|
||||||
|
oeeDropEnabled: true,
|
||||||
|
performanceDegradationEnabled: true,
|
||||||
|
qualitySpikeEnabled: true,
|
||||||
|
predictiveOeeDeclineEnabled: true,
|
||||||
|
},
|
||||||
|
defaultsJson: {
|
||||||
|
moldTotal: 1,
|
||||||
|
moldActive: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingShift = await prisma.orgShift.findFirst({
|
||||||
|
where: { orgId: org.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingShift) {
|
||||||
|
await prisma.orgShift.create({
|
||||||
|
data: {
|
||||||
|
orgId: org.id,
|
||||||
|
name: "Shift 1",
|
||||||
|
startTime: "06:00",
|
||||||
|
endTime: "15:00",
|
||||||
|
sortOrder: 1,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log("Seeded admin user");
|
console.log("Seeded admin user");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user