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/machines" className="hover:text-slate-900">Machines</Link>
|
||||||
<Link href="/admin/operations" className="hover:text-slate-900">Operation templates</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/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>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-3 text-sm">
|
<div className="ml-auto flex items-center gap-3 text-sm">
|
||||||
<span className="text-slate-500">{user.name}</span>
|
<span className="text-slate-500">{user.name}</span>
|
||||||
|
|||||||
+284
-8
@@ -1,20 +1,40 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
getWipOperations,
|
||||||
|
getOverdueProjects,
|
||||||
|
getAuditLog,
|
||||||
|
getQcFailures,
|
||||||
|
formatRelative,
|
||||||
|
} from "@/lib/reports";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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() {
|
export default async function AdminDashboardPage() {
|
||||||
|
const now = new Date();
|
||||||
const [
|
const [
|
||||||
projectsTotal,
|
projectsTotal,
|
||||||
projectsActive,
|
projectsActive,
|
||||||
assembliesTotal,
|
assembliesTotal,
|
||||||
partsTotal,
|
partsTotal,
|
||||||
operationsTotal,
|
operationsTotal,
|
||||||
operationsInProgress,
|
operationsActive,
|
||||||
machinesActive,
|
machinesActive,
|
||||||
templatesActive,
|
templatesActive,
|
||||||
operatorsActive,
|
operatorsActive,
|
||||||
adminsActive,
|
adminsActive,
|
||||||
|
wip,
|
||||||
|
overdue,
|
||||||
|
qcFailures,
|
||||||
|
{ rows: recentAudit },
|
||||||
recentProjects,
|
recentProjects,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
prisma.project.count(),
|
prisma.project.count(),
|
||||||
@@ -22,11 +42,15 @@ export default async function AdminDashboardPage() {
|
|||||||
prisma.assembly.count(),
|
prisma.assembly.count(),
|
||||||
prisma.part.count(),
|
prisma.part.count(),
|
||||||
prisma.operation.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.machine.count({ where: { active: true } }),
|
||||||
prisma.operationTemplate.count({ where: { active: true } }),
|
prisma.operationTemplate.count({ where: { active: true } }),
|
||||||
prisma.user.count({ where: { role: "operator", active: true } }),
|
prisma.user.count({ where: { role: "operator", active: true } }),
|
||||||
prisma.user.count({ where: { role: "admin", active: true } }),
|
prisma.user.count({ where: { role: "admin", active: true } }),
|
||||||
|
getWipOperations(20),
|
||||||
|
getOverdueProjects(now, 10),
|
||||||
|
getQcFailures(10),
|
||||||
|
getAuditLog({ limit: 8 }),
|
||||||
prisma.project.findMany({
|
prisma.project.findMany({
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
take: 5,
|
take: 5,
|
||||||
@@ -57,7 +81,7 @@ export default async function AdminDashboardPage() {
|
|||||||
href="/admin/projects"
|
href="/admin/projects"
|
||||||
title="Operations"
|
title="Operations"
|
||||||
primary={operationsTotal}
|
primary={operationsTotal}
|
||||||
secondary={`${operationsInProgress} in progress`}
|
secondary={`${operationsActive} in progress or partial`}
|
||||||
/>
|
/>
|
||||||
<Tile
|
<Tile
|
||||||
href="/admin/machines"
|
href="/admin/machines"
|
||||||
@@ -77,12 +101,246 @@ export default async function AdminDashboardPage() {
|
|||||||
primary={adminsActive + operatorsActive}
|
primary={adminsActive + operatorsActive}
|
||||||
secondary={`${adminsActive} admin${adminsActive === 1 ? "" : "s"} · ${operatorsActive} operator${operatorsActive === 1 ? "" : "s"}`}
|
secondary={`${adminsActive} admin${adminsActive === 1 ? "" : "s"} · ${operatorsActive} operator${operatorsActive === 1 ? "" : "s"}`}
|
||||||
/>
|
/>
|
||||||
<div className="rounded-xl bg-white border border-slate-200 p-5">
|
<Tile
|
||||||
<h2 className="font-medium text-slate-700">Fasteners & POs</h2>
|
href="/admin/reports"
|
||||||
<p className="text-sm text-slate-500 mt-1">Purchasing lifecycle lands in step 6.</p>
|
title="Hours report"
|
||||||
</div>
|
primary={"→"}
|
||||||
|
secondary="Plan vs actual by machine and operator"
|
||||||
|
/>
|
||||||
</div>
|
</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">
|
<section className="mt-10">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-lg font-semibold">Recent projects</h2>
|
<h2 className="text-lg font-semibold">Recent projects</h2>
|
||||||
@@ -148,7 +406,7 @@ function Tile({
|
|||||||
}: {
|
}: {
|
||||||
href: string;
|
href: string;
|
||||||
title: string;
|
title: string;
|
||||||
primary: number;
|
primary: number | string;
|
||||||
secondary: string;
|
secondary: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -162,3 +420,21 @@ function Tile({
|
|||||||
</Link>
|
</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;
|
id: string;
|
||||||
sequence: number;
|
sequence: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
kind: string;
|
||||||
machineId: string | null;
|
machineId: string | null;
|
||||||
machineName: string | null;
|
machineName: string | null;
|
||||||
templateId: string | null;
|
templateId: string | null;
|
||||||
@@ -89,6 +90,7 @@ const STATUS_TONE: Record<string, "slate" | "blue" | "green" | "amber" | "red">
|
|||||||
in_progress: "blue",
|
in_progress: "blue",
|
||||||
partial: "amber",
|
partial: "amber",
|
||||||
completed: "green",
|
completed: "green",
|
||||||
|
qc_failed: "red",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
@@ -96,6 +98,7 @@ const STATUS_LABEL: Record<string, string> = {
|
|||||||
in_progress: "In progress",
|
in_progress: "In progress",
|
||||||
partial: "Partial",
|
partial: "Partial",
|
||||||
completed: "Completed",
|
completed: "Completed",
|
||||||
|
qc_failed: "QC failed",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatBytes(n: number) {
|
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 (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<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">
|
<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 text-slate-600">{op.sequence}</td>
|
||||||
<td className="px-3 py-3">
|
<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 ? (
|
{op.templateName ? (
|
||||||
<div className="text-xs text-slate-500">from {op.templateName}</div>
|
<div className="text-xs text-slate-500">from {op.templateName}</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -376,6 +402,16 @@ function OperationsSection({
|
|||||||
<Button variant="ghost" size="sm" onClick={() => setEdit(op)} disabled={busyId !== null}>
|
<Button variant="ghost" size="sm" onClick={() => setEdit(op)} disabled={busyId !== null}>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
|
{op.status === "qc_failed" ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => resetQc(op)}
|
||||||
|
disabled={busyId !== null}
|
||||||
|
>
|
||||||
|
Reset QC
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -528,6 +564,9 @@ function OperationModal({
|
|||||||
const editing = !!operation;
|
const editing = !!operation;
|
||||||
const [templateId, setTemplateId] = useState(operation?.templateId ?? "");
|
const [templateId, setTemplateId] = useState(operation?.templateId ?? "");
|
||||||
const [name, setName] = useState(operation?.name ?? "");
|
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 [machineId, setMachineId] = useState(operation?.machineId ?? "");
|
||||||
const [settings, setSettings] = useState(operation?.settings ?? "");
|
const [settings, setSettings] = useState(operation?.settings ?? "");
|
||||||
const [materialNotes, setMaterialNotes] = useState(operation?.materialNotes ?? "");
|
const [materialNotes, setMaterialNotes] = useState(operation?.materialNotes ?? "");
|
||||||
@@ -563,6 +602,7 @@ function OperationModal({
|
|||||||
const body = {
|
const body = {
|
||||||
templateId: templateId || null,
|
templateId: templateId || null,
|
||||||
name,
|
name,
|
||||||
|
kind,
|
||||||
machineId: machineId || null,
|
machineId: machineId || null,
|
||||||
settings,
|
settings,
|
||||||
materialNotes,
|
materialNotes,
|
||||||
@@ -623,6 +663,15 @@ function OperationModal({
|
|||||||
<Field label="Name" required>
|
<Field label="Name" required>
|
||||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||||
</Field>
|
</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">
|
<Field label="Machine">
|
||||||
<Select value={machineId} onChange={(e) => setMachineId(e.target.value)}>
|
<Select value={machineId} onChange={(e) => setMachineId(e.target.value)}>
|
||||||
<option value="">— none —</option>
|
<option value="">— none —</option>
|
||||||
@@ -669,12 +718,16 @@ function OperationModal({
|
|||||||
<span>QC check required on close-out</span>
|
<span>QC check required on close-out</span>
|
||||||
</label>
|
</label>
|
||||||
{editing && (
|
{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)}>
|
<Select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||||
<option value="pending">Pending</option>
|
<option value="pending">Pending</option>
|
||||||
<option value="in_progress">In progress</option>
|
<option value="in_progress">In progress</option>
|
||||||
<option value="partial">Partial</option>
|
<option value="partial">Partial</option>
|
||||||
<option value="completed">Completed</option>
|
<option value="completed">Completed</option>
|
||||||
|
<option value="qc_failed">QC failed</option>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export default async function AdminPartDetailPage({
|
|||||||
id: op.id,
|
id: op.id,
|
||||||
sequence: op.sequence,
|
sequence: op.sequence,
|
||||||
name: op.name,
|
name: op.name,
|
||||||
|
kind: op.kind,
|
||||||
machineId: op.machineId,
|
machineId: op.machineId,
|
||||||
machineName: op.machine?.name ?? null,
|
machineName: op.machine?.name ?? null,
|
||||||
templateId: op.templateId,
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,13 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
if (existing.status === "completed") {
|
if (existing.status === "completed") {
|
||||||
throw new ApiError(409, "op_completed", "This step is already completed");
|
throw new ApiError(409, "op_completed", "This step is already completed");
|
||||||
}
|
}
|
||||||
|
if (existing.status === "qc_failed") {
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
"op_qc_failed",
|
||||||
|
"This step failed QC and is locked until an admin resets it",
|
||||||
|
);
|
||||||
|
}
|
||||||
if (existing.claimedByUserId && existing.claimedByUserId !== actor.id) {
|
if (existing.claimedByUserId && existing.claimedByUserId !== actor.id) {
|
||||||
throw new ApiError(409, "op_claimed", "Another operator is already working on this step");
|
throw new ApiError(409, "op_claimed", "Another operator is already working on this step");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
kind: true,
|
||||||
claimedByUserId: true,
|
claimedByUserId: true,
|
||||||
qcRequired: true,
|
qcRequired: true,
|
||||||
unitsCompleted: true,
|
unitsCompleted: true,
|
||||||
@@ -46,8 +47,18 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
if (existing.status !== "in_progress") {
|
if (existing.status !== "in_progress") {
|
||||||
throw new ApiError(409, "op_not_active", "Step is not active");
|
throw new ApiError(409, "op_not_active", "Step is not active");
|
||||||
}
|
}
|
||||||
if (existing.qcRequired && !body.qc) {
|
// Dedicated QC ops (kind="qc") are all-about-QC — always demand the inline
|
||||||
throw new ApiError(400, "qc_required", "This step requires an inline QC check before completing");
|
// payload. Regular work ops only demand it when the template/op was flagged
|
||||||
|
// qcRequired. Either way, without a QC block we short-circuit.
|
||||||
|
const qcMandatory = existing.kind === "qc" || existing.qcRequired;
|
||||||
|
if (qcMandatory && !body.qc) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
"qc_required",
|
||||||
|
existing.kind === "qc"
|
||||||
|
? "QC result is required to complete an inspection step"
|
||||||
|
: "This step requires an inline QC check before completing",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Partial vs fully-done detection --------------------------------
|
// --- Partial vs fully-done detection --------------------------------
|
||||||
@@ -63,11 +74,23 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
//
|
//
|
||||||
// QC-required ops still demand a QC record even on partial close —
|
// QC-required ops still demand a QC record even on partial close —
|
||||||
// that's about material checks, not about finishing the batch.
|
// that's about material checks, not about finishing the batch.
|
||||||
|
// QC ops don't track units at all; treat them as "done" by default.
|
||||||
const units = body.unitsProcessed ?? 0;
|
const units = body.unitsProcessed ?? 0;
|
||||||
const totalUnits = existing.part.assembly.qty * existing.part.qty;
|
const totalUnits = existing.part.assembly.qty * existing.part.qty;
|
||||||
const remaining = Math.max(0, totalUnits - existing.unitsCompleted);
|
const remaining = Math.max(0, totalUnits - existing.unitsCompleted);
|
||||||
const wouldFinish = units === 0 || units >= remaining;
|
const isQcOp = existing.kind === "qc";
|
||||||
const nextStatus: "completed" | "partial" = wouldFinish ? "completed" : "partial";
|
const wouldFinish = isQcOp || units === 0 || units >= remaining;
|
||||||
|
|
||||||
|
// QC failure short-circuits the usual partial-vs-complete decision: the
|
||||||
|
// step moves to `qc_failed` which blocks further work until an admin
|
||||||
|
// clears it via /api/v1/operations/:id/qc-reset. We still log the QC
|
||||||
|
// record + close the timelog so the failure is on the paper trail.
|
||||||
|
const qcFailed = body.qc !== undefined && body.qc.passed === false;
|
||||||
|
const nextStatus: "completed" | "partial" | "qc_failed" = qcFailed
|
||||||
|
? "qc_failed"
|
||||||
|
: wouldFinish
|
||||||
|
? "completed"
|
||||||
|
: "partial";
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
@@ -99,8 +122,9 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
}
|
}
|
||||||
// unitsCompleted is cumulative across pause/resume cycles; on close we
|
// unitsCompleted is cumulative across pause/resume cycles; on close we
|
||||||
// add this session's batch so the total reflects everything the step
|
// add this session's batch so the total reflects everything the step
|
||||||
// actually produced. Partial close releases the claim so the next
|
// actually produced. Partial + qc_failed close release the claim so the
|
||||||
// operator can resume; completed close sets completedAt for reporting.
|
// next operator (or admin, in the fail case) can act; completed close
|
||||||
|
// sets completedAt for reporting.
|
||||||
await tx.operation.update({
|
await tx.operation.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -114,9 +138,15 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
});
|
});
|
||||||
|
|
||||||
const op = await prisma.operation.findUnique({ where: { id } });
|
const op = await prisma.operation.findUnique({ where: { id } });
|
||||||
|
const action =
|
||||||
|
nextStatus === "completed"
|
||||||
|
? "close_op"
|
||||||
|
: nextStatus === "qc_failed"
|
||||||
|
? "qc_fail_op"
|
||||||
|
: "partial_close_op";
|
||||||
await audit({
|
await audit({
|
||||||
actorId: actor.id,
|
actorId: actor.id,
|
||||||
action: nextStatus === "completed" ? "close_op" : "partial_close_op",
|
action,
|
||||||
entity: "Operation",
|
entity: "Operation",
|
||||||
entityId: id,
|
entityId: id,
|
||||||
after: {
|
after: {
|
||||||
@@ -128,7 +158,11 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
ipAddress: clientIp(req),
|
ipAddress: clientIp(req),
|
||||||
});
|
});
|
||||||
|
|
||||||
return ok({ operation: op, partial: nextStatus === "partial" });
|
return ok({
|
||||||
|
operation: op,
|
||||||
|
partial: nextStatus === "partial",
|
||||||
|
qcFailed: nextStatus === "qc_failed",
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return errorResponse(err);
|
return errorResponse(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { type NextRequest } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ok, errorResponse, requireRole, ApiError } from "@/lib/api";
|
||||||
|
import { audit } from "@/lib/audit";
|
||||||
|
import { clientIp } from "@/lib/request";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only: clear a QC failure so the step can be reworked. We roll the
|
||||||
|
* op back to either `pending` (nothing produced yet — the failed unit was
|
||||||
|
* the first attempt) or `partial` (some units had already been logged as
|
||||||
|
* good before the fail) based on the cumulative `unitsCompleted` counter.
|
||||||
|
*
|
||||||
|
* The failed QCRecord is intentionally LEFT IN PLACE so reporting can still
|
||||||
|
* count rework events — we just unblock the op.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const actor = await requireRole("admin");
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
|
||||||
|
const existing = await prisma.operation.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, status: true, unitsCompleted: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new ApiError(404, "not_found", "Operation not found");
|
||||||
|
if (existing.status !== "qc_failed") {
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
"op_not_qc_failed",
|
||||||
|
"Only steps in qc_failed state can be reset",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStatus = existing.unitsCompleted > 0 ? "partial" : "pending";
|
||||||
|
const updated = await prisma.operation.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: nextStatus,
|
||||||
|
// Claim was already cleared on the failing close; belt-and-braces
|
||||||
|
// defensively zero it here in case something set it back somehow.
|
||||||
|
claimedByUserId: null,
|
||||||
|
claimedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await audit({
|
||||||
|
actorId: actor.id,
|
||||||
|
action: "qc_reset_op",
|
||||||
|
entity: "Operation",
|
||||||
|
entityId: id,
|
||||||
|
before: { status: "qc_failed" },
|
||||||
|
after: { status: nextStatus, unitsCompleted: existing.unitsCompleted },
|
||||||
|
ipAddress: clientIp(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return ok({ operation: updated });
|
||||||
|
} catch (err) {
|
||||||
|
return errorResponse(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,13 @@ export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: strin
|
|||||||
data: {
|
data: {
|
||||||
...(body.templateId !== undefined ? { templateId: body.templateId } : {}),
|
...(body.templateId !== undefined ? { templateId: body.templateId } : {}),
|
||||||
...(body.name !== undefined ? { name: body.name } : {}),
|
...(body.name !== undefined ? { name: body.name } : {}),
|
||||||
|
// Switching to kind="qc" implicitly flips qcRequired on (an inspection
|
||||||
|
// step without mandatory QC is a contradiction). Switching back to
|
||||||
|
// kind="work" leaves qcRequired alone — the admin can toggle it
|
||||||
|
// explicitly if they want to drop the check.
|
||||||
|
...(body.kind !== undefined
|
||||||
|
? { kind: body.kind, ...(body.kind === "qc" ? { qcRequired: true } : {}) }
|
||||||
|
: {}),
|
||||||
...(body.machineId !== undefined ? { machineId: body.machineId } : {}),
|
...(body.machineId !== undefined ? { machineId: body.machineId } : {}),
|
||||||
...(body.settings !== undefined ? { settings: body.settings } : {}),
|
...(body.settings !== undefined ? { settings: body.settings } : {}),
|
||||||
...(body.materialNotes !== undefined ? { materialNotes: body.materialNotes } : {}),
|
...(body.materialNotes !== undefined ? { materialNotes: body.materialNotes } : {}),
|
||||||
|
|||||||
@@ -106,11 +106,14 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
sequence,
|
sequence,
|
||||||
templateId: template?.id ?? null,
|
templateId: template?.id ?? null,
|
||||||
name: body.name,
|
name: body.name,
|
||||||
|
kind: body.kind ?? "work",
|
||||||
machineId: effectiveMachineId,
|
machineId: effectiveMachineId,
|
||||||
settings: effectiveSettings,
|
settings: effectiveSettings,
|
||||||
materialNotes: body.materialNotes ?? null,
|
materialNotes: body.materialNotes ?? null,
|
||||||
instructions: effectiveInstructions,
|
instructions: effectiveInstructions,
|
||||||
qcRequired: effectiveQcRequired,
|
// Dedicated inspection ops are always QC-on-close — force the flag on
|
||||||
|
// at create time so downstream code doesn't have to special-case kind.
|
||||||
|
qcRequired: (body.kind ?? "work") === "qc" ? true : effectiveQcRequired,
|
||||||
plannedMinutes: body.plannedMinutes ?? null,
|
plannedMinutes: body.plannedMinutes ?? null,
|
||||||
plannedUnits: body.plannedUnits ?? null,
|
plannedUnits: body.plannedUnits ?? null,
|
||||||
qrToken,
|
qrToken,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type ScanOp = {
|
|||||||
id: string;
|
id: string;
|
||||||
sequence: number;
|
sequence: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
kind: string;
|
||||||
status: string;
|
status: string;
|
||||||
qcRequired: boolean;
|
qcRequired: boolean;
|
||||||
instructions: string | null;
|
instructions: string | null;
|
||||||
@@ -70,6 +71,13 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
|||||||
const active = op.status === "in_progress";
|
const active = op.status === "in_progress";
|
||||||
const partial = op.status === "partial";
|
const partial = op.status === "partial";
|
||||||
const completed = op.status === "completed";
|
const completed = op.status === "completed";
|
||||||
|
const qcFailed = op.status === "qc_failed";
|
||||||
|
// Dedicated inspection step: we show only the QC panel + Done button and
|
||||||
|
// hide unit counts / partial previews. Close demands a pass/fail record.
|
||||||
|
const isQcStep = op.kind === "qc";
|
||||||
|
// Any path that forces the inline QC block — dedicated QC ops, or work ops
|
||||||
|
// the admin flagged qcRequired.
|
||||||
|
const qcMandatory = isQcStep || op.qcRequired;
|
||||||
|
|
||||||
// Total units the shop needs to run through this op to satisfy the project:
|
// Total units the shop needs to run through this op to satisfy the project:
|
||||||
// assembly.qty (how many assemblies we're building) × part.qty (parts per
|
// assembly.qty (how many assemblies we're building) × part.qty (parts per
|
||||||
@@ -133,8 +141,12 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onClose() {
|
function onClose() {
|
||||||
if (op.qcRequired && qcPassed === null) {
|
if (qcMandatory && qcPassed === null) {
|
||||||
setError("This step requires QC — mark pass or fail before completing");
|
setError(
|
||||||
|
isQcStep
|
||||||
|
? "Inspection step — mark pass or fail to complete"
|
||||||
|
: "This step requires QC — mark pass or fail before completing",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -189,18 +201,26 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
|||||||
? "bg-amber-100 text-amber-800"
|
? "bg-amber-100 text-amber-800"
|
||||||
: partial
|
: partial
|
||||||
? "bg-orange-100 text-orange-800"
|
? "bg-orange-100 text-orange-800"
|
||||||
: "bg-slate-100 text-slate-700"
|
: qcFailed
|
||||||
|
? "bg-red-100 text-red-800"
|
||||||
|
: "bg-slate-100 text-slate-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{op.status === "in_progress"
|
{op.status === "in_progress"
|
||||||
? "in progress"
|
? "in progress"
|
||||||
: op.status === "partial"
|
: op.status === "partial"
|
||||||
? "partial"
|
? "partial"
|
||||||
: op.status}
|
: op.status === "qc_failed"
|
||||||
|
? "QC failed"
|
||||||
|
: op.status}
|
||||||
</span>
|
</span>
|
||||||
{op.qcRequired ? (
|
{isQcStep ? (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-blue-100 text-blue-800 text-xs px-2 py-1">
|
||||||
|
Inspection step
|
||||||
|
</span>
|
||||||
|
) : op.qcRequired ? (
|
||||||
<span className="inline-flex items-center rounded-full bg-purple-100 text-purple-800 text-xs px-2 py-1">
|
<span className="inline-flex items-center rounded-full bg-purple-100 text-purple-800 text-xs px-2 py-1">
|
||||||
QC
|
QC required
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -299,7 +319,17 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isOperator && !completed ? (
|
{qcFailed ? (
|
||||||
|
<div className="rounded-2xl bg-red-50 border border-red-200 p-5 text-center">
|
||||||
|
<div className="text-red-900 font-semibold">Blocked — QC failed</div>
|
||||||
|
<div className="text-sm text-red-800 mt-1">
|
||||||
|
The last run on this step failed inspection. An admin has to clear the failure
|
||||||
|
before the step can be reworked.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isOperator && !completed && !qcFailed ? (
|
||||||
<div className="rounded-2xl bg-white border border-slate-200 p-5 space-y-4">
|
<div className="rounded-2xl bg-white border border-slate-200 p-5 space-y-4">
|
||||||
{!claimedByMe && op.claimedBy && op.claimedByUserId !== viewer.id ? (
|
{!claimedByMe && op.claimedBy && op.claimedByUserId !== viewer.id ? (
|
||||||
<div className="rounded-md bg-amber-50 border border-amber-200 text-amber-900 text-sm px-3 py-2">
|
<div className="rounded-md bg-amber-50 border border-amber-200 text-amber-900 text-sm px-3 py-2">
|
||||||
@@ -316,37 +346,46 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
|||||||
{isPending
|
{isPending
|
||||||
? partial
|
? partial
|
||||||
? "Resuming…"
|
? "Resuming…"
|
||||||
: "Claiming…"
|
: isQcStep
|
||||||
|
? "Starting inspection…"
|
||||||
|
: "Claiming…"
|
||||||
: partial
|
: partial
|
||||||
? "Resume this step"
|
? "Resume this step"
|
||||||
: "Start this step"}
|
: isQcStep
|
||||||
|
? "Start inspection"
|
||||||
|
: "Start this step"}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* Dedicated QC ops don't track units — the purpose is the
|
||||||
|
pass/fail record, not a count — so we hide the units input
|
||||||
|
entirely and keep only the free-form note. */}
|
||||||
<div className="grid grid-cols-1 gap-3">
|
<div className="grid grid-cols-1 gap-3">
|
||||||
<label className="block">
|
{!isQcStep ? (
|
||||||
<span className="text-sm font-medium text-slate-900">
|
<label className="block">
|
||||||
Units processed
|
<span className="text-sm font-medium text-slate-900">
|
||||||
{op.unitsCompleted > 0 ? (
|
Units processed
|
||||||
<span className="ml-2 text-xs text-slate-500 font-normal">
|
{op.unitsCompleted > 0 ? (
|
||||||
{op.unitsCompleted} of {totalUnits} already done
|
<span className="ml-2 text-xs text-slate-500 font-normal">
|
||||||
</span>
|
{op.unitsCompleted} of {totalUnits} already done
|
||||||
) : null}
|
</span>
|
||||||
</span>
|
) : null}
|
||||||
<input
|
</span>
|
||||||
type="number"
|
<input
|
||||||
inputMode="numeric"
|
type="number"
|
||||||
min={0}
|
inputMode="numeric"
|
||||||
value={units}
|
min={0}
|
||||||
onChange={(e) => setUnits(e.target.value)}
|
value={units}
|
||||||
placeholder={
|
onChange={(e) => setUnits(e.target.value)}
|
||||||
op.unitsCompleted > 0
|
placeholder={
|
||||||
? `${Math.max(0, totalUnits - op.unitsCompleted)} remaining`
|
op.unitsCompleted > 0
|
||||||
: op.plannedUnits?.toString() ?? totalUnits.toString()
|
? `${Math.max(0, totalUnits - op.unitsCompleted)} remaining`
|
||||||
}
|
: op.plannedUnits?.toString() ?? totalUnits.toString()
|
||||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-base"
|
}
|
||||||
/>
|
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-base"
|
||||||
</label>
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-sm font-medium text-slate-900">Note (optional)</span>
|
<span className="text-sm font-medium text-slate-900">Note (optional)</span>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -358,9 +397,11 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{op.qcRequired ? (
|
{qcMandatory ? (
|
||||||
<div className="rounded-md bg-purple-50 border border-purple-200 p-3 space-y-2">
|
<div className="rounded-md bg-purple-50 border border-purple-200 p-3 space-y-2">
|
||||||
<div className="text-sm font-medium text-purple-900">QC check (required)</div>
|
<div className="text-sm font-medium text-purple-900">
|
||||||
|
{isQcStep ? "Inspection result (required)" : "QC check (required)"}
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -385,6 +426,13 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
|||||||
Fail
|
Fail
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{qcPassed === false ? (
|
||||||
|
<div className="rounded-md bg-red-50 border border-red-200 text-red-800 text-xs px-3 py-2">
|
||||||
|
Submitting <span className="font-semibold">Fail</span> will lock this step
|
||||||
|
in <span className="font-semibold">QC failed</span> — an admin has to clear
|
||||||
|
it before anyone can rework it.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{qcPassed !== null ? (
|
{qcPassed !== null ? (
|
||||||
<textarea
|
<textarea
|
||||||
value={qcNotes}
|
value={qcNotes}
|
||||||
@@ -402,39 +450,55 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
|||||||
close route's partial-detection logic: units blank or >=
|
close route's partial-detection logic: units blank or >=
|
||||||
remaining means "fully done", anything less is a partial
|
remaining means "fully done", anything less is a partial
|
||||||
handoff that releases the claim so the next operator can pick
|
handoff that releases the claim so the next operator can pick
|
||||||
it up.
|
it up. QC ops don't apply — they're all-or-nothing.
|
||||||
*/}
|
*/}
|
||||||
{(() => {
|
{!isQcStep
|
||||||
const typed = units ? Number(units) : 0;
|
? (() => {
|
||||||
const remaining = Math.max(0, totalUnits - op.unitsCompleted);
|
const typed = units ? Number(units) : 0;
|
||||||
const willPartial = typed > 0 && typed < remaining;
|
const remaining = Math.max(0, totalUnits - op.unitsCompleted);
|
||||||
return willPartial ? (
|
const willPartial = typed > 0 && typed < remaining;
|
||||||
<div className="rounded-md bg-orange-50 border border-orange-200 text-orange-900 text-xs px-3 py-2">
|
return willPartial ? (
|
||||||
Pressing <span className="font-semibold">Done</span> with {typed} of {remaining}{" "}
|
<div className="rounded-md bg-orange-50 border border-orange-200 text-orange-900 text-xs px-3 py-2">
|
||||||
remaining will mark this step <span className="font-semibold">Partial</span> and
|
Pressing <span className="font-semibold">Done</span> with {typed} of{" "}
|
||||||
release the claim so another operator can resume. Enter{" "}
|
{remaining} remaining will mark this step{" "}
|
||||||
<span className="font-mono">{remaining}</span> (or leave blank) if you actually
|
<span className="font-semibold">Partial</span> and release the claim so
|
||||||
finished the batch.
|
another operator can resume. Enter{" "}
|
||||||
</div>
|
<span className="font-mono">{remaining}</span> (or leave blank) if you
|
||||||
) : null;
|
actually finished the batch.
|
||||||
})()}
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()
|
||||||
|
: null}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{/* Pause doesn't make sense on a dedicated inspection step —
|
||||||
<button
|
either the checker passes, fails, or walks away without
|
||||||
onClick={onRelease}
|
stamping anything. Show Done only for QC ops. */}
|
||||||
disabled={isPending}
|
{isQcStep ? (
|
||||||
className="h-14 rounded-xl bg-white border border-slate-300 text-slate-900 text-lg font-medium disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{isPending ? "…" : "Pause"}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="h-14 rounded-xl bg-emerald-600 text-white text-lg font-medium disabled:bg-emerald-400"
|
className="w-full h-14 rounded-xl bg-emerald-600 text-white text-lg font-medium disabled:bg-emerald-400"
|
||||||
>
|
>
|
||||||
{isPending ? "…" : "Done"}
|
{isPending ? "…" : qcPassed === false ? "Submit failure" : "Submit inspection"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onRelease}
|
||||||
|
disabled={isPending}
|
||||||
|
className="h-14 rounded-xl bg-white border border-slate-300 text-slate-900 text-lg font-medium disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isPending ? "…" : "Pause"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isPending}
|
||||||
|
className="h-14 rounded-xl bg-emerald-600 text-white text-lg font-medium disabled:bg-emerald-400"
|
||||||
|
>
|
||||||
|
{isPending ? "…" : "Done"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+30
-3
@@ -10,16 +10,43 @@ The roadmap agreed at project kickoff. Each step is committed separately so the
|
|||||||
| 4 | Operator scan flow: claim → start → units/notes → QC prompt → close | **done** |
|
| 4 | Operator scan flow: claim → start → units/notes → QC prompt → close | **done** |
|
||||||
| 5 | PDF generation: per-operation card + per-part cover sheet | **done** |
|
| 5 | PDF generation: per-operation card + per-part cover sheet | **done** |
|
||||||
| 6 | Fasteners + PO (PDF + lifecycle: draft → sent → partial → received) | **done** |
|
| 6 | Fasteners + PO (PDF + lifecycle: draft → sent → partial → received) | **done** |
|
||||||
| 7 | Admin dashboard (WIP, overdue, plan-vs-actual) + audit log viewer | planned |
|
| 7 | Admin dashboard (WIP, overdue, plan-vs-actual hours by machine/operator) + audit log viewer | **done** |
|
||||||
| 8 | In-browser STEP viewer + server-side thumbnails | **done** |
|
| 8 | In-browser STEP viewer + server-side thumbnails | **done** |
|
||||||
| 9 | QC records (inline checkboxes + dedicated QC operation type) | planned |
|
| 9 | QC records (inline checkboxes + dedicated QC op type + fail-handling workflow) | **done** |
|
||||||
| 10 | OpenAPI docs at `/api/docs`, nightly SQLite backup script | planned |
|
| 10 | OpenAPI docs at `/api/docs`, nightly SQLite backup script | planned |
|
||||||
|
|
||||||
|
## Post-kickoff additions
|
||||||
|
|
||||||
|
Items that came out of floor-testing, not in the original roadmap.
|
||||||
|
|
||||||
|
### Landed
|
||||||
|
|
||||||
|
| Step | What | Status |
|
||||||
|
| ---- | ---- | ------ |
|
||||||
|
| A1 | File attachments on Assembly (STEP / drawing / cut) — shared across all parts in the assembly | **done** |
|
||||||
|
| A2 | Quick-link files on operator scan card (drawing + cut tap-targets; STEP rendered inline instead of downloaded) | **done** |
|
||||||
|
| A3 | Total quantity to produce (`assembly.qty × part.qty`) shown on scan card, traveler cover, op cards | **done** |
|
||||||
|
| A4 | Part + assembly drawing PDFs inlined into the traveler output (cover → assembly drawing → part drawing → op cards) | **done** |
|
||||||
|
| A5 | Partial-completion state (`partial` status + cumulative `unitsCompleted` counter); `claim` accepts pending OR partial; `release` → partial when units>0 | **done** |
|
||||||
|
| A6 | 3D STEP viewer embedded on operator scan card + admin assembly page (shared `StepViewerPanel`, load-on-tap) | **done** |
|
||||||
|
| A7 | "Done" button auto-detects partial: if typed units < remaining, step ends `partial` and releases claim instead of locking `completed` | **done** |
|
||||||
|
| A8 | QC fail workflow: `kind="qc"` dedicated inspection steps + `qc_failed` status blocks reclaim until admin hits qc-reset (landed together with Step 9) | **done** |
|
||||||
|
|
||||||
|
### Planned
|
||||||
|
|
||||||
|
| Step | What | Notes |
|
||||||
|
| ---- | ---- | ----- |
|
||||||
|
| B1 | Operator `/op` dashboard lists **resumable** ops (`status = "partial"` with no current claim) | Needed because A5+A7 mean partial ops become unclaimed — they currently only re-surface when someone physically re-scans the card |
|
||||||
|
| B2 | Progress column on traveler cover + op cards (`X of Y done`) driven by `unitsCompleted` | Makes paper reflect reality when a traveler is re-printed mid-run |
|
||||||
|
| ~~B3~~ | ~~QC fail workflow on close~~ | Landed as A8 alongside Step 9 — `qc_failed` locks the step; admin `qc-reset` rolls it back to `pending`/`partial` based on `unitsCompleted` |
|
||||||
|
|
||||||
|
Hours-by-machine / plan-vs-actual reporting is already scoped in Step 7. Fresh-testing findings are tracked ad-hoc.
|
||||||
|
|
||||||
## Locked design decisions
|
## Locked design decisions
|
||||||
|
|
||||||
- **Hierarchy:** Project → Assembly → Part → Operation.
|
- **Hierarchy:** Project → Assembly → Part → Operation.
|
||||||
- **QR granularity:** one QR per Operation; each step prints its own card.
|
- **QR granularity:** one QR per Operation; each step prints its own card.
|
||||||
- **Claim model:** an Operation locks to one operator on Start; other scans of an in-progress operation show a read-only view noting who holds it.
|
- **Claim model:** an Operation locks to one operator on Start; other scans of an in-progress operation show a read-only view noting who holds it. `partial` ops are unclaimed and resumable by any operator.
|
||||||
- **Operators can hold multiple operations at once** (across different parts).
|
- **Operators can hold multiple operations at once** (across different parts).
|
||||||
- **Purchase orders:** PDF generation + lifecycle states (`draft → sent → partial → received → cancelled`).
|
- **Purchase orders:** PDF generation + lifecycle states (`draft → sent → partial → received → cancelled`).
|
||||||
- **No offline mode.** The app assumes the shop LAN is up.
|
- **No offline mode.** The app assumes the shop LAN is up.
|
||||||
|
|||||||
+403
@@ -0,0 +1,403 @@
|
|||||||
|
// Admin-dashboard data-access helpers. Built as plain async functions so admin
|
||||||
|
// server components can await them directly (no HTTP round-trip) and we can
|
||||||
|
// still expose them under /api/v1 later if a client UI needs live refresh.
|
||||||
|
//
|
||||||
|
// All of these are read-only. Write paths stay in the existing route handlers.
|
||||||
|
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Work-in-progress
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations currently being worked on the shop floor: status is either
|
||||||
|
* `in_progress` (an operator holds the claim) or `partial` (unclaimed but has
|
||||||
|
* unitsCompleted logged — resumable). Ordered newest-claim first so the most
|
||||||
|
* recent activity surfaces at the top of the dashboard.
|
||||||
|
*/
|
||||||
|
export async function getWipOperations(limit = 50) {
|
||||||
|
const rows = await prisma.operation.findMany({
|
||||||
|
where: { status: { in: ["in_progress", "partial"] } },
|
||||||
|
orderBy: [{ status: "asc" }, { claimedAt: "desc" }, { updatedAt: "desc" }],
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
sequence: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
claimedAt: true,
|
||||||
|
unitsCompleted: true,
|
||||||
|
plannedMinutes: true,
|
||||||
|
plannedUnits: true,
|
||||||
|
machine: { select: { name: true, kind: true } },
|
||||||
|
claimedBy: { select: { id: true, name: true } },
|
||||||
|
part: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
qty: true,
|
||||||
|
assembly: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
qty: true,
|
||||||
|
project: { select: { id: true, code: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// QC failures (blocked steps)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations currently in `qc_failed` — an operator stamped a failing QC
|
||||||
|
* record on close and the step is blocked until an admin hits qc-reset.
|
||||||
|
* We surface the most recent failing QCRecord alongside so the dashboard
|
||||||
|
* can show the failing note / who did the inspection without needing a
|
||||||
|
* second round-trip.
|
||||||
|
*/
|
||||||
|
export async function getQcFailures(limit = 25) {
|
||||||
|
const rows = await prisma.operation.findMany({
|
||||||
|
where: { status: "qc_failed" },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
sequence: true,
|
||||||
|
name: true,
|
||||||
|
kind: true,
|
||||||
|
updatedAt: true,
|
||||||
|
part: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
assembly: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
project: { select: { id: true, code: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
qcRecords: {
|
||||||
|
where: { passed: false },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
notes: true,
|
||||||
|
operator: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Overdue projects
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projects whose `dueDate` has passed and which are not yet `completed` /
|
||||||
|
* `cancelled`. We also compute a completion percentage (completed ops /
|
||||||
|
* total ops) so the dashboard can show "40% through, 3 days late" at a
|
||||||
|
* glance. Project is the only entity in the schema with a due-date field;
|
||||||
|
* there's no per-operation deadline.
|
||||||
|
*/
|
||||||
|
export async function getOverdueProjects(now: Date = new Date(), limit = 50) {
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
dueDate: { lt: now },
|
||||||
|
status: { notIn: ["completed", "cancelled"] },
|
||||||
|
},
|
||||||
|
orderBy: { dueDate: "asc" },
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
dueDate: true,
|
||||||
|
assemblies: {
|
||||||
|
select: {
|
||||||
|
parts: {
|
||||||
|
select: {
|
||||||
|
_count: { select: { operations: true } },
|
||||||
|
operations: { select: { status: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return projects.map((p) => {
|
||||||
|
let total = 0;
|
||||||
|
let completed = 0;
|
||||||
|
for (const a of p.assemblies) {
|
||||||
|
for (const part of a.parts) {
|
||||||
|
for (const op of part.operations) {
|
||||||
|
total += 1;
|
||||||
|
if (op.status === "completed") completed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
code: p.code,
|
||||||
|
name: p.name,
|
||||||
|
status: p.status,
|
||||||
|
dueDate: p.dueDate!, // guaranteed non-null by the where clause
|
||||||
|
totalOps: total,
|
||||||
|
completedOps: completed,
|
||||||
|
progressPct: total === 0 ? 0 : Math.round((completed / total) * 100),
|
||||||
|
daysLate: Math.max(0, Math.floor((now.getTime() - p.dueDate!.getTime()) / 86_400_000)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hours: plan vs actual
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// We fetch every closed TimeLog in the window (endedAt IS NOT NULL, inside
|
||||||
|
// since..until) and sum durations ourselves rather than using SQL GROUP BY —
|
||||||
|
// Prisma's groupBy doesn't support computed duration columns, and a shop
|
||||||
|
// floor's TimeLog volume (tens/day) makes in-memory aggregation trivial.
|
||||||
|
//
|
||||||
|
// "Plan" is the sum of Operation.plannedMinutes scoped the same way: ops
|
||||||
|
// that have ANY TimeLog overlapping the window. That keeps plan and actual
|
||||||
|
// comparable — we don't count ops the team never touched.
|
||||||
|
|
||||||
|
export interface HoursRow {
|
||||||
|
id: string; // machine or operator id
|
||||||
|
name: string;
|
||||||
|
kind: "machine" | "operator";
|
||||||
|
actualMinutes: number;
|
||||||
|
plannedMinutes: number | null; // null when no ops in the window had plans
|
||||||
|
operations: number; // distinct ops touched
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HoursReport {
|
||||||
|
sinceIso: string;
|
||||||
|
untilIso: string;
|
||||||
|
byMachine: HoursRow[];
|
||||||
|
byOperator: HoursRow[];
|
||||||
|
unassignedMachineMinutes: number; // ops with machineId null
|
||||||
|
totalActualMinutes: number;
|
||||||
|
totalPlannedMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHoursReport(since: Date, until: Date = new Date()): Promise<HoursReport> {
|
||||||
|
const logs = await prisma.timeLog.findMany({
|
||||||
|
where: {
|
||||||
|
endedAt: { not: null, gte: since, lte: until },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
startedAt: true,
|
||||||
|
endedAt: true,
|
||||||
|
operatorId: true,
|
||||||
|
operator: { select: { name: true } },
|
||||||
|
operation: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
plannedMinutes: true,
|
||||||
|
machineId: true,
|
||||||
|
machine: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const byMachine = new Map<string, HoursRow>();
|
||||||
|
const byOperator = new Map<string, HoursRow>();
|
||||||
|
// Ops we've already counted a plan for (plannedMinutes is per-op, not per-log)
|
||||||
|
const planCountedOps = new Set<string>();
|
||||||
|
// Distinct ops seen per bucket, so `operations` isn't double-counted across logs
|
||||||
|
const opsPerMachine = new Map<string, Set<string>>();
|
||||||
|
const opsPerOperator = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
let unassignedMachineMinutes = 0;
|
||||||
|
let totalActual = 0;
|
||||||
|
let totalPlanned = 0;
|
||||||
|
|
||||||
|
for (const log of logs) {
|
||||||
|
const minutes = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round((log.endedAt!.getTime() - log.startedAt.getTime()) / 60_000),
|
||||||
|
);
|
||||||
|
if (minutes === 0) continue;
|
||||||
|
totalActual += minutes;
|
||||||
|
|
||||||
|
// --- by machine ---
|
||||||
|
const mId = log.operation.machineId;
|
||||||
|
if (mId) {
|
||||||
|
const row = byMachine.get(mId) ?? {
|
||||||
|
id: mId,
|
||||||
|
name: log.operation.machine?.name ?? "(unknown)",
|
||||||
|
kind: "machine" as const,
|
||||||
|
actualMinutes: 0,
|
||||||
|
plannedMinutes: 0,
|
||||||
|
operations: 0,
|
||||||
|
};
|
||||||
|
row.actualMinutes += minutes;
|
||||||
|
byMachine.set(mId, row);
|
||||||
|
const ops = opsPerMachine.get(mId) ?? new Set<string>();
|
||||||
|
ops.add(log.operation.id);
|
||||||
|
opsPerMachine.set(mId, ops);
|
||||||
|
} else {
|
||||||
|
unassignedMachineMinutes += minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- by operator ---
|
||||||
|
const opRow = byOperator.get(log.operatorId) ?? {
|
||||||
|
id: log.operatorId,
|
||||||
|
name: log.operator.name,
|
||||||
|
kind: "operator" as const,
|
||||||
|
actualMinutes: 0,
|
||||||
|
plannedMinutes: 0,
|
||||||
|
operations: 0,
|
||||||
|
};
|
||||||
|
opRow.actualMinutes += minutes;
|
||||||
|
byOperator.set(log.operatorId, opRow);
|
||||||
|
const ops = opsPerOperator.get(log.operatorId) ?? new Set<string>();
|
||||||
|
ops.add(log.operation.id);
|
||||||
|
opsPerOperator.set(log.operatorId, ops);
|
||||||
|
|
||||||
|
// --- planned total (per-op, once) ---
|
||||||
|
if (log.operation.plannedMinutes != null && !planCountedOps.has(log.operation.id)) {
|
||||||
|
planCountedOps.add(log.operation.id);
|
||||||
|
totalPlanned += log.operation.plannedMinutes;
|
||||||
|
if (mId) {
|
||||||
|
const row = byMachine.get(mId)!;
|
||||||
|
row.plannedMinutes = (row.plannedMinutes ?? 0) + log.operation.plannedMinutes;
|
||||||
|
}
|
||||||
|
// Operator plans don't really make sense (one op, many operators) —
|
||||||
|
// leave byOperator.plannedMinutes at 0 and only show actual in that table.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve distinct op counts
|
||||||
|
for (const [id, ops] of opsPerMachine) byMachine.get(id)!.operations = ops.size;
|
||||||
|
for (const [id, ops] of opsPerOperator) byOperator.get(id)!.operations = ops.size;
|
||||||
|
|
||||||
|
// Machines without plans → display null so UI shows "—"
|
||||||
|
for (const row of byMachine.values()) {
|
||||||
|
if (row.plannedMinutes === 0) row.plannedMinutes = null;
|
||||||
|
}
|
||||||
|
for (const row of byOperator.values()) row.plannedMinutes = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sinceIso: since.toISOString(),
|
||||||
|
untilIso: until.toISOString(),
|
||||||
|
byMachine: [...byMachine.values()].sort((a, b) => b.actualMinutes - a.actualMinutes),
|
||||||
|
byOperator: [...byOperator.values()].sort((a, b) => b.actualMinutes - a.actualMinutes),
|
||||||
|
unassignedMachineMinutes,
|
||||||
|
totalActualMinutes: totalActual,
|
||||||
|
totalPlannedMinutes: totalPlanned,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audit log
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface AuditQuery {
|
||||||
|
limit?: number;
|
||||||
|
cursor?: string | null; // AuditLog.id — we order by at desc, so cursor = last id on the page
|
||||||
|
action?: string | null;
|
||||||
|
entity?: string | null;
|
||||||
|
actorId?: string | null;
|
||||||
|
since?: Date | null;
|
||||||
|
until?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuditLog(q: AuditQuery = {}) {
|
||||||
|
const limit = Math.min(200, Math.max(1, q.limit ?? 50));
|
||||||
|
const rows = await prisma.auditLog.findMany({
|
||||||
|
where: {
|
||||||
|
...(q.action ? { action: q.action } : {}),
|
||||||
|
...(q.entity ? { entity: q.entity } : {}),
|
||||||
|
...(q.actorId ? { actorId: q.actorId } : {}),
|
||||||
|
...(q.since || q.until
|
||||||
|
? { at: { ...(q.since ? { gte: q.since } : {}), ...(q.until ? { lte: q.until } : {}) } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
orderBy: { at: "desc" },
|
||||||
|
take: limit + 1, // over-fetch by one so we can tell if there's a next page
|
||||||
|
...(q.cursor ? { cursor: { id: q.cursor }, skip: 1 } : {}),
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
actorId: true,
|
||||||
|
action: true,
|
||||||
|
entity: true,
|
||||||
|
entityId: true,
|
||||||
|
before: true,
|
||||||
|
after: true,
|
||||||
|
ipAddress: true,
|
||||||
|
at: true,
|
||||||
|
actor: { select: { id: true, name: true, role: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const hasMore = rows.length > limit;
|
||||||
|
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||||
|
return { rows: page, nextCursor: hasMore ? page[page.length - 1]?.id ?? null : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distinct facet values so the admin filter dropdowns know what to offer
|
||||||
|
* (instead of making the user guess action/entity names).
|
||||||
|
*/
|
||||||
|
export async function getAuditFacets() {
|
||||||
|
const [actions, entities, actors] = await Promise.all([
|
||||||
|
prisma.auditLog.findMany({ select: { action: true }, distinct: ["action"], orderBy: { action: "asc" } }),
|
||||||
|
prisma.auditLog.findMany({ select: { entity: true }, distinct: ["entity"], orderBy: { entity: "asc" } }),
|
||||||
|
prisma.user.findMany({
|
||||||
|
where: { auditLogs: { some: {} } },
|
||||||
|
select: { id: true, name: true, role: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
actions: actions.map((a) => a.action),
|
||||||
|
entities: entities.map((e) => e.entity),
|
||||||
|
actors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Formatters (shared across admin pages)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function formatMinutes(m: number): string {
|
||||||
|
if (m < 60) return `${m}m`;
|
||||||
|
const hours = Math.floor(m / 60);
|
||||||
|
const mins = m % 60;
|
||||||
|
return mins === 0 ? `${hours}h` : `${hours}h ${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelative(d: Date, now: Date = new Date()): string {
|
||||||
|
const sec = Math.round((now.getTime() - d.getTime()) / 1000);
|
||||||
|
if (sec < 45) return "just now";
|
||||||
|
if (sec < 90) return "a minute ago";
|
||||||
|
const min = Math.round(sec / 60);
|
||||||
|
if (min < 60) return `${min}m ago`;
|
||||||
|
const hr = Math.round(min / 60);
|
||||||
|
if (hr < 24) return `${hr}h ago`;
|
||||||
|
const day = Math.round(hr / 24);
|
||||||
|
if (day < 30) return `${day}d ago`;
|
||||||
|
return d.toLocaleDateString();
|
||||||
|
}
|
||||||
+19
-1
@@ -209,11 +209,28 @@ export const UpdatePartSchema = z
|
|||||||
// Behaves like "pending" for claim purposes (any operator can resume it) but
|
// Behaves like "pending" for claim purposes (any operator can resume it) but
|
||||||
// visually distinct so admins can see work-in-flight that isn't actively
|
// visually distinct so admins can see work-in-flight that isn't actively
|
||||||
// being run right now.
|
// being run right now.
|
||||||
export const OperationStatuses = ["pending", "in_progress", "partial", "completed"] as const;
|
// "qc_failed" = operator submitted a failing QC record on close. The step is
|
||||||
|
// blocked until an admin clears the failure via the qc-reset route (which
|
||||||
|
// rolls the step back to pending or partial depending on how much work was
|
||||||
|
// already logged against it).
|
||||||
|
export const OperationStatuses = [
|
||||||
|
"pending",
|
||||||
|
"in_progress",
|
||||||
|
"partial",
|
||||||
|
"completed",
|
||||||
|
"qc_failed",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// "work" (default) = normal production step; may optionally require QC on close.
|
||||||
|
// "qc" = dedicated inspection step — the whole point of the op is the QC
|
||||||
|
// record, so close always requires the inline qc payload and we don't care
|
||||||
|
// about unit counts or machine assignment.
|
||||||
|
export const OperationKinds = ["work", "qc"] as const;
|
||||||
|
|
||||||
export const CreateOperationSchema = z.object({
|
export const CreateOperationSchema = z.object({
|
||||||
templateId: z.string().min(1).nullable().optional(),
|
templateId: z.string().min(1).nullable().optional(),
|
||||||
name: NonEmpty,
|
name: NonEmpty,
|
||||||
|
kind: z.enum(OperationKinds).default("work").optional(),
|
||||||
machineId: z.string().min(1).nullable().optional(),
|
machineId: z.string().min(1).nullable().optional(),
|
||||||
settings: JsonString,
|
settings: JsonString,
|
||||||
materialNotes: OptionalText,
|
materialNotes: OptionalText,
|
||||||
@@ -228,6 +245,7 @@ export const UpdateOperationSchema = z
|
|||||||
.object({
|
.object({
|
||||||
templateId: z.string().min(1).nullable().optional(),
|
templateId: z.string().min(1).nullable().optional(),
|
||||||
name: NonEmpty.optional(),
|
name: NonEmpty.optional(),
|
||||||
|
kind: z.enum(OperationKinds).optional(),
|
||||||
machineId: z.string().min(1).nullable().optional(),
|
machineId: z.string().min(1).nullable().optional(),
|
||||||
settings: JsonString,
|
settings: JsonString,
|
||||||
materialNotes: OptionalText,
|
materialNotes: OptionalText,
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Adds Operation.kind ("work" | "qc") for dedicated inspection steps.
|
||||||
|
-- The `qc_failed` status value is application-level only (status is TEXT),
|
||||||
|
-- so no schema change is needed for that — migration is just the column add.
|
||||||
|
ALTER TABLE "Operation" ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'work';
|
||||||
@@ -167,11 +167,18 @@ model Operation {
|
|||||||
templateId String?
|
templateId String?
|
||||||
name String
|
name String
|
||||||
machineId String?
|
machineId String?
|
||||||
|
/// "work" (default) = regular production step, "qc" = dedicated inspection
|
||||||
|
/// step whose entire purpose is pass/fail. QC ops always require a QC record
|
||||||
|
/// on close and don't care about machines/units.
|
||||||
|
kind String @default("work") // work | qc
|
||||||
settings String? // JSON
|
settings String? // JSON
|
||||||
materialNotes String?
|
materialNotes String?
|
||||||
instructions String?
|
instructions String?
|
||||||
qcRequired Boolean @default(false)
|
qcRequired Boolean @default(false)
|
||||||
status String @default("pending") // pending | in_progress | partial | completed
|
/// pending | in_progress | partial | completed | qc_failed
|
||||||
|
/// qc_failed: operator submitted QC fail on close — step is blocked until
|
||||||
|
/// an admin resets it via /api/v1/operations/:id/qc-reset.
|
||||||
|
status String @default("pending")
|
||||||
qrToken String @unique
|
qrToken String @unique
|
||||||
claimedByUserId String?
|
claimedByUserId String?
|
||||||
claimedAt DateTime?
|
claimedAt DateTime?
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user