"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 TimeLogRow { id: string; startedAt: string; endedAt: string | null; unitsProcessed: number | null; note: string | null; operatorName: string; } export interface OperationRow { id: string; sequence: number; name: string; kind: 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; timeLogs: TimeLogRow[]; } interface MachineOption { id: string; name: string; } interface TemplateOption { id: string; name: string; machineId: string | null; defaultSettings: string | null; defaultInstructions: string | null; qcRequired: boolean; } export interface QcRecordRow { id: string; kind: string; passed: boolean; notes: string | null; measurements: string | null; createdAt: string; operatorName: string; operationSequence: number; operationName: string; } type Slot = "stepFileId" | "drawingFileId" | "cutFileId"; const STATUS_TONE: Record = { pending: "slate", in_progress: "blue", partial: "amber", completed: "green", qc_failed: "red", }; const STATUS_LABEL: Record = { pending: "Pending", in_progress: "In progress", partial: "Partial", completed: "Completed", qc_failed: "QC failed", }; 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, qcRecords, }: { project: { id: string; code: string; name: string }; assembly: { id: string; code: string; name: string }; part: PartInfo; operations: OperationRow[]; machines: MachineOption[]; templates: TemplateOption[]; qcRecords: QcRecordRow[]; }) { const router = useRouter(); const [editOpen, setEditOpen] = useState(false); const [dupeOpen, setDupeOpen] = 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}`)} /> )} {dupeOpen && ( setDupeOpen(false)} onCreated={(newPartId) => { setDupeOpen(false); router.push( `/admin/projects/${project.id}/assemblies/${assembly.id}/parts/${newPartId}`, ); }} /> )}
); } // -------- QC history ----------------------------------------------------- function QcHistorySection({ records }: { records: QcRecordRow[] }) { if (records.length === 0) { return (

QC history

No QC records yet. Inline QC stamps and dedicated inspection steps will appear here as they're recorded.
); } const fails = records.filter((r) => !r.passed).length; return (

QC history

{records.length} record{records.length === 1 ? "" : "s"} {fails > 0 ? ` · ${fails} failing` : ""}
{records.map((r) => ( ))}
When Step Operator Kind Result Notes
{new Date(r.createdAt).toLocaleString()}
{r.operationSequence}. {r.operationName}
{r.operatorName} {r.kind} {r.passed ? "Pass" : "Fail"} {r.notes ? r.notes : } {r.measurements ? (
Measurements
                        {r.measurements}
                      
) : null}
); } // -------- Duplicate part ------------------------------------------------- function DuplicatePartModal({ part, onClose, onCreated, }: { part: PartInfo; onClose: () => void; onCreated: (id: string) => void; }) { // Default to "-COPY" so the admin just has to edit a suffix rather // than retyping the whole thing; the uniqueness constraint is enforced // server-side and reports back as a 409 if it clashes. const [code, setCode] = useState(`${part.code}-COPY`); const [name, setName] = useState(part.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<{ part: { id: string }; operationsCopied: number }>( `/api/v1/parts/${part.id}/duplicate`, { method: "POST", body: JSON.stringify({ code, name, includeOperations }), }, ); onCreated(res.part.id); } catch (err) { setError(err instanceof ApiClientError ? err.message : "Duplicate failed"); setBusy(false); } } return (
} >

Creates a new part in the same assembly. File attachments are re-used; operations are cloned with fresh QR codes and reset to pending.

setCode(e.target.value)} required autoFocus /> setName(e.target.value)} /> ); } // -------- 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 [logsFor, setLogsFor] = 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); } } async function resetQc(op: OperationRow) { if ( !confirm( `Clear QC failure on step ${op.sequence}. ${op.name}? The step will reopen for rework; the failing QC record stays on file.`, ) ) { return; } setBusyId(op.id); setError(null); try { await apiFetch(`/api/v1/operations/${op.id}/qc-reset`, { method: "POST" }); onChange(); } catch (err) { setError(err instanceof ApiClientError ? err.message : "Reset failed"); } finally { setBusyId(null); } } return (

Operations

