stage 8-complete
Build and Push Docker Image / build (push) Successful in 1m4s

This commit is contained in:
jason
2026-04-21 14:21:53 -05:00
parent 76308b8aa3
commit bb452a59ae
13 changed files with 281 additions and 44 deletions
+3 -1
View File
@@ -9,7 +9,9 @@
"Bash(npx tsc *)", "Bash(npx tsc *)",
"Bash(npx next *)", "Bash(npx next *)",
"Bash(npm run *)", "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)"
] ]
} }
} }
+3 -2
View File
@@ -4,7 +4,7 @@ A single-container, self-hosted Manufacturing Resource Planning (MRP) app built
## Status ## 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). - **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. - **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/<token>`, single-claim enforced at DB level, Start / Pause / Done with inline QC for steps that require it, TimeLog rows for every claim. - **4.** Operator scan flow — phone scan resolves `/op/scan/<token>`, 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). - **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. - **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 ## Core concepts
@@ -39,6 +39,7 @@ interface PartRow {
hasStep: boolean; hasStep: boolean;
hasDrawing: boolean; hasDrawing: boolean;
hasCut: boolean; hasCut: boolean;
thumbnailFileId: string | null;
operationCount: number; operationCount: number;
} }
@@ -100,6 +101,7 @@ export default function AssemblyDetailClient({
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200"> <thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
<tr> <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">Code</th>
<th className="px-4 py-2 font-medium">Name</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">Material</th>
@@ -112,6 +114,26 @@ export default function AssemblyDetailClient({
<tbody> <tbody>
{parts.map((p) => ( {parts.map((p) => (
<tr key={p.id} className="border-b border-slate-100 last:border-0"> <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-mono text-slate-700">{p.code}</td>
<td className="px-4 py-3 font-medium">{p.name}</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.material ?? "—"}</td>
@@ -136,7 +158,7 @@ export default function AssemblyDetailClient({
))} ))}
{parts.length === 0 && ( {parts.length === 0 && (
<tr> <tr>
<td colSpan={7} className="px-4 py-10 text-center text-slate-500"> <td colSpan={8} className="px-4 py-10 text-center text-slate-500">
No parts yet. No parts yet.
</td> </td>
</tr> </tr>
@@ -21,6 +21,7 @@ export default async function AdminAssemblyDetailPage({
stepFile: { select: { id: true } }, stepFile: { select: { id: true } },
drawingFile: { select: { id: true } }, drawingFile: { select: { id: true } },
cutFile: { select: { id: true } }, cutFile: { select: { id: true } },
thumbnailFile: { select: { id: true } },
}, },
}, },
}, },
@@ -46,6 +47,7 @@ export default async function AdminAssemblyDetailPage({
hasStep: !!p.stepFile, hasStep: !!p.stepFile,
hasDrawing: !!p.drawingFile, hasDrawing: !!p.drawingFile,
hasCut: !!p.cutFile, hasCut: !!p.cutFile,
thumbnailFileId: p.thumbnailFile?.id ?? null,
operationCount: p._count.operations, operationCount: p._count.operations,
}))} }))}
/> />
@@ -47,6 +47,7 @@ interface PartInfo {
stepFile: FileView | null; stepFile: FileView | null;
drawingFile: FileView | null; drawingFile: FileView | null;
cutFile: FileView | null; cutFile: FileView | null;
thumbnailFileId: string | null;
} }
export interface OperationRow { export interface OperationRow {
@@ -208,7 +209,13 @@ export default function PartDetailClient({
</section> </section>
{part.stepFile ? ( {part.stepFile ? (
<StepViewerSection fileId={part.stepFile.id} fileName={part.stepFile.originalName} /> <StepViewerSection
partId={part.id}
fileId={part.stepFile.id}
fileName={part.stepFile.originalName}
hasThumbnail={!!part.thumbnailFileId}
onThumbnailSaved={() => router.refresh()}
/>
) : null} ) : null}
<OperationsSection <OperationsSection
@@ -676,27 +683,96 @@ function OperationModal({
// -------- 3D viewer ------------------------------------------------------ // -------- 3D viewer ------------------------------------------------------
function StepViewerSection({ fileId, fileName }: { fileId: string; fileName: string }) { function StepViewerSection({
partId,
fileId,
fileName,
hasThumbnail,
onThumbnailSaved,
}: {
partId: string;
fileId: string;
fileName: string;
hasThumbnail: boolean;
onThumbnailSaved: () => void;
}) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [edges, setEdges] = useState(true); const [edges, setEdges] = useState(true);
const [savingThumb, setSavingThumb] = useState(false);
const [thumbMessage, setThumbMessage] = useState<string | null>(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 // The viewer fetches via `/api/v1/files/:id/download`, which streams the
// stored STEP bytes with correct auth (admin session cookie is already set). // stored STEP bytes with correct auth (admin session cookie is already set).
const url = `/api/v1/files/${fileId}/download`; 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 ( return (
<section className="mb-8"> <section className="mb-8">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">3D viewer</h2> <h2 className="text-lg font-semibold">3D viewer</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{open ? ( {open ? (
<label className="flex items-center gap-1.5 text-xs text-slate-600"> <>
<input <label className="flex items-center gap-1.5 text-xs text-slate-600">
type="checkbox" <input
checked={edges} type="checkbox"
onChange={(e) => setEdges(e.target.checked)} checked={edges}
/> onChange={(e) => setEdges(e.target.checked)}
Show edges />
</label> Show edges
</label>
{hasThumbnail ? (
<Button
variant="ghost"
size="sm"
disabled={savingThumb}
onClick={() => {
capturedRef.current = false;
setThumbMessage("Regenerating on next frame…");
setCaptureNonce((n) => n + 1);
}}
>
{savingThumb ? "Saving…" : "Regenerate thumbnail"}
</Button>
) : null}
</>
) : null} ) : null}
<Button variant={open ? "secondary" : "primary"} size="sm" onClick={() => setOpen((v) => !v)}> <Button variant={open ? "secondary" : "primary"} size="sm" onClick={() => setOpen((v) => !v)}>
{open ? "Hide" : "Show 3D"} {open ? "Hide" : "Show 3D"}
@@ -706,13 +782,21 @@ function StepViewerSection({ fileId, fileName }: { fileId: string; fileName: str
{open ? ( {open ? (
<Card> <Card>
<div className="p-3"> <div className="p-3">
<div className="mb-2 text-xs text-slate-500 truncate" title={fileName}> <div className="mb-2 flex items-center justify-between gap-3 text-xs text-slate-500">
Viewing: <code className="font-mono text-slate-700">{fileName}</code> <span className="truncate" title={fileName}>
Viewing: <code className="font-mono text-slate-700">{fileName}</code>
</span>
{thumbMessage ? <span className="text-slate-500">{thumbMessage}</span> : null}
</div> </div>
<StepViewer key={`${fileId}-${edges ? "e" : "ne"}`} url={url} showEdges={edges} /> <StepViewer
key={`${fileId}-${edges ? "e" : "ne"}-${captureNonce}`}
url={url}
showEdges={edges}
onFirstFrame={shouldCapture ? handleFirstFrame : undefined}
/>
<p className="mt-2 text-[11px] text-slate-500"> <p className="mt-2 text-[11px] text-slate-500">
Drag to rotate · scroll to zoom · right-drag to pan. Parsing and rendering happens Drag to rotate · scroll to zoom · right-drag to pan. Parsing and rendering happen in
in your browser nothing is uploaded. your browser; the first view also uploads a ~480×360 JPEG thumbnail for the parts list.
</p> </p>
</div> </div>
</Card> </Card>
@@ -25,6 +25,7 @@ export default async function AdminPartDetailPage({
stepFile: true, stepFile: true,
drawingFile: true, drawingFile: true,
cutFile: true, cutFile: true,
thumbnailFile: true,
operations: { operations: {
orderBy: { sequence: "asc" }, orderBy: { sequence: "asc" },
include: { include: {
@@ -79,6 +80,7 @@ export default async function AdminPartDetailPage({
stepFile: fileView(part.stepFile), stepFile: fileView(part.stepFile),
drawingFile: fileView(part.drawingFile), drawingFile: fileView(part.drawingFile),
cutFile: fileView(part.cutFile), cutFile: fileView(part.cutFile),
thumbnailFileId: part.thumbnailFileId,
}} }}
operations={part.operations.map((op) => ({ operations={part.operations.map((op) => ({
id: op.id, id: op.id,
+1
View File
@@ -51,6 +51,7 @@ export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: strin
...(body.stepFileId !== undefined ? { stepFileId: body.stepFileId } : {}), ...(body.stepFileId !== undefined ? { stepFileId: body.stepFileId } : {}),
...(body.drawingFileId !== undefined ? { drawingFileId: body.drawingFileId } : {}), ...(body.drawingFileId !== undefined ? { drawingFileId: body.drawingFileId } : {}),
...(body.cutFileId !== undefined ? { cutFileId: body.cutFileId } : {}), ...(body.cutFileId !== undefined ? { cutFileId: body.cutFileId } : {}),
...(body.thumbnailFileId !== undefined ? { thumbnailFileId: body.thumbnailFileId } : {}),
}, },
}); });
await audit({ await audit({
+92 -2
View File
@@ -21,6 +21,12 @@ interface Props {
className?: string; className?: string;
/** Show mesh edges for readability. Defaults to true. Disable on very complex assemblies. */ /** Show mesh edges for readability. Defaults to true. Disable on very complex assemblies. */
showEdges?: boolean; showEdges?: boolean;
/**
* Called once, after the first successful render, with a downsampled JPEG
* thumbnail of the 3D view (~480x360). Host is responsible for uploading
* it. Errors in the host callback are swallowed — the viewer still runs.
*/
onFirstFrame?: (blob: Blob) => void | Promise<void>;
} }
type ViewerState = type ViewerState =
@@ -29,10 +35,17 @@ type ViewerState =
| { kind: "ready"; triangleCount: number; meshCount: number } | { kind: "ready"; triangleCount: number; meshCount: number }
| { kind: "error"; message: string }; | { kind: "error"; message: string };
export default function StepViewer({ url, className, showEdges = true }: Props) { export default function StepViewer({ url, className, showEdges = true, onFirstFrame }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const [state, setState] = useState<ViewerState>({ kind: "idle" }); const [state, setState] = useState<ViewerState>({ kind: "idle" });
// Hold the latest callback in a ref so updates don't re-trigger the setup
// effect (which would reload the STEP file every render).
const onFirstFrameRef = useRef(onFirstFrame);
useEffect(() => {
onFirstFrameRef.current = onFirstFrame;
}, [onFirstFrame]);
useEffect(() => { useEffect(() => {
const hostNullable = containerRef.current; const hostNullable = containerRef.current;
if (!hostNullable) return; if (!hostNullable) return;
@@ -89,7 +102,13 @@ export default function StepViewer({ url, className, showEdges = true }: Props)
const width = host.clientWidth || 600; const width = host.clientWidth || 600;
const height = host.clientHeight || 400; const height = host.clientHeight || 400;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); // `preserveDrawingBuffer` so we can capture a thumbnail via toBlob().
// Slight perf cost; acceptable for the one-at-a-time viewer use case.
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
preserveDrawingBuffer: true,
});
renderer.setPixelRatio(window.devicePixelRatio || 1); renderer.setPixelRatio(window.devicePixelRatio || 1);
renderer.setSize(width, height, false); renderer.setSize(width, height, false);
renderer.setClearColor(0xf8fafc); // slate-50 renderer.setClearColor(0xf8fafc); // slate-50
@@ -150,10 +169,34 @@ export default function StepViewer({ url, className, showEdges = true }: Props)
// Render loop. // Render loop.
let rafId = 0; let rafId = 0;
let capturedFirstFrame = false;
const render = () => { const render = () => {
controls.update(); controls.update();
renderer.render(scene, camera); renderer.render(scene, camera);
rafId = requestAnimationFrame(render); rafId = requestAnimationFrame(render);
// After the first real paint, grab a thumbnail if the host wants
// one. We wait one extra frame so antialiasing and OrbitControls
// have settled. Reads through a ref so callback identity changes
// don't reload the model.
const cb = onFirstFrameRef.current;
if (!capturedFirstFrame && cb) {
capturedFirstFrame = true;
// Defer a tick so the paint actually commits before we read back.
requestAnimationFrame(() => {
try {
captureThumbnail(renderer.domElement, 480, 360).then((blob) => {
if (!cancelled && blob) {
Promise.resolve(cb(blob)).catch(() => {
/* host failure must not break the viewer */
});
}
});
} catch {
/* ignore thumbnail capture errors */
}
});
}
}; };
render(); render();
@@ -240,6 +283,53 @@ export default function StepViewer({ url, className, showEdges = true }: Props)
import type * as THREENS from "three"; import type * as THREENS from "three";
import type { OcctMesh } from "occt-import-js"; import type { OcctMesh } from "occt-import-js";
/**
* Downsample the viewer canvas to a fixed size and return a JPEG blob.
* JPEG instead of PNG because thumbnails don't need transparency and JPEG
* cuts the byte count roughly 4× on typical 3D renders.
*/
function captureThumbnail(
source: HTMLCanvasElement,
width: number,
height: number,
): Promise<Blob | null> {
return new Promise((resolve) => {
try {
const off = document.createElement("canvas");
off.width = width;
off.height = height;
const ctx = off.getContext("2d");
if (!ctx) return resolve(null);
// Letterbox if aspect ratios differ — fill with slate-50 to match the
// viewer background so thumbnails look continuous with the live view.
ctx.fillStyle = "#f8fafc";
ctx.fillRect(0, 0, width, height);
const srcAspect = source.width / source.height;
const dstAspect = width / height;
let dw = width;
let dh = height;
if (srcAspect > dstAspect) {
dh = Math.round(width / srcAspect);
} else {
dw = Math.round(height * srcAspect);
}
const dx = Math.round((width - dw) / 2);
const dy = Math.round((height - dh) / 2);
ctx.drawImage(source, dx, dy, dw, dh);
off.toBlob(
(blob) => resolve(blob),
"image/jpeg",
0.85,
);
} catch {
resolve(null);
}
});
}
interface BuiltMesh { interface BuiltMesh {
mesh: THREENS.Mesh; mesh: THREENS.Mesh;
edges: THREENS.Group | null; edges: THREENS.Group | null;
+1 -1
View File
@@ -11,7 +11,7 @@ The roadmap agreed at project kickoff. Each step is committed separately so the
| 5 | PDF generation: per-operation card + per-part cover sheet | **done** | | 5 | PDF generation: per-operation card + per-part cover sheet | **done** |
| 6 | Fasteners + PO (PDF + lifecycle: draft → sent → partial → received) | **done** | | 6 | Fasteners + PO (PDF + lifecycle: draft → sent → partial → received) | **done** |
| 7 | Admin dashboard (WIP, overdue, plan-vs-actual) + audit log viewer | planned | | 7 | Admin dashboard (WIP, overdue, plan-vs-actual) + audit log viewer | planned |
| 8 | In-browser STEP viewer + server-side thumbnails | planned | | 8 | In-browser STEP viewer + server-side thumbnails | **done** |
| 9 | QC records (inline checkboxes + dedicated QC operation type) | planned | | 9 | QC records (inline checkboxes + dedicated QC operation type) | planned |
| 10 | OpenAPI docs at `/api/docs`, nightly SQLite backup script | planned | | 10 | OpenAPI docs at `/api/docs`, nightly SQLite backup script | planned |
+1
View File
@@ -196,6 +196,7 @@ export const UpdatePartSchema = z
stepFileId: z.string().min(1).nullable().optional(), stepFileId: z.string().min(1).nullable().optional(),
drawingFileId: z.string().min(1).nullable().optional(), drawingFileId: z.string().min(1).nullable().optional(),
cutFileId: z.string().min(1).nullable().optional(), cutFileId: z.string().min(1).nullable().optional(),
thumbnailFileId: z.string().min(1).nullable().optional(),
}) })
.strict(); .strict();
@@ -0,0 +1,29 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Part" (
"id" TEXT NOT NULL PRIMARY KEY,
"assemblyId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"material" TEXT,
"qty" INTEGER NOT NULL DEFAULT 1,
"notes" TEXT,
"stepFileId" TEXT,
"drawingFileId" TEXT,
"cutFileId" TEXT,
"thumbnailFileId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Part_assemblyId_fkey" FOREIGN KEY ("assemblyId") REFERENCES "Assembly" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Part_stepFileId_fkey" FOREIGN KEY ("stepFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_drawingFileId_fkey" FOREIGN KEY ("drawingFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_cutFileId_fkey" FOREIGN KEY ("cutFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_thumbnailFileId_fkey" FOREIGN KEY ("thumbnailFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Part" ("assemblyId", "code", "createdAt", "cutFileId", "drawingFileId", "id", "material", "name", "notes", "qty", "stepFileId", "updatedAt") SELECT "assemblyId", "code", "createdAt", "cutFileId", "drawingFileId", "id", "material", "name", "notes", "qty", "stepFileId", "updatedAt" FROM "Part";
DROP TABLE "Part";
ALTER TABLE "new_Part" RENAME TO "Part";
CREATE UNIQUE INDEX "Part_assemblyId_code_key" ON "Part"("assemblyId", "code");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+24 -21
View File
@@ -128,24 +128,26 @@ model Assembly {
} }
model Part { model Part {
id String @id @default(cuid()) id String @id @default(cuid())
assemblyId String assemblyId String
code String code String
name String name String
material String? material String?
qty Int @default(1) qty Int @default(1)
notes String? notes String?
stepFileId String? stepFileId String?
drawingFileId String? drawingFileId String?
cutFileId String? cutFileId String?
createdAt DateTime @default(now()) thumbnailFileId String?
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assembly Assembly @relation(fields: [assemblyId], references: [id], onDelete: Cascade) assembly Assembly @relation(fields: [assemblyId], references: [id], onDelete: Cascade)
stepFile FileAsset? @relation("PartStep", fields: [stepFileId], references: [id], onDelete: SetNull) stepFile FileAsset? @relation("PartStep", fields: [stepFileId], references: [id], onDelete: SetNull)
drawingFile FileAsset? @relation("PartDrawing", fields: [drawingFileId], references: [id], onDelete: SetNull) drawingFile FileAsset? @relation("PartDrawing", fields: [drawingFileId], references: [id], onDelete: SetNull)
cutFile FileAsset? @relation("PartCut", fields: [cutFileId], references: [id], onDelete: SetNull) cutFile FileAsset? @relation("PartCut", fields: [cutFileId], references: [id], onDelete: SetNull)
operations Operation[] thumbnailFile FileAsset? @relation("PartThumbnail", fields: [thumbnailFileId], references: [id], onDelete: SetNull)
operations Operation[]
@@unique([assemblyId, code]) @@unique([assemblyId, code])
} }
@@ -284,10 +286,11 @@ model FileAsset {
uploadedBy String? uploadedBy String?
uploadedAt DateTime @default(now()) uploadedAt DateTime @default(now())
partStep Part[] @relation("PartStep") partStep Part[] @relation("PartStep")
partDrawing Part[] @relation("PartDrawing") partDrawing Part[] @relation("PartDrawing")
partCut Part[] @relation("PartCut") partCut Part[] @relation("PartCut")
poPdfs PurchaseOrder[] @relation("PoPdf") partThumbnail Part[] @relation("PartThumbnail")
poPdfs PurchaseOrder[] @relation("PoPdf")
} }
model AuditLog { model AuditLog {
+1 -1
View File
File diff suppressed because one or more lines are too long