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>
);
}
@@ -14,6 +14,9 @@ export default async function AdminAssemblyDetailPage({
where: { id: assemblyId, projectId: id },
include: {
project: { select: { id: true, code: true, name: true } },
stepFile: true,
drawingFile: true,
cutFile: true,
parts: {
orderBy: { code: "asc" },
include: {
@@ -28,6 +31,17 @@ export default async function AdminAssemblyDetailPage({
});
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 (
<AssemblyDetailClient
project={assembly.project}
@@ -37,6 +51,9 @@ export default async function AdminAssemblyDetailPage({
name: assembly.name,
qty: assembly.qty,
notes: assembly.notes,
stepFile: fileView(assembly.stepFile),
drawingFile: fileView(assembly.drawingFile),
cutFile: fileView(assembly.cutFile),
}}
parts={assembly.parts.map((p) => ({
id: p.id,
@@ -87,12 +87,14 @@ type Slot = "stepFileId" | "drawingFileId" | "cutFileId";
const STATUS_TONE: Record<string, "slate" | "blue" | "green" | "amber" | "red"> = {
pending: "slate",
in_progress: "blue",
partial: "amber",
completed: "green",
};
const STATUS_LABEL: Record<string, string> = {
pending: "Pending",
in_progress: "In progress",
partial: "Partial",
completed: "Completed",
};
@@ -671,6 +673,7 @@ function OperationModal({
<Select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="pending">Pending</option>
<option value="in_progress">In progress</option>
<option value="partial">Partial</option>
<option value="completed">Completed</option>
</Select>
</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.qty !== undefined ? { qty: body.qty } : {}),
...(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({
+6 -1
View File
@@ -28,6 +28,7 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
select: {
code: true,
name: true,
qty: 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 = {
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: {
code: op.part.code,
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();
// `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({
where: { id, claimedByUserId: null, status: "pending" },
where: { id, claimedByUserId: null, status: { in: ["pending", "partial"] } },
data: {
status: "in_progress",
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({
where: { id },
data: {
@@ -73,6 +77,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
completedAt: now,
claimedByUserId: 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");
}
// 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();
await prisma.$transaction(async (tx) => {
// 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({
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",
entity: "Operation",
entityId: id,
after: { status: "pending", unitsProcessed: body.unitsProcessed ?? null },
after: { status: nextStatus, unitsProcessed: body.unitsProcessed ?? null },
ipAddress: clientIp(req),
});
+38 -4
View File
@@ -1,6 +1,7 @@
import { type NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { errorResponse, requireRole, ApiError } from "@/lib/api";
import { readFileBytes } from "@/lib/files";
import {
renderPartTravelers,
type OperationCardData,
@@ -24,10 +25,15 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
where: { id },
include: {
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 } },
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 } },
operations: {
orderBy: { sequence: "asc" },
@@ -42,7 +48,11 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
}
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 = {
code: part.code,
name: part.name,
@@ -50,6 +60,14 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
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 = {
project,
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, "_");
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);
}
}
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;
}
}
+99 -7
View File
@@ -3,6 +3,8 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
type ScanFile = { id: string; originalName: string; kind: string };
export type ScanOp = {
id: string;
sequence: number;
@@ -14,6 +16,7 @@ export type ScanOp = {
settings: string | null;
plannedMinutes: number | null;
plannedUnits: number | null;
unitsCompleted: number;
claimedByUserId: string | null;
claimedAt: string | null;
machine: { id: string; name: string; kind: string } | null;
@@ -23,10 +26,18 @@ export type ScanOp = {
name: string;
material: string | null;
qty: number;
stepFile: ScanFile | null;
drawingFile: ScanFile | null;
cutFile: ScanFile | null;
thumbnailFileId: string | null;
assembly: {
id: string;
code: string;
name: string;
qty: number;
stepFile: ScanFile | null;
drawingFile: ScanFile | null;
cutFile: ScanFile | null;
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 active = op.status === "in_progress";
const partial = op.status === "partial";
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) {
setError(null);
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>
<div className="text-slate-600 text-sm">
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 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">
Step {op.sequence}
</span>
@@ -142,10 +185,16 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
? "bg-emerald-100 text-emerald-800"
: active
? "bg-amber-100 text-amber-800"
: "bg-slate-100 text-slate-700"
: partial
? "bg-orange-100 text-orange-800"
: "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>
{op.qcRequired ? (
<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}
</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 ? (
<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>
@@ -214,20 +289,37 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
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"
>
{isPending ? "Claiming…" : "Start this step"}
{isPending
? partial
? "Resuming…"
: "Claiming…"
: partial
? "Resume this step"
: "Start this step"}
</button>
) : (
<>
<div className="grid grid-cols-1 gap-3">
<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
type="number"
inputMode="numeric"
min={0}
value={units}
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"
/>
</label>
+8
View File
@@ -35,11 +35,19 @@ export default async function ScanPage({ params }: { params: Promise<{ token: st
name: true,
material: 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: {
select: {
id: true,
code: 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 } },
},
},