Files
mrp-qrcode/app/admin/page.tsx
T
jason e0dfac2d48
Build and Push Docker Image / build (push) Successful in 1m4s
step 9 and cleanup
2026-04-22 09:27:01 -05:00

441 lines
18 KiB
TypeScript

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,
operationsActive,
machinesActive,
templatesActive,
operatorsActive,
adminsActive,
wip,
overdue,
qcFailures,
{ rows: recentAudit },
recentProjects,
] = await Promise.all([
prisma.project.count(),
prisma.project.count({ where: { status: { in: ["planning", "in_progress"] } } }),
prisma.assembly.count(),
prisma.part.count(),
prisma.operation.count(),
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,
select: {
id: true,
code: true,
name: true,
status: true,
updatedAt: true,
_count: { select: { assemblies: true } },
},
}),
]);
return (
<div className="mx-auto max-w-7xl px-4 py-8">
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-slate-500 mt-1">Snapshot of the shop. Click any tile to dive in.</p>
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Tile
href="/admin/projects"
title="Projects"
primary={projectsTotal}
secondary={`${projectsActive} active · ${assembliesTotal} assemblies · ${partsTotal} parts`}
/>
<Tile
href="/admin/projects"
title="Operations"
primary={operationsTotal}
secondary={`${operationsActive} in progress or partial`}
/>
<Tile
href="/admin/machines"
title="Machines"
primary={machinesActive}
secondary="active"
/>
<Tile
href="/admin/operations"
title="Operation templates"
primary={templatesActive}
secondary="active"
/>
<Tile
href="/admin/users"
title="Users"
primary={adminsActive + operatorsActive}
secondary={`${adminsActive} admin${adminsActive === 1 ? "" : "s"} · ${operatorsActive} operator${operatorsActive === 1 ? "" : "s"}`}
/>
<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>
<Link href="/admin/projects" className="text-sm text-blue-600 hover:underline">
All projects
</Link>
</div>
<div className="rounded-xl bg-white border border-slate-200 overflow-hidden">
<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">Status</th>
<th className="px-4 py-2 font-medium">Assemblies</th>
<th className="px-4 py-2 font-medium">Updated</th>
</tr>
</thead>
<tbody>
{recentProjects.map((p) => (
<tr key={p.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-3 font-mono">
<Link href={`/admin/projects/${p.id}`} className="text-blue-600 hover:underline">
{p.code}
</Link>
</td>
<td className="px-4 py-3">{p.name}</td>
<td className="px-4 py-3 text-slate-600">{p.status}</td>
<td className="px-4 py-3 text-slate-600">{p._count.assemblies}</td>
<td className="px-4 py-3 text-slate-500">
{p.updatedAt.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</td>
</tr>
))}
{recentProjects.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-10 text-center text-slate-500">
No projects yet.{" "}
<Link href="/admin/projects" className="text-blue-600 hover:underline">
Create one
</Link>
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
</div>
);
}
function Tile({
href,
title,
primary,
secondary,
}: {
href: string;
title: string;
primary: number | string;
secondary: string;
}) {
return (
<Link
href={href}
className="block rounded-xl bg-white border border-slate-200 p-5 transition hover:border-slate-400 hover:shadow-sm"
>
<h2 className="font-medium text-slate-700">{title}</h2>
<p className="text-3xl font-semibold tracking-tight mt-2">{primary}</p>
<p className="text-xs text-slate-500 mt-1">{secondary}</p>
</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>
);
}