This commit is contained in:
@@ -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 <details> 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 (
|
||||
<div className="mx-auto max-w-7xl px-4 py-8">
|
||||
<div className="flex items-start justify-between gap-6 mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Audit log</h1>
|
||||
<p className="text-slate-500 mt-1 text-sm">
|
||||
Every write — claim, release, close, create, update, delete, login — lands here.
|
||||
Newest first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Filter bar ──────────────────────────────────────────── */}
|
||||
<form method="get" className="mb-6 rounded-xl bg-white border border-slate-200 p-4">
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-slate-700">Action</span>
|
||||
<select
|
||||
name="action"
|
||||
defaultValue={action ?? ""}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm bg-white"
|
||||
>
|
||||
<option value="">Any action</option>
|
||||
{facets.actions.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-slate-700">Entity</span>
|
||||
<select
|
||||
name="entity"
|
||||
defaultValue={entity ?? ""}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm bg-white"
|
||||
>
|
||||
<option value="">Any entity</option>
|
||||
{facets.entities.map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{e}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-slate-700">Actor</span>
|
||||
<select
|
||||
name="actorId"
|
||||
defaultValue={actorId ?? ""}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm bg-white"
|
||||
>
|
||||
<option value="">Any user</option>
|
||||
{facets.actors.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name} ({u.role})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex items-end gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="h-10 rounded-md bg-slate-900 text-white text-sm font-medium px-4"
|
||||
>
|
||||
Filter
|
||||
</button>
|
||||
{filtersActive ? (
|
||||
<Link
|
||||
href="/admin/audit"
|
||||
className="h-10 inline-flex items-center rounded-md border border-slate-300 text-sm font-medium px-4 text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Clear
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* ─── Results ─────────────────────────────────────────────── */}
|
||||
<div className="rounded-xl bg-white border border-slate-200 overflow-hidden">
|
||||
{rows.length === 0 ? (
|
||||
<p className="px-4 py-10 text-center text-slate-500 text-sm">
|
||||
No events match. Clear filters or widen your search.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium w-[140px]">When</th>
|
||||
<th className="px-4 py-2 font-medium w-[150px]">Actor</th>
|
||||
<th className="px-4 py-2 font-medium w-[150px]">Action</th>
|
||||
<th className="px-4 py-2 font-medium w-[220px]">Entity</th>
|
||||
<th className="px-4 py-2 font-medium">Payload</th>
|
||||
<th className="px-4 py-2 font-medium w-[100px]">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-slate-100 last:border-0 align-top">
|
||||
<td className="px-4 py-2 text-slate-600 whitespace-nowrap">
|
||||
<div>{formatRelative(row.at, now)}</div>
|
||||
<div className="text-xs text-slate-400">{row.at.toLocaleString()}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{row.actor ? (
|
||||
<>
|
||||
<div className="text-slate-900">{row.actor.name}</div>
|
||||
<div className="text-xs text-slate-500">{row.actor.role}</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-slate-400 italic">system</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="inline-flex rounded-full bg-slate-100 text-slate-700 text-xs px-2 py-0.5 font-mono">
|
||||
{row.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-slate-700 break-all">
|
||||
{row.entity}
|
||||
{row.entityId ? <span className="text-slate-500">/{row.entityId}</span> : null}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{row.before || row.after ? (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-xs text-blue-600">
|
||||
{row.after ? "after" : "before"}
|
||||
{row.before && row.after ? " / before" : ""}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{row.after ? (
|
||||
<JsonBlock label="after" value={row.after} />
|
||||
) : null}
|
||||
{row.before ? (
|
||||
<JsonBlock label="before" value={row.before} />
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<span className="text-slate-400 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-slate-500 font-mono">
|
||||
{row.ipAddress ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Pagination ──────────────────────────────────────────── */}
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-slate-600">
|
||||
<div>{rows.length} row{rows.length === 1 ? "" : "s"} on this page</div>
|
||||
{nextHref ? (
|
||||
<Link
|
||||
href={nextHref}
|
||||
className="inline-flex items-center rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm hover:bg-slate-50"
|
||||
>
|
||||
Older →
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-slate-400">End of log</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-slate-500">{label}</div>
|
||||
<pre className="mt-1 text-xs bg-slate-50 border border-slate-200 rounded-md p-2 overflow-x-auto">
|
||||
{pretty}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
<Link href="/admin/machines" className="hover:text-slate-900">Machines</Link>
|
||||
<Link href="/admin/operations" className="hover:text-slate-900">Operation templates</Link>
|
||||
<Link href="/admin/users" className="hover:text-slate-900">Users</Link>
|
||||
<Link href="/admin/reports" className="hover:text-slate-900">Reports</Link>
|
||||
<Link href="/admin/audit" className="hover:text-slate-900">Audit</Link>
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3 text-sm">
|
||||
<span className="text-slate-500">{user.name}</span>
|
||||
|
||||
+284
-8
@@ -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`}
|
||||
/>
|
||||
<Tile
|
||||
href="/admin/machines"
|
||||
@@ -77,12 +101,246 @@ export default async function AdminDashboardPage() {
|
||||
primary={adminsActive + operatorsActive}
|
||||
secondary={`${adminsActive} admin${adminsActive === 1 ? "" : "s"} · ${operatorsActive} operator${operatorsActive === 1 ? "" : "s"}`}
|
||||
/>
|
||||
<div className="rounded-xl bg-white border border-slate-200 p-5">
|
||||
<h2 className="font-medium text-slate-700">Fasteners & POs</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">Purchasing lifecycle lands in step 6.</p>
|
||||
</div>
|
||||
<Tile
|
||||
href="/admin/reports"
|
||||
title="Hours report"
|
||||
primary={"→"}
|
||||
secondary="Plan vs actual by machine and operator"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ─── Work in progress ─────────────────────────────────────── */}
|
||||
<section className="mt-10">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Work in progress</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Active claims plus resumable (partial) steps. {wip.length} shown.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white border border-slate-200 overflow-hidden">
|
||||
{wip.length === 0 ? (
|
||||
<p className="px-4 py-10 text-center text-slate-500 text-sm">
|
||||
Nothing active. Operators scan travelers to begin a step.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Project · Part</th>
|
||||
<th className="px-4 py-2 font-medium">Step</th>
|
||||
<th className="px-4 py-2 font-medium">Machine</th>
|
||||
<th className="px-4 py-2 font-medium">Operator</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2 font-medium">Progress</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{wip.map((op) => {
|
||||
const totalUnits = op.part.qty * op.part.assembly.qty;
|
||||
return (
|
||||
<tr key={op.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-2">
|
||||
<Link
|
||||
href={`/admin/projects/${op.part.assembly.project.id}/assemblies/${op.part.assembly.id}/parts/${op.part.id}`}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
<span className="font-mono">{op.part.assembly.project.code}</span>
|
||||
{" · "}
|
||||
<span className="font-mono">{op.part.code}</span>
|
||||
</Link>
|
||||
<div className="text-xs text-slate-500">{op.part.name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="text-slate-700">#{op.sequence}</span>{" "}
|
||||
<span className="text-slate-900">{op.name}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-600">
|
||||
{op.machine?.name ?? <span className="text-slate-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-700">
|
||||
{op.claimedBy?.name ?? <span className="text-slate-400">—</span>}
|
||||
{op.claimedAt ? (
|
||||
<div className="text-xs text-slate-500">
|
||||
since {formatRelative(op.claimedAt, now)}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<StatusPill status={op.status} />
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-600">
|
||||
{op.unitsCompleted} / {totalUnits}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── Overdue projects ─────────────────────────────────────── */}
|
||||
<section className="mt-10">
|
||||
<h2 className="text-lg font-semibold mb-3">Overdue projects</h2>
|
||||
<div className="rounded-xl bg-white border border-slate-200 overflow-hidden">
|
||||
{overdue.length === 0 ? (
|
||||
<p className="px-4 py-10 text-center text-slate-500 text-sm">
|
||||
Nothing overdue. Due dates live on the project edit screen.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Code</th>
|
||||
<th className="px-4 py-2 font-medium">Name</th>
|
||||
<th className="px-4 py-2 font-medium">Due</th>
|
||||
<th className="px-4 py-2 font-medium">Late</th>
|
||||
<th className="px-4 py-2 font-medium">Progress</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{overdue.map((p) => (
|
||||
<tr key={p.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-2 font-mono">
|
||||
<Link href={`/admin/projects/${p.id}`} className="text-blue-600 hover:underline">
|
||||
{p.code}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2">{p.name}</td>
|
||||
<td className="px-4 py-2 text-slate-600">{p.dueDate.toLocaleDateString()}</td>
|
||||
<td className="px-4 py-2 text-red-600 font-medium">{p.daysLate}d</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-24 rounded-full bg-slate-100 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500"
|
||||
style={{ width: `${p.progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-600">
|
||||
{p.completedOps}/{p.totalOps} ops · {p.progressPct}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── QC failures ──────────────────────────────────────────── */}
|
||||
{qcFailures.length > 0 ? (
|
||||
<section className="mt-10">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-red-800">QC failures</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Steps blocked by a failing inspection. Open the part to review and hit{" "}
|
||||
<span className="font-medium">Reset QC</span> to reopen for rework.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white border border-red-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-red-50 text-left text-red-900 border-b border-red-200">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Project · Part</th>
|
||||
<th className="px-4 py-2 font-medium">Step</th>
|
||||
<th className="px-4 py-2 font-medium">Kind</th>
|
||||
<th className="px-4 py-2 font-medium">Failed by</th>
|
||||
<th className="px-4 py-2 font-medium">Notes</th>
|
||||
<th className="px-4 py-2 font-medium">When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{qcFailures.map((op) => {
|
||||
const last = op.qcRecords[0] ?? null;
|
||||
return (
|
||||
<tr key={op.id} className="border-b border-slate-100 last:border-0 align-top">
|
||||
<td className="px-4 py-2">
|
||||
<Link
|
||||
href={`/admin/projects/${op.part.assembly.project.id}/assemblies/${op.part.assembly.id}/parts/${op.part.id}`}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
<span className="font-mono">{op.part.assembly.project.code}</span>
|
||||
{" · "}
|
||||
<span className="font-mono">{op.part.code}</span>
|
||||
</Link>
|
||||
<div className="text-xs text-slate-500">{op.part.name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="text-slate-700">#{op.sequence}</span>{" "}
|
||||
<span className="text-slate-900">{op.name}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-600">
|
||||
{op.kind === "qc" ? "Inspection" : "Work"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-700">
|
||||
{last?.operator.name ?? <span className="text-slate-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-600 max-w-[24ch]">
|
||||
{last?.notes ? (
|
||||
<span className="line-clamp-2" title={last.notes}>
|
||||
{last.notes}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-500 text-xs whitespace-nowrap">
|
||||
{last ? formatRelative(last.createdAt, now) : formatRelative(op.updatedAt, now)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* ─── Recent activity (audit log peek) ───────────────────── */}
|
||||
<section className="mt-10">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Recent activity</h2>
|
||||
<Link href="/admin/audit" className="text-sm text-blue-600 hover:underline">
|
||||
Full audit log →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white border border-slate-200 overflow-hidden">
|
||||
{recentAudit.length === 0 ? (
|
||||
<p className="px-4 py-10 text-center text-slate-500 text-sm">No activity yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{recentAudit.map((row) => (
|
||||
<li key={row.id} className="px-4 py-2 flex items-start gap-3 text-sm">
|
||||
<span className="text-xs text-slate-500 w-24 shrink-0 mt-0.5">
|
||||
{formatRelative(row.at, now)}
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
<span className="font-medium text-slate-900">
|
||||
{row.actor?.name ?? "system"}
|
||||
</span>
|
||||
<span className="text-slate-500"> · {row.action}</span>
|
||||
<span className="text-slate-500"> · </span>
|
||||
<span className="font-mono text-xs text-slate-600">
|
||||
{row.entity}
|
||||
{row.entityId ? `/${row.entityId.slice(0, 8)}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── Recent projects ─────────────────────────────────────── */}
|
||||
<section className="mt-10">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Recent projects</h2>
|
||||
@@ -148,7 +406,7 @@ function Tile({
|
||||
}: {
|
||||
href: string;
|
||||
title: string;
|
||||
primary: number;
|
||||
primary: number | string;
|
||||
secondary: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -162,3 +420,21 @@ function Tile({
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className={`inline-flex items-center rounded-full text-xs px-2 py-0.5 ${tone}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
+55
-2
@@ -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<string, "slate" | "blue" | "green" | "amber" | "red">
|
||||
in_progress: "blue",
|
||||
partial: "amber",
|
||||
completed: "green",
|
||||
qc_failed: "red",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
@@ -96,6 +98,7 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
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 (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -322,7 +345,10 @@ function OperationsSection({
|
||||
<tr key={op.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-3 py-3 text-slate-600">{op.sequence}</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="font-medium">{op.name}</div>
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
<span>{op.name}</span>
|
||||
{op.kind === "qc" ? <Badge tone="blue">QC step</Badge> : null}
|
||||
</div>
|
||||
{op.templateName ? (
|
||||
<div className="text-xs text-slate-500">from {op.templateName}</div>
|
||||
) : null}
|
||||
@@ -376,6 +402,16 @@ function OperationsSection({
|
||||
<Button variant="ghost" size="sm" onClick={() => setEdit(op)} disabled={busyId !== null}>
|
||||
Edit
|
||||
</Button>
|
||||
{op.status === "qc_failed" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetQc(op)}
|
||||
disabled={busyId !== null}
|
||||
>
|
||||
Reset QC
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -528,6 +564,9 @@ function OperationModal({
|
||||
const editing = !!operation;
|
||||
const [templateId, setTemplateId] = useState(operation?.templateId ?? "");
|
||||
const [name, setName] = useState(operation?.name ?? "");
|
||||
const [kind, setKind] = useState<"work" | "qc">(
|
||||
(operation?.kind as "work" | "qc") ?? "work",
|
||||
);
|
||||
const [machineId, setMachineId] = useState(operation?.machineId ?? "");
|
||||
const [settings, setSettings] = useState(operation?.settings ?? "");
|
||||
const [materialNotes, setMaterialNotes] = useState(operation?.materialNotes ?? "");
|
||||
@@ -563,6 +602,7 @@ function OperationModal({
|
||||
const body = {
|
||||
templateId: templateId || null,
|
||||
name,
|
||||
kind,
|
||||
machineId: machineId || null,
|
||||
settings,
|
||||
materialNotes,
|
||||
@@ -623,6 +663,15 @@ function OperationModal({
|
||||
<Field label="Name" required>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</Field>
|
||||
<Field
|
||||
label="Kind"
|
||||
hint='"Work" is a normal production step. "QC" is a dedicated inspection step — close always demands a pass/fail record and unit counts are ignored.'
|
||||
>
|
||||
<Select value={kind} onChange={(e) => setKind(e.target.value as "work" | "qc")}>
|
||||
<option value="work">Work — production step</option>
|
||||
<option value="qc">QC — dedicated inspection</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Machine">
|
||||
<Select value={machineId} onChange={(e) => setMachineId(e.target.value)}>
|
||||
<option value="">— none —</option>
|
||||
@@ -669,12 +718,16 @@ function OperationModal({
|
||||
<span>QC check required on close-out</span>
|
||||
</label>
|
||||
{editing && (
|
||||
<Field label="Status">
|
||||
<Field
|
||||
label="Status"
|
||||
hint="Use QC-failed only if you need to block a step out-of-band; the normal path is for the operator's Done/fail to set it."
|
||||
>
|
||||
<Select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="in_progress">In progress</option>
|
||||
<option value="partial">Partial</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="qc_failed">QC failed</option>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
@@ -86,6 +86,7 @@ export default async function AdminPartDetailPage({
|
||||
id: op.id,
|
||||
sequence: op.sequence,
|
||||
name: op.name,
|
||||
kind: op.kind,
|
||||
machineId: op.machineId,
|
||||
machineName: op.machine?.name ?? null,
|
||||
templateId: op.templateId,
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import Link from "next/link";
|
||||
import { getHoursReport, formatMinutes } from "@/lib/reports";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Plan-vs-actual hours report.
|
||||
*
|
||||
* Two tables: by machine, by operator. Durations come from closed TimeLog
|
||||
* rows in the selected window (default: last 30 days). "Plan" is the sum
|
||||
* of Operation.plannedMinutes for ops that had ANY TimeLog in the window
|
||||
* — scoped the same way as actual so the two columns are apples-to-apples.
|
||||
* Operator plans don't exist in the schema (an op has no planned operator),
|
||||
* so the operator table only shows actuals.
|
||||
*/
|
||||
|
||||
type RangeKey = "7d" | "30d" | "90d" | "all";
|
||||
|
||||
function rangeStart(key: RangeKey): Date {
|
||||
const now = new Date();
|
||||
if (key === "all") return new Date(0);
|
||||
const days = key === "7d" ? 7 : key === "30d" ? 30 : 90;
|
||||
return new Date(now.getTime() - days * 86_400_000);
|
||||
}
|
||||
|
||||
function isRange(v: unknown): v is RangeKey {
|
||||
return v === "7d" || v === "30d" || v === "90d" || v === "all";
|
||||
}
|
||||
|
||||
export default async function ReportsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ range?: string }>;
|
||||
}) {
|
||||
const { range: rawRange } = await searchParams;
|
||||
const range: RangeKey = isRange(rawRange) ? rawRange : "30d";
|
||||
const since = rangeStart(range);
|
||||
const report = await getHoursReport(since);
|
||||
|
||||
const planVarianceClass = (actual: number, planned: number | null) => {
|
||||
if (planned == null) return "text-slate-500";
|
||||
if (planned === 0) return "text-slate-500";
|
||||
const ratio = actual / planned;
|
||||
if (ratio > 1.15) return "text-red-600 font-medium";
|
||||
if (ratio < 0.85) return "text-emerald-600 font-medium";
|
||||
return "text-slate-700";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Hours report</h1>
|
||||
<p className="text-slate-500 mt-1 text-sm">
|
||||
Plan vs actual based on closed time logs. Longer sessions where an operator
|
||||
paused and resumed count every segment separately.
|
||||
</p>
|
||||
</div>
|
||||
<nav className="flex items-center gap-1 text-sm rounded-md border border-slate-200 bg-white p-1">
|
||||
{(["7d", "30d", "90d", "all"] as const).map((k) => (
|
||||
<Link
|
||||
key={k}
|
||||
href={`/admin/reports?range=${k}`}
|
||||
className={`px-3 py-1 rounded ${
|
||||
k === range
|
||||
? "bg-slate-900 text-white"
|
||||
: "text-slate-600 hover:text-slate-900 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
{k === "all" ? "All time" : `Last ${k}`}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* ─── Summary strip ───────────────────────────────────────── */}
|
||||
<div className="grid gap-4 sm:grid-cols-3 mb-8">
|
||||
<SummaryCard
|
||||
label="Total actual"
|
||||
value={formatMinutes(report.totalActualMinutes)}
|
||||
hint="Summed across every closed TimeLog"
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Total planned"
|
||||
value={report.totalPlannedMinutes > 0 ? formatMinutes(report.totalPlannedMinutes) : "—"}
|
||||
hint="Sum of plannedMinutes for ops touched in range"
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Variance"
|
||||
value={
|
||||
report.totalPlannedMinutes > 0
|
||||
? `${Math.round(
|
||||
((report.totalActualMinutes - report.totalPlannedMinutes) /
|
||||
report.totalPlannedMinutes) *
|
||||
100,
|
||||
)}%`
|
||||
: "—"
|
||||
}
|
||||
hint="Positive means actual > plan"
|
||||
tone={
|
||||
report.totalPlannedMinutes === 0
|
||||
? "neutral"
|
||||
: report.totalActualMinutes > report.totalPlannedMinutes * 1.15
|
||||
? "bad"
|
||||
: report.totalActualMinutes < report.totalPlannedMinutes * 0.85
|
||||
? "good"
|
||||
: "neutral"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ─── By machine ──────────────────────────────────────────── */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-lg font-semibold mb-3">By machine</h2>
|
||||
<div className="rounded-xl bg-white border border-slate-200 overflow-hidden">
|
||||
{report.byMachine.length === 0 ? (
|
||||
<p className="px-4 py-10 text-center text-slate-500 text-sm">
|
||||
No machine-attached time in this window.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Machine</th>
|
||||
<th className="px-4 py-2 font-medium">Ops</th>
|
||||
<th className="px-4 py-2 font-medium">Planned</th>
|
||||
<th className="px-4 py-2 font-medium">Actual</th>
|
||||
<th className="px-4 py-2 font-medium">Variance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.byMachine.map((row) => (
|
||||
<tr key={row.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-2">{row.name}</td>
|
||||
<td className="px-4 py-2 text-slate-600">{row.operations}</td>
|
||||
<td className="px-4 py-2 text-slate-700">
|
||||
{row.plannedMinutes == null ? "—" : formatMinutes(row.plannedMinutes)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-2 ${planVarianceClass(
|
||||
row.actualMinutes,
|
||||
row.plannedMinutes,
|
||||
)}`}
|
||||
>
|
||||
{formatMinutes(row.actualMinutes)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-600">
|
||||
{row.plannedMinutes == null || row.plannedMinutes === 0
|
||||
? "—"
|
||||
: `${Math.round(
|
||||
((row.actualMinutes - row.plannedMinutes) / row.plannedMinutes) * 100,
|
||||
)}%`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{report.unassignedMachineMinutes > 0 && (
|
||||
<tr className="border-t border-slate-200 bg-slate-50 text-xs text-slate-500">
|
||||
<td className="px-4 py-2 italic">Ops without a machine assigned</td>
|
||||
<td className="px-4 py-2">—</td>
|
||||
<td className="px-4 py-2">—</td>
|
||||
<td className="px-4 py-2">{formatMinutes(report.unassignedMachineMinutes)}</td>
|
||||
<td className="px-4 py-2">—</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── By operator ─────────────────────────────────────────── */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-3">By operator</h2>
|
||||
<p className="text-xs text-slate-500 mb-3">
|
||||
Plans are per-operation, not per-operator, so only actual time is shown here.
|
||||
</p>
|
||||
<div className="rounded-xl bg-white border border-slate-200 overflow-hidden">
|
||||
{report.byOperator.length === 0 ? (
|
||||
<p className="px-4 py-10 text-center text-slate-500 text-sm">
|
||||
No operator time in this window.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Operator</th>
|
||||
<th className="px-4 py-2 font-medium">Ops touched</th>
|
||||
<th className="px-4 py-2 font-medium">Actual</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.byOperator.map((row) => (
|
||||
<tr key={row.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-2">{row.name}</td>
|
||||
<td className="px-4 py-2 text-slate-600">{row.operations}</td>
|
||||
<td className="px-4 py-2 text-slate-700">{formatMinutes(row.actualMinutes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
tone?: "neutral" | "good" | "bad";
|
||||
}) {
|
||||
const valueClass =
|
||||
tone === "bad" ? "text-red-600" : tone === "good" ? "text-emerald-600" : "text-slate-900";
|
||||
return (
|
||||
<div className="rounded-xl bg-white border border-slate-200 p-5">
|
||||
<p className="text-xs text-slate-500">{label}</p>
|
||||
<p className={`text-2xl font-semibold tracking-tight mt-1 ${valueClass}`}>{value}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user