Files
mrp-qrcode/app/admin/projects/[id]/assemblies/[assemblyId]/AssemblyDetailClient.tsx
T
jason bb452a59ae
Build and Push Docker Image / build (push) Successful in 1m4s
stage 8-complete
2026-04-21 14:21:53 -05:00

361 lines
11 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 { 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";
interface AssemblyInfo {
id: string;
code: string;
name: string;
qty: number;
notes: string | null;
}
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);
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>
<span>{assembly.code}</span>
</nav>
<PageHeader
title={
<span className="flex items-center gap-3">
<span className="font-mono text-slate-500 text-lg">{assembly.code}</span>
<span>{assembly.name}</span>
</span>
}
description={
<span className="text-slate-500">
Qty {assembly.qty} · {parts.length} part{parts.length === 1 ? "" : "s"}
</span>
}
actions={
<>
<Button variant="secondary" onClick={() => setEditOpen(true)}>
Edit assembly
</Button>
<Button onClick={() => setNewPartOpen(true)}>New part</Button>
</>
}
/>
{assembly.notes ? (
<Card className="mb-4">
<div className="p-4 text-sm text-slate-700 whitespace-pre-wrap">{assembly.notes}</div>
</Card>
) : null}
<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-4 py-2 font-medium w-[90px]">Preview</th>
<th className="px-4 py-2 font-medium">Code</th>
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">Material</th>
<th className="px-4 py-2 font-medium">Qty</th>
<th className="px-4 py-2 font-medium">Files</th>
<th className="px-4 py-2 font-medium">Ops</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{parts.map((p) => (
<tr key={p.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-3">
{p.thumbnailFileId ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/v1/files/${p.thumbnailFileId}/download`}
alt={`${p.code} preview`}
width={72}
height={54}
className="rounded border border-slate-200 bg-slate-50 object-cover w-[72px] h-[54px]"
/>
) : p.hasStep ? (
<div className="rounded border border-dashed border-slate-300 bg-slate-50 w-[72px] h-[54px] flex items-center justify-center text-[10px] text-slate-400">
open to render
</div>
) : (
<div className="rounded border border-slate-200 bg-slate-50 w-[72px] h-[54px] flex items-center justify-center text-[10px] text-slate-400">
no STEP
</div>
)}
</td>
<td className="px-4 py-3 font-mono text-slate-700">{p.code}</td>
<td className="px-4 py-3 font-medium">{p.name}</td>
<td className="px-4 py-3 text-slate-600">{p.material ?? "—"}</td>
<td className="px-4 py-3 text-slate-600">{p.qty}</td>
<td className="px-4 py-3">
<div className="flex gap-1">
<Badge tone={p.hasStep ? "green" : "slate"}>STEP</Badge>
<Badge tone={p.hasDrawing ? "green" : "slate"}>DWG</Badge>
<Badge tone={p.hasCut ? "green" : "slate"}>CUT</Badge>
</div>
</td>
<td className="px-4 py-3 text-slate-600">{p.operationCount}</td>
<td className="px-4 py-3 text-right">
<Link
href={`/admin/projects/${project.id}/assemblies/${assembly.id}/parts/${p.id}`}
className="text-sm text-blue-600 hover:underline"
>
Open
</Link>
</td>
</tr>
))}
{parts.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-slate-500">
No parts yet.
</td>
</tr>
)}
</tbody>
</table>
</Card>
{editOpen && (
<EditAssemblyModal
assembly={assembly}
projectId={project.id}
onClose={() => setEditOpen(false)}
onSaved={() => router.refresh()}
onDeleted={() => router.push(`/admin/projects/${project.id}`)}
/>
)}
{newPartOpen && (
<NewPartModal
assemblyId={assembly.id}
onClose={() => setNewPartOpen(false)}
onSaved={(partId) => {
setNewPartOpen(false);
router.push(`/admin/projects/${project.id}/assemblies/${assembly.id}/parts/${partId}`);
}}
/>
)}
</div>
);
}
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<string | null>(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 (
<Modal
open
onClose={onClose}
title={`Edit ${assembly.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="asm-edit-form" disabled={busy}>
{busy ? "Saving…" : "Save"}
</Button>
</>
}
>
<form id="asm-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="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>
);
}
function NewPartModal({
assemblyId,
onClose,
onSaved,
}: {
assemblyId: string;
onClose: () => void;
onSaved: (id: string) => void;
}) {
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [material, setMaterial] = useState("");
const [qty, setQty] = useState("1");
const [notes, setNotes] = useState("");
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 {
const res = await apiFetch<{ part: { id: string } }>(
`/api/v1/assemblies/${assemblyId}/parts`,
{
method: "POST",
body: JSON.stringify({
code,
name,
material: material || null,
qty: Number(qty),
notes: notes || null,
}),
},
);
onSaved(res.part.id);
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Save failed");
setBusy(false);
}
}
return (
<Modal
open
onClose={onClose}
title="New part"
footer={
<>
<div className="flex-1" />
<Button variant="secondary" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="submit" form="part-form" disabled={busy}>
{busy ? "Creating…" : "Create"}
</Button>
</>
}
>
<form id="part-form" onSubmit={submit} className="space-y-4">
<Field label="Code" required hint="Unique within this assembly.">
<Input value={code} onChange={(e) => setCode(e.target.value)} required autoFocus />
</Field>
<Field label="Name" required>
<Input value={name} onChange={(e) => setName(e.target.value)} required />
</Field>
<Field label="Material" hint="Free text, e.g. 5052-H32 0.125&quot;">
<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>
);
}