Files
mrp-qrcode/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx
T
2026-04-21 08:56:51 -05:00

785 lines
24 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 { useRef, useState } from "react";
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";
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;
}
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<string, "slate" | "blue" | "green" | "amber" | "red"> = {
pending: "slate",
in_progress: "blue",
completed: "green",
};
const STATUS_LABEL: Record<string, string> = {
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 (
<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={
<Button variant="secondary" onClick={() => setEditOpen(true)}>
Edit part
</Button>
}
/>
{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>
<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 [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);
}
}
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">{op.name}</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={() => setEdit(op)} disabled={busyId !== null}>
Edit
</Button>
<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();
}}
/>
)}
</section>
);
}
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<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,
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="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">
<Select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="pending">Pending</option>
<option value="in_progress">In progress</option>
<option value="completed">Completed</option>
</Select>
</Field>
)}
<ErrorBanner message={error} />
</form>
</Modal>
);
}
// -------- 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>
);
}