All pages active
This commit is contained in:
@@ -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 });
|
||||
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user