diff --git a/app/(app)/overview/page.tsx b/app/(app)/overview/page.tsx new file mode 100644 index 0000000..59ae519 --- /dev/null +++ b/app/(app)/overview/page.tsx @@ -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([]); + const [events, setEvents] = useState([]); + 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(); + 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 ( +
+
+
+

Overview

+

Fleet pulse, alerts, and top attention items.

+
+ + + View Machines + +
+ + {loading &&
Loading overview...
} + +
+
+
Fleet Health
+
{stats.total}
+
Machines total
+
+ + Online {stats.online} + + + Offline {stats.offline} + + + Run {stats.running} + + + Idle {stats.idle} + + + Stop {stats.stopped} + +
+
+ +
+
Production Totals
+
+
+
Good
+
{fmtNum(stats.goodSum)}
+
+
+
Scrap
+
{fmtNum(stats.scrapSum)}
+
+
+
Target
+
{fmtNum(stats.targetSum)}
+
+
+
Sum of latest KPIs across machines.
+
+ +
+
Activity Feed
+
{events.length}
+
+ {eventsLoading ? "Refreshing recent events..." : "Last 30 merged events"} +
+
+ {events.slice(0, 3).map((e) => ( +
+
+ {e.machineName ? `${e.machineName}: ` : ""} + {e.title} +
+
{secondsAgo(e.ts)}
+
+ ))} + {events.length === 0 && !eventsLoading ? ( +
No recent events.
+ ) : null} +
+
+
+ +
+
+
OEE (avg)
+
{fmtPct(stats.oee)}
+
+
+
Availability (avg)
+
{fmtPct(stats.availability)}
+
+
+
Performance (avg)
+
{fmtPct(stats.performance)}
+
+
+
Quality (avg)
+
{fmtPct(stats.quality)}
+
+
+ +
+
+
+
Attention List
+
{attention.length} shown
+
+ {attention.length === 0 ? ( +
No urgent issues detected.
+ ) : ( +
+ {attention.map(({ machine, offline, oee }) => ( +
+
+
+
{machine.name}
+
+ {machine.code ?? ""} {machine.location ? `- ${machine.location}` : ""} +
+
+
{secondsAgo(machine.latestHeartbeat?.ts)}
+
+
+ + {offline ? "OFFLINE" : "ONLINE"} + + {oee != null && ( + + OEE {fmtPct(oee)} + + )} +
+
+ ))} +
+ )} +
+ +
+
+
Unified Timeline
+
{events.length} items
+
+ + {events.length === 0 && !eventsLoading ? ( +
No events yet.
+ ) : ( +
+ {events.map((e) => ( +
+
+
+
+ + {e.severity.toUpperCase()} + + + {e.eventType} + + + {e.source} + + {e.requiresAck ? ( + ACK + ) : null} +
+ +
+ {e.machineName ? `${e.machineName}: ` : ""} + {e.title} +
+ {e.description ? ( +
{e.description}
+ ) : null} +
+
{secondsAgo(e.ts)}
+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/app/(app)/reports/page.tsx b/app/(app)/reports/page.tsx new file mode 100644 index 0000000..b69474d --- /dev/null +++ b/app/(app)/reports/page.tsx @@ -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(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 ( +
+
{p.label}
+
+
+ Cycles: {p.count} +
+ {detail ? ( +
+ Range: {detail} +
+ ) : null} + {extreme ?
{extreme}
: null} +
+
+ ); +} + +function buildCsv(report: ReportPayload) { + const rows = new Map>(); + 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 ` + + + + + Report Export + + + +

Reports

+
Range: ${rangeLabel} | Machine: ${filters.machine} | Work Order: ${filters.workOrder} | SKU: ${filters.sku}
+ +
+
+
OEE (avg)
+
${summary.oeeAvg != null ? summary.oeeAvg.toFixed(1) + "%" : "--"}
+
+
+
Availability (avg)
+
${summary.availabilityAvg != null ? summary.availabilityAvg.toFixed(1) + "%" : "--"}
+
+
+
Performance (avg)
+
${summary.performanceAvg != null ? summary.performanceAvg.toFixed(1) + "%" : "--"}
+
+
+
Quality (avg)
+
${summary.qualityAvg != null ? summary.qualityAvg.toFixed(1) + "%" : "--"}
+
+
+ +
+
Top Loss Drivers
+ + + + + + + + + + + + +
MetricValue
Macrostop (sec)${downtime.macrostopSec}
Microstop (sec)${downtime.microstopSec}
Slow Cycles${downtime.slowCycleCount}
Quality Spikes${downtime.qualitySpikeCount}
Performance Degradation${downtime.performanceDegradationCount}
OEE Drops${downtime.oeeDropCount}
+
+ +
+
Quality Summary
+ + + + + + + + + + + + +
MetricValue
Scrap Rate${summary.scrapRate != null ? summary.scrapRate.toFixed(1) + "%" : "--"}
Good Total${summary.goodTotal ?? "--"}
Scrap Total${summary.scrapTotal ?? "--"}
Target Total${summary.targetTotal ?? "--"}
Top Scrap SKU${summary.topScrapSku ?? "--"}
Top Scrap Work Order${summary.topScrapWorkOrder ?? "--"}
+
+ +
+
Cycle Time Distribution
+ + + + + + ${cycleBins + .map((bin) => ``) + .join("")} + +
BinCount
${bin.label}${bin.count}
+
+ +
+
Notes for Ops
+ ${insights.length ? `
    ${insights.map((n) => `
  • ${n}
  • `).join("")}
` : "
None
"} +
+ + + `.trim(); +} + +export default function ReportsPage() { + const [range, setRange] = useState("24h"); + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [machines, setMachines] = useState([]); + const [filterOptions, setFilterOptions] = useState({ 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 = { + 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 ( +
+
+
+

Reports

+

+ Trends, downtime, and quality analytics across machines. +

+
+ +
+ + +
+
+ +
+
+
Filters
+
{rangeLabel}
+
+ +
+
+
Range
+
+ {(["24h", "7d", "30d", "custom"] as RangeKey[]).map((k) => ( + + ))} +
+
+ +
+
Machine
+ +
+ +
+
Work Order
+ 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" + /> + + {filterOptions.workOrders.map((wo) => ( + +
+ +
+
SKU
+ 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" + /> + + {filterOptions.skus.map((s) => ( + +
+
+
+ +
+ {loading &&
Loading reports...
} + {error && !loading && ( +
+ {error} +
+ )} +
+ +
+ {[ + { 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) => ( +
+
{kpi.label} (avg)
+
{kpi.value}
+
+ {summary ? "Computed from KPI snapshots." : "No data in selected range."} +
+
+ ))} +
+ +
+
+
OEE Trend
+
+ {oeeSeries.length ? ( + + + + + + { + const row = payload?.[0]?.payload; + return row?.ts ? new Date(row.ts).toLocaleString() : ""; + }} + formatter={(val: any) => [`${Number(val).toFixed(1)}%`, "OEE"]} + /> + + + + ) : ( +
+ No trend data yet. +
+ )} +
+
+ +
+
Downtime Pareto
+
+ {downtimeSeries.length ? ( + + + + + + [`${Number(val)} min`, "Downtime"]} + /> + + {downtimeSeries.map((row, idx) => ( + + ))} + + + + ) : ( +
+ No downtime data yet. +
+ )} +
+
+
+ +
+
+
Cycle Time Distribution
+
+ {cycleHistogram.length ? ( + + + + + + } /> + + + + ) : ( +
+ No cycle data yet. +
+ )} +
+
+ +
+
Scrap Trend
+
+ {scrapSeries.length ? ( + + + + + + { + const row = payload?.[0]?.payload; + return row?.ts ? new Date(row.ts).toLocaleString() : ""; + }} + formatter={(val: any) => [`${Number(val).toFixed(1)}%`, "Scrap Rate"]} + /> + + + + ) : ( +
+ No scrap data yet. +
+ )} +
+
+ +
+
Top Loss Drivers
+
+ {[ + { 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) => ( +
+ {row.label} + {row.value} +
+ ))} +
+
+
+ +
+
+
Quality Summary
+
+
+
Scrap Rate
+
+ {summary?.scrapRate != null ? fmtPct(summary.scrapRate) : "--"} +
+
+
+
Top Scrap SKU
+
{summary?.topScrapSku ?? "--"}
+
+
+
Top Scrap Work Order
+
{summary?.topScrapWorkOrder ?? "--"}
+
+
+
+ +
+
Notes for Ops
+
+
Suggested actions
+ {report?.insights && report.insights.length > 0 ? ( +
+ {report.insights.map((note, idx) => ( +
{note}
+ ))} +
+ ) : ( +
No insights yet. Generate reports after data collection.
+ )} +
+
+
+
+ ); +} diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx new file mode 100644 index 0000000..04f2a11 --- /dev/null +++ b/app/(app)/settings/page.tsx @@ -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 ( + + ); +} + +export default function SettingsPage() { + const [emailEnabled, setEmailEnabled] = useState(true); + const [smsEnabled, setSmsEnabled] = useState(false); + const [webhookEnabled, setWebhookEnabled] = useState(true); + + return ( +
+
+

Settings

+

Configure alerts, shifts, and integrations.

+
+ +
+
+
Organization
+
+
+
Plant Name
+
MIS Plant
+
+
+
Time Zone
+
America/Mexico_City
+
+ +
+
+ +
+
+
Alert Thresholds
+
Applies to all machines
+
+ +
+ {[ + { 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) => ( +
+
{row.label}
+
+
{row.value}
+ +
+
{row.helper}
+
+ ))} +
+
+
+ +
+
+
+
Shift Schedule
+
Used for Availability calculations
+
+ +
+ {[ + { 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) => ( +
+
{shift.label}
+
{shift.time}
+
{shift.days}
+
+ ))} +
+ +
+ +
+
+ +
+
Notification Channels
+
+ + + +
+
+
+ +
+
+
+
Integrations
+
Live endpoints
+
+
+
+
Webhook URL
+
https://hooks.example.com/iiot
+
+
+
ERP Sync
+
Not configured
+
+
+
+ +
+
Users & Roles
+
+ {[ + { name: "Juan Perez", role: "Plant Manager" }, + { name: "Sandra Rivera", role: "Supervisor" }, + { name: "Maintenance", role: "Technician" }, + ].map((user) => ( +
+
+
{user.name}
+
{user.role}
+
+ +
+ ))} +
+
+ +
+
+
+
+ ); +} diff --git a/app/api/machines/route.ts b/app/api/machines/route.ts index e0024ea..6cac465 100644 --- a/app/api/machines/route.ts +++ b/app/api/machines/route.ts @@ -33,14 +33,34 @@ export async function GET() { take: 1, 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 const out = machines.map((m) => ({ ...m, latestHeartbeat: m.heartbeats[0] ?? null, + latestKpi: m.kpiSnapshots[0] ?? null, heartbeats: undefined, + kpiSnapshots: undefined, })); return NextResponse.json({ ok: true, machines: out }); diff --git a/app/api/reports/filters/route.ts b/app/api/reports/filters/route.ts new file mode 100644 index 0000000..104f88f --- /dev/null +++ b/app/api/reports/filters/route.ts @@ -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 = { + "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 }); +} diff --git a/app/api/reports/route.ts b/app/api/reports/route.ts new file mode 100644 index 0000000..bbb5033 --- /dev/null +++ b/app/api/reports/route.ts @@ -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 = { + "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(); + const scrapByWo = new Map(); + + 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 + }, + + }); +} diff --git a/app/api/settings/machines/[machineId]/route.ts b/app/api/settings/machines/[machineId]/route.ts new file mode 100644 index 0000000..0ce3fa5 --- /dev/null +++ b/app/api/settings/machines/[machineId]/route.ts @@ -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 { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function pickAllowedOverrides(raw: any) { + if (!isPlainObject(raw)) return {}; + const out: Record = {}; + 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, + }); +} diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts new file mode 100644 index 0000000..d88f8a0 --- /dev/null +++ b/app/api/settings/route.ts @@ -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 { + 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 }); +} diff --git a/lib/settings.ts b/lib/settings.ts new file mode 100644 index 0000000..efc170e --- /dev/null +++ b/lib/settings.ts @@ -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; + +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; +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9789665..7e6a5e7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,12 +13,16 @@ model Org { slug String @unique createdAt DateTime @default(now()) - members OrgUser[] - sessions Session[] - machines Machine[] - heartbeats MachineHeartbeat[] - kpiSnapshots MachineKpiSnapshot[] - events MachineEvent[] + members OrgUser[] + sessions Session[] + machines Machine[] + heartbeats MachineHeartbeat[] + kpiSnapshots MachineKpiSnapshot[] + events MachineEvent[] + settings OrgSettings? + shifts OrgShift[] + machineSettings MachineSettings[] + settingsAudits SettingsAudit[] } model User { @@ -68,38 +72,39 @@ model Session { } model Machine { - id String @id @default(uuid()) - orgId String - name String - apiKey String? @unique - code String? - location String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - tsDevice DateTime @default(now()) @map("ts") - tsServer DateTime @default(now()) @map("ts_server") - schemaVersion String? @map("schema_version") - seq BigInt? @map("seq") - - org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) - heartbeats MachineHeartbeat[] - kpiSnapshots MachineKpiSnapshot[] - events MachineEvent[] - cycles MachineCycle[] + id String @id @default(uuid()) + orgId String + name String + apiKey String? @unique + code String? + location String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + tsDevice DateTime @default(now()) @map("ts") + tsServer DateTime @default(now()) @map("ts_server") + schemaVersion String? @map("schema_version") + seq BigInt? @map("seq") + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + heartbeats MachineHeartbeat[] + kpiSnapshots MachineKpiSnapshot[] + events MachineEvent[] + cycles MachineCycle[] + settings MachineSettings? + settingsAudits SettingsAudit[] @@unique([orgId, name]) @@index([orgId]) } model MachineHeartbeat { - id String @id @default(uuid()) - orgId String - machineId String - ts DateTime @default(now()) - tsServer DateTime @default(now()) @map("ts_server") - schemaVersion String? @map("schema_version") - seq BigInt? @map("seq") + id String @id @default(uuid()) + orgId String + machineId String + ts DateTime @default(now()) + tsServer DateTime @default(now()) @map("ts_server") + schemaVersion String? @map("schema_version") + seq BigInt? @map("seq") status String message String? @@ -138,9 +143,9 @@ model MachineKpiSnapshot { trackingEnabled Boolean? productionStarted Boolean? - tsServer DateTime @default(now()) @map("ts_server") - schemaVersion String? @map("schema_version") - seq BigInt? @map("seq") + tsServer DateTime @default(now()) @map("ts_server") + schemaVersion String? @map("schema_version") + seq BigInt? @map("seq") org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade) @@ -154,15 +159,15 @@ model MachineEvent { machineId String ts DateTime @default(now()) - topic String // "anomaly-detected" - eventType String // "slow-cycle" - severity String // "critical" - requiresAck Boolean @default(false) - title String - description String? - tsServer DateTime @default(now()) @map("ts_server") - schemaVersion String? @map("schema_version") - seq BigInt? @map("seq") + topic String // "anomaly-detected" + eventType String // "slow-cycle" + severity String // "critical" + requiresAck Boolean @default(false) + title String + description String? + tsServer DateTime @default(now()) @map("ts_server") + schemaVersion String? @map("schema_version") + seq BigInt? @map("seq") // store the raw data blob so we don't lose fields data Json? @@ -176,42 +181,44 @@ model MachineEvent { @@index([orgId, machineId, ts]) @@index([orgId, machineId, eventType, ts]) } -model MachineCycle { - id String @id @default(uuid()) - orgId String - machineId String - ts DateTime @default(now()) - cycleCount Int? - actualCycleTime Float +model MachineCycle { + id String @id @default(uuid()) + orgId String + machineId String + ts DateTime @default(now()) + + cycleCount Int? + actualCycleTime Float theoreticalCycleTime Float? - workOrderId String? - sku String? + workOrderId String? + sku String? - cavities Int? - goodDelta Int? - scrapDelta Int? - tsServer DateTime @default(now()) @map("ts_server") - schemaVersion String? @map("schema_version") - seq BigInt? @map("seq") + cavities Int? + goodDelta Int? + scrapDelta Int? + tsServer DateTime @default(now()) @map("ts_server") + schemaVersion String? @map("schema_version") + 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, cycleCount]) } model IngestLog { - id String @id @default(uuid()) - orgId String? - machineId String? - endpoint String + id String @id @default(uuid()) + orgId String? + machineId String? + endpoint String schemaVersion String? - seq BigInt? - tsDevice DateTime? - tsServer DateTime @default(now()) + seq BigInt? + tsDevice DateTime? + tsServer DateTime @default(now()) ok Boolean status Int @@ -226,4 +233,69 @@ model IngestLog { @@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") +} diff --git a/prisma/seed.ts b/prisma/seed.ts index aa6812e..f3fc915 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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"); }