step 9 and cleanup
Build and Push Docker Image / build (push) Successful in 1m4s

This commit is contained in:
jason
2026-04-22 09:27:01 -05:00
parent c8c86c9ca4
commit e0dfac2d48
18 changed files with 1521 additions and 85 deletions
+241
View File
@@ -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>
);
}
+2
View File
@@ -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
View File
@@ -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>
);
}
@@ -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,
+228
View File
@@ -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>
);
}