@@ -3,6 +3,8 @@
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type ScanFile = { id: string; originalName: string; kind: string };
|
||||
|
||||
export type ScanOp = {
|
||||
id: string;
|
||||
sequence: number;
|
||||
@@ -14,6 +16,7 @@ export type ScanOp = {
|
||||
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;
|
||||
@@ -23,10 +26,18 @@ export type ScanOp = {
|
||||
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 };
|
||||
};
|
||||
};
|
||||
@@ -56,8 +67,29 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
||||
|
||||
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 / STEP right from the
|
||||
// scan page without bouncing to the admin UI.
|
||||
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.stepFile) quickFiles.push({ label: "3D / STEP", file: op.part.stepFile, 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.stepFile)
|
||||
quickFiles.push({ label: "Assembly 3D", file: op.part.assembly.stepFile, 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, {
|
||||
@@ -129,10 +161,21 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
||||
<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}
|
||||
{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">
|
||||
<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>
|
||||
@@ -142,10 +185,16 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: active
|
||||
? "bg-amber-100 text-amber-800"
|
||||
: "bg-slate-100 text-slate-700"
|
||||
: partial
|
||||
? "bg-orange-100 text-orange-800"
|
||||
: "bg-slate-100 text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{op.status === "in_progress" ? "in progress" : op.status}
|
||||
{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">
|
||||
@@ -171,6 +220,32 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
||||
) : 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.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>
|
||||
@@ -214,20 +289,37 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
||||
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"}
|
||||
{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</span>
|
||||
<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.plannedUnits?.toString() ?? "0"}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user