// 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 { 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(); const byOperator = new Map(); // Ops we've already counted a plan for (plannedMinutes is per-op, not per-log) const planCountedOps = new Set(); // Distinct ops seen per bucket, so `operations` isn't double-counted across logs const opsPerMachine = new Map>(); const opsPerOperator = new Map>(); 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(); 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(); 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(); }