314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
"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>
|
|
);
|
|
}
|