"use client"; import Link from "next/link"; import { useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Badge, Button, Card, ErrorBanner, Field, Input, Modal, PageHeader, Textarea, } from "@/components/ui"; import { apiFetch, ApiClientError } from "@/lib/client-api"; import StepViewerPanel from "@/components/StepViewerPanel"; interface FileView { id: string; originalName: string; sizeBytes: number; kind: string; mimeType: string | null; } interface AssemblyInfo { id: string; code: string; name: string; qty: number; notes: string | null; stepFile: FileView | null; drawingFile: FileView | null; cutFile: FileView | null; } type AssemblySlot = "stepFileId" | "drawingFileId" | "cutFileId"; 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`; } interface ProjectInfo { id: string; code: string; name: string; } interface PartRow { id: string; code: string; name: string; material: string | null; qty: number; hasStep: boolean; hasDrawing: boolean; hasCut: boolean; thumbnailFileId: string | null; operationCount: number; } export default function AssemblyDetailClient({ project, assembly, parts, }: { project: ProjectInfo; assembly: AssemblyInfo; parts: PartRow[]; }) { const router = useRouter(); const [editOpen, setEditOpen] = useState(false); const [newPartOpen, setNewPartOpen] = useState(false); const [dupeOpen, setDupeOpen] = useState(false); return (
{assembly.code} {assembly.name} } description={ Qty {assembly.qty} · {parts.length} part{parts.length === 1 ? "" : "s"} } actions={ <> } /> {assembly.notes ? (
{assembly.notes}
) : null}

Assembly files

Shared across every part in this assembly — overall STEP model, assembly drawing, welding/fit-up layouts, etc. These also appear as quick-links on every traveler scan page.

router.refresh()} /> router.refresh()} /> router.refresh()} />
{assembly.stepFile ? ( ) : null} {parts.map((p) => ( ))} {parts.length === 0 && ( )}
Preview Code Name Material Qty Files Ops
{p.thumbnailFileId ? ( // eslint-disable-next-line @next/next/no-img-element {`${p.code} ) : p.hasStep ? (
open to render
) : (
no STEP
)}
{p.code} {p.name} {p.material ?? "—"} {p.qty}
STEP DWG CUT
{p.operationCount} Open →
No parts yet.
{editOpen && ( setEditOpen(false)} onSaved={() => router.refresh()} onDeleted={() => router.push(`/admin/projects/${project.id}`)} /> )} {newPartOpen && ( setNewPartOpen(false)} onSaved={(partId) => { setNewPartOpen(false); router.push(`/admin/projects/${project.id}/assemblies/${assembly.id}/parts/${partId}`); }} /> )} {dupeOpen && ( setDupeOpen(false)} onCreated={(newAssemblyId) => { setDupeOpen(false); router.push(`/admin/projects/${project.id}/assemblies/${newAssemblyId}`); }} /> )}
); } function DuplicateAssemblyModal({ assembly, onClose, onCreated, }: { assembly: AssemblyInfo; onClose: () => void; onCreated: (id: string) => void; }) { const [code, setCode] = useState(`${assembly.code}-COPY`); const [name, setName] = useState(assembly.name); const [includeOperations, setIncludeOperations] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); async function submit(e: React.FormEvent) { e.preventDefault(); setBusy(true); setError(null); try { const res = await apiFetch<{ assembly: { id: string } }>( `/api/v1/assemblies/${assembly.id}/duplicate`, { method: "POST", body: JSON.stringify({ code, name, includeOperations }), }, ); onCreated(res.assembly.id); } catch (err) { setError(err instanceof ApiClientError ? err.message : "Duplicate failed"); setBusy(false); } } return (
} >

Creates a new assembly in the same project. Every part is cloned along with its file attachments. Operations get fresh QR codes and reset to pending.

setCode(e.target.value)} required autoFocus /> setName(e.target.value)} /> ); } function EditAssemblyModal({ assembly, onClose, onSaved, onDeleted, }: { assembly: AssemblyInfo; projectId: string; onClose: () => void; onSaved: () => void; onDeleted: () => void; }) { const [code, setCode] = useState(assembly.code); const [name, setName] = useState(assembly.name); const [qty, setQty] = useState(String(assembly.qty)); const [notes, setNotes] = useState(assembly.notes ?? ""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); async function submit(e: React.FormEvent) { e.preventDefault(); setBusy(true); setError(null); try { await apiFetch(`/api/v1/assemblies/${assembly.id}`, { method: "PATCH", body: JSON.stringify({ code, name, qty: Number(qty), notes: notes || null }), }); onSaved(); onClose(); } catch (err) { setError(err instanceof ApiClientError ? err.message : "Save failed"); setBusy(false); } } async function remove() { if (!confirm(`Delete assembly ${assembly.code}? All parts and operations under it will be removed.`)) return; setBusy(true); setError(null); try { await apiFetch(`/api/v1/assemblies/${assembly.id}`, { method: "DELETE" }); onDeleted(); } catch (err) { setError(err instanceof ApiClientError ? err.message : "Delete failed"); setBusy(false); } } return (
} >
setCode(e.target.value)} required /> setName(e.target.value)} required /> setQty(e.target.value)} required />