"use client"; import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; import { Badge, Button, Card, ErrorBanner, Field, Input, Modal, PageHeader, Select, Textarea, } from "@/components/ui"; import { apiFetch, ApiClientError } from "@/lib/client-api"; // three.js + occt wasm are heavy — keep them out of the page's initial bundle // and out of SSR. The viewer only loads when the user clicks "Show 3D". const StepViewer = dynamic(() => import("@/components/StepViewer"), { ssr: false, loading: () => (
Loading viewer…
), }); interface FileView { id: string; originalName: string; sizeBytes: number; kind: string; mimeType: string | null; } interface PartInfo { id: string; code: string; name: string; material: string | null; qty: number; notes: string | null; stepFile: FileView | null; drawingFile: FileView | null; cutFile: FileView | null; thumbnailFileId: string | null; } export interface OperationRow { id: string; sequence: number; name: string; machineId: string | null; machineName: string | null; templateId: string | null; templateName: string | null; settings: string | null; materialNotes: string | null; instructions: string | null; qcRequired: boolean; plannedMinutes: number | null; plannedUnits: number | null; status: string; qrToken: string; } interface MachineOption { id: string; name: string; } interface TemplateOption { id: string; name: string; machineId: string | null; defaultSettings: string | null; defaultInstructions: string | null; qcRequired: boolean; } type Slot = "stepFileId" | "drawingFileId" | "cutFileId"; const STATUS_TONE: Record = { pending: "slate", in_progress: "blue", completed: "green", }; const STATUS_LABEL: Record = { pending: "Pending", in_progress: "In progress", completed: "Completed", }; function formatBytes(n: number) { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } export default function PartDetailClient({ project, assembly, part, operations, machines, templates, }: { project: { id: string; code: string; name: string }; assembly: { id: string; code: string; name: string }; part: PartInfo; operations: OperationRow[]; machines: MachineOption[]; templates: TemplateOption[]; }) { const router = useRouter(); const [editOpen, setEditOpen] = useState(false); return (
{part.code} {part.name} } description={ {part.material ? `${part.material} · ` : ""}Qty {part.qty} } actions={
Print travelers (PDF)
} /> {part.notes ? (
{part.notes}
) : null}

Files

router.refresh()} /> router.refresh()} /> router.refresh()} />
{part.stepFile ? ( router.refresh()} /> ) : null} router.refresh()} /> {editOpen && ( setEditOpen(false)} onSaved={() => router.refresh()} onDeleted={() => router.push(`/admin/projects/${project.id}/assemblies/${assembly.id}`)} /> )}
); } // -------- Operations ----------------------------------------------------- function OperationsSection({ partId, operations, machines, templates, onChange, }: { partId: string; operations: OperationRow[]; machines: MachineOption[]; templates: TemplateOption[]; onChange: () => void; }) { const [newOpen, setNewOpen] = useState(false); const [edit, setEdit] = useState(null); const [qrFor, setQrFor] = useState(null); const [busyId, setBusyId] = useState(null); const [error, setError] = useState(null); async function move(index: number, dir: -1 | 1) { const target = index + dir; if (target < 0 || target >= operations.length) return; const order = operations.map((o) => o.id); [order[index], order[target]] = [order[target], order[index]]; setBusyId(operations[index].id); setError(null); try { await apiFetch(`/api/v1/parts/${partId}/operations/reorder`, { method: "POST", body: JSON.stringify({ order }), }); onChange(); } catch (err) { setError(err instanceof ApiClientError ? err.message : "Reorder failed"); } finally { setBusyId(null); } } async function remove(op: OperationRow) { if (!confirm(`Delete operation ${op.sequence}. ${op.name}?`)) return; setBusyId(op.id); setError(null); try { await apiFetch(`/api/v1/operations/${op.id}`, { method: "DELETE" }); onChange(); } catch (err) { setError(err instanceof ApiClientError ? err.message : "Delete failed"); } finally { setBusyId(null); } } return (

Operations

