This commit is contained in:
+403
@@ -0,0 +1,403 @@
|
||||
// Admin-dashboard data-access helpers. Built as plain async functions so admin
|
||||
// server components can await them directly (no HTTP round-trip) and we can
|
||||
// still expose them under /api/v1 later if a client UI needs live refresh.
|
||||
//
|
||||
// All of these are read-only. Write paths stay in the existing route handlers.
|
||||
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Work-in-progress
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Operations currently being worked on the shop floor: status is either
|
||||
* `in_progress` (an operator holds the claim) or `partial` (unclaimed but has
|
||||
* unitsCompleted logged — resumable). Ordered newest-claim first so the most
|
||||
* recent activity surfaces at the top of the dashboard.
|
||||
*/
|
||||
export async function getWipOperations(limit = 50) {
|
||||
const rows = await prisma.operation.findMany({
|
||||
where: { status: { in: ["in_progress", "partial"] } },
|
||||
orderBy: [{ status: "asc" }, { claimedAt: "desc" }, { updatedAt: "desc" }],
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
sequence: true,
|
||||
name: true,
|
||||
status: true,
|
||||
claimedAt: true,
|
||||
unitsCompleted: true,
|
||||
plannedMinutes: true,
|
||||
plannedUnits: true,
|
||||
machine: { select: { name: true, kind: true } },
|
||||
claimedBy: { select: { id: true, name: true } },
|
||||
part: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
qty: true,
|
||||
assembly: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
qty: true,
|
||||
project: { select: { id: true, code: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QC failures (blocked steps)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Operations currently in `qc_failed` — an operator stamped a failing QC
|
||||
* record on close and the step is blocked until an admin hits qc-reset.
|
||||
* We surface the most recent failing QCRecord alongside so the dashboard
|
||||
* can show the failing note / who did the inspection without needing a
|
||||
* second round-trip.
|
||||
*/
|
||||
export async function getQcFailures(limit = 25) {
|
||||
const rows = await prisma.operation.findMany({
|
||||
where: { status: "qc_failed" },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
sequence: true,
|
||||
name: true,
|
||||
kind: true,
|
||||
updatedAt: true,
|
||||
part: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
assembly: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
project: { select: { id: true, code: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
qcRecords: {
|
||||
where: { passed: false },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
notes: true,
|
||||
operator: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overdue projects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Projects whose `dueDate` has passed and which are not yet `completed` /
|
||||
* `cancelled`. We also compute a completion percentage (completed ops /
|
||||
* total ops) so the dashboard can show "40% through, 3 days late" at a
|
||||
* glance. Project is the only entity in the schema with a due-date field;
|
||||
* there's no per-operation deadline.
|
||||
*/
|
||||
export async function getOverdueProjects(now: Date = new Date(), limit = 50) {
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
dueDate: { lt: now },
|
||||
status: { notIn: ["completed", "cancelled"] },
|
||||
},
|
||||
orderBy: { dueDate: "asc" },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
status: true,
|
||||
dueDate: true,
|
||||
assemblies: {
|
||||
select: {
|
||||
parts: {
|
||||
select: {
|
||||
_count: { select: { operations: true } },
|
||||
operations: { select: { status: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return projects.map((p) => {
|
||||
let total = 0;
|
||||
let completed = 0;
|
||||
for (const a of p.assemblies) {
|
||||
for (const part of a.parts) {
|
||||
for (const op of part.operations) {
|
||||
total += 1;
|
||||
if (op.status === "completed") completed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: p.id,
|
||||
code: p.code,
|
||||
name: p.name,
|
||||
status: p.status,
|
||||
dueDate: p.dueDate!, // guaranteed non-null by the where clause
|
||||
totalOps: total,
|
||||
completedOps: completed,
|
||||
progressPct: total === 0 ? 0 : Math.round((completed / total) * 100),
|
||||
daysLate: Math.max(0, Math.floor((now.getTime() - p.dueDate!.getTime()) / 86_400_000)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hours: plan vs actual
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// We fetch every closed TimeLog in the window (endedAt IS NOT NULL, inside
|
||||
// since..until) and sum durations ourselves rather than using SQL GROUP BY —
|
||||
// Prisma's groupBy doesn't support computed duration columns, and a shop
|
||||
// floor's TimeLog volume (tens/day) makes in-memory aggregation trivial.
|
||||
//
|
||||
// "Plan" is the sum of Operation.plannedMinutes scoped the same way: ops
|
||||
// that have ANY TimeLog overlapping the window. That keeps plan and actual
|
||||
// comparable — we don't count ops the team never touched.
|
||||
|
||||
export interface HoursRow {
|
||||
id: string; // machine or operator id
|
||||
name: string;
|
||||
kind: "machine" | "operator";
|
||||
actualMinutes: number;
|
||||
plannedMinutes: number | null; // null when no ops in the window had plans
|
||||
operations: number; // distinct ops touched
|
||||
}
|
||||
|
||||
export interface HoursReport {
|
||||
sinceIso: string;
|
||||
untilIso: string;
|
||||
byMachine: HoursRow[];
|
||||
byOperator: HoursRow[];
|
||||
unassignedMachineMinutes: number; // ops with machineId null
|
||||
totalActualMinutes: number;
|
||||
totalPlannedMinutes: number;
|
||||
}
|
||||
|
||||
export async function getHoursReport(since: Date, until: Date = new Date()): Promise<HoursReport> {
|
||||
const logs = await prisma.timeLog.findMany({
|
||||
where: {
|
||||
endedAt: { not: null, gte: since, lte: until },
|
||||
},
|
||||
select: {
|
||||
startedAt: true,
|
||||
endedAt: true,
|
||||
operatorId: true,
|
||||
operator: { select: { name: true } },
|
||||
operation: {
|
||||
select: {
|
||||
id: true,
|
||||
plannedMinutes: true,
|
||||
machineId: true,
|
||||
machine: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const byMachine = new Map<string, HoursRow>();
|
||||
const byOperator = new Map<string, HoursRow>();
|
||||
// Ops we've already counted a plan for (plannedMinutes is per-op, not per-log)
|
||||
const planCountedOps = new Set<string>();
|
||||
// Distinct ops seen per bucket, so `operations` isn't double-counted across logs
|
||||
const opsPerMachine = new Map<string, Set<string>>();
|
||||
const opsPerOperator = new Map<string, Set<string>>();
|
||||
|
||||
let unassignedMachineMinutes = 0;
|
||||
let totalActual = 0;
|
||||
let totalPlanned = 0;
|
||||
|
||||
for (const log of logs) {
|
||||
const minutes = Math.max(
|
||||
0,
|
||||
Math.round((log.endedAt!.getTime() - log.startedAt.getTime()) / 60_000),
|
||||
);
|
||||
if (minutes === 0) continue;
|
||||
totalActual += minutes;
|
||||
|
||||
// --- by machine ---
|
||||
const mId = log.operation.machineId;
|
||||
if (mId) {
|
||||
const row = byMachine.get(mId) ?? {
|
||||
id: mId,
|
||||
name: log.operation.machine?.name ?? "(unknown)",
|
||||
kind: "machine" as const,
|
||||
actualMinutes: 0,
|
||||
plannedMinutes: 0,
|
||||
operations: 0,
|
||||
};
|
||||
row.actualMinutes += minutes;
|
||||
byMachine.set(mId, row);
|
||||
const ops = opsPerMachine.get(mId) ?? new Set<string>();
|
||||
ops.add(log.operation.id);
|
||||
opsPerMachine.set(mId, ops);
|
||||
} else {
|
||||
unassignedMachineMinutes += minutes;
|
||||
}
|
||||
|
||||
// --- by operator ---
|
||||
const opRow = byOperator.get(log.operatorId) ?? {
|
||||
id: log.operatorId,
|
||||
name: log.operator.name,
|
||||
kind: "operator" as const,
|
||||
actualMinutes: 0,
|
||||
plannedMinutes: 0,
|
||||
operations: 0,
|
||||
};
|
||||
opRow.actualMinutes += minutes;
|
||||
byOperator.set(log.operatorId, opRow);
|
||||
const ops = opsPerOperator.get(log.operatorId) ?? new Set<string>();
|
||||
ops.add(log.operation.id);
|
||||
opsPerOperator.set(log.operatorId, ops);
|
||||
|
||||
// --- planned total (per-op, once) ---
|
||||
if (log.operation.plannedMinutes != null && !planCountedOps.has(log.operation.id)) {
|
||||
planCountedOps.add(log.operation.id);
|
||||
totalPlanned += log.operation.plannedMinutes;
|
||||
if (mId) {
|
||||
const row = byMachine.get(mId)!;
|
||||
row.plannedMinutes = (row.plannedMinutes ?? 0) + log.operation.plannedMinutes;
|
||||
}
|
||||
// Operator plans don't really make sense (one op, many operators) —
|
||||
// leave byOperator.plannedMinutes at 0 and only show actual in that table.
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve distinct op counts
|
||||
for (const [id, ops] of opsPerMachine) byMachine.get(id)!.operations = ops.size;
|
||||
for (const [id, ops] of opsPerOperator) byOperator.get(id)!.operations = ops.size;
|
||||
|
||||
// Machines without plans → display null so UI shows "—"
|
||||
for (const row of byMachine.values()) {
|
||||
if (row.plannedMinutes === 0) row.plannedMinutes = null;
|
||||
}
|
||||
for (const row of byOperator.values()) row.plannedMinutes = null;
|
||||
|
||||
return {
|
||||
sinceIso: since.toISOString(),
|
||||
untilIso: until.toISOString(),
|
||||
byMachine: [...byMachine.values()].sort((a, b) => b.actualMinutes - a.actualMinutes),
|
||||
byOperator: [...byOperator.values()].sort((a, b) => b.actualMinutes - a.actualMinutes),
|
||||
unassignedMachineMinutes,
|
||||
totalActualMinutes: totalActual,
|
||||
totalPlannedMinutes: totalPlanned,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit log
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AuditQuery {
|
||||
limit?: number;
|
||||
cursor?: string | null; // AuditLog.id — we order by at desc, so cursor = last id on the page
|
||||
action?: string | null;
|
||||
entity?: string | null;
|
||||
actorId?: string | null;
|
||||
since?: Date | null;
|
||||
until?: Date | null;
|
||||
}
|
||||
|
||||
export async function getAuditLog(q: AuditQuery = {}) {
|
||||
const limit = Math.min(200, Math.max(1, q.limit ?? 50));
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
...(q.action ? { action: q.action } : {}),
|
||||
...(q.entity ? { entity: q.entity } : {}),
|
||||
...(q.actorId ? { actorId: q.actorId } : {}),
|
||||
...(q.since || q.until
|
||||
? { at: { ...(q.since ? { gte: q.since } : {}), ...(q.until ? { lte: q.until } : {}) } }
|
||||
: {}),
|
||||
},
|
||||
orderBy: { at: "desc" },
|
||||
take: limit + 1, // over-fetch by one so we can tell if there's a next page
|
||||
...(q.cursor ? { cursor: { id: q.cursor }, skip: 1 } : {}),
|
||||
select: {
|
||||
id: true,
|
||||
actorId: true,
|
||||
action: true,
|
||||
entity: true,
|
||||
entityId: true,
|
||||
before: true,
|
||||
after: true,
|
||||
ipAddress: true,
|
||||
at: true,
|
||||
actor: { select: { id: true, name: true, role: true } },
|
||||
},
|
||||
});
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
return { rows: page, nextCursor: hasMore ? page[page.length - 1]?.id ?? null : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct facet values so the admin filter dropdowns know what to offer
|
||||
* (instead of making the user guess action/entity names).
|
||||
*/
|
||||
export async function getAuditFacets() {
|
||||
const [actions, entities, actors] = await Promise.all([
|
||||
prisma.auditLog.findMany({ select: { action: true }, distinct: ["action"], orderBy: { action: "asc" } }),
|
||||
prisma.auditLog.findMany({ select: { entity: true }, distinct: ["entity"], orderBy: { entity: "asc" } }),
|
||||
prisma.user.findMany({
|
||||
where: { auditLogs: { some: {} } },
|
||||
select: { id: true, name: true, role: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
actions: actions.map((a) => a.action),
|
||||
entities: entities.map((e) => e.entity),
|
||||
actors,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatters (shared across admin pages)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatMinutes(m: number): string {
|
||||
if (m < 60) return `${m}m`;
|
||||
const hours = Math.floor(m / 60);
|
||||
const mins = m % 60;
|
||||
return mins === 0 ? `${hours}h` : `${hours}h ${mins}m`;
|
||||
}
|
||||
|
||||
export function formatRelative(d: Date, now: Date = new Date()): string {
|
||||
const sec = Math.round((now.getTime() - d.getTime()) / 1000);
|
||||
if (sec < 45) return "just now";
|
||||
if (sec < 90) return "a minute ago";
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
const day = Math.round(hr / 24);
|
||||
if (day < 30) return `${day}d ago`;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
Reference in New Issue
Block a user