This commit is contained in:
+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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user