{operations.map((op, i) => ( ))} {operations.length === 0 && ( )}
# Name Machine QC Status QR
{op.sequence}
{op.name}
{op.templateName ? (
from {op.templateName}
) : null}
{op.machineName ?? "—"} {op.qcRequired ? required : } {STATUS_LABEL[op.status] ?? op.status} {op.qrToken.slice(0, 8)}… Print
No operations on this part yet.
{newOpen && ( setNewOpen(false)} onSaved={() => { setNewOpen(false); onChange(); }} /> )} {edit && ( setEdit(null)} onSaved={() => { setEdit(null); onChange(); }} /> )} {qrFor && setQrFor(null)} />}
); } function QrModal({ operation, onClose }: { operation: OperationRow; onClose: () => void }) { const [data, setData] = useState< { dataUrl: string; scanUrl: string; token: string } | null >(null); const [error, setError] = useState(null); // Fetch lazily so we don't pre-render QRs for every op on the page. The // data URL is ~1 KB so this is cheap, but it does require a server hop. useEffect(() => { let cancelled = false; apiFetch<{ dataUrl: string; scanUrl: string; token: string }>( `/api/v1/operations/${operation.id}/qr`, ) .then((d) => { if (!cancelled) setData(d); }) .catch((err) => { if (!cancelled) setError(err instanceof ApiClientError ? err.message : "Load failed"); }); return () => { cancelled = true; }; }, [operation.id]); return (
{data ? ( Download PNG ) : null} } >
{error ? ( ) : data ? ( <>
{/* eslint-disable-next-line @next/next/no-img-element */} {`QR
Scan URL:{" "} {data.scanUrl}
Token:{" "} {data.token}
) : (
Rendering QR…
)}
); } function OperationModal({ partId, operation, machines, templates, onClose, onSaved, }: { partId: string; operation?: OperationRow; machines: MachineOption[]; templates: TemplateOption[]; onClose: () => void; onSaved: () => void; }) { const editing = !!operation; const [templateId, setTemplateId] = useState(operation?.templateId ?? ""); const [name, setName] = useState(operation?.name ?? ""); const [machineId, setMachineId] = useState(operation?.machineId ?? ""); const [settings, setSettings] = useState(operation?.settings ?? ""); const [materialNotes, setMaterialNotes] = useState(operation?.materialNotes ?? ""); const [instructions, setInstructions] = useState(operation?.instructions ?? ""); const [qcRequired, setQcRequired] = useState(operation?.qcRequired ?? false); const [plannedMinutes, setPlannedMinutes] = useState( operation?.plannedMinutes ? String(operation.plannedMinutes) : "", ); const [plannedUnits, setPlannedUnits] = useState( operation?.plannedUnits ? String(operation.plannedUnits) : "", ); const [status, setStatus] = useState(operation?.status ?? "pending"); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); function applyTemplate(id: string) { setTemplateId(id); if (!id) return; const t = templates.find((x) => x.id === id); if (!t) return; if (!name) setName(t.name); if (!machineId && t.machineId) setMachineId(t.machineId); if (!settings && t.defaultSettings) setSettings(t.defaultSettings); if (!instructions && t.defaultInstructions) setInstructions(t.defaultInstructions); if (t.qcRequired) setQcRequired(true); } async function submit(e: React.FormEvent) { e.preventDefault(); setBusy(true); setError(null); try { const body = { templateId: templateId || null, name, machineId: machineId || null, settings, materialNotes, instructions, qcRequired, plannedMinutes: plannedMinutes ? Number(plannedMinutes) : null, plannedUnits: plannedUnits ? Number(plannedUnits) : null, ...(editing ? { status } : {}), }; if (editing) { await apiFetch(`/api/v1/operations/${operation!.id}`, { method: "PATCH", body: JSON.stringify(body), }); } else { await apiFetch(`/api/v1/parts/${partId}/operations`, { method: "POST", body: JSON.stringify(body), }); } onSaved(); } catch (err) { setError(err instanceof ApiClientError ? err.message : "Save failed"); setBusy(false); } } return (
} >
{!editing && ( )} setName(e.target.value)} required />
setPlannedMinutes(e.target.value)} /> setPlannedUnits(e.target.value)} />