{operations.map((op, i) => ( ))} {operations.length === 0 && ( )}
# Name Machine QC Status QR
{op.sequence}
{op.name} {op.kind === "qc" ? QC step : null}
{op.templateName ? (
from {op.templateName}
) : null}
{op.machineName ?? "—"} {op.qcRequired ? required : } {STATUS_LABEL[op.status] ?? op.status} {op.qrToken.slice(0, 8)}… Print {op.status === "qc_failed" ? ( ) : null}
No operations on this part yet.
{newOpen && ( setNewOpen(false)} onSaved={() => { setNewOpen(false); onChange(); }} /> )} {edit && ( setEdit(null)} onSaved={() => { setEdit(null); onChange(); }} /> )} {qrFor && setQrFor(null)} />} {logsFor && ( setLogsFor(null)} onChange={() => { onChange(); setLogsFor(null); }} /> )}
); } // -------- Time log correction ------------------------------------------- function formatDateTimeLocal(iso: string | null): string { // wants "YYYY-MM-DDTHH:mm" in local time. // Avoid `toISOString()` here — that's UTC and shifts the displayed value. if (!iso) return ""; const d = new Date(iso); const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } function parseLocalDateTime(local: string): string | null { if (!local) return null; // Treat the input as local time — new Date("YYYY-MM-DDTHH:mm") already does, // but we ensure seconds are zero so the round-trip is stable. const d = new Date(local); if (Number.isNaN(d.getTime())) return null; return d.toISOString(); } function durationText(startedAt: string, endedAt: string | null): string { if (!endedAt) return "open"; const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime(); if (ms < 0) return "inverted"; const totalMin = Math.round(ms / 60000); const h = Math.floor(totalMin / 60); const m = totalMin % 60; return h > 0 ? `${h}h ${m}m` : `${m}m`; } function TimeLogsModal({ operation, onClose, onChange, }: { operation: OperationRow; onClose: () => void; onChange: () => void; }) { const [editing, setEditing] = useState(null); return (
} > {operation.timeLogs.length === 0 ? (

No time logs recorded on this operation yet.

) : (

Adjust a stale or mis-entered log. Edits are audited; the operator's original row is kept in the audit log.

{operation.timeLogs.map((log) => { const isOpen = log.endedAt === null; return ( ); })}
Operator Started Ended Duration Units Note
{log.operatorName} {new Date(log.startedAt).toLocaleString()} {isOpen ? ( open ) : ( new Date(log.endedAt!).toLocaleString() )} {durationText(log.startedAt, log.endedAt)} {log.unitsProcessed ?? } {log.note ? ( {log.note} ) : ( )}
)} {editing && ( setEditing(null)} onSaved={() => { setEditing(null); onChange(); }} /> )} ); } function EditTimeLogModal({ log, onClose, onSaved, }: { log: TimeLogRow; onClose: () => void; onSaved: () => void; }) { const [startedAt, setStartedAt] = useState(formatDateTimeLocal(log.startedAt)); const [endedAt, setEndedAt] = useState(formatDateTimeLocal(log.endedAt)); const [units, setUnits] = useState(log.unitsProcessed === null ? "" : String(log.unitsProcessed)); const [note, setNote] = useState(log.note ?? ""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); async function submit(e: React.FormEvent) { e.preventDefault(); setBusy(true); setError(null); const patch: Record = {}; const startedIso = parseLocalDateTime(startedAt); if (startedIso && startedIso !== log.startedAt) patch.startedAt = startedIso; const endedIso = endedAt ? parseLocalDateTime(endedAt) : null; if (endedIso !== log.endedAt) patch.endedAt = endedIso; if (units === "") { if (log.unitsProcessed !== null) patch.unitsProcessed = null; } else { const n = Number(units); if (Number.isFinite(n) && n !== log.unitsProcessed) patch.unitsProcessed = n; } const nextNote = note.trim() === "" ? null : note; if (nextNote !== log.note) patch.note = nextNote; if (Object.keys(patch).length === 0) { onClose(); return; } try { await apiFetch(`/api/v1/timelogs/${log.id}`, { method: "PATCH", body: JSON.stringify(patch), }); onSaved(); } catch (err) { setError(err instanceof ApiClientError ? err.message : "Save failed"); setBusy(false); } } async function remove() { if ( !confirm( "Delete this time log entry? Prefer editing to zero units out when the row represents real work.", ) ) { return; } setBusy(true); setError(null); try { await apiFetch(`/api/v1/timelogs/${log.id}`, { method: "DELETE" }); onSaved(); } catch (err) { setError(err instanceof ApiClientError ? err.message : "Delete failed"); setBusy(false); } } return (
} >
setStartedAt(e.target.value)} required /> setEndedAt(e.target.value)} /> setUnits(e.target.value)} />