Files
jason 04ae88ca0d
Build and Push Docker Image / build (push) Successful in 45s
QoL changes and additions
2026-04-22 13:16:42 -05:00

654 lines
20 KiB
TypeScript
Raw Permalink 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,
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 (
<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={() => setDupeOpen(true)}>
Duplicate
</Button>
<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}
<section className="mb-6">
<h2 className="text-lg font-semibold mb-3">Assembly files</h2>
<p className="text-xs text-slate-500 mb-3">
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.
</p>
<div className="grid gap-3 md:grid-cols-3">
<AssemblyFileSlot
assemblyId={assembly.id}
label="STEP / 3D"
description="Full assembly 3D model."
accept=".step,.stp"
slot="stepFileId"
file={assembly.stepFile}
onChange={() => router.refresh()}
/>
<AssemblyFileSlot
assemblyId={assembly.id}
label="Drawing (PDF)"
description="Assembly drawing / weldment print."
accept=".pdf"
slot="drawingFileId"
file={assembly.drawingFile}
onChange={() => router.refresh()}
/>
<AssemblyFileSlot
assemblyId={assembly.id}
label="Cut / layout"
description="DXF / SVG layout for the assembly."
accept=".dxf,.svg"
slot="cutFileId"
file={assembly.cutFile}
onChange={() => router.refresh()}
/>
</div>
</section>
{assembly.stepFile ? (
<StepViewerPanel
className="mb-6"
title="Assembly 3D"
fileId={assembly.stepFile.id}
fileName={assembly.stepFile.originalName}
height={480}
/>
) : 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}`);
}}
/>
)}
{dupeOpen && (
<DuplicateAssemblyModal
assembly={assembly}
onClose={() => setDupeOpen(false)}
onCreated={(newAssemblyId) => {
setDupeOpen(false);
router.push(`/admin/projects/${project.id}/assemblies/${newAssemblyId}`);
}}
/>
)}
</div>
);
}
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<string | null>(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 (
<Modal
open
onClose={onClose}
title={`Duplicate ${assembly.code}`}
footer={
<>
<div className="flex-1" />
<Button variant="secondary" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="submit" form="asm-dup-form" disabled={busy}>
{busy ? "Duplicating…" : "Duplicate"}
</Button>
</>
}
>
<form id="asm-dup-form" onSubmit={submit} className="space-y-4">
<p className="text-sm text-slate-600">
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.
</p>
<Field label="New code" required hint="Must be unique within this project.">
<Input value={code} onChange={(e) => setCode(e.target.value)} required autoFocus />
</Field>
<Field label="Name">
<Input value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={includeOperations}
onChange={(e) => setIncludeOperations(e.target.checked)}
/>
Copy operations (fresh QR codes, status reset to pending)
</label>
<ErrorBanner message={error} />
</form>
</Modal>
);
}
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>
);
}
// -------- Assembly file slot --------------------------------------------
// Small variant of the FileSlot used on the part detail page — uploads a file
// via /api/v1/files, then PATCHes the assembly with the new ID. Null-id
// detaches. Refresh is delegated to the caller (router.refresh()).
function AssemblyFileSlot({
assemblyId,
label,
description,
accept,
slot,
file,
onChange,
}: {
assemblyId: string;
label: string;
description: string;
accept: string;
slot: AssemblySlot;
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/assemblies/${assemblyId}`, {
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/assemblies/${assemblyId}`, {
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>
);
}