diff --git a/app/admin/audit/page.tsx b/app/admin/audit/page.tsx new file mode 100644 index 0000000..641e6c9 --- /dev/null +++ b/app/admin/audit/page.tsx @@ -0,0 +1,241 @@ +import Link from "next/link"; +import { getAuditLog, getAuditFacets, formatRelative } from "@/lib/reports"; + +export const dynamic = "force-dynamic"; + +/** + * Audit log viewer. Cursor-paginated, with action / entity / actor filters + * driven by the GET query string. Server-rendered so the URL is the source + * of truth — bookmarking a filtered view just works, and page reloads don't + * drop state. + * + * The JSON `before`/`after` payloads can be long; we render them lazily + * inside a
block so the table stays scannable. + */ +export default async function AuditPage({ + searchParams, +}: { + searchParams: Promise<{ + action?: string; + entity?: string; + actorId?: string; + cursor?: string; + }>; +}) { + const { action, entity, actorId, cursor } = await searchParams; + const [facets, { rows, nextCursor }] = await Promise.all([ + getAuditFacets(), + getAuditLog({ + limit: 50, + action: action || null, + entity: entity || null, + actorId: actorId || null, + cursor: cursor || null, + }), + ]); + const now = new Date(); + + // Build a filter-preserving URL for the "Next page" link. + const nextHref = nextCursor + ? `/admin/audit?${new URLSearchParams({ + ...(action ? { action } : {}), + ...(entity ? { entity } : {}), + ...(actorId ? { actorId } : {}), + cursor: nextCursor, + }).toString()}` + : null; + + const filtersActive = !!(action || entity || actorId); + + return ( +
+
+
+

Audit log

+

+ Every write — claim, release, close, create, update, delete, login — lands here. + Newest first. +

+
+
+ + {/* ─── Filter bar ──────────────────────────────────────────── */} +
+
+ + + +
+ + {filtersActive ? ( + + Clear + + ) : null} +
+
+
+ + {/* ─── Results ─────────────────────────────────────────────── */} +
+ {rows.length === 0 ? ( +

+ No events match. Clear filters or widen your search. +

+ ) : ( + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + +
WhenActorActionEntityPayloadIP
+
{formatRelative(row.at, now)}
+
{row.at.toLocaleString()}
+
+ {row.actor ? ( + <> +
{row.actor.name}
+
{row.actor.role}
+ + ) : ( + system + )} +
+ + {row.action} + + + {row.entity} + {row.entityId ? /{row.entityId} : null} + + {row.before || row.after ? ( +
+ + {row.after ? "after" : "before"} + {row.before && row.after ? " / before" : ""} + +
+ {row.after ? ( + + ) : null} + {row.before ? ( + + ) : null} +
+
+ ) : ( + + )} +
+ {row.ipAddress ?? "—"} +
+ )} +
+ + {/* ─── Pagination ──────────────────────────────────────────── */} +
+
{rows.length} row{rows.length === 1 ? "" : "s"} on this page
+ {nextHref ? ( + + Older → + + ) : ( + End of log + )} +
+
+ ); +} + +/** + * Pretty-prints a JSON audit payload. Falls back to the raw string if the + * row happens to contain non-JSON (older rows, or writes that logged a + * free-form message). + */ +function JsonBlock({ label, value }: { label: string; value: string }) { + let pretty = value; + try { + pretty = JSON.stringify(JSON.parse(value), null, 2); + } catch { + // leave as-is + } + return ( +
+
{label}
+
+        {pretty}
+      
+
+ ); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index f00e691..337b51f 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -18,6 +18,8 @@ export default async function AdminLayout({ children }: { children: React.ReactN Machines Operation templates Users + Reports + Audit
{user.name} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 53bb53a..7d7b534 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,20 +1,40 @@ import Link from "next/link"; import { prisma } from "@/lib/prisma"; +import { + getWipOperations, + getOverdueProjects, + getAuditLog, + getQcFailures, + formatRelative, +} from "@/lib/reports"; export const dynamic = "force-dynamic"; +/** + * Admin landing. Three jobs: + * 1. Top tiles — counts so the shop owner can spot a drop-off at a glance. + * 2. WIP + Overdue cards — "what's hot right now, what's slipping." + * 3. Recent activity — last handful of audit rows, linking to full log. + * + * Data comes from lib/reports.ts; the page itself is a dumb composition. + */ export default async function AdminDashboardPage() { + const now = new Date(); const [ projectsTotal, projectsActive, assembliesTotal, partsTotal, operationsTotal, - operationsInProgress, + operationsActive, machinesActive, templatesActive, operatorsActive, adminsActive, + wip, + overdue, + qcFailures, + { rows: recentAudit }, recentProjects, ] = await Promise.all([ prisma.project.count(), @@ -22,11 +42,15 @@ export default async function AdminDashboardPage() { prisma.assembly.count(), prisma.part.count(), prisma.operation.count(), - prisma.operation.count({ where: { status: "in_progress" } }), + prisma.operation.count({ where: { status: { in: ["in_progress", "partial"] } } }), prisma.machine.count({ where: { active: true } }), prisma.operationTemplate.count({ where: { active: true } }), prisma.user.count({ where: { role: "operator", active: true } }), prisma.user.count({ where: { role: "admin", active: true } }), + getWipOperations(20), + getOverdueProjects(now, 10), + getQcFailures(10), + getAuditLog({ limit: 8 }), prisma.project.findMany({ orderBy: { updatedAt: "desc" }, take: 5, @@ -57,7 +81,7 @@ export default async function AdminDashboardPage() { href="/admin/projects" title="Operations" primary={operationsTotal} - secondary={`${operationsInProgress} in progress`} + secondary={`${operationsActive} in progress or partial`} /> -
-

Fasteners & POs

-

Purchasing lifecycle lands in step 6.

-
+
+ {/* ─── Work in progress ─────────────────────────────────────── */} +
+
+
+

Work in progress

+

+ Active claims plus resumable (partial) steps. {wip.length} shown. +

+
+
+
+ {wip.length === 0 ? ( +

+ Nothing active. Operators scan travelers to begin a step. +

+ ) : ( + + + + + + + + + + + + + {wip.map((op) => { + const totalUnits = op.part.qty * op.part.assembly.qty; + return ( + + + + + + + + + ); + })} + +
Project · PartStepMachineOperatorStatusProgress
+ + {op.part.assembly.project.code} + {" · "} + {op.part.code} + +
{op.part.name}
+
+ #{op.sequence}{" "} + {op.name} + + {op.machine?.name ?? } + + {op.claimedBy?.name ?? } + {op.claimedAt ? ( +
+ since {formatRelative(op.claimedAt, now)} +
+ ) : null} +
+ + + {op.unitsCompleted} / {totalUnits} +
+ )} +
+
+ + {/* ─── Overdue projects ─────────────────────────────────────── */} +
+

Overdue projects

+
+ {overdue.length === 0 ? ( +

+ Nothing overdue. Due dates live on the project edit screen. +

+ ) : ( + + + + + + + + + + + + {overdue.map((p) => ( + + + + + + + + ))} + +
CodeNameDueLateProgress
+ + {p.code} + + {p.name}{p.dueDate.toLocaleDateString()}{p.daysLate}d +
+
+
+
+ + {p.completedOps}/{p.totalOps} ops · {p.progressPct}% + +
+
+ )} +
+
+ + {/* ─── QC failures ──────────────────────────────────────────── */} + {qcFailures.length > 0 ? ( +
+
+
+

QC failures

+

+ Steps blocked by a failing inspection. Open the part to review and hit{" "} + Reset QC to reopen for rework. +

+
+
+
+ + + + + + + + + + + + + {qcFailures.map((op) => { + const last = op.qcRecords[0] ?? null; + return ( + + + + + + + + + ); + })} + +
Project · PartStepKindFailed byNotesWhen
+ + {op.part.assembly.project.code} + {" · "} + {op.part.code} + +
{op.part.name}
+
+ #{op.sequence}{" "} + {op.name} + + {op.kind === "qc" ? "Inspection" : "Work"} + + {last?.operator.name ?? } + + {last?.notes ? ( + + {last.notes} + + ) : ( + + )} + + {last ? formatRelative(last.createdAt, now) : formatRelative(op.updatedAt, now)} +
+
+
+ ) : null} + + {/* ─── Recent activity (audit log peek) ───────────────────── */} +
+
+

Recent activity

+ + Full audit log → + +
+
+ {recentAudit.length === 0 ? ( +

No activity yet.

+ ) : ( +
    + {recentAudit.map((row) => ( +
  • + + {formatRelative(row.at, now)} + + + + {row.actor?.name ?? "system"} + + · {row.action} + · + + {row.entity} + {row.entityId ? `/${row.entityId.slice(0, 8)}` : ""} + + +
  • + ))} +
+ )} +
+
+ + {/* ─── Recent projects ─────────────────────────────────────── */}

Recent projects

@@ -148,7 +406,7 @@ function Tile({ }: { href: string; title: string; - primary: number; + primary: number | string; secondary: string; }) { return ( @@ -162,3 +420,21 @@ function Tile({ ); } + +function StatusPill({ status }: { status: string }) { + const tone = + status === "in_progress" + ? "bg-blue-100 text-blue-800" + : status === "partial" + ? "bg-orange-100 text-orange-800" + : status === "qc_failed" + ? "bg-red-100 text-red-800" + : "bg-slate-100 text-slate-700"; + const label = + status === "in_progress" ? "in progress" : status === "qc_failed" ? "QC failed" : status; + return ( + + {label} + + ); +} diff --git a/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx b/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx index 0ff0fcc..f4da39a 100644 --- a/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx +++ b/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx @@ -54,6 +54,7 @@ export interface OperationRow { id: string; sequence: number; name: string; + kind: string; machineId: string | null; machineName: string | null; templateId: string | null; @@ -89,6 +90,7 @@ const STATUS_TONE: Record in_progress: "blue", partial: "amber", completed: "green", + qc_failed: "red", }; const STATUS_LABEL: Record = { @@ -96,6 +98,7 @@ const STATUS_LABEL: Record = { in_progress: "In progress", partial: "Partial", completed: "Completed", + qc_failed: "QC failed", }; function formatBytes(n: number) { @@ -295,6 +298,26 @@ function OperationsSection({ } } + async function resetQc(op: OperationRow) { + if ( + !confirm( + `Clear QC failure on step ${op.sequence}. ${op.name}? The step will reopen for rework; the failing QC record stays on file.`, + ) + ) { + return; + } + setBusyId(op.id); + setError(null); + try { + await apiFetch(`/api/v1/operations/${op.id}/qc-reset`, { method: "POST" }); + onChange(); + } catch (err) { + setError(err instanceof ApiClientError ? err.message : "Reset failed"); + } finally { + setBusyId(null); + } + } + return (
@@ -322,7 +345,10 @@ function OperationsSection({ {op.sequence} -
{op.name}
+
+ {op.name} + {op.kind === "qc" ? QC step : null} +
{op.templateName ? (
from {op.templateName}
) : null} @@ -376,6 +402,16 @@ function OperationsSection({ + {op.status === "qc_failed" ? ( + + ) : null}
@@ -299,7 +319,17 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
) : null} - {isOperator && !completed ? ( + {qcFailed ? ( +
+
Blocked — QC failed
+
+ The last run on this step failed inspection. An admin has to clear the failure + before the step can be reworked. +
+
+ ) : null} + + {isOperator && !completed && !qcFailed ? (
{!claimedByMe && op.claimedBy && op.claimedByUserId !== viewer.id ? (
@@ -316,37 +346,46 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v {isPending ? partial ? "Resuming…" - : "Claiming…" + : isQcStep + ? "Starting inspection…" + : "Claiming…" : partial ? "Resume this step" - : "Start this step"} + : isQcStep + ? "Start inspection" + : "Start this step"} ) : ( <> + {/* Dedicated QC ops don't track units — the purpose is the + pass/fail record, not a count — so we hide the units input + entirely and keep only the free-form note. */}
- + {!isQcStep ? ( + + ) : null}