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

This commit is contained in:
jason
2026-04-22 09:27:01 -05:00
parent c8c86c9ca4
commit e0dfac2d48
18 changed files with 1521 additions and 85 deletions
+241
View File
@@ -0,0 +1,241 @@
import Link from "next/link";
import { getAuditLog, getAuditFacets, formatRelative } from "@/lib/reports";
export const dynamic = "force-dynamic";
/**
* Audit log viewer. Cursor-paginated, with action / entity / actor filters
* driven by the GET query string. Server-rendered so the URL is the source
* of truth — bookmarking a filtered view just works, and page reloads don't
* drop state.
*
* The JSON `before`/`after` payloads can be long; we render them lazily
* inside a <details> block so the table stays scannable.
*/
export default async function AuditPage({
searchParams,
}: {
searchParams: Promise<{
action?: string;
entity?: string;
actorId?: string;
cursor?: string;
}>;
}) {
const { action, entity, actorId, cursor } = await searchParams;
const [facets, { rows, nextCursor }] = await Promise.all([
getAuditFacets(),
getAuditLog({
limit: 50,
action: action || null,
entity: entity || null,
actorId: actorId || null,
cursor: cursor || null,
}),
]);
const now = new Date();
// Build a filter-preserving URL for the "Next page" link.
const nextHref = nextCursor
? `/admin/audit?${new URLSearchParams({
...(action ? { action } : {}),
...(entity ? { entity } : {}),
...(actorId ? { actorId } : {}),
cursor: nextCursor,
}).toString()}`
: null;
const filtersActive = !!(action || entity || actorId);
return (
<div className="mx-auto max-w-7xl px-4 py-8">
<div className="flex items-start justify-between gap-6 mb-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Audit log</h1>
<p className="text-slate-500 mt-1 text-sm">
Every write claim, release, close, create, update, delete, login lands here.
Newest first.
</p>
</div>
</div>
{/* ─── Filter bar ──────────────────────────────────────────── */}
<form method="get" className="mb-6 rounded-xl bg-white border border-slate-200 p-4">
<div className="grid gap-3 md:grid-cols-4">
<label className="block text-sm">
<span className="text-slate-700">Action</span>
<select
name="action"
defaultValue={action ?? ""}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm bg-white"
>
<option value="">Any action</option>
{facets.actions.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</label>
<label className="block text-sm">
<span className="text-slate-700">Entity</span>
<select
name="entity"
defaultValue={entity ?? ""}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm bg-white"
>
<option value="">Any entity</option>
{facets.entities.map((e) => (
<option key={e} value={e}>
{e}
</option>
))}
</select>
</label>
<label className="block text-sm">
<span className="text-slate-700">Actor</span>
<select
name="actorId"
defaultValue={actorId ?? ""}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm bg-white"
>
<option value="">Any user</option>
{facets.actors.map((u) => (
<option key={u.id} value={u.id}>
{u.name} ({u.role})
</option>
))}
</select>
</label>
<div className="flex items-end gap-2">
<button
type="submit"
className="h-10 rounded-md bg-slate-900 text-white text-sm font-medium px-4"
>
Filter
</button>
{filtersActive ? (
<Link
href="/admin/audit"
className="h-10 inline-flex items-center rounded-md border border-slate-300 text-sm font-medium px-4 text-slate-700 hover:bg-slate-50"
>
Clear
</Link>
) : null}
</div>
</div>
</form>
{/* ─── Results ─────────────────────────────────────────────── */}
<div className="rounded-xl bg-white border border-slate-200 overflow-hidden">
{rows.length === 0 ? (
<p className="px-4 py-10 text-center text-slate-500 text-sm">
No events match. Clear filters or widen your search.
</p>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
<tr>
<th className="px-4 py-2 font-medium w-[140px]">When</th>
<th className="px-4 py-2 font-medium w-[150px]">Actor</th>
<th className="px-4 py-2 font-medium w-[150px]">Action</th>
<th className="px-4 py-2 font-medium w-[220px]">Entity</th>
<th className="px-4 py-2 font-medium">Payload</th>
<th className="px-4 py-2 font-medium w-[100px]">IP</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className="border-b border-slate-100 last:border-0 align-top">
<td className="px-4 py-2 text-slate-600 whitespace-nowrap">
<div>{formatRelative(row.at, now)}</div>
<div className="text-xs text-slate-400">{row.at.toLocaleString()}</div>
</td>
<td className="px-4 py-2">
{row.actor ? (
<>
<div className="text-slate-900">{row.actor.name}</div>
<div className="text-xs text-slate-500">{row.actor.role}</div>
</>
) : (
<span className="text-slate-400 italic">system</span>
)}
</td>
<td className="px-4 py-2">
<span className="inline-flex rounded-full bg-slate-100 text-slate-700 text-xs px-2 py-0.5 font-mono">
{row.action}
</span>
</td>
<td className="px-4 py-2 font-mono text-xs text-slate-700 break-all">
{row.entity}
{row.entityId ? <span className="text-slate-500">/{row.entityId}</span> : null}
</td>
<td className="px-4 py-2">
{row.before || row.after ? (
<details>
<summary className="cursor-pointer text-xs text-blue-600">
{row.after ? "after" : "before"}
{row.before && row.after ? " / before" : ""}
</summary>
<div className="mt-2 space-y-2">
{row.after ? (
<JsonBlock label="after" value={row.after} />
) : null}
{row.before ? (
<JsonBlock label="before" value={row.before} />
) : null}
</div>
</details>
) : (
<span className="text-slate-400 text-xs"></span>
)}
</td>
<td className="px-4 py-2 text-xs text-slate-500 font-mono">
{row.ipAddress ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* ─── Pagination ──────────────────────────────────────────── */}
<div className="mt-4 flex items-center justify-between text-sm text-slate-600">
<div>{rows.length} row{rows.length === 1 ? "" : "s"} on this page</div>
{nextHref ? (
<Link
href={nextHref}
className="inline-flex items-center rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm hover:bg-slate-50"
>
Older
</Link>
) : (
<span className="text-slate-400">End of log</span>
)}
</div>
</div>
);
}
/**
* Pretty-prints a JSON audit payload. Falls back to the raw string if the
* row happens to contain non-JSON (older rows, or writes that logged a
* free-form message).
*/
function JsonBlock({ label, value }: { label: string; value: string }) {
let pretty = value;
try {
pretty = JSON.stringify(JSON.parse(value), null, 2);
} catch {
// leave as-is
}
return (
<div>
<div className="text-[10px] uppercase tracking-wide text-slate-500">{label}</div>
<pre className="mt-1 text-xs bg-slate-50 border border-slate-200 rounded-md p-2 overflow-x-auto">
{pretty}
</pre>
</div>
);
}
+2
View File
@@ -18,6 +18,8 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<Link href="/admin/machines" className="hover:text-slate-900">Machines</Link> <Link href="/admin/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
View File
@@ -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>
);
}
@@ -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,
+228
View File
@@ -0,0 +1,228 @@
import Link from "next/link";
import { getHoursReport, formatMinutes } from "@/lib/reports";
export const dynamic = "force-dynamic";
/**
* Plan-vs-actual hours report.
*
* Two tables: by machine, by operator. Durations come from closed TimeLog
* rows in the selected window (default: last 30 days). "Plan" is the sum
* of Operation.plannedMinutes for ops that had ANY TimeLog in the window
* — scoped the same way as actual so the two columns are apples-to-apples.
* Operator plans don't exist in the schema (an op has no planned operator),
* so the operator table only shows actuals.
*/
type RangeKey = "7d" | "30d" | "90d" | "all";
function rangeStart(key: RangeKey): Date {
const now = new Date();
if (key === "all") return new Date(0);
const days = key === "7d" ? 7 : key === "30d" ? 30 : 90;
return new Date(now.getTime() - days * 86_400_000);
}
function isRange(v: unknown): v is RangeKey {
return v === "7d" || v === "30d" || v === "90d" || v === "all";
}
export default async function ReportsPage({
searchParams,
}: {
searchParams: Promise<{ range?: string }>;
}) {
const { range: rawRange } = await searchParams;
const range: RangeKey = isRange(rawRange) ? rawRange : "30d";
const since = rangeStart(range);
const report = await getHoursReport(since);
const planVarianceClass = (actual: number, planned: number | null) => {
if (planned == null) return "text-slate-500";
if (planned === 0) return "text-slate-500";
const ratio = actual / planned;
if (ratio > 1.15) return "text-red-600 font-medium";
if (ratio < 0.85) return "text-emerald-600 font-medium";
return "text-slate-700";
};
return (
<div className="mx-auto max-w-7xl px-4 py-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Hours report</h1>
<p className="text-slate-500 mt-1 text-sm">
Plan vs actual based on closed time logs. Longer sessions where an operator
paused and resumed count every segment separately.
</p>
</div>
<nav className="flex items-center gap-1 text-sm rounded-md border border-slate-200 bg-white p-1">
{(["7d", "30d", "90d", "all"] as const).map((k) => (
<Link
key={k}
href={`/admin/reports?range=${k}`}
className={`px-3 py-1 rounded ${
k === range
? "bg-slate-900 text-white"
: "text-slate-600 hover:text-slate-900 hover:bg-slate-50"
}`}
>
{k === "all" ? "All time" : `Last ${k}`}
</Link>
))}
</nav>
</div>
{/* ─── Summary strip ───────────────────────────────────────── */}
<div className="grid gap-4 sm:grid-cols-3 mb-8">
<SummaryCard
label="Total actual"
value={formatMinutes(report.totalActualMinutes)}
hint="Summed across every closed TimeLog"
/>
<SummaryCard
label="Total planned"
value={report.totalPlannedMinutes > 0 ? formatMinutes(report.totalPlannedMinutes) : "—"}
hint="Sum of plannedMinutes for ops touched in range"
/>
<SummaryCard
label="Variance"
value={
report.totalPlannedMinutes > 0
? `${Math.round(
((report.totalActualMinutes - report.totalPlannedMinutes) /
report.totalPlannedMinutes) *
100,
)}%`
: "—"
}
hint="Positive means actual > plan"
tone={
report.totalPlannedMinutes === 0
? "neutral"
: report.totalActualMinutes > report.totalPlannedMinutes * 1.15
? "bad"
: report.totalActualMinutes < report.totalPlannedMinutes * 0.85
? "good"
: "neutral"
}
/>
</div>
{/* ─── By machine ──────────────────────────────────────────── */}
<section className="mb-8">
<h2 className="text-lg font-semibold mb-3">By machine</h2>
<div className="rounded-xl bg-white border border-slate-200 overflow-hidden">
{report.byMachine.length === 0 ? (
<p className="px-4 py-10 text-center text-slate-500 text-sm">
No machine-attached time in this window.
</p>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
<tr>
<th className="px-4 py-2 font-medium">Machine</th>
<th className="px-4 py-2 font-medium">Ops</th>
<th className="px-4 py-2 font-medium">Planned</th>
<th className="px-4 py-2 font-medium">Actual</th>
<th className="px-4 py-2 font-medium">Variance</th>
</tr>
</thead>
<tbody>
{report.byMachine.map((row) => (
<tr key={row.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-2">{row.name}</td>
<td className="px-4 py-2 text-slate-600">{row.operations}</td>
<td className="px-4 py-2 text-slate-700">
{row.plannedMinutes == null ? "—" : formatMinutes(row.plannedMinutes)}
</td>
<td
className={`px-4 py-2 ${planVarianceClass(
row.actualMinutes,
row.plannedMinutes,
)}`}
>
{formatMinutes(row.actualMinutes)}
</td>
<td className="px-4 py-2 text-slate-600">
{row.plannedMinutes == null || row.plannedMinutes === 0
? "—"
: `${Math.round(
((row.actualMinutes - row.plannedMinutes) / row.plannedMinutes) * 100,
)}%`}
</td>
</tr>
))}
{report.unassignedMachineMinutes > 0 && (
<tr className="border-t border-slate-200 bg-slate-50 text-xs text-slate-500">
<td className="px-4 py-2 italic">Ops without a machine assigned</td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2">{formatMinutes(report.unassignedMachineMinutes)}</td>
<td className="px-4 py-2"></td>
</tr>
)}
</tbody>
</table>
)}
</div>
</section>
{/* ─── By operator ─────────────────────────────────────────── */}
<section>
<h2 className="text-lg font-semibold mb-3">By operator</h2>
<p className="text-xs text-slate-500 mb-3">
Plans are per-operation, not per-operator, so only actual time is shown here.
</p>
<div className="rounded-xl bg-white border border-slate-200 overflow-hidden">
{report.byOperator.length === 0 ? (
<p className="px-4 py-10 text-center text-slate-500 text-sm">
No operator time in this window.
</p>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
<tr>
<th className="px-4 py-2 font-medium">Operator</th>
<th className="px-4 py-2 font-medium">Ops touched</th>
<th className="px-4 py-2 font-medium">Actual</th>
</tr>
</thead>
<tbody>
{report.byOperator.map((row) => (
<tr key={row.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-2">{row.name}</td>
<td className="px-4 py-2 text-slate-600">{row.operations}</td>
<td className="px-4 py-2 text-slate-700">{formatMinutes(row.actualMinutes)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
</div>
);
}
function SummaryCard({
label,
value,
hint,
tone = "neutral",
}: {
label: string;
value: string;
hint: string;
tone?: "neutral" | "good" | "bad";
}) {
const valueClass =
tone === "bad" ? "text-red-600" : tone === "good" ? "text-emerald-600" : "text-slate-900";
return (
<div className="rounded-xl bg-white border border-slate-200 p-5">
<p className="text-xs text-slate-500">{label}</p>
<p className={`text-2xl font-semibold tracking-tight mt-1 ${valueClass}`}>{value}</p>
<p className="text-xs text-slate-500 mt-1">{hint}</p>
</div>
);
}
@@ -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");
} }
+42 -8
View File
@@ -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);
}
}
+7
View File
@@ -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 } : {}),
+4 -1
View File
@@ -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,
+124 -60
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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';
+8 -1
View File
@@ -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?
+1 -1
View File
File diff suppressed because one or more lines are too long