Final MVP valid

This commit is contained in:
Marcelo
2026-01-21 01:45:57 +00:00
parent c183dda383
commit 511d80b629
29 changed files with 4827 additions and 381 deletions

View 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.",
});
}

View 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,
});
}

View 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,
});
}