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
+5 -1
View File
@@ -11,7 +11,11 @@
"Bash(npm run *)", "Bash(npm run *)",
"Bash(node scripts/copy-viewer-assets.mjs)", "Bash(node scripts/copy-viewer-assets.mjs)",
"Bash(npx prisma *)", "Bash(npx prisma *)",
"Bash(DATABASE_URL=\"file:./data/app.db\" npx prisma migrate dev --name add_part_thumbnail)" "Bash(DATABASE_URL=\"file:./data/app.db\" npx prisma migrate dev --name add_part_thumbnail)",
"Bash(DATABASE_URL=\"file:./data/app.db\" npx prisma migrate dev --name assembly_files_and_partial_state --create-only)",
"Bash(DATABASE_URL=\"file:./data/app.db\" npx prisma migrate deploy)",
"Bash(DATABASE_URL=\"file:./data/app.db\" npx prisma generate)",
"Bash(DATABASE_URL=\"file:./data/app.db\" npm run build)"
] ]
} }
} }
@@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
Badge, Badge,
@@ -16,12 +16,31 @@ import {
} from "@/components/ui"; } from "@/components/ui";
import { apiFetch, ApiClientError } from "@/lib/client-api"; import { apiFetch, ApiClientError } from "@/lib/client-api";
interface FileView {
id: string;
originalName: string;
sizeBytes: number;
kind: string;
mimeType: string | null;
}
interface AssemblyInfo { interface AssemblyInfo {
id: string; id: string;
code: string; code: string;
name: string; name: string;
qty: number; qty: number;
notes: string | null; 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 { interface ProjectInfo {
@@ -97,6 +116,43 @@ export default function AssemblyDetailClient({
</Card> </Card>
) : null} ) : 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> <Card>
<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">
@@ -358,3 +414,138 @@ function NewPartModal({
</Modal> </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>
);
}
@@ -14,6 +14,9 @@ export default async function AdminAssemblyDetailPage({
where: { id: assemblyId, projectId: id }, where: { id: assemblyId, projectId: id },
include: { include: {
project: { select: { id: true, code: true, name: true } }, project: { select: { id: true, code: true, name: true } },
stepFile: true,
drawingFile: true,
cutFile: true,
parts: { parts: {
orderBy: { code: "asc" }, orderBy: { code: "asc" },
include: { include: {
@@ -28,6 +31,17 @@ export default async function AdminAssemblyDetailPage({
}); });
if (!assembly) notFound(); if (!assembly) notFound();
const fileView = (f: typeof assembly.stepFile) =>
f
? {
id: f.id,
originalName: f.originalName,
sizeBytes: f.sizeBytes,
kind: f.kind,
mimeType: f.mimeType,
}
: null;
return ( return (
<AssemblyDetailClient <AssemblyDetailClient
project={assembly.project} project={assembly.project}
@@ -37,6 +51,9 @@ export default async function AdminAssemblyDetailPage({
name: assembly.name, name: assembly.name,
qty: assembly.qty, qty: assembly.qty,
notes: assembly.notes, notes: assembly.notes,
stepFile: fileView(assembly.stepFile),
drawingFile: fileView(assembly.drawingFile),
cutFile: fileView(assembly.cutFile),
}} }}
parts={assembly.parts.map((p) => ({ parts={assembly.parts.map((p) => ({
id: p.id, id: p.id,
@@ -87,12 +87,14 @@ type Slot = "stepFileId" | "drawingFileId" | "cutFileId";
const STATUS_TONE: Record<string, "slate" | "blue" | "green" | "amber" | "red"> = { const STATUS_TONE: Record<string, "slate" | "blue" | "green" | "amber" | "red"> = {
pending: "slate", pending: "slate",
in_progress: "blue", in_progress: "blue",
partial: "amber",
completed: "green", completed: "green",
}; };
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
pending: "Pending", pending: "Pending",
in_progress: "In progress", in_progress: "In progress",
partial: "Partial",
completed: "Completed", completed: "Completed",
}; };
@@ -671,6 +673,7 @@ function OperationModal({
<Select value={status} onChange={(e) => setStatus(e.target.value)}> <Select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="in_progress">In progress</option> <option value="in_progress">In progress</option>
<option value="partial">Partial</option>
<option value="completed">Completed</option> <option value="completed">Completed</option>
</Select> </Select>
</Field> </Field>
+3
View File
@@ -39,6 +39,9 @@ export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: strin
...(body.name !== undefined ? { name: body.name } : {}), ...(body.name !== undefined ? { name: body.name } : {}),
...(body.qty !== undefined ? { qty: body.qty } : {}), ...(body.qty !== undefined ? { qty: body.qty } : {}),
...(body.notes !== undefined ? { notes: body.notes } : {}), ...(body.notes !== undefined ? { notes: body.notes } : {}),
...(body.stepFileId !== undefined ? { stepFileId: body.stepFileId } : {}),
...(body.drawingFileId !== undefined ? { drawingFileId: body.drawingFileId } : {}),
...(body.cutFileId !== undefined ? { cutFileId: body.cutFileId } : {}),
}, },
}); });
await audit({ await audit({
+6 -1
View File
@@ -28,6 +28,7 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
select: { select: {
code: true, code: true,
name: true, name: true,
qty: true,
project: { select: { code: true, name: true } }, project: { select: { code: true, name: true } },
}, },
}, },
@@ -39,7 +40,11 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
const data: OperationCardData = { const data: OperationCardData = {
project: op.part.assembly.project, project: op.part.assembly.project,
assembly: { code: op.part.assembly.code, name: op.part.assembly.name }, assembly: {
code: op.part.assembly.code,
name: op.part.assembly.name,
qty: op.part.assembly.qty,
},
part: { part: {
code: op.part.code, code: op.part.code,
name: op.part.name, name: op.part.name,
+4 -1
View File
@@ -37,8 +37,11 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
} }
const now = new Date(); const now = new Date();
// `partial` behaves exactly like `pending` for claim purposes — it just
// means a previous operator paused with some units done. Either is fair
// game to resume.
const updateResult = await prisma.operation.updateMany({ const updateResult = await prisma.operation.updateMany({
where: { id, claimedByUserId: null, status: "pending" }, where: { id, claimedByUserId: null, status: { in: ["pending", "partial"] } },
data: { data: {
status: "in_progress", status: "in_progress",
claimedByUserId: actor.id, claimedByUserId: actor.id,
@@ -66,6 +66,10 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
}, },
}); });
} }
// unitsCompleted is cumulative across pause/resume cycles; on close we
// add this session's batch so the total reflects everything the step
// actually produced.
const units = body.unitsProcessed ?? 0;
await tx.operation.update({ await tx.operation.update({
where: { id }, where: { id },
data: { data: {
@@ -73,6 +77,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
completedAt: now, completedAt: now,
claimedByUserId: null, claimedByUserId: null,
claimedAt: null, claimedAt: null,
...(units > 0 ? { unitsCompleted: { increment: units } } : {}),
}, },
}); });
}); });
+13 -2
View File
@@ -29,6 +29,12 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
throw new ApiError(409, "op_not_active", "Step is not active"); throw new ApiError(409, "op_not_active", "Step is not active");
} }
// If the operator logged any units during this session we flip status to
// `partial` (instead of `pending`) so the scan card can say "Resume this
// step" and the counter survives across pauses.
const units = body.unitsProcessed ?? 0;
const nextStatus: "pending" | "partial" = units > 0 ? "partial" : "pending";
const now = new Date(); const now = new Date();
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
// Close the most recent open TimeLog for (op, operator). We accept that // Close the most recent open TimeLog for (op, operator). We accept that
@@ -50,7 +56,12 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
} }
await tx.operation.update({ await tx.operation.update({
where: { id }, where: { id },
data: { status: "pending", claimedByUserId: null, claimedAt: null }, data: {
status: nextStatus,
claimedByUserId: null,
claimedAt: null,
...(units > 0 ? { unitsCompleted: { increment: units } } : {}),
},
}); });
}); });
@@ -60,7 +71,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
action: "release_op", action: "release_op",
entity: "Operation", entity: "Operation",
entityId: id, entityId: id,
after: { status: "pending", unitsProcessed: body.unitsProcessed ?? null }, after: { status: nextStatus, unitsProcessed: body.unitsProcessed ?? null },
ipAddress: clientIp(req), ipAddress: clientIp(req),
}); });
+38 -4
View File
@@ -1,6 +1,7 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { errorResponse, requireRole, ApiError } from "@/lib/api"; import { errorResponse, requireRole, ApiError } from "@/lib/api";
import { readFileBytes } from "@/lib/files";
import { import {
renderPartTravelers, renderPartTravelers,
type OperationCardData, type OperationCardData,
@@ -24,10 +25,15 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
where: { id }, where: { id },
include: { include: {
assembly: { assembly: {
include: { project: { select: { code: true, name: true } } }, include: {
project: { select: { code: true, name: true } },
drawingFile: { select: { path: true, originalName: true } },
},
}, },
stepFile: { select: { originalName: true, sizeBytes: true, sha256: true } }, stepFile: { select: { originalName: true, sizeBytes: true, sha256: true } },
drawingFile: { select: { originalName: true, sizeBytes: true, sha256: true } }, drawingFile: {
select: { originalName: true, sizeBytes: true, sha256: true, path: true },
},
cutFile: { select: { originalName: true, sizeBytes: true, sha256: true } }, cutFile: { select: { originalName: true, sizeBytes: true, sha256: true } },
operations: { operations: {
orderBy: { sequence: "asc" }, orderBy: { sequence: "asc" },
@@ -42,7 +48,11 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
} }
const project = part.assembly.project; const project = part.assembly.project;
const assembly = { code: part.assembly.code, name: part.assembly.name }; const assembly = {
code: part.assembly.code,
name: part.assembly.name,
qty: part.assembly.qty,
};
const partHeader = { const partHeader = {
code: part.code, code: part.code,
name: part.name, name: part.name,
@@ -50,6 +60,14 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
qty: part.qty, qty: part.qty,
}; };
// Pull drawing PDFs off disk so pdf-lib can inline them behind the cover /
// op cards. Missing / unreadable files are logged and skipped — the
// traveler still prints without them rather than 500ing.
const drawingPdfBytes = await tryReadPdf(part.drawingFile?.path ?? null);
const assemblyDrawingPdfBytes = await tryReadPdf(
part.assembly.drawingFile?.path ?? null,
);
const cover: PartCoverData = { const cover: PartCoverData = {
project, project,
assembly, assembly,
@@ -88,7 +106,12 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
}, },
})); }));
const pdf = await renderPartTravelers({ cover, cards }); const pdf = await renderPartTravelers({
cover,
cards,
drawingPdfBytes,
assemblyDrawingPdfBytes,
});
const safeName = `${part.code}-travelers.pdf`.replace(/[^A-Za-z0-9._-]/g, "_"); const safeName = `${part.code}-travelers.pdf`.replace(/[^A-Za-z0-9._-]/g, "_");
return new NextResponse(pdf as unknown as BodyInit, { return new NextResponse(pdf as unknown as BodyInit, {
@@ -103,3 +126,14 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
return errorResponse(err); return errorResponse(err);
} }
} }
async function tryReadPdf(path: string | null): Promise<Uint8Array | null> {
if (!path) return null;
try {
const buf = await readFileBytes(path);
return new Uint8Array(buf);
} catch (err) {
console.warn("[travelers.pdf] could not read drawing file", { path, err });
return null;
}
}
+98 -6
View File
@@ -3,6 +3,8 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
type ScanFile = { id: string; originalName: string; kind: string };
export type ScanOp = { export type ScanOp = {
id: string; id: string;
sequence: number; sequence: number;
@@ -14,6 +16,7 @@ export type ScanOp = {
settings: string | null; settings: string | null;
plannedMinutes: number | null; plannedMinutes: number | null;
plannedUnits: number | null; plannedUnits: number | null;
unitsCompleted: number;
claimedByUserId: string | null; claimedByUserId: string | null;
claimedAt: string | null; claimedAt: string | null;
machine: { id: string; name: string; kind: string } | null; machine: { id: string; name: string; kind: string } | null;
@@ -23,10 +26,18 @@ export type ScanOp = {
name: string; name: string;
material: string | null; material: string | null;
qty: number; qty: number;
stepFile: ScanFile | null;
drawingFile: ScanFile | null;
cutFile: ScanFile | null;
thumbnailFileId: string | null;
assembly: { assembly: {
id: string; id: string;
code: string; code: string;
name: string; name: string;
qty: number;
stepFile: ScanFile | null;
drawingFile: ScanFile | null;
cutFile: ScanFile | null;
project: { id: string; code: string; name: string }; project: { id: string; code: string; name: string };
}; };
}; };
@@ -56,8 +67,29 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
const isOperator = viewer.role === "operator"; const isOperator = viewer.role === "operator";
const active = op.status === "in_progress"; const active = op.status === "in_progress";
const partial = op.status === "partial";
const completed = op.status === "completed"; const completed = op.status === "completed";
// Total units the shop needs to run through this op to satisfy the project:
// assembly.qty (how many assemblies we're building) × part.qty (parts per
// assembly). This is distinct from plannedUnits, which is the admin's
// optional time estimate for a single run.
const totalUnits = op.part.assembly.qty * op.part.qty;
// Flat list of attached files (part first, then assembly). Rendered as big
// tap targets so the operator can pull the drawing / STEP right from the
// scan page without bouncing to the admin UI.
const quickFiles: Array<{ label: string; file: ScanFile; scope: "part" | "assembly" }> = [];
if (op.part.drawingFile) quickFiles.push({ label: "Drawing", file: op.part.drawingFile, scope: "part" });
if (op.part.stepFile) quickFiles.push({ label: "3D / STEP", file: op.part.stepFile, scope: "part" });
if (op.part.cutFile) quickFiles.push({ label: "Cut file", file: op.part.cutFile, scope: "part" });
if (op.part.assembly.drawingFile)
quickFiles.push({ label: "Assembly drawing", file: op.part.assembly.drawingFile, scope: "assembly" });
if (op.part.assembly.stepFile)
quickFiles.push({ label: "Assembly 3D", file: op.part.assembly.stepFile, scope: "assembly" });
if (op.part.assembly.cutFile)
quickFiles.push({ label: "Assembly cut", file: op.part.assembly.cutFile, scope: "assembly" });
async function call(path: string, body?: unknown) { async function call(path: string, body?: unknown) {
setError(null); setError(null);
const res = await fetch(path, { const res = await fetch(path, {
@@ -129,10 +161,21 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
<h1 className="text-xl font-semibold mt-1">{op.part.name}</h1> <h1 className="text-xl font-semibold mt-1">{op.part.name}</h1>
<div className="text-slate-600 text-sm"> <div className="text-slate-600 text-sm">
Part <span className="font-mono">{op.part.code}</span> Part <span className="font-mono">{op.part.code}</span>
{op.part.material ? ` · ${op.part.material}` : null} · qty {op.part.qty} {op.part.material ? ` · ${op.part.material}` : null}
</div>
<div className="text-slate-700 text-sm mt-2">
<span className="font-semibold text-slate-900">Total to produce: {totalUnits}</span>
<span className="text-slate-500">
{" "}({op.part.assembly.qty} assemblies × {op.part.qty} per assembly)
</span>
{op.unitsCompleted > 0 ? (
<span className="ml-2 text-amber-700 font-medium">
{op.unitsCompleted} done so far
</span>
) : null}
</div> </div>
<div className="mt-4 flex items-center gap-2"> <div className="mt-4 flex items-center gap-2 flex-wrap">
<span className="inline-flex items-center rounded-full bg-slate-100 text-slate-700 text-xs px-2 py-1"> <span className="inline-flex items-center rounded-full bg-slate-100 text-slate-700 text-xs px-2 py-1">
Step {op.sequence} Step {op.sequence}
</span> </span>
@@ -142,10 +185,16 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
? "bg-emerald-100 text-emerald-800" ? "bg-emerald-100 text-emerald-800"
: active : active
? "bg-amber-100 text-amber-800" ? "bg-amber-100 text-amber-800"
: partial
? "bg-orange-100 text-orange-800"
: "bg-slate-100 text-slate-700" : "bg-slate-100 text-slate-700"
}`} }`}
> >
{op.status === "in_progress" ? "in progress" : op.status} {op.status === "in_progress"
? "in progress"
: op.status === "partial"
? "partial"
: op.status}
</span> </span>
{op.qcRequired ? ( {op.qcRequired ? (
<span className="inline-flex items-center rounded-full bg-purple-100 text-purple-800 text-xs px-2 py-1"> <span className="inline-flex items-center rounded-full bg-purple-100 text-purple-800 text-xs px-2 py-1">
@@ -171,6 +220,32 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
) : null} ) : null}
</div> </div>
{quickFiles.length > 0 ? (
<div className="rounded-2xl bg-white border border-slate-200 p-5">
<h2 className="text-sm font-semibold text-slate-900 mb-3">Quick files</h2>
<div className="grid grid-cols-2 gap-2">
{quickFiles.map(({ label, file, scope }) => (
<a
key={`${scope}-${file.id}`}
href={`/api/v1/files/${file.id}/download`}
target="_blank"
rel="noopener"
className="flex flex-col rounded-xl border border-slate-200 bg-slate-50 active:bg-slate-100 px-3 py-2 min-h-[64px]"
>
<span className="text-sm font-medium text-slate-900">{label}</span>
<span className="text-[11px] text-slate-500 mt-0.5">
{scope === "assembly" ? "Assembly · " : "Part · "}
{file.kind.toUpperCase()}
</span>
<span className="text-[10px] text-slate-400 truncate mt-0.5" title={file.originalName}>
{file.originalName}
</span>
</a>
))}
</div>
</div>
) : null}
{op.instructions ? ( {op.instructions ? (
<div className="rounded-2xl bg-white border border-slate-200 p-5"> <div className="rounded-2xl bg-white border border-slate-200 p-5">
<h2 className="text-sm font-semibold text-slate-900 mb-1">Instructions</h2> <h2 className="text-sm font-semibold text-slate-900 mb-1">Instructions</h2>
@@ -214,20 +289,37 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
disabled={isPending || (!!op.claimedByUserId && op.claimedByUserId !== viewer.id)} disabled={isPending || (!!op.claimedByUserId && op.claimedByUserId !== viewer.id)}
className="w-full h-14 rounded-xl bg-slate-900 text-white text-lg font-medium disabled:bg-slate-400" className="w-full h-14 rounded-xl bg-slate-900 text-white text-lg font-medium disabled:bg-slate-400"
> >
{isPending ? "Claiming…" : "Start this step"} {isPending
? partial
? "Resuming…"
: "Claiming…"
: partial
? "Resume this step"
: "Start this step"}
</button> </button>
) : ( ) : (
<> <>
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
<label className="block"> <label className="block">
<span className="text-sm font-medium text-slate-900">Units processed</span> <span className="text-sm font-medium text-slate-900">
Units processed
{op.unitsCompleted > 0 ? (
<span className="ml-2 text-xs text-slate-500 font-normal">
{op.unitsCompleted} of {totalUnits} already done
</span>
) : null}
</span>
<input <input
type="number" type="number"
inputMode="numeric" inputMode="numeric"
min={0} min={0}
value={units} value={units}
onChange={(e) => setUnits(e.target.value)} onChange={(e) => setUnits(e.target.value)}
placeholder={op.plannedUnits?.toString() ?? "0"} placeholder={
op.unitsCompleted > 0
? `${Math.max(0, totalUnits - op.unitsCompleted)} remaining`
: op.plannedUnits?.toString() ?? totalUnits.toString()
}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-base" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-base"
/> />
</label> </label>
+8
View File
@@ -35,11 +35,19 @@ export default async function ScanPage({ params }: { params: Promise<{ token: st
name: true, name: true,
material: true, material: true,
qty: true, qty: true,
stepFile: { select: { id: true, originalName: true, kind: true } },
drawingFile: { select: { id: true, originalName: true, kind: true } },
cutFile: { select: { id: true, originalName: true, kind: true } },
thumbnailFileId: true,
assembly: { assembly: {
select: { select: {
id: true, id: true,
code: true, code: true,
name: true, name: true,
qty: true,
stepFile: { select: { id: true, originalName: true, kind: true } },
drawingFile: { select: { id: true, originalName: true, kind: true } },
cutFile: { select: { id: true, originalName: true, kind: true } },
project: { select: { id: true, code: true, name: true } }, project: { select: { id: true, code: true, name: true } },
}, },
}, },
+45 -5
View File
@@ -20,7 +20,9 @@ const MARGIN = 48; // 2/3"
export interface OperationCardData { export interface OperationCardData {
project: { code: string; name: string }; project: { code: string; name: string };
assembly: { code: string; name: string }; /** `qty` is the number of assemblies of this kind in the project. */
assembly: { code: string; name: string; qty: number };
/** `qty` is the per-assembly part count (so total parts = assembly.qty × part.qty). */
part: { code: string; name: string; material: string | null; qty: number }; part: { code: string; name: string; material: string | null; qty: number };
operation: { operation: {
id: string; id: string;
@@ -59,7 +61,7 @@ export interface PurchaseOrderPdfData {
export interface PartCoverData { export interface PartCoverData {
project: { code: string; name: string }; project: { code: string; name: string };
assembly: { code: string; name: string }; assembly: { code: string; name: string; qty: number };
part: { part: {
code: string; code: string;
name: string; name: string;
@@ -98,10 +100,21 @@ export async function renderPurchaseOrder(data: PurchaseOrderPdfData): Promise<U
return doc.save(); return doc.save();
} }
/** Entry point: cover sheet + every operation card, all in one PDF. */ /** Entry point: cover sheet + every operation card, all in one PDF.
*
* If `drawingPdfBytes` is provided (raw bytes of the part's PDF drawing),
* those pages are inlined right after the cover sheet so the printed stack
* is: cover → drawing(s) → op 1 → op 2 … Operators see the drawing on the
* same sheet they're holding while running the part — no separate print.
*
* Assembly-level drawings can be appended too (`assemblyDrawingPdfBytes`),
* rendered before the part drawing.
*/
export async function renderPartTravelers(payload: { export async function renderPartTravelers(payload: {
cover: PartCoverData; cover: PartCoverData;
cards: OperationCardData[]; cards: OperationCardData[];
drawingPdfBytes?: Uint8Array | null;
assemblyDrawingPdfBytes?: Uint8Array | null;
}): Promise<Uint8Array> { }): Promise<Uint8Array> {
const doc = await PDFDocument.create(); const doc = await PDFDocument.create();
const fonts = await embedFonts(doc); const fonts = await embedFonts(doc);
@@ -109,6 +122,16 @@ export async function renderPartTravelers(payload: {
const coverPage = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]); const coverPage = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
await drawCoverSheet(doc, coverPage, fonts, payload.cover); await drawCoverSheet(doc, coverPage, fonts, payload.cover);
// Inline the assembly-level drawing first, then the part drawing. Both are
// optional. We swallow per-PDF errors so a corrupt drawing doesn't block
// the op cards from printing.
if (payload.assemblyDrawingPdfBytes) {
await appendPdfPages(doc, payload.assemblyDrawingPdfBytes, "assembly drawing");
}
if (payload.drawingPdfBytes) {
await appendPdfPages(doc, payload.drawingPdfBytes, "part drawing");
}
for (const card of payload.cards) { for (const card of payload.cards) {
const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]); const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
await drawOperationCard(doc, page, fonts, card); await drawOperationCard(doc, page, fonts, card);
@@ -117,6 +140,19 @@ export async function renderPartTravelers(payload: {
return doc.save(); return doc.save();
} }
// Load an external PDF's pages and copy them into `doc`. Best-effort: if the
// upstream PDF is unreadable we log to stderr (server-side) and skip; the
// caller's traveler PDF is still produced.
async function appendPdfPages(doc: PDFDocument, bytes: Uint8Array, label: string): Promise<void> {
try {
const src = await PDFDocument.load(bytes, { ignoreEncryption: true });
const pages = await doc.copyPages(src, src.getPageIndices());
for (const p of pages) doc.addPage(p);
} catch (err) {
console.warn(`[travelers.pdf] skipped ${label}: ${(err as Error).message}`);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Layout helpers // Layout helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -258,10 +294,11 @@ async function drawOperationCard(
drawText(page, line, { x: MARGIN, y: cursor.top, font: fonts.bold, size: 22 }); drawText(page, line, { x: MARGIN, y: cursor.top, font: fonts.bold, size: 22 });
} }
cursor.top -= 14; cursor.top -= 14;
const totalUnits = data.assembly.qty * data.part.qty;
const partMeta = [ const partMeta = [
`Part ${data.part.code}`, `Part ${data.part.code}`,
data.part.material ? `${data.part.material}` : null, data.part.material ? `${data.part.material}` : null,
`qty ${data.part.qty}`, `${data.part.qty}/assembly × ${data.assembly.qty} assy = ${totalUnits} to produce`,
] ]
.filter(Boolean) .filter(Boolean)
.join(" · "); .join(" · ");
@@ -471,9 +508,12 @@ async function drawCoverSheet(
}); });
cursor.top -= 18; cursor.top -= 18;
const totalUnits = data.assembly.qty * data.part.qty;
const meta = [ const meta = [
data.part.material ? `Material: ${data.part.material}` : null, data.part.material ? `Material: ${data.part.material}` : null,
`Quantity: ${data.part.qty}`, `Per-assembly qty: ${data.part.qty}`,
`Assemblies: ${data.assembly.qty}`,
`Total to produce: ${totalUnits}`,
`Project: ${data.project.name}`, `Project: ${data.project.name}`,
`Assembly: ${data.assembly.name}`, `Assembly: ${data.assembly.name}`,
].filter(Boolean) as string[]; ].filter(Boolean) as string[];
+8 -1
View File
@@ -163,6 +163,9 @@ export const UpdateAssemblySchema = z
name: NonEmpty.optional(), name: NonEmpty.optional(),
qty: z.coerce.number().int().positive().max(100000).optional(), qty: z.coerce.number().int().positive().max(100000).optional(),
notes: OptionalText, notes: OptionalText,
stepFileId: z.string().min(1).nullable().optional(),
drawingFileId: z.string().min(1).nullable().optional(),
cutFileId: z.string().min(1).nullable().optional(),
}) })
.strict(); .strict();
@@ -202,7 +205,11 @@ export const UpdatePartSchema = z
// ---- operations --------------------------------------------------------- // ---- operations ---------------------------------------------------------
export const OperationStatuses = ["pending", "in_progress", "completed"] as const; // "partial" = an operation that was started, had units logged, and then paused.
// Behaves like "pending" for claim purposes (any operator can resume it) but
// visually distinct so admins can see work-in-flight that isn't actively
// being run right now.
export const OperationStatuses = ["pending", "in_progress", "partial", "completed"] as const;
export const CreateOperationSchema = z.object({ export const CreateOperationSchema = z.object({
templateId: z.string().min(1).nullable().optional(), templateId: z.string().min(1).nullable().optional(),
@@ -0,0 +1,59 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Assembly" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"qty" INTEGER NOT NULL DEFAULT 1,
"notes" TEXT,
"stepFileId" TEXT,
"drawingFileId" TEXT,
"cutFileId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Assembly_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Assembly_stepFileId_fkey" FOREIGN KEY ("stepFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Assembly_drawingFileId_fkey" FOREIGN KEY ("drawingFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Assembly_cutFileId_fkey" FOREIGN KEY ("cutFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Assembly" ("code", "createdAt", "id", "name", "notes", "projectId", "qty", "updatedAt") SELECT "code", "createdAt", "id", "name", "notes", "projectId", "qty", "updatedAt" FROM "Assembly";
DROP TABLE "Assembly";
ALTER TABLE "new_Assembly" RENAME TO "Assembly";
CREATE UNIQUE INDEX "Assembly_projectId_code_key" ON "Assembly"("projectId", "code");
CREATE TABLE "new_Operation" (
"id" TEXT NOT NULL PRIMARY KEY,
"partId" TEXT NOT NULL,
"sequence" INTEGER NOT NULL,
"templateId" TEXT,
"name" TEXT NOT NULL,
"machineId" TEXT,
"settings" TEXT,
"materialNotes" TEXT,
"instructions" TEXT,
"qcRequired" BOOLEAN NOT NULL DEFAULT false,
"status" TEXT NOT NULL DEFAULT 'pending',
"qrToken" TEXT NOT NULL,
"claimedByUserId" TEXT,
"claimedAt" DATETIME,
"completedAt" DATETIME,
"plannedMinutes" INTEGER,
"plannedUnits" INTEGER,
"unitsCompleted" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Operation_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Operation_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "OperationTemplate" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Operation_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Operation_claimedByUserId_fkey" FOREIGN KEY ("claimedByUserId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Operation" ("claimedAt", "claimedByUserId", "completedAt", "createdAt", "id", "instructions", "machineId", "materialNotes", "name", "partId", "plannedMinutes", "plannedUnits", "qcRequired", "qrToken", "sequence", "settings", "status", "templateId", "updatedAt") SELECT "claimedAt", "claimedByUserId", "completedAt", "createdAt", "id", "instructions", "machineId", "materialNotes", "name", "partId", "plannedMinutes", "plannedUnits", "qcRequired", "qrToken", "sequence", "settings", "status", "templateId", "updatedAt" FROM "Operation";
DROP TABLE "Operation";
ALTER TABLE "new_Operation" RENAME TO "Operation";
CREATE UNIQUE INDEX "Operation_qrToken_key" ON "Operation"("qrToken");
CREATE INDEX "Operation_status_idx" ON "Operation"("status");
CREATE INDEX "Operation_claimedByUserId_idx" ON "Operation"("claimedByUserId");
CREATE UNIQUE INDEX "Operation_partId_sequence_key" ON "Operation"("partId", "sequence");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+13 -1
View File
@@ -118,11 +118,17 @@ model Assembly {
name String name String
qty Int @default(1) qty Int @default(1)
notes String? notes String?
stepFileId String?
drawingFileId String?
cutFileId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
parts Part[] parts Part[]
stepFile FileAsset? @relation("AssemblyStep", fields: [stepFileId], references: [id], onDelete: SetNull)
drawingFile FileAsset? @relation("AssemblyDrawing", fields: [drawingFileId], references: [id], onDelete: SetNull)
cutFile FileAsset? @relation("AssemblyCut", fields: [cutFileId], references: [id], onDelete: SetNull)
@@unique([projectId, code]) @@unique([projectId, code])
} }
@@ -165,13 +171,16 @@ model Operation {
materialNotes String? materialNotes String?
instructions String? instructions String?
qcRequired Boolean @default(false) qcRequired Boolean @default(false)
status String @default("pending") // pending | in_progress | completed status String @default("pending") // pending | in_progress | partial | completed
qrToken String @unique qrToken String @unique
claimedByUserId String? claimedByUserId String?
claimedAt DateTime? claimedAt DateTime?
completedAt DateTime? completedAt DateTime?
plannedMinutes Int? plannedMinutes Int?
plannedUnits Int? plannedUnits Int?
/// Cumulative units recorded across every Start→Pause/Done cycle on this op.
/// Incremented whenever an operator hands in a non-zero `unitsProcessed`.
unitsCompleted Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -290,6 +299,9 @@ model FileAsset {
partDrawing Part[] @relation("PartDrawing") partDrawing Part[] @relation("PartDrawing")
partCut Part[] @relation("PartCut") partCut Part[] @relation("PartCut")
partThumbnail Part[] @relation("PartThumbnail") partThumbnail Part[] @relation("PartThumbnail")
assemblyStep Assembly[] @relation("AssemblyStep")
assemblyDrawing Assembly[] @relation("AssemblyDrawing")
assemblyCut Assembly[] @relation("AssemblyCut")
poPdfs PurchaseOrder[] @relation("PoPdf") poPdfs PurchaseOrder[] @relation("PoPdf")
} }
+1 -1
View File
File diff suppressed because one or more lines are too long