fixes
Build and Push Docker Image / build (push) Successful in 1m6s

This commit is contained in:
jason
2026-04-21 20:59:55 -05:00
parent bb452a59ae
commit bc3b78aa33
17 changed files with 534 additions and 40 deletions
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
Badge,
@@ -16,12 +16,31 @@ import {
} from "@/components/ui";
import { apiFetch, ApiClientError } from "@/lib/client-api";
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 {
@@ -97,6 +116,43 @@ export default function AssemblyDetailClient({
</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>
<Card>
<table className="w-full text-sm">
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
@@ -358,3 +414,138 @@ function NewPartModal({
</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>
);
}