Final MVP valid
This commit is contained in:
64
app/api/analytics/coverage/route.ts
Normal file
64
app/api/analytics/coverage/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
||||
|
||||
const bad = (status: number, error: string) =>
|
||||
NextResponse.json({ ok: false, error }, { status });
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await requireSession();
|
||||
if (!session) return bad(401, "Unauthorized");
|
||||
const orgId = session.orgId;
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// ✅ Parse params INSIDE handler
|
||||
const range = coerceDowntimeRange(url.searchParams.get("range"));
|
||||
const start = rangeToStart(range);
|
||||
|
||||
const machineId = url.searchParams.get("machineId"); // optional
|
||||
const kind = (url.searchParams.get("kind") || "downtime").toLowerCase();
|
||||
|
||||
// coverage is only meaningful for downtime
|
||||
if (kind !== "downtime") return bad(400, "Invalid kind (downtime only)");
|
||||
|
||||
let resolvedMachineId: string | null = null;
|
||||
|
||||
// If machineId provided, validate ownership
|
||||
if (machineId) {
|
||||
const m = await prisma.machine.findFirst({
|
||||
where: { id: machineId, orgId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!m) return bad(404, "Machine not found");
|
||||
resolvedMachineId = m.id;
|
||||
}
|
||||
|
||||
const rows = await prisma.reasonEntry.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
...(resolvedMachineId ? { machineId: resolvedMachineId } : {}),
|
||||
kind: "downtime",
|
||||
capturedAt: { gte: start },
|
||||
},
|
||||
select: { durationSeconds: true, episodeId: true },
|
||||
});
|
||||
|
||||
const receivedEpisodes = new Set(rows.map((r) => r.episodeId).filter(Boolean)).size;
|
||||
|
||||
const receivedMinutes =
|
||||
Math.round((rows.reduce((acc, r) => acc + (r.durationSeconds ?? 0), 0) / 60) * 10) / 10;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
orgId,
|
||||
machineId: resolvedMachineId, // null => org-wide
|
||||
range,
|
||||
start,
|
||||
receivedEpisodes,
|
||||
receivedMinutes,
|
||||
note:
|
||||
"Control Tower received coverage (sync health). True coverage vs total downtime minutes can be added once CT has total downtime minutes per window.",
|
||||
});
|
||||
}
|
||||
130
app/api/analytics/downtime-events/route.ts
Normal file
130
app/api/analytics/downtime-events/route.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
||||
|
||||
const bad = (status: number, error: string) =>
|
||||
NextResponse.json({ ok: false, error }, { status });
|
||||
|
||||
function toISO(d: Date | null | undefined) {
|
||||
return d ? d.toISOString() : null;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
// ✅ Session auth (cookie)
|
||||
const session = await requireSession();
|
||||
if (!session) return bad(401, "Unauthorized");
|
||||
const orgId = session.orgId;
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// ✅ Params
|
||||
const range = coerceDowntimeRange(url.searchParams.get("range"));
|
||||
const start = rangeToStart(range);
|
||||
|
||||
const machineId = url.searchParams.get("machineId"); // optional
|
||||
const reasonCode = url.searchParams.get("reasonCode"); // optional
|
||||
|
||||
const limitRaw = url.searchParams.get("limit");
|
||||
const limit = Math.min(Math.max(Number(limitRaw || 200), 1), 500);
|
||||
|
||||
// Optional pagination: return events before this timestamp (capturedAt)
|
||||
const before = url.searchParams.get("before"); // ISO string
|
||||
const beforeDate = before ? new Date(before) : null;
|
||||
if (before && isNaN(beforeDate!.getTime())) return bad(400, "Invalid before timestamp");
|
||||
|
||||
// ✅ If machineId provided, verify it belongs to this org
|
||||
if (machineId) {
|
||||
const m = await prisma.machine.findFirst({
|
||||
where: { id: machineId, orgId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!m) return bad(404, "Machine not found");
|
||||
}
|
||||
|
||||
// ✅ Query ReasonEntry as the "episode" table for downtime
|
||||
// We only return rows that have an episodeId (true downtime episodes)
|
||||
const where: any = {
|
||||
orgId,
|
||||
kind: "downtime",
|
||||
episodeId: { not: null },
|
||||
capturedAt: {
|
||||
gte: start,
|
||||
...(beforeDate ? { lt: beforeDate } : {}),
|
||||
},
|
||||
...(machineId ? { machineId } : {}),
|
||||
...(reasonCode ? { reasonCode } : {}),
|
||||
};
|
||||
|
||||
const rows = await prisma.reasonEntry.findMany({
|
||||
where,
|
||||
orderBy: { capturedAt: "desc" },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
episodeId: true,
|
||||
machineId: true,
|
||||
reasonCode: true,
|
||||
reasonLabel: true,
|
||||
reasonText: true,
|
||||
durationSeconds: true,
|
||||
capturedAt: true,
|
||||
episodeEndTs: true,
|
||||
workOrderId: true,
|
||||
meta: true,
|
||||
createdAt: true,
|
||||
machine: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const events = rows.map((r) => {
|
||||
const startAt = r.capturedAt;
|
||||
const endAt =
|
||||
r.episodeEndTs ??
|
||||
(r.durationSeconds != null
|
||||
? new Date(startAt.getTime() + r.durationSeconds * 1000)
|
||||
: null);
|
||||
|
||||
const durationSeconds = r.durationSeconds ?? null;
|
||||
const durationMinutes =
|
||||
durationSeconds != null ? Math.round((durationSeconds / 60) * 10) / 10 : null;
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
episodeId: r.episodeId,
|
||||
machineId: r.machineId,
|
||||
machineName: r.machine?.name ?? null,
|
||||
|
||||
reasonCode: r.reasonCode,
|
||||
reasonLabel: r.reasonLabel ?? r.reasonCode,
|
||||
reasonText: r.reasonText ?? null,
|
||||
|
||||
durationSeconds,
|
||||
durationMinutes,
|
||||
|
||||
startAt: toISO(startAt),
|
||||
endAt: toISO(endAt),
|
||||
capturedAt: toISO(r.capturedAt),
|
||||
|
||||
workOrderId: r.workOrderId ?? null,
|
||||
meta: r.meta ?? null,
|
||||
createdAt: toISO(r.createdAt),
|
||||
};
|
||||
});
|
||||
|
||||
const nextBefore =
|
||||
events.length > 0 ? events[events.length - 1]?.capturedAt ?? null : null;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
orgId,
|
||||
range,
|
||||
start,
|
||||
machineId: machineId ?? null,
|
||||
reasonCode: reasonCode ?? null,
|
||||
limit,
|
||||
before: before ?? null,
|
||||
nextBefore, // pass this back for pagination
|
||||
events,
|
||||
});
|
||||
}
|
||||
123
app/api/analytics/pareto/route.ts
Normal file
123
app/api/analytics/pareto/route.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
||||
|
||||
const bad = (status: number, error: string) =>
|
||||
NextResponse.json({ ok: false, error }, { status });
|
||||
|
||||
export async function GET(req: Request) {
|
||||
// ✅ Session auth (cookie)
|
||||
const session = await requireSession();
|
||||
if (!session) return bad(401, "Unauthorized");
|
||||
const orgId = session.orgId;
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// ✅ Parse params INSIDE handler
|
||||
const range = coerceDowntimeRange(url.searchParams.get("range"));
|
||||
const start = rangeToStart(range);
|
||||
|
||||
const machineId = url.searchParams.get("machineId"); // optional
|
||||
const kind = (url.searchParams.get("kind") || "downtime").toLowerCase();
|
||||
|
||||
if (kind !== "downtime" && kind !== "scrap") {
|
||||
return bad(400, "Invalid kind (downtime|scrap)");
|
||||
}
|
||||
|
||||
// ✅ If machineId provided, verify it belongs to this org
|
||||
if (machineId) {
|
||||
const m = await prisma.machine.findFirst({
|
||||
where: { id: machineId, orgId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!m) return bad(404, "Machine not found");
|
||||
}
|
||||
|
||||
// ✅ Scope by orgId (+ machineId if provided)
|
||||
const grouped = await prisma.reasonEntry.groupBy({
|
||||
by: ["reasonCode", "reasonLabel"],
|
||||
where: {
|
||||
orgId,
|
||||
...(machineId ? { machineId } : {}),
|
||||
kind,
|
||||
capturedAt: { gte: start },
|
||||
},
|
||||
_sum: {
|
||||
durationSeconds: true,
|
||||
scrapQty: true,
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const itemsRaw = grouped
|
||||
.map((g) => {
|
||||
const value =
|
||||
kind === "downtime"
|
||||
? Math.round(((g._sum.durationSeconds ?? 0) / 60) * 10) / 10 // minutes, 1 decimal
|
||||
: g._sum.scrapQty ?? 0;
|
||||
|
||||
return {
|
||||
reasonCode: g.reasonCode,
|
||||
reasonLabel: g.reasonLabel ?? g.reasonCode,
|
||||
value,
|
||||
count: g._count._all,
|
||||
};
|
||||
})
|
||||
.filter((x) => x.value > 0);
|
||||
|
||||
itemsRaw.sort((a, b) => b.value - a.value);
|
||||
|
||||
const total = itemsRaw.reduce((acc, x) => acc + x.value, 0);
|
||||
|
||||
let cum = 0;
|
||||
let threshold80Index: number | null = null;
|
||||
|
||||
const rows = itemsRaw.map((x, idx) => {
|
||||
const pctOfTotal = total > 0 ? (x.value / total) * 100 : 0;
|
||||
cum += x.value;
|
||||
const cumulativePct = total > 0 ? (cum / total) * 100 : 0;
|
||||
|
||||
if (threshold80Index === null && cumulativePct >= 80) threshold80Index = idx;
|
||||
|
||||
return {
|
||||
reasonCode: x.reasonCode,
|
||||
reasonLabel: x.reasonLabel,
|
||||
minutesLost: kind === "downtime" ? x.value : undefined,
|
||||
scrapQty: kind === "scrap" ? x.value : undefined,
|
||||
pctOfTotal,
|
||||
cumulativePct,
|
||||
count: x.count,
|
||||
};
|
||||
});
|
||||
|
||||
const top3 = rows.slice(0, 3);
|
||||
const threshold80 =
|
||||
threshold80Index === null
|
||||
? null
|
||||
: {
|
||||
index: threshold80Index,
|
||||
reasonCode: rows[threshold80Index].reasonCode,
|
||||
reasonLabel: rows[threshold80Index].reasonLabel,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
orgId,
|
||||
machineId: machineId ?? null,
|
||||
kind,
|
||||
range, // ✅ now defined correctly
|
||||
start, // ✅ now defined correctly
|
||||
totalMinutesLost: kind === "downtime" ? total : undefined,
|
||||
totalScrap: kind === "scrap" ? total : undefined,
|
||||
rows,
|
||||
top3,
|
||||
threshold80,
|
||||
// (optional) keep old shape if anything else uses it:
|
||||
items: itemsRaw.map((x, i) => ({
|
||||
...x,
|
||||
cumPct: rows[i]?.cumulativePct ?? 0,
|
||||
})),
|
||||
total,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user