Files
mrp-qrcode/app/op/scan/[token]/ScanClient.tsx
T
jason 95774c9c21
Build and Push Docker Image / build (push) Successful in 43s
fixes
2026-04-21 21:47:52 -05:00

430 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}