430 lines
16 KiB
TypeScript
430 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useTransition } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import StepViewerPanel from "@/components/StepViewerPanel";
|
||
|
||
type ScanFile = { id: string; originalName: string; kind: string };
|
||
|
||
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;
|
||
unitsCompleted: number;
|
||
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;
|
||
stepFile: ScanFile | null;
|
||
drawingFile: ScanFile | null;
|
||
cutFile: ScanFile | null;
|
||
thumbnailFileId: string | null;
|
||
assembly: {
|
||
id: string;
|
||
code: string;
|
||
name: string;
|
||
qty: number;
|
||
stepFile: ScanFile | null;
|
||
drawingFile: ScanFile | null;
|
||
cutFile: ScanFile | null;
|
||
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 partial = op.status === "partial";
|
||
const completed = op.status === "completed";
|
||
|
||
// 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). This is distinct from plannedUnits, which is the admin's
|
||
// optional time estimate for a single run.
|
||
const totalUnits = op.part.assembly.qty * op.part.qty;
|
||
|
||
// Flat list of attached files (part first, then assembly). Rendered as big
|
||
// tap targets so the operator can pull the drawing / cut file right from
|
||
// the scan page without bouncing to the admin UI.
|
||
//
|
||
// STEP files are intentionally excluded — we render them inline via
|
||
// StepViewerPanel below instead, so the operator never has to download a
|
||
// .stp to the phone.
|
||
const quickFiles: Array<{ label: string; file: ScanFile; scope: "part" | "assembly" }> = [];
|
||
if (op.part.drawingFile) quickFiles.push({ label: "Drawing", file: op.part.drawingFile, scope: "part" });
|
||
if (op.part.cutFile) quickFiles.push({ label: "Cut file", file: op.part.cutFile, scope: "part" });
|
||
if (op.part.assembly.drawingFile)
|
||
quickFiles.push({ label: "Assembly drawing", file: op.part.assembly.drawingFile, scope: "assembly" });
|
||
if (op.part.assembly.cutFile)
|
||
quickFiles.push({ label: "Assembly cut", file: op.part.assembly.cutFile, scope: "assembly" });
|
||
|
||
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}
|
||
</div>
|
||
<div className="text-slate-700 text-sm mt-2">
|
||
<span className="font-semibold text-slate-900">Total to produce: {totalUnits}</span>
|
||
<span className="text-slate-500">
|
||
{" "}({op.part.assembly.qty} assemblies × {op.part.qty} per assembly)
|
||
</span>
|
||
{op.unitsCompleted > 0 ? (
|
||
<span className="ml-2 text-amber-700 font-medium">
|
||
{op.unitsCompleted} done so far
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="mt-4 flex items-center gap-2 flex-wrap">
|
||
<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"
|
||
: partial
|
||
? "bg-orange-100 text-orange-800"
|
||
: "bg-slate-100 text-slate-700"
|
||
}`}
|
||
>
|
||
{op.status === "in_progress"
|
||
? "in progress"
|
||
: op.status === "partial"
|
||
? "partial"
|
||
: 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>
|
||
|
||
{quickFiles.length > 0 ? (
|
||
<div className="rounded-2xl bg-white border border-slate-200 p-5">
|
||
<h2 className="text-sm font-semibold text-slate-900 mb-3">Quick files</h2>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{quickFiles.map(({ label, file, scope }) => (
|
||
<a
|
||
key={`${scope}-${file.id}`}
|
||
href={`/api/v1/files/${file.id}/download`}
|
||
target="_blank"
|
||
rel="noopener"
|
||
className="flex flex-col rounded-xl border border-slate-200 bg-slate-50 active:bg-slate-100 px-3 py-2 min-h-[64px]"
|
||
>
|
||
<span className="text-sm font-medium text-slate-900">{label}</span>
|
||
<span className="text-[11px] text-slate-500 mt-0.5">
|
||
{scope === "assembly" ? "Assembly · " : "Part · "}
|
||
{file.kind.toUpperCase()}
|
||
</span>
|
||
<span className="text-[10px] text-slate-400 truncate mt-0.5" title={file.originalName}>
|
||
{file.originalName}
|
||
</span>
|
||
</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{op.part.stepFile ? (
|
||
<div className="rounded-2xl bg-white border border-slate-200 p-5">
|
||
<StepViewerPanel
|
||
title="Part 3D"
|
||
fileId={op.part.stepFile.id}
|
||
fileName={op.part.stepFile.originalName}
|
||
height={320}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{op.part.assembly.stepFile ? (
|
||
<div className="rounded-2xl bg-white border border-slate-200 p-5">
|
||
<StepViewerPanel
|
||
title="Assembly 3D"
|
||
fileId={op.part.assembly.stepFile.id}
|
||
fileName={op.part.assembly.stepFile.originalName}
|
||
height={320}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{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
|
||
? partial
|
||
? "Resuming…"
|
||
: "Claiming…"
|
||
: partial
|
||
? "Resume this step"
|
||
: "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
|
||
{op.unitsCompleted > 0 ? (
|
||
<span className="ml-2 text-xs text-slate-500 font-normal">
|
||
{op.unitsCompleted} of {totalUnits} already done
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
<input
|
||
type="number"
|
||
inputMode="numeric"
|
||
min={0}
|
||
value={units}
|
||
onChange={(e) => setUnits(e.target.value)}
|
||
placeholder={
|
||
op.unitsCompleted > 0
|
||
? `${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"
|
||
/>
|
||
</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>
|
||
);
|
||
}
|