From bb452a59aeee86877ec55e44adb39a103adba30a Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 21 Apr 2026 14:21:53 -0500 Subject: [PATCH] stage 8-complete --- .claude/settings.local.json | 4 +- README.md | 5 +- .../[assemblyId]/AssemblyDetailClient.tsx | 24 +++- .../[id]/assemblies/[assemblyId]/page.tsx | 2 + .../parts/[partId]/PartDetailClient.tsx | 114 +++++++++++++++--- .../[assemblyId]/parts/[partId]/page.tsx | 2 + app/api/v1/parts/[id]/route.ts | 1 + components/StepViewer.tsx | 94 ++++++++++++++- docs/BUILD-PLAN.md | 2 +- lib/schemas.ts | 1 + .../migration.sql | 29 +++++ prisma/schema.prisma | 45 +++---- tsconfig.tsbuildinfo | 2 +- 13 files changed, 281 insertions(+), 44 deletions(-) create mode 100644 prisma/migrations/20260421191456_add_part_thumbnail/migration.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2a67078..d2c27eb 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,9 @@ "Bash(npx tsc *)", "Bash(npx next *)", "Bash(npm run *)", - "Bash(node scripts/copy-viewer-assets.mjs)" + "Bash(node scripts/copy-viewer-assets.mjs)", + "Bash(npx prisma *)", + "Bash(DATABASE_URL=\"file:./data/app.db\" npx prisma migrate dev --name add_part_thumbnail)" ] } } diff --git a/README.md b/README.md index 7c440da..806f612 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A single-container, self-hosted Manufacturing Resource Planning (MRP) app built ## Status -Steps 1 – 6 of the build plan are in this repo: +Shipped (see [`docs/BUILD-PLAN.md`](docs/BUILD-PLAN.md)): steps 1, 2, 3, 4, 5, 6, 8. - **1.** Scaffold + auth (admin email/password, operator name/4-digit PIN with 12 h device session, PIN lockout, audited sessions). - **2.** Admin CRUD (users, machines, operation templates, projects / assemblies / parts) with content-addressed STEP / PDF / DXF / SVG file uploads. @@ -12,8 +12,9 @@ Steps 1 – 6 of the build plan are in this repo: - **4.** Operator scan flow — phone scan resolves `/op/scan/`, single-claim enforced at DB level, Start / Pause / Done with inline QC for steps that require it, TimeLog rows for every claim. - **5.** PDF traveler generation — per-operation card + per-part cover sheet with the full operation list and file manifest. Printed via `pdf-lib` (no native deps). - **6.** Fasteners + purchase orders — per-project BOM of fasteners with unresolved-need rollups, PO lifecycle (`draft → sent → partial → received`, or `cancelled`), per-line receipt entry with auto-advance, and vendor-ready PDF downloads. +- **8.** In-browser STEP viewer — OpenCascade (WASM, via `occt-import-js`) parses STEP/STP, `three.js` renders with OrbitControls. Thumbnails are captured from the first rendered frame, uploaded to the content-addressed file store, and displayed on the assembly parts list. No native deps, no server-side GL. -Planned (not yet shipped): dashboard → STEP viewer → dedicated QC operations → OpenAPI docs + backups. See [`docs/BUILD-PLAN.md`](docs/BUILD-PLAN.md). +Planned (not yet shipped): dashboard (step 7) → dedicated QC operations (9) → OpenAPI docs + backups (10). ## Core concepts diff --git a/app/admin/projects/[id]/assemblies/[assemblyId]/AssemblyDetailClient.tsx b/app/admin/projects/[id]/assemblies/[assemblyId]/AssemblyDetailClient.tsx index a60cfac..f8638d6 100644 --- a/app/admin/projects/[id]/assemblies/[assemblyId]/AssemblyDetailClient.tsx +++ b/app/admin/projects/[id]/assemblies/[assemblyId]/AssemblyDetailClient.tsx @@ -39,6 +39,7 @@ interface PartRow { hasStep: boolean; hasDrawing: boolean; hasCut: boolean; + thumbnailFileId: string | null; operationCount: number; } @@ -100,6 +101,7 @@ export default function AssemblyDetailClient({ + @@ -112,6 +114,26 @@ export default function AssemblyDetailClient({ {parts.map((p) => ( + @@ -136,7 +158,7 @@ export default function AssemblyDetailClient({ ))} {parts.length === 0 && ( - diff --git a/app/admin/projects/[id]/assemblies/[assemblyId]/page.tsx b/app/admin/projects/[id]/assemblies/[assemblyId]/page.tsx index 77e9f24..40002aa 100644 --- a/app/admin/projects/[id]/assemblies/[assemblyId]/page.tsx +++ b/app/admin/projects/[id]/assemblies/[assemblyId]/page.tsx @@ -21,6 +21,7 @@ export default async function AdminAssemblyDetailPage({ stepFile: { select: { id: true } }, drawingFile: { select: { id: true } }, cutFile: { select: { id: true } }, + thumbnailFile: { select: { id: true } }, }, }, }, @@ -46,6 +47,7 @@ export default async function AdminAssemblyDetailPage({ hasStep: !!p.stepFile, hasDrawing: !!p.drawingFile, hasCut: !!p.cutFile, + thumbnailFileId: p.thumbnailFile?.id ?? null, operationCount: p._count.operations, }))} /> diff --git a/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx b/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx index 303de92..b135bea 100644 --- a/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx +++ b/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx @@ -47,6 +47,7 @@ interface PartInfo { stepFile: FileView | null; drawingFile: FileView | null; cutFile: FileView | null; + thumbnailFileId: string | null; } export interface OperationRow { @@ -208,7 +209,13 @@ export default function PartDetailClient({ {part.stepFile ? ( - + router.refresh()} + /> ) : null} void; +}) { const [open, setOpen] = useState(false); const [edges, setEdges] = useState(true); + const [savingThumb, setSavingThumb] = useState(false); + const [thumbMessage, setThumbMessage] = useState(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 (

3D viewer

{open ? ( - + <> + + {hasThumbnail ? ( + + ) : null} + ) : null}
Preview Code Name Material
+ {p.thumbnailFileId ? ( + // eslint-disable-next-line @next/next/no-img-element + {`${p.code} + ) : p.hasStep ? ( +
+ open to render +
+ ) : ( +
+ no STEP +
+ )} +
{p.code} {p.name} {p.material ?? "—"}
+ No parts yet.