Files
mrp-qrcode/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx
T
jason e0dfac2d48
Build and Push Docker Image / build (push) Successful in 1m4s
step 9 and cleanup
2026-04-22 09:27:01 -05:00

1100 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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: () => (
<div className="relative w-full h-[480px] rounded-lg border border-slate-200 bg-slate-50 flex items-center justify-center text-sm text-slate-500">
Loading viewer
</div>
),
});
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;
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;
}
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<string, "slate" | "blue" | "green" | "amber" | "red"> = {
pending: "slate",
in_progress: "blue",
partial: "amber",
completed: "green",
qc_failed: "red",
};
const STATUS_LABEL: Record<string, string> = {
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,
}: {
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 (
<div className="mx-auto max-w-6xl px-4 py-8">
<nav className="mb-3 text-sm text-slate-500">
<Link href="/admin/projects" className="hover:underline">
Projects
</Link>
<span className="mx-1"></span>
<Link href={`/admin/projects/${project.id}`} className="hover:underline">
{project.code}
</Link>
<span className="mx-1"></span>
<Link
href={`/admin/projects/${project.id}/assemblies/${assembly.id}`}
className="hover:underline"
>
{assembly.code}
</Link>
<span className="mx-1"></span>
<span>{part.code}</span>
</nav>
<PageHeader
title={
<span className="flex items-center gap-3">
<span className="font-mono text-slate-500 text-lg">{part.code}</span>
<span>{part.name}</span>
</span>
}
description={
<span className="text-slate-500">
{part.material ? `${part.material} · ` : ""}Qty {part.qty}
</span>
}
actions={
<div className="flex items-center gap-2">
<a
href={`/api/v1/parts/${part.id}/travelers.pdf`}
target="_blank"
rel="noopener"
className="inline-flex items-center rounded-md bg-white border border-slate-300 text-slate-900 text-sm px-3 py-1.5 hover:border-slate-900"
>
Print travelers (PDF)
</a>
<Button variant="secondary" onClick={() => setEditOpen(true)}>
Edit part
</Button>
</div>
}
/>
{part.notes ? (
<Card className="mb-4">
<div className="p-4 text-sm text-slate-700 whitespace-pre-wrap">{part.notes}</div>
</Card>
) : null}
<section className="mb-8">
<h2 className="text-lg font-semibold mb-3">Files</h2>
<div className="grid gap-3 md:grid-cols-3">
<FileSlot
partId={part.id}
label="STEP / 3D"
description="3D model for in-app viewer."
accept=".step,.stp"
slot="stepFileId"
file={part.stepFile}
onChange={() => router.refresh()}
/>
<FileSlot
partId={part.id}
label="Drawing (PDF)"
description="Dimensioned drawing."
accept=".pdf"
slot="drawingFileId"
file={part.drawingFile}
onChange={() => router.refresh()}
/>
<FileSlot
partId={part.id}
label="Cut file"
description="DXF or SVG for the punch/laser."
accept=".dxf,.svg"
slot="cutFileId"
file={part.cutFile}
onChange={() => router.refresh()}
/>
</div>
</section>
{part.stepFile ? (
<StepViewerSection
partId={part.id}
fileId={part.stepFile.id}
fileName={part.stepFile.originalName}
hasThumbnail={!!part.thumbnailFileId}
onThumbnailSaved={() => router.refresh()}
/>
) : null}
<OperationsSection
partId={part.id}
operations={operations}
machines={machines}
templates={templates}
onChange={() => router.refresh()}
/>
{editOpen && (
<EditPartModal
part={part}
onClose={() => setEditOpen(false)}
onSaved={() => router.refresh()}
onDeleted={() => router.push(`/admin/projects/${project.id}/assemblies/${assembly.id}`)}
/>
)}
</div>
);
}
// -------- 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<OperationRow | null>(null);
const [qrFor, setQrFor] = useState<OperationRow | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Operations</h2>
<Button size="sm" onClick={() => setNewOpen(true)}>
Add operation
</Button>
</div>
<ErrorBanner message={error} />
<Card>
<table className="w-full text-sm">
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
<tr>
<th className="px-3 py-2 font-medium w-10">#</th>
<th className="px-3 py-2 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Machine</th>
<th className="px-3 py-2 font-medium">QC</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">QR</th>
<th className="px-3 py-2 text-right"></th>
</tr>
</thead>
<tbody>
{operations.map((op, i) => (
<tr key={op.id} className="border-b border-slate-100 last:border-0">
<td className="px-3 py-3 text-slate-600">{op.sequence}</td>
<td className="px-3 py-3">
<div className="font-medium flex items-center gap-2">
<span>{op.name}</span>
{op.kind === "qc" ? <Badge tone="blue">QC step</Badge> : null}
</div>
{op.templateName ? (
<div className="text-xs text-slate-500">from {op.templateName}</div>
) : null}
</td>
<td className="px-3 py-3 text-slate-600">{op.machineName ?? "—"}</td>
<td className="px-3 py-3">
{op.qcRequired ? <Badge tone="amber">required</Badge> : <span className="text-slate-400"></span>}
</td>
<td className="px-3 py-3">
<Badge tone={STATUS_TONE[op.status] ?? "slate"}>
{STATUS_LABEL[op.status] ?? op.status}
</Badge>
</td>
<td className="px-3 py-3">
<code className="text-xs text-slate-500" title={op.qrToken}>
{op.qrToken.slice(0, 8)}
</code>
</td>
<td className="px-3 py-3 text-right whitespace-nowrap">
<button
type="button"
className="text-slate-500 hover:text-slate-900 px-1 disabled:opacity-40"
disabled={i === 0 || busyId !== null}
onClick={() => move(i, -1)}
aria-label="Move up"
>
</button>
<button
type="button"
className="text-slate-500 hover:text-slate-900 px-1 disabled:opacity-40"
disabled={i === operations.length - 1 || busyId !== null}
onClick={() => move(i, 1)}
aria-label="Move down"
>
</button>
<Button variant="ghost" size="sm" onClick={() => setQrFor(op)} disabled={busyId !== null}>
QR
</Button>
<a
href={`/api/v1/operations/${op.id}/card.pdf`}
target="_blank"
rel="noopener"
className={`inline-flex items-center rounded-md px-2 py-1 text-xs text-slate-600 hover:text-slate-900 hover:underline ${
busyId !== null ? "pointer-events-none opacity-50" : ""
}`}
>
Print
</a>
<Button variant="ghost" size="sm" onClick={() => setEdit(op)} disabled={busyId !== null}>
Edit
</Button>
{op.status === "qc_failed" ? (
<Button
variant="ghost"
size="sm"
onClick={() => resetQc(op)}
disabled={busyId !== null}
>
Reset QC
</Button>
) : null}
<Button
variant="ghost"
size="sm"
onClick={() => remove(op)}
disabled={busyId !== null || op.status === "in_progress"}
>
Delete
</Button>
</td>
</tr>
))}
{operations.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-slate-500">
No operations on this part yet.
</td>
</tr>
)}
</tbody>
</table>
</Card>
{newOpen && (
<OperationModal
partId={partId}
machines={machines}
templates={templates}
onClose={() => setNewOpen(false)}
onSaved={() => {
setNewOpen(false);
onChange();
}}
/>
)}
{edit && (
<OperationModal
partId={partId}
operation={edit}
machines={machines}
templates={templates}
onClose={() => setEdit(null)}
onSaved={() => {
setEdit(null);
onChange();
}}
/>
)}
{qrFor && <QrModal operation={qrFor} onClose={() => setQrFor(null)} />}
</section>
);
}
function QrModal({ operation, onClose }: { operation: OperationRow; onClose: () => void }) {
const [data, setData] = useState<
{ dataUrl: string; scanUrl: string; token: string } | null
>(null);
const [error, setError] = useState<string | null>(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 (
<Modal
open
onClose={onClose}
title={`QR: step ${operation.sequence}. ${operation.name}`}
footer={
<>
<div className="flex-1" />
<Button variant="secondary" onClick={onClose}>
Close
</Button>
{data ? (
<a
href={data.dataUrl}
download={`op-${operation.sequence}-${operation.id}.png`}
className="inline-flex items-center rounded-md bg-slate-900 text-white text-sm px-3 py-1.5 hover:bg-slate-800"
>
Download PNG
</a>
) : null}
</>
}
>
<div className="space-y-3">
{error ? (
<ErrorBanner message={error} />
) : data ? (
<>
<div className="flex justify-center bg-white border border-slate-200 rounded-md p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={data.dataUrl}
alt={`QR for ${operation.name}`}
width={256}
height={256}
/>
</div>
<div className="text-xs text-slate-600 space-y-1">
<div>
<span className="font-medium">Scan URL:</span>{" "}
<a href={data.scanUrl} className="text-blue-600 hover:underline break-all">
{data.scanUrl}
</a>
</div>
<div>
<span className="font-medium">Token:</span>{" "}
<code className="text-slate-500">{data.token}</code>
</div>
</div>
</>
) : (
<div className="text-center text-slate-500 text-sm py-10">Rendering QR</div>
)}
</div>
</Modal>
);
}
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 [kind, setKind] = useState<"work" | "qc">(
(operation?.kind as "work" | "qc") ?? "work",
);
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<string | null>(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,
kind,
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 (
<Modal
open
onClose={onClose}
title={editing ? `Edit operation ${operation!.sequence}. ${operation!.name}` : "New operation"}
footer={
<>
<div className="flex-1" />
<Button variant="secondary" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="submit" form="op-form" disabled={busy}>
{busy ? "Saving…" : editing ? "Save" : "Create"}
</Button>
</>
}
>
<form id="op-form" onSubmit={submit} className="space-y-4">
{!editing && (
<Field label="Start from template" hint="Pre-fills machine, settings, instructions, and QC.">
<Select value={templateId} onChange={(e) => applyTemplate(e.target.value)}>
<option value=""> ad-hoc, no template </option>
{templates.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</Select>
</Field>
)}
<Field label="Name" required>
<Input value={name} onChange={(e) => setName(e.target.value)} required />
</Field>
<Field
label="Kind"
hint='"Work" is a normal production step. "QC" is a dedicated inspection step — close always demands a pass/fail record and unit counts are ignored.'
>
<Select value={kind} onChange={(e) => setKind(e.target.value as "work" | "qc")}>
<option value="work">Work production step</option>
<option value="qc">QC dedicated inspection</option>
</Select>
</Field>
<Field label="Machine">
<Select value={machineId} onChange={(e) => setMachineId(e.target.value)}>
<option value=""> none </option>
{machines.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</Select>
</Field>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Planned minutes" hint="Optional estimate.">
<Input
type="number"
min={1}
value={plannedMinutes}
onChange={(e) => setPlannedMinutes(e.target.value)}
/>
</Field>
<Field label="Planned units" hint="Target count for this step.">
<Input
type="number"
min={1}
value={plannedUnits}
onChange={(e) => setPlannedUnits(e.target.value)}
/>
</Field>
</div>
<Field label="Settings (JSON)" hint='e.g. {"programNumber":47}. Optional.'>
<Textarea
value={settings}
onChange={(e) => setSettings(e.target.value)}
placeholder='{"programNumber":47}'
/>
</Field>
<Field label="Material notes" hint="Stock sizing, grain direction, etc.">
<Textarea value={materialNotes} onChange={(e) => setMaterialNotes(e.target.value)} />
</Field>
<Field label="Instructions" hint="Printed on the traveler card.">
<Textarea value={instructions} onChange={(e) => setInstructions(e.target.value)} />
</Field>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={qcRequired} onChange={(e) => setQcRequired(e.target.checked)} />
<span>QC check required on close-out</span>
</label>
{editing && (
<Field
label="Status"
hint="Use QC-failed only if you need to block a step out-of-band; the normal path is for the operator's Done/fail to set it."
>
<Select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="pending">Pending</option>
<option value="in_progress">In progress</option>
<option value="partial">Partial</option>
<option value="completed">Completed</option>
<option value="qc_failed">QC failed</option>
</Select>
</Field>
)}
<ErrorBanner message={error} />
</form>
</Modal>
);
}
// -------- 3D viewer ------------------------------------------------------
function StepViewerSection({
partId,
fileId,
fileName,
hasThumbnail,
onThumbnailSaved,
}: {
partId: string;
fileId: string;
fileName: string;
hasThumbnail: boolean;
onThumbnailSaved: () => void;
}) {
const [open, setOpen] = useState(false);
const [edges, setEdges] = useState(true);
const [savingThumb, setSavingThumb] = useState(false);
const [thumbMessage, setThumbMessage] = useState<string | null>(null);
// Bump this to force a remount of the viewer (and therefore a fresh
// first-frame capture). Used by "Regenerate thumbnail".
const [captureNonce, setCaptureNonce] = useState(0);
// One-shot guard so onFirstFrame from the render loop only fires once per
// viewer mount. Reset whenever captureNonce advances.
const capturedRef = useRef(false);
// The viewer fetches via `/api/v1/files/:id/download`, which streams the
// stored STEP bytes with correct auth (admin session cookie is already set).
const url = `/api/v1/files/${fileId}/download`;
// Capture + upload the first-frame thumbnail. Runs at most once per open.
async function handleFirstFrame(blob: Blob) {
if (capturedRef.current) return;
capturedRef.current = true;
setSavingThumb(true);
setThumbMessage(null);
try {
const form = new FormData();
form.set("file", new File([blob], `part-${partId}.jpg`, { type: "image/jpeg" }));
const res = await apiFetch<{ file: { id: string } }>("/api/v1/files", {
method: "POST",
body: form,
});
await apiFetch(`/api/v1/parts/${partId}`, {
method: "PATCH",
body: JSON.stringify({ thumbnailFileId: res.file.id }),
});
setThumbMessage("Thumbnail saved.");
onThumbnailSaved();
} catch (err) {
setThumbMessage(
err instanceof ApiClientError ? `Thumbnail save failed: ${err.message}` : "Thumbnail save failed",
);
} finally {
setSavingThumb(false);
}
}
// Only request a new thumbnail if there isn't one already (or the user hit
// "Regenerate"), to avoid overwriting a good capture with a worse one every
// time someone opens the viewer.
const shouldCapture = open && (!hasThumbnail || captureNonce > 0) && !capturedRef.current;
return (
<section className="mb-8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">3D viewer</h2>
<div className="flex items-center gap-2">
{open ? (
<>
<label className="flex items-center gap-1.5 text-xs text-slate-600">
<input
type="checkbox"
checked={edges}
onChange={(e) => setEdges(e.target.checked)}
/>
Show edges
</label>
{hasThumbnail ? (
<Button
variant="ghost"
size="sm"
disabled={savingThumb}
onClick={() => {
capturedRef.current = false;
setThumbMessage("Regenerating on next frame…");
setCaptureNonce((n) => n + 1);
}}
>
{savingThumb ? "Saving…" : "Regenerate thumbnail"}
</Button>
) : null}
</>
) : null}
<Button variant={open ? "secondary" : "primary"} size="sm" onClick={() => setOpen((v) => !v)}>
{open ? "Hide" : "Show 3D"}
</Button>
</div>
</div>
{open ? (
<Card>
<div className="p-3">
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-slate-500">
<span className="truncate" title={fileName}>
Viewing: <code className="font-mono text-slate-700">{fileName}</code>
</span>
{thumbMessage ? <span className="text-slate-500">{thumbMessage}</span> : null}
</div>
<StepViewer
key={`${fileId}-${edges ? "e" : "ne"}-${captureNonce}`}
url={url}
showEdges={edges}
onFirstFrame={shouldCapture ? handleFirstFrame : undefined}
/>
<p className="mt-2 text-[11px] text-slate-500">
Drag to rotate · scroll to zoom · right-drag to pan. Parsing and rendering happen in
your browser; the first view also uploads a ~480×360 JPEG thumbnail for the parts list.
</p>
</div>
</Card>
) : (
<p className="text-sm text-slate-500">
Loads the STEP file into an interactive 3D view. Click <span className="font-medium">Show 3D</span>{" "}
to start the viewer downloads ~2 MB of OpenCascade WASM on first use.
</p>
)}
</section>
);
}
// -------- Files ----------------------------------------------------------
function FileSlot({
partId,
label,
description,
accept,
slot,
file,
onChange,
}: {
partId: string;
label: string;
description: string;
accept: string;
slot: Slot;
file: FileView | null;
onChange: () => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function upload(f: File) {
setBusy(true);
setError(null);
try {
const form = new FormData();
form.set("file", f);
const uploaded = await apiFetch<{ file: { id: string } }>("/api/v1/files", {
method: "POST",
body: form,
});
await apiFetch(`/api/v1/parts/${partId}`, {
method: "PATCH",
body: JSON.stringify({ [slot]: uploaded.file.id }),
});
onChange();
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Upload failed");
} finally {
setBusy(false);
if (inputRef.current) inputRef.current.value = "";
}
}
async function detach() {
if (!confirm(`Detach ${label}?`)) return;
setBusy(true);
setError(null);
try {
await apiFetch(`/api/v1/parts/${partId}`, {
method: "PATCH",
body: JSON.stringify({ [slot]: null }),
});
onChange();
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Detach failed");
} finally {
setBusy(false);
}
}
return (
<Card>
<div className="p-4 space-y-3">
<div>
<h3 className="font-semibold">{label}</h3>
<p className="text-xs text-slate-500">{description}</p>
</div>
{file ? (
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm">
<div className="font-medium truncate" title={file.originalName}>
{file.originalName}
</div>
<div className="text-xs text-slate-500 mt-0.5">
{formatBytes(file.sizeBytes)} · {file.kind}
</div>
<div className="flex gap-2 mt-2">
<a
href={`/api/v1/files/${file.id}/download`}
className="text-xs text-blue-600 hover:underline"
>
Download
</a>
<button
type="button"
onClick={detach}
disabled={busy}
className="text-xs text-red-600 hover:underline disabled:opacity-50"
>
Detach
</button>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={busy}
className="text-xs text-slate-600 hover:underline disabled:opacity-50 ml-auto"
>
Replace
</button>
</div>
</div>
) : (
<div className="rounded-md border border-dashed border-slate-300 p-4 text-center">
<p className="text-xs text-slate-500 mb-2">No file attached.</p>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => inputRef.current?.click()}
disabled={busy}
>
{busy ? "Uploading…" : "Upload"}
</Button>
</div>
)}
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) upload(f);
}}
/>
<ErrorBanner message={error} />
</div>
</Card>
);
}
// -------- Edit part ------------------------------------------------------
function EditPartModal({
part,
onClose,
onSaved,
onDeleted,
}: {
part: PartInfo;
onClose: () => void;
onSaved: () => void;
onDeleted: () => void;
}) {
const [code, setCode] = useState(part.code);
const [name, setName] = useState(part.name);
const [material, setMaterial] = useState(part.material ?? "");
const [qty, setQty] = useState(String(part.qty));
const [notes, setNotes] = useState(part.notes ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await apiFetch(`/api/v1/parts/${part.id}`, {
method: "PATCH",
body: JSON.stringify({
code,
name,
material: material || null,
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 part ${part.code}? All operations on it will be removed.`)) return;
setBusy(true);
setError(null);
try {
await apiFetch(`/api/v1/parts/${part.id}`, { method: "DELETE" });
onDeleted();
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Delete failed");
setBusy(false);
}
}
return (
<Modal
open
onClose={onClose}
title={`Edit ${part.code}`}
footer={
<>
<Button variant="danger" size="sm" onClick={remove} disabled={busy}>
Delete
</Button>
<div className="flex-1" />
<Button variant="secondary" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="submit" form="part-edit-form" disabled={busy}>
{busy ? "Saving…" : "Save"}
</Button>
</>
}
>
<form id="part-edit-form" onSubmit={submit} className="space-y-4">
<Field label="Code" required>
<Input value={code} onChange={(e) => setCode(e.target.value)} required />
</Field>
<Field label="Name" required>
<Input value={name} onChange={(e) => setName(e.target.value)} required />
</Field>
<Field label="Material">
<Input value={material} onChange={(e) => setMaterial(e.target.value)} />
</Field>
<Field label="Quantity" required>
<Input type="number" min={1} value={qty} onChange={(e) => setQty(e.target.value)} required />
</Field>
<Field label="Notes">
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
</Field>
<ErrorBanner message={error} />
</form>
</Modal>
);
}