441 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|