2026-04-21 09:29:44 -05:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import { useState, useTransition } from "react";
|
|
|
|
|
|
import { useRouter } from "next/navigation";
|
2026-04-21 21:47:52 -05:00
|
|
|
|
import StepViewerPanel from "@/components/StepViewerPanel";
|
2026-04-21 09:29:44 -05:00
|
|
|
|
|
2026-04-21 20:59:55 -05:00
|
|
|
|
type ScanFile = { id: string; originalName: string; kind: string };
|
|
|
|
|
|
|
2026-04-21 09:29:44 -05:00
|
|
|
|
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;
|
2026-04-21 20:59:55 -05:00
|
|
|
|
unitsCompleted: number;
|
2026-04-21 09:29:44 -05:00
|
|
|
|
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;
|
2026-04-21 20:59:55 -05:00
|
|
|
|
stepFile: ScanFile | null;
|
|
|
|
|
|
drawingFile: ScanFile | null;
|
|
|
|
|
|
cutFile: ScanFile | null;
|
|
|
|
|
|
thumbnailFileId: string | null;
|
2026-04-21 09:29:44 -05:00
|
|
|
|
assembly: {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
code: string;
|
|
|
|
|
|
name: string;
|
2026-04-21 20:59:55 -05:00
|
|
|
|
qty: number;
|
|
|
|
|
|
stepFile: ScanFile | null;
|
|
|
|
|
|
drawingFile: ScanFile | null;
|
|
|
|
|
|
cutFile: ScanFile | null;
|
2026-04-21 09:29:44 -05:00
|
|
|
|
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";
|
2026-04-21 20:59:55 -05:00
|
|
|
|
const partial = op.status === "partial";
|
2026-04-21 09:29:44 -05:00
|
|
|
|
const completed = op.status === "completed";
|
|
|
|
|
|
|
2026-04-21 20:59:55 -05:00
|
|
|
|
// 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
|
2026-04-21 21:47:52 -05:00
|
|
|
|
// 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.
|
2026-04-21 20:59:55 -05:00
|
|
|
|
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" });
|
|
|
|
|
|
|
2026-04-21 09:29:44 -05:00
|
|
|
|
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>
|
2026-04-21 20:59:55 -05:00
|
|
|
|
{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}
|
2026-04-21 09:29:44 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-21 20:59:55 -05:00
|
|
|
|
<div className="mt-4 flex items-center gap-2 flex-wrap">
|
2026-04-21 09:29:44 -05:00
|
|
|
|
<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"
|
2026-04-21 20:59:55 -05:00
|
|
|
|
: partial
|
|
|
|
|
|
? "bg-orange-100 text-orange-800"
|
|
|
|
|
|
: "bg-slate-100 text-slate-700"
|
2026-04-21 09:29:44 -05:00
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-04-21 20:59:55 -05:00
|
|
|
|
{op.status === "in_progress"
|
|
|
|
|
|
? "in progress"
|
|
|
|
|
|
: op.status === "partial"
|
|
|
|
|
|
? "partial"
|
|
|
|
|
|
: op.status}
|
2026-04-21 09:29:44 -05:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-04-21 20:59:55 -05:00
|
|
|
|
{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}
|
|
|
|
|
|
|
2026-04-21 21:47:52 -05:00
|
|
|
|
{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}
|
|
|
|
|
|
|
2026-04-21 09:29:44 -05:00
|
|
|
|
{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"
|
|
|
|
|
|
>
|
2026-04-21 20:59:55 -05:00
|
|
|
|
{isPending
|
|
|
|
|
|
? partial
|
|
|
|
|
|
? "Resuming…"
|
|
|
|
|
|
: "Claiming…"
|
|
|
|
|
|
: partial
|
|
|
|
|
|
? "Resume this step"
|
|
|
|
|
|
: "Start this step"}
|
2026-04-21 09:29:44 -05:00
|
|
|
|
</button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-3">
|
|
|
|
|
|
<label className="block">
|
2026-04-21 20:59:55 -05:00
|
|
|
|
<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>
|
2026-04-21 09:29:44 -05:00
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
|
min={0}
|
|
|
|
|
|
value={units}
|
|
|
|
|
|
onChange={(e) => setUnits(e.target.value)}
|
2026-04-21 20:59:55 -05:00
|
|
|
|
placeholder={
|
|
|
|
|
|
op.unitsCompleted > 0
|
|
|
|
|
|
? `${Math.max(0, totalUnits - op.unitsCompleted)} remaining`
|
|
|
|
|
|
: op.plannedUnits?.toString() ?? totalUnits.toString()
|
|
|
|
|
|
}
|
2026-04-21 09:29:44 -05:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|