stage 4
Build and Push Docker Image / build (push) Successful in 1m6s

This commit is contained in:
jason
2026-04-21 09:29:44 -05:00
parent 41b06f89c0
commit fc5bce4868
19 changed files with 1469 additions and 190 deletions
+313
View File
@@ -0,0 +1,313 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
export type ScanOp = {
id: string;
sequence: number;
name: string;
status: string;
qcRequired: boolean;
instructions: string | null;
materialNotes: string | null;
settings: string | null;
plannedMinutes: number | null;
plannedUnits: number | null;
claimedByUserId: string | null;
claimedAt: string | null;
machine: { id: string; name: string; kind: string } | null;
part: {
id: string;
code: string;
name: string;
material: string | null;
qty: number;
assembly: {
id: string;
code: string;
name: string;
project: { id: string; code: string; name: string };
};
};
claimedBy: { id: string; name: string } | null;
};
type Viewer = {
id: string;
role: "admin" | "operator";
claimedByMe: boolean;
};
export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; viewer: Viewer }) {
const router = useRouter();
const [op, setOp] = useState(initialOp);
const [claimedByMe, setClaimedByMe] = useState(viewer.claimedByMe);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
// Inline form state for pause/done. We keep one set of fields and let the
// active button decide which API to hit — the fields (units processed, note)
// are identical between release and close.
const [units, setUnits] = useState("");
const [note, setNote] = useState("");
const [qcPassed, setQcPassed] = useState<null | boolean>(null);
const [qcNotes, setQcNotes] = useState("");
const isOperator = viewer.role === "operator";
const active = op.status === "in_progress";
const completed = op.status === "completed";
async function call(path: string, body?: unknown) {
setError(null);
const res = await fetch(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data.error ?? "Request failed");
return null;
}
return data;
}
function onClaim() {
startTransition(async () => {
const data = await call(`/api/v1/operations/${op.id}/claim`);
if (data?.operation) {
setOp({ ...op, ...data.operation });
setClaimedByMe(true);
}
});
}
function onRelease() {
startTransition(async () => {
const data = await call(`/api/v1/operations/${op.id}/release`, {
unitsProcessed: units ? Number(units) : undefined,
note: note || undefined,
});
if (data?.operation) {
setOp({ ...op, ...data.operation });
setClaimedByMe(false);
// Bounce back to /op so the operator sees their queue.
router.push("/op");
}
});
}
function onClose() {
if (op.qcRequired && qcPassed === null) {
setError("This step requires QC — mark pass or fail before completing");
return;
}
startTransition(async () => {
const data = await call(`/api/v1/operations/${op.id}/close`, {
unitsProcessed: units ? Number(units) : undefined,
note: note || undefined,
qc:
qcPassed === null
? undefined
: { passed: qcPassed, notes: qcNotes || undefined, measurements: "" },
});
if (data?.operation) {
setOp({ ...op, ...data.operation });
setClaimedByMe(false);
router.push("/op");
}
});
}
return (
<div className="space-y-4">
<div className="rounded-2xl bg-white border border-slate-200 p-5 shadow-sm">
<div className="text-xs text-slate-500">
{op.part.assembly.project.code} · {op.part.assembly.code}
</div>
<h1 className="text-xl font-semibold mt-1">{op.part.name}</h1>
<div className="text-slate-600 text-sm">
Part <span className="font-mono">{op.part.code}</span>
{op.part.material ? ` · ${op.part.material}` : null} · qty {op.part.qty}
</div>
<div className="mt-4 flex items-center gap-2">
<span className="inline-flex items-center rounded-full bg-slate-100 text-slate-700 text-xs px-2 py-1">
Step {op.sequence}
</span>
<span
className={`inline-flex items-center rounded-full text-xs px-2 py-1 ${
completed
? "bg-emerald-100 text-emerald-800"
: active
? "bg-amber-100 text-amber-800"
: "bg-slate-100 text-slate-700"
}`}
>
{op.status === "in_progress" ? "in progress" : op.status}
</span>
{op.qcRequired ? (
<span className="inline-flex items-center rounded-full bg-purple-100 text-purple-800 text-xs px-2 py-1">
QC
</span>
) : null}
</div>
<div className="mt-3 text-lg">{op.name}</div>
{op.machine ? (
<div className="text-sm text-slate-600 mt-1">
Machine: <span className="font-medium">{op.machine.name}</span>
<span className="text-slate-400"> ({op.machine.kind})</span>
</div>
) : null}
{op.plannedMinutes || op.plannedUnits ? (
<div className="text-sm text-slate-600 mt-1">
Plan:
{op.plannedMinutes ? ` ${op.plannedMinutes} min` : ""}
{op.plannedMinutes && op.plannedUnits ? " ·" : ""}
{op.plannedUnits ? ` ${op.plannedUnits} units` : ""}
</div>
) : null}
</div>
{op.instructions ? (
<div className="rounded-2xl bg-white border border-slate-200 p-5">
<h2 className="text-sm font-semibold text-slate-900 mb-1">Instructions</h2>
<p className="whitespace-pre-wrap text-sm text-slate-700">{op.instructions}</p>
</div>
) : null}
{op.materialNotes ? (
<div className="rounded-2xl bg-white border border-slate-200 p-5">
<h2 className="text-sm font-semibold text-slate-900 mb-1">Material notes</h2>
<p className="whitespace-pre-wrap text-sm text-slate-700">{op.materialNotes}</p>
</div>
) : null}
{op.settings ? (
<div className="rounded-2xl bg-white border border-slate-200 p-5">
<h2 className="text-sm font-semibold text-slate-900 mb-1">Settings</h2>
<pre className="text-xs bg-slate-50 border border-slate-200 rounded-md p-3 overflow-auto">
{op.settings}
</pre>
</div>
) : null}
{error ? (
<div className="rounded-md bg-red-50 border border-red-200 text-red-700 text-sm px-3 py-2">
{error}
</div>
) : null}
{isOperator && !completed ? (
<div className="rounded-2xl bg-white border border-slate-200 p-5 space-y-4">
{!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">
<span className="font-medium">{op.claimedBy.name}</span> is currently on this step.
</div>
) : null}
{!claimedByMe ? (
<button
onClick={onClaim}
disabled={isPending || (!!op.claimedByUserId && op.claimedByUserId !== viewer.id)}
className="w-full h-14 rounded-xl bg-slate-900 text-white text-lg font-medium disabled:bg-slate-400"
>
{isPending ? "Claiming…" : "Start this step"}
</button>
) : (
<>
<div className="grid grid-cols-1 gap-3">
<label className="block">
<span className="text-sm font-medium text-slate-900">Units processed</span>
<input
type="number"
inputMode="numeric"
min={0}
value={units}
onChange={(e) => setUnits(e.target.value)}
placeholder={op.plannedUnits?.toString() ?? "0"}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-base"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-900">Note (optional)</span>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={2}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
</label>
</div>
{op.qcRequired ? (
<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="flex gap-2">
<button
type="button"
onClick={() => setQcPassed(true)}
className={`flex-1 h-10 rounded-md border text-sm font-medium ${
qcPassed === true
? "bg-emerald-600 text-white border-emerald-700"
: "bg-white text-slate-700 border-slate-300"
}`}
>
Pass
</button>
<button
type="button"
onClick={() => setQcPassed(false)}
className={`flex-1 h-10 rounded-md border text-sm font-medium ${
qcPassed === false
? "bg-red-600 text-white border-red-700"
: "bg-white text-slate-700 border-slate-300"
}`}
>
Fail
</button>
</div>
{qcPassed !== null ? (
<textarea
value={qcNotes}
onChange={(e) => setQcNotes(e.target.value)}
rows={2}
placeholder="QC notes"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
) : null}
</div>
) : null}
<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>
) : null}
{completed ? (
<div className="rounded-2xl bg-emerald-50 border border-emerald-200 p-5 text-center">
<div className="text-emerald-900 font-semibold">Step completed</div>
<div className="text-sm text-emerald-800 mt-1">Scan the next card or head back to your dashboard.</div>
</div>
) : null}
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/auth";
import ScanClient, { type ScanOp } from "./ScanClient";
/**
* Entry point for a QR scan. Resolves the token server-side so we can:
* 1. Redirect unauthenticated visitors to /login/operator with ?next=<here>,
* so they land back on this same page after PIN entry.
* 2. Show a clean "unknown token" page for bad scans (old card, typo) instead
* of the generic 404.
* 3. Render the interactive ScanClient with everything the operator needs
* to read the work card and claim / pause / complete.
*
* Admins who scan get a read-only preview — we don't let them claim because
* the claim holder is supposed to be the one running the machine.
*/
export default async function ScanPage({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const user = await getCurrentUser();
if (!user) {
redirect(`/login/operator?next=${encodeURIComponent(`/op/scan/${token}`)}`);
}
const op = await prisma.operation.findUnique({
where: { qrToken: token },
include: {
machine: { select: { id: true, name: true, kind: true } },
part: {
select: {
id: true,
code: true,
name: true,
material: true,
qty: true,
assembly: {
select: {
id: true,
code: true,
name: true,
project: { select: { id: true, code: true, name: true } },
},
},
},
},
claimedBy: { select: { id: true, name: true } },
},
});
if (!op) {
return (
<div className="mx-auto max-w-xl px-4 py-10">
<div className="rounded-xl bg-white border border-slate-200 p-6 text-center">
<h1 className="text-xl font-semibold">QR code not recognised</h1>
<p className="text-slate-600 mt-2 text-sm">
This card might be from an older print, or the operation was deleted. Ask your supervisor
to reprint the traveler.
</p>
<Link
href="/op"
className="inline-block mt-4 rounded-md bg-slate-900 text-white px-4 py-2 text-sm"
>
Back to dashboard
</Link>
</div>
</div>
);
}
if (user.role !== "operator") {
// Admin preview: render a read-only summary, no action buttons.
return (
<div className="mx-auto max-w-xl px-4 py-8">
<p className="text-xs uppercase tracking-wide text-slate-500 mb-2">Admin preview</p>
<ScanClient
initialOp={serialise(op)}
viewer={{ id: user.id, role: "admin", claimedByMe: false }}
/>
</div>
);
}
const claimedByMe = op.claimedByUserId === user.id;
return (
<div className="mx-auto max-w-xl px-4 py-6">
<ScanClient
initialOp={serialise(op)}
viewer={{ id: user.id, role: "operator", claimedByMe }}
/>
</div>
);
}
// Date fields come back as JS Date objects from Prisma; convert for the client
// component payload so it can be serialised into the RSC stream without tripping
// on non-plain objects. We widen to `ScanOp` which types the dates as strings.
function serialise(obj: unknown): ScanOp {
return JSON.parse(JSON.stringify(obj)) as ScanOp;
}