@@ -11,7 +11,11 @@
|
||||
"Bash(npm run *)",
|
||||
"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)"
|
||||
"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";
|
||||
|
||||
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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
: 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>
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
|
||||
+45
-5
@@ -20,7 +20,9 @@ const MARGIN = 48; // 2/3"
|
||||
|
||||
export interface OperationCardData {
|
||||
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 };
|
||||
operation: {
|
||||
id: string;
|
||||
@@ -59,7 +61,7 @@ export interface PurchaseOrderPdfData {
|
||||
|
||||
export interface PartCoverData {
|
||||
project: { code: string; name: string };
|
||||
assembly: { code: string; name: string };
|
||||
assembly: { code: string; name: string; qty: number };
|
||||
part: {
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -98,10 +100,21 @@ export async function renderPurchaseOrder(data: PurchaseOrderPdfData): Promise<U
|
||||
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: {
|
||||
cover: PartCoverData;
|
||||
cards: OperationCardData[];
|
||||
drawingPdfBytes?: Uint8Array | null;
|
||||
assemblyDrawingPdfBytes?: Uint8Array | null;
|
||||
}): Promise<Uint8Array> {
|
||||
const doc = await PDFDocument.create();
|
||||
const fonts = await embedFonts(doc);
|
||||
@@ -109,6 +122,16 @@ export async function renderPartTravelers(payload: {
|
||||
const coverPage = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
|
||||
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) {
|
||||
const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
|
||||
await drawOperationCard(doc, page, fonts, card);
|
||||
@@ -117,6 +140,19 @@ export async function renderPartTravelers(payload: {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -258,10 +294,11 @@ async function drawOperationCard(
|
||||
drawText(page, line, { x: MARGIN, y: cursor.top, font: fonts.bold, size: 22 });
|
||||
}
|
||||
cursor.top -= 14;
|
||||
const totalUnits = data.assembly.qty * data.part.qty;
|
||||
const partMeta = [
|
||||
`Part ${data.part.code}`,
|
||||
data.part.material ? `${data.part.material}` : null,
|
||||
`qty ${data.part.qty}`,
|
||||
`${data.part.qty}/assembly × ${data.assembly.qty} assy = ${totalUnits} to produce`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
@@ -471,9 +508,12 @@ async function drawCoverSheet(
|
||||
});
|
||||
cursor.top -= 18;
|
||||
|
||||
const totalUnits = data.assembly.qty * data.part.qty;
|
||||
const meta = [
|
||||
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}`,
|
||||
`Assembly: ${data.assembly.name}`,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
+8
-1
@@ -163,6 +163,9 @@ export const UpdateAssemblySchema = z
|
||||
name: NonEmpty.optional(),
|
||||
qty: z.coerce.number().int().positive().max(100000).optional(),
|
||||
notes: OptionalText,
|
||||
stepFileId: z.string().min(1).nullable().optional(),
|
||||
drawingFileId: z.string().min(1).nullable().optional(),
|
||||
cutFileId: z.string().min(1).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -202,7 +205,11 @@ export const UpdatePartSchema = z
|
||||
|
||||
// ---- 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({
|
||||
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
@@ -118,11 +118,17 @@ model Assembly {
|
||||
name String
|
||||
qty Int @default(1)
|
||||
notes String?
|
||||
stepFileId String?
|
||||
drawingFileId String?
|
||||
cutFileId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
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])
|
||||
}
|
||||
@@ -165,13 +171,16 @@ model Operation {
|
||||
materialNotes String?
|
||||
instructions String?
|
||||
qcRequired Boolean @default(false)
|
||||
status String @default("pending") // pending | in_progress | completed
|
||||
status String @default("pending") // pending | in_progress | partial | completed
|
||||
qrToken String @unique
|
||||
claimedByUserId String?
|
||||
claimedAt DateTime?
|
||||
completedAt DateTime?
|
||||
plannedMinutes 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())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -290,6 +299,9 @@ model FileAsset {
|
||||
partDrawing Part[] @relation("PartDrawing")
|
||||
partCut Part[] @relation("PartCut")
|
||||
partThumbnail Part[] @relation("PartThumbnail")
|
||||
assemblyStep Assembly[] @relation("AssemblyStep")
|
||||
assemblyDrawing Assembly[] @relation("AssemblyDrawing")
|
||||
assemblyCut Assembly[] @relation("AssemblyCut")
|
||||
poPdfs PurchaseOrder[] @relation("PoPdf")
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user