Files
mrp-qrcode/lib/pdf.ts
T
jason 04ae88ca0d
Build and Push Docker Image / build (push) Successful in 45s
QoL changes and additions
2026-04-22 13:16:42 -05:00

1099 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { PDFDocument, StandardFonts, rgb, type PDFFont, type PDFPage } from "pdf-lib";
import { renderQrPngBuffer, scanUrlForToken } from "@/lib/qr";
/**
* Traveler PDF generation. Shipped as pdf-lib (pure JS, no system deps) so
* the Docker image stays slim. Two public entry points:
*
* - renderOperationCard(op) -> single Letter page, big QR
* - renderPartTravelers({cover, ops}) -> cover sheet + one card per op
*
* Everything else in this file is layout glue. Nothing here hits the DB — the
* route handlers load the data and hand fully-denormalised structs down so
* this module stays easy to unit-test later.
*/
// Letter @ 72 dpi. Points (pt) are the native pdf-lib unit.
const PAGE_WIDTH = 612;
const PAGE_HEIGHT = 792;
const MARGIN = 48; // 2/3"
export interface OperationCardData {
project: { 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;
sequence: number;
name: string;
/// "work" | "qc". Dedicated QC ops don't track units, so we suppress the
/// "Progress" row and lean on the QC-required call-out instead.
kind: string;
qrToken: string;
machineName: string | null;
machineKind: string | null;
settings: string | null;
materialNotes: string | null;
instructions: string | null;
qcRequired: boolean;
plannedMinutes: number | null;
plannedUnits: number | null;
/// Cumulative units logged across every Start→Pause/Done cycle. Printed
/// as "X of Y done" so a reprinted traveler reflects real progress.
unitsCompleted: number;
/// Live status at print time — one of OperationStatuses. Printed as a
/// small pill in the header so the shop can tell at a glance whether a
/// reprint is mid-run, blocked (qc_failed), already done, etc.
status: string;
};
}
export interface PurchaseOrderPdfData {
po: {
id: string;
vendor: string;
status: string;
createdAt: Date;
sentAt: Date | null;
notes: string | null;
};
project: { code: string; name: string };
lines: {
partNumber: string;
description: string;
supplier: string | null;
qty: number;
unitCost: number | null;
}[];
}
export interface PartCoverData {
project: { code: string; name: string };
assembly: { code: string; name: string; qty: number };
part: {
code: string;
name: string;
material: string | null;
qty: number;
notes: string | null;
};
files: {
label: string;
file: { originalName: string; sizeBytes: number; sha256: string } | null;
}[];
operations: {
sequence: number;
name: string;
kind: string;
machineName: string | null;
qcRequired: boolean;
qrToken: string;
unitsCompleted: number;
status: string;
}[];
}
/** Entry point: render a single operation card as a PDF byte array. */
export async function renderOperationCard(data: OperationCardData): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const fonts = await embedFonts(doc);
const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
await drawOperationCard(doc, page, fonts, data);
return doc.save();
}
/** Entry point: render a purchase order PDF for sending to a vendor. */
export async function renderPurchaseOrder(data: PurchaseOrderPdfData): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const fonts = await embedFonts(doc);
const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
drawPurchaseOrder(page, fonts, data);
return doc.save();
}
/** 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);
await appendPartBundle(doc, fonts, payload);
return doc.save();
}
/** Entry point: one combined PDF for an entire project — every part bundle
* concatenated (cover → drawings → op cards, repeated). Prefixed with a short
* project-level summary page so the binder has a front sheet. Useful for
* bulk reprints when a run changes.
*/
export async function renderProjectTravelers(payload: {
project: {
code: string;
name: string;
customerCode: string | null;
dueDate: Date | null;
};
bundles: {
cover: PartCoverData;
cards: OperationCardData[];
drawingPdfBytes?: Uint8Array | null;
assemblyDrawingPdfBytes?: Uint8Array | null;
}[];
}): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const fonts = await embedFonts(doc);
const summaryPage = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
drawProjectSummary(summaryPage, fonts, payload.project, payload.bundles);
for (const bundle of payload.bundles) {
await appendPartBundle(doc, fonts, bundle);
}
return doc.save();
}
/** Append one part's cover + drawings + op cards onto an existing doc. */
async function appendPartBundle(
doc: PDFDocument,
fonts: Fonts,
bundle: {
cover: PartCoverData;
cards: OperationCardData[];
drawingPdfBytes?: Uint8Array | null;
assemblyDrawingPdfBytes?: Uint8Array | null;
},
): Promise<void> {
const coverPage = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
await drawCoverSheet(doc, coverPage, fonts, bundle.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 (bundle.assemblyDrawingPdfBytes) {
await appendPdfPages(doc, bundle.assemblyDrawingPdfBytes, "assembly drawing");
}
if (bundle.drawingPdfBytes) {
await appendPdfPages(doc, bundle.drawingPdfBytes, "part drawing");
}
for (const card of bundle.cards) {
const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
await drawOperationCard(doc, page, fonts, card);
}
}
// Project-level front sheet. Deliberately minimal — the real detail lives on
// each part's own cover sheet further in. Shows project header, a totals
// line, and a table of contents listing every part being included.
function drawProjectSummary(
page: PDFPage,
fonts: Fonts,
project: { code: string; name: string; customerCode: string | null; dueDate: Date | null },
bundles: { cover: PartCoverData; cards: OperationCardData[] }[],
): void {
const cursor: Cursor = { top: PAGE_HEIGHT - MARGIN };
const contentWidth = PAGE_WIDTH - MARGIN * 2;
drawText(page, "PROJECT TRAVELERS", {
x: MARGIN,
y: cursor.top - 10,
font: fonts.bold,
size: 10,
color: rgb(0.4, 0.4, 0.45),
});
cursor.top -= 28;
drawText(page, `${project.code}${project.name}`, {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 20,
});
cursor.top -= 20;
const metaBits = [
project.customerCode ? `Customer ${project.customerCode}` : null,
project.dueDate ? `Due ${project.dueDate.toISOString().slice(0, 10)}` : null,
`${bundles.length} part${bundles.length === 1 ? "" : "s"}`,
`${bundles.reduce((acc, b) => acc + b.cards.length, 0)} operations total`,
]
.filter(Boolean)
.join(" · ");
drawText(page, metaBits, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 11,
color: rgb(0.35, 0.4, 0.5),
});
cursor.top -= 20;
page.drawLine({
start: { x: MARGIN, y: cursor.top },
end: { x: PAGE_WIDTH - MARGIN, y: cursor.top },
thickness: 0.75,
color: rgb(0.8, 0.82, 0.88),
});
cursor.top -= 18;
drawText(page, "CONTENTS", {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 9,
color: rgb(0.45, 0.5, 0.6),
});
cursor.top -= 14;
// Simple TOC. Long projects (50+ parts) will overflow the page; we cap the
// visible rows and footnote the rest rather than spilling into a multi-page
// TOC — the goal here is a summary, not a catalogue.
const maxRows = 38;
const rows = bundles.slice(0, maxRows);
for (const b of rows) {
const label = `${b.cover.assembly.code} · ${b.cover.part.code}${b.cover.part.name}`;
const line = wrapLines(label, fonts.regular, 10, contentWidth - 60)[0] ?? label;
drawText(page, line, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 10,
});
const opsLabel = `${b.cards.length} op${b.cards.length === 1 ? "" : "s"}`;
const opsLabelW = fonts.regular.widthOfTextAtSize(opsLabel, 10);
drawText(page, opsLabel, {
x: PAGE_WIDTH - MARGIN - opsLabelW,
y: cursor.top,
font: fonts.regular,
size: 10,
color: rgb(0.45, 0.5, 0.6),
});
cursor.top -= 13;
}
if (bundles.length > maxRows) {
cursor.top -= 4;
drawText(page, `… and ${bundles.length - maxRows} more part${bundles.length - maxRows === 1 ? "" : "s"}.`, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 10,
color: rgb(0.45, 0.5, 0.6),
});
}
}
// 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
// ---------------------------------------------------------------------------
interface Fonts {
regular: PDFFont;
bold: PDFFont;
mono: PDFFont;
}
async function embedFonts(doc: PDFDocument): Promise<Fonts> {
return {
regular: await doc.embedFont(StandardFonts.Helvetica),
bold: await doc.embedFont(StandardFonts.HelveticaBold),
mono: await doc.embedFont(StandardFonts.Courier),
};
}
// pdf-lib uses a Y-up coordinate system anchored at the page's bottom-left.
// We track the cursor from the top to keep drawing code readable; `top` is
// the y-coordinate of the next line's baseline in pt.
interface Cursor {
top: number;
}
function drawText(
page: PDFPage,
text: string,
opts: { x: number; y: number; font: PDFFont; size: number; color?: ReturnType<typeof rgb> },
): void {
page.drawText(text, {
x: opts.x,
y: opts.y,
font: opts.font,
size: opts.size,
color: opts.color ?? rgb(0.07, 0.09, 0.15),
});
}
// Break a long string into lines that fit a given width, honouring \n. We
// measure in pt with the font's width table so it actually matches the output.
function wrapLines(text: string, font: PDFFont, size: number, maxWidth: number): string[] {
const out: string[] = [];
for (const rawLine of text.split(/\r?\n/)) {
if (rawLine.length === 0) {
out.push("");
continue;
}
const words = rawLine.split(/\s+/);
let line = "";
for (const word of words) {
const candidate = line.length === 0 ? word : `${line} ${word}`;
if (font.widthOfTextAtSize(candidate, size) <= maxWidth) {
line = candidate;
} else {
if (line.length > 0) out.push(line);
// Word itself overflows — hard-chop so we don't stall.
if (font.widthOfTextAtSize(word, size) > maxWidth) {
let chunk = "";
for (const ch of word) {
if (font.widthOfTextAtSize(chunk + ch, size) > maxWidth) {
out.push(chunk);
chunk = ch;
} else {
chunk += ch;
}
}
line = chunk;
} else {
line = word;
}
}
}
if (line.length > 0) out.push(line);
}
return out;
}
// Printed-status pill colours. Keep in sync with the UI tones in
// ScanClient / PartDetailClient so the paper traveler matches what operators
// see on-screen. Returned as background + text RGB so pdf-lib can fill a
// rounded-ish rectangle and overlay the label.
function statusPillColours(status: string): { fill: ReturnType<typeof rgb>; text: ReturnType<typeof rgb>; label: string } {
switch (status) {
case "in_progress":
return { fill: rgb(0.87, 0.93, 1), text: rgb(0.16, 0.32, 0.6), label: "IN PROGRESS" };
case "partial":
return { fill: rgb(1, 0.92, 0.82), text: rgb(0.55, 0.32, 0.06), label: "PARTIAL" };
case "completed":
return { fill: rgb(0.86, 0.96, 0.88), text: rgb(0.1, 0.42, 0.2), label: "COMPLETED" };
case "qc_failed":
return { fill: rgb(1, 0.88, 0.88), text: rgb(0.62, 0.1, 0.15), label: "QC FAILED" };
default:
return { fill: rgb(0.93, 0.95, 0.98), text: rgb(0.35, 0.4, 0.5), label: "PENDING" };
}
}
// Draw a small status pill at (x, y) — y is the baseline of the label text.
// Returns the total pill width so callers can chain.
function drawStatusPill(
page: PDFPage,
fonts: Fonts,
status: string,
x: number,
y: number,
): number {
const { fill, text, label } = statusPillColours(status);
const size = 8;
const padX = 6;
const padY = 3;
const textW = fonts.bold.widthOfTextAtSize(label, size);
const width = textW + padX * 2;
const height = size + padY * 2;
page.drawRectangle({
x,
y: y - padY,
width,
height,
color: fill,
});
drawText(page, label, {
x: x + padX,
y,
font: fonts.bold,
size,
color: text,
});
return width;
}
// ---------------------------------------------------------------------------
// Operation card
// ---------------------------------------------------------------------------
async function drawOperationCard(
doc: PDFDocument,
page: PDFPage,
fonts: Fonts,
data: OperationCardData,
): Promise<void> {
const cursor: Cursor = { top: PAGE_HEIGHT - MARGIN };
const contentWidth = PAGE_WIDTH - MARGIN * 2;
// --- Header strip: project / assembly ------------------------------------
drawText(page, "TRAVELER CARD", {
x: MARGIN,
y: cursor.top - 10,
font: fonts.bold,
size: 10,
color: rgb(0.4, 0.4, 0.45),
});
// Status pill right after the label so the printed sheet matches the live
// status at print time. Pending ops still show a pill (slate) so the field
// layout is stable — an orphan "PENDING" badge reads fine.
const travelerLabelW = fonts.bold.widthOfTextAtSize("TRAVELER CARD", 10);
drawStatusPill(
page,
fonts,
data.operation.status,
MARGIN + travelerLabelW + 8,
cursor.top - 10,
);
drawText(page, `${data.project.code} · ${data.assembly.code}`, {
x: PAGE_WIDTH - MARGIN,
y: cursor.top - 10,
font: fonts.regular,
size: 10,
color: rgb(0.4, 0.4, 0.45),
});
// Right-align fix: pdf-lib doesn't do alignment for us, so re-measure.
const rightLabel = `${data.project.code} · ${data.assembly.code}`;
const rightLabelW = fonts.regular.widthOfTextAtSize(rightLabel, 10);
page.drawRectangle({
x: MARGIN,
y: cursor.top - 16,
width: contentWidth,
height: 0,
color: rgb(1, 1, 1),
});
// Redraw the right label at the correct x now that we know its width.
page.drawRectangle({
x: PAGE_WIDTH - MARGIN - rightLabelW - 1,
y: cursor.top - 14,
width: rightLabelW + 2,
height: 14,
color: rgb(1, 1, 1),
});
drawText(page, rightLabel, {
x: PAGE_WIDTH - MARGIN - rightLabelW,
y: cursor.top - 10,
font: fonts.regular,
size: 10,
color: rgb(0.4, 0.4, 0.45),
});
cursor.top -= 20;
// --- Part name (big) ----------------------------------------------------
const partTitle = data.part.name;
const partLines = wrapLines(partTitle, fonts.bold, 22, contentWidth);
for (const line of partLines) {
cursor.top -= 24;
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,
`${data.part.qty}/assembly × ${data.assembly.qty} assy = ${totalUnits} to produce`,
]
.filter(Boolean)
.join(" · ");
drawText(page, partMeta, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 11,
color: rgb(0.35, 0.4, 0.5),
});
cursor.top -= 18;
// Divider
page.drawLine({
start: { x: MARGIN, y: cursor.top },
end: { x: PAGE_WIDTH - MARGIN, y: cursor.top },
thickness: 0.75,
color: rgb(0.8, 0.82, 0.88),
});
cursor.top -= 24;
// --- Step header + QR side-by-side --------------------------------------
// Left column: step label, name, machine, plan. Right column: QR image.
const qrSize = 156; // pt -> ~2.2" on paper, well above the 20 mm phone minimum
const qrX = PAGE_WIDTH - MARGIN - qrSize;
const qrY = cursor.top - qrSize;
const qrBytes = await renderQrPngBuffer(data.operation.qrToken);
const qrImage = await doc.embedPng(qrBytes);
page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize });
// Text under QR: scan URL + last 8 of token for manual lookup if it smudges.
const scanUrl = scanUrlForToken(data.operation.qrToken);
const scanLines = wrapLines(scanUrl, fonts.mono, 7, qrSize);
let scanY = qrY - 10;
for (const l of scanLines) {
drawText(page, l, {
x: qrX,
y: scanY,
font: fonts.mono,
size: 7,
color: rgb(0.4, 0.4, 0.45),
});
scanY -= 9;
}
// Left column content, constrained so it doesn't collide with the QR.
const leftColWidth = qrX - MARGIN - 18;
drawText(page, `STEP ${data.operation.sequence}`, {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 11,
color: rgb(0.38, 0.45, 0.85),
});
cursor.top -= 6;
const stepNameLines = wrapLines(data.operation.name, fonts.bold, 18, leftColWidth);
for (const line of stepNameLines) {
cursor.top -= 22;
drawText(page, line, { x: MARGIN, y: cursor.top, font: fonts.bold, size: 18 });
}
cursor.top -= 10;
const metaRows: [string, string][] = [];
if (data.operation.machineName) {
metaRows.push([
"Machine",
data.operation.machineKind
? `${data.operation.machineName} (${data.operation.machineKind})`
: data.operation.machineName,
]);
}
if (data.operation.plannedMinutes) metaRows.push(["Planned time", `${data.operation.plannedMinutes} min`]);
if (data.operation.plannedUnits) metaRows.push(["Planned units", `${data.operation.plannedUnits}`]);
// Work ops: show cumulative progress so a reprinted traveler mid-run
// reflects what's actually already produced. QC-dedicated ops don't track
// units (close is pass/fail), so we skip the row for them.
if (data.operation.kind !== "qc") {
const total = data.assembly.qty * data.part.qty;
const done = data.operation.unitsCompleted;
const remaining = Math.max(0, total - done);
metaRows.push([
"Progress",
done === 0
? `0 of ${total} done`
: done >= total
? `${done} of ${total} — complete`
: `${done} of ${total} done (${remaining} remaining)`,
]);
}
if (data.operation.qcRequired) metaRows.push(["QC", "Required on close-out"]);
for (const [k, v] of metaRows) {
cursor.top -= 13;
drawText(page, k.toUpperCase(), {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 7,
color: rgb(0.45, 0.5, 0.6),
});
const valueLines = wrapLines(v, fonts.regular, 11, leftColWidth);
for (let i = 0; i < valueLines.length; i++) {
if (i > 0) cursor.top -= 13;
drawText(page, valueLines[i], {
x: MARGIN + 80,
y: cursor.top,
font: fonts.regular,
size: 11,
});
}
}
// Align the cursor below whichever column is taller (QR column vs. left).
cursor.top = Math.min(cursor.top, qrY - 10 - scanLines.length * 9);
cursor.top -= 26;
// --- Full-width sections -------------------------------------------------
drawSection(page, fonts, cursor, "Instructions", data.operation.instructions, contentWidth);
drawSection(page, fonts, cursor, "Material notes", data.operation.materialNotes, contentWidth);
drawSection(page, fonts, cursor, "Settings", data.operation.settings, contentWidth, fonts.mono);
// --- Footer: sign-off strip ---------------------------------------------
const footerY = MARGIN + 20;
page.drawLine({
start: { x: MARGIN, y: footerY + 14 },
end: { x: PAGE_WIDTH - MARGIN, y: footerY + 14 },
thickness: 0.5,
color: rgb(0.82, 0.84, 0.88),
});
drawText(page, "Operator", {
x: MARGIN,
y: footerY,
font: fonts.bold,
size: 7,
color: rgb(0.45, 0.5, 0.6),
});
drawText(page, "Start / End", {
x: MARGIN + 180,
y: footerY,
font: fonts.bold,
size: 7,
color: rgb(0.45, 0.5, 0.6),
});
drawText(page, "Units", {
x: MARGIN + 340,
y: footerY,
font: fonts.bold,
size: 7,
color: rgb(0.45, 0.5, 0.6),
});
drawText(page, "QC", {
x: MARGIN + 420,
y: footerY,
font: fonts.bold,
size: 7,
color: rgb(0.45, 0.5, 0.6),
});
}
// Inline helper that renders a labelled prose block and advances the cursor.
// Silently does nothing if `body` is empty, so we don't leave orphan labels.
function drawSection(
page: PDFPage,
fonts: Fonts,
cursor: Cursor,
label: string,
body: string | null,
width: number,
font: PDFFont = fonts.regular,
): void {
if (!body || !body.trim()) return;
drawText(page, label.toUpperCase(), {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 8,
color: rgb(0.45, 0.5, 0.6),
});
cursor.top -= 12;
const lines = wrapLines(body.trim(), font, 10, width);
for (const line of lines) {
drawText(page, line, { x: MARGIN, y: cursor.top, font, size: 10 });
cursor.top -= 13;
}
cursor.top -= 8;
}
// ---------------------------------------------------------------------------
// Cover sheet
// ---------------------------------------------------------------------------
async function drawCoverSheet(
doc: PDFDocument,
page: PDFPage,
fonts: Fonts,
data: PartCoverData,
): Promise<void> {
const cursor: Cursor = { top: PAGE_HEIGHT - MARGIN };
const contentWidth = PAGE_WIDTH - MARGIN * 2;
drawText(page, "WORK ORDER", {
x: MARGIN,
y: cursor.top - 10,
font: fonts.bold,
size: 10,
color: rgb(0.4, 0.4, 0.45),
});
cursor.top -= 28;
const title = `${data.project.code} · ${data.assembly.code} · ${data.part.code}`;
drawText(page, title, { x: MARGIN, y: cursor.top, font: fonts.bold, size: 20 });
cursor.top -= 26;
drawText(page, data.part.name, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 14,
color: rgb(0.2, 0.25, 0.35),
});
cursor.top -= 18;
const totalUnits = data.assembly.qty * data.part.qty;
const meta = [
data.part.material ? `Material: ${data.part.material}` : null,
`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[];
for (const line of meta) {
drawText(page, line, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 10,
color: rgb(0.35, 0.4, 0.5),
});
cursor.top -= 13;
}
cursor.top -= 6;
if (data.part.notes && data.part.notes.trim()) {
drawSection(page, fonts, cursor, "Part notes", data.part.notes, contentWidth);
}
// --- Files manifest ------------------------------------------------------
const filesWithContent = data.files.filter((f) => f.file !== null);
if (filesWithContent.length > 0) {
drawText(page, "FILES", {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 8,
color: rgb(0.45, 0.5, 0.6),
});
cursor.top -= 14;
for (const f of filesWithContent) {
if (!f.file) continue;
drawText(page, `${f.label}`, {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 10,
});
drawText(page, f.file.originalName, {
x: MARGIN + 110,
y: cursor.top,
font: fonts.regular,
size: 10,
});
drawText(page, `${formatBytes(f.file.sizeBytes)} · sha256 ${f.file.sha256.slice(0, 12)}`, {
x: MARGIN + 110,
y: cursor.top - 11,
font: fonts.mono,
size: 8,
color: rgb(0.5, 0.5, 0.55),
});
cursor.top -= 26;
}
cursor.top -= 6;
}
// --- Operations table ----------------------------------------------------
drawText(page, "OPERATIONS", {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 8,
color: rgb(0.45, 0.5, 0.6),
});
cursor.top -= 14;
const rowThumb = 44; // QR thumbnail side (pt)
const rowGap = 10;
const rowHeight = rowThumb + rowGap;
for (const op of data.operations) {
// Stop drawing if we run out of room; the cards that follow are the
// authoritative per-step references anyway.
if (cursor.top - rowHeight < MARGIN + 30) {
drawText(page, `${data.operations.length} operations total (see following pages)`, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 9,
color: rgb(0.5, 0.5, 0.55),
});
break;
}
const thumbBytes = await renderQrPngBuffer(op.qrToken, 256);
const thumbImg = await doc.embedPng(thumbBytes);
const thumbY = cursor.top - rowThumb;
page.drawImage(thumbImg, {
x: MARGIN,
y: thumbY,
width: rowThumb,
height: rowThumb,
});
const textX = MARGIN + rowThumb + 12;
const titleText = `${op.sequence}. ${op.name}`;
drawText(page, titleText, {
x: textX,
y: cursor.top - 12,
font: fonts.bold,
size: 11,
});
// Status pill right after the title. We size off the printed title so the
// pill hugs the text; on a very long title it'll drift further right,
// which is fine — cover rows are wide.
const titleW = fonts.bold.widthOfTextAtSize(titleText, 11);
drawStatusPill(page, fonts, op.status, textX + titleW + 8, cursor.top - 12);
// Subline: machine · QC? · progress (work ops only). The progress chunk
// is the point of B2: a reprinted cover sheet should show real numbers
// instead of forcing the admin to flip back to the screen.
const totalUnits = data.part.qty * data.assembly.qty;
const progressText =
op.kind === "qc"
? null
: op.unitsCompleted === 0
? `0 / ${totalUnits}`
: op.unitsCompleted >= totalUnits
? `${op.unitsCompleted} / ${totalUnits} done`
: `${op.unitsCompleted} / ${totalUnits}`;
const subline = [
op.machineName ?? "no machine",
op.qcRequired ? "QC" : null,
progressText,
]
.filter(Boolean)
.join(" · ");
drawText(page, subline, {
x: textX,
y: cursor.top - 26,
font: fonts.regular,
size: 9,
color: rgb(0.4, 0.45, 0.55),
});
drawText(page, scanUrlForToken(op.qrToken), {
x: textX,
y: cursor.top - 40,
font: fonts.mono,
size: 7,
color: rgb(0.5, 0.5, 0.55),
});
cursor.top -= rowHeight;
}
// --- Footer timestamp ----------------------------------------------------
drawText(page, `Printed ${new Date().toISOString().slice(0, 16).replace("T", " ")}Z`, {
x: MARGIN,
y: MARGIN - 10,
font: fonts.regular,
size: 8,
color: rgb(0.55, 0.58, 0.65),
});
}
// ---------------------------------------------------------------------------
// Purchase order
// ---------------------------------------------------------------------------
function drawPurchaseOrder(page: PDFPage, fonts: Fonts, data: PurchaseOrderPdfData): void {
const cursor: Cursor = { top: PAGE_HEIGHT - MARGIN };
const contentWidth = PAGE_WIDTH - MARGIN * 2;
// --- Header --------------------------------------------------------------
drawText(page, "PURCHASE ORDER", {
x: MARGIN,
y: cursor.top - 10,
font: fonts.bold,
size: 10,
color: rgb(0.4, 0.4, 0.45),
});
const poRef = `PO-${data.po.id.slice(0, 8).toUpperCase()}`;
const poRefW = fonts.bold.widthOfTextAtSize(poRef, 10);
drawText(page, poRef, {
x: PAGE_WIDTH - MARGIN - poRefW,
y: cursor.top - 10,
font: fonts.bold,
size: 10,
});
cursor.top -= 28;
drawText(page, data.po.vendor, {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 22,
});
cursor.top -= 26;
const meta: string[] = [
`Project: ${data.project.code}${data.project.name}`,
`Status: ${data.po.status}`,
`Issued: ${(data.po.sentAt ?? data.po.createdAt).toISOString().slice(0, 10)}`,
];
for (const m of meta) {
drawText(page, m, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 10,
color: rgb(0.35, 0.4, 0.5),
});
cursor.top -= 13;
}
cursor.top -= 10;
// --- Line table ---------------------------------------------------------
const cols = {
partNo: MARGIN,
desc: MARGIN + 110,
qty: MARGIN + 360,
unit: MARGIN + 410,
total: MARGIN + 480,
};
const headerRow = cursor.top;
drawText(page, "PART NO.", { x: cols.partNo, y: headerRow, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6) });
drawText(page, "DESCRIPTION", { x: cols.desc, y: headerRow, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6) });
drawText(page, "QTY", { x: cols.qty, y: headerRow, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6) });
drawText(page, "UNIT", { x: cols.unit, y: headerRow, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6) });
drawText(page, "TOTAL", { x: cols.total, y: headerRow, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6) });
cursor.top -= 4;
page.drawLine({
start: { x: MARGIN, y: cursor.top },
end: { x: PAGE_WIDTH - MARGIN, y: cursor.top },
thickness: 0.75,
color: rgb(0.82, 0.84, 0.88),
});
cursor.top -= 10;
const lineSize = 10;
const descWidth = cols.qty - cols.desc - 10;
let grandTotal = 0;
let hasAnyCost = false;
for (const l of data.lines) {
const descLines = wrapLines(l.description, fonts.regular, lineSize, descWidth);
const rowHeight = Math.max(14, descLines.length * 13) + 4;
// Page break guard — if we run out of space, stop. (Multi-page POs can
// be added later; for now a single page is fine for typical line counts.)
if (cursor.top - rowHeight < MARGIN + 60) {
drawText(page, `… additional ${data.lines.length} lines truncated`, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 8,
color: rgb(0.5, 0.5, 0.55),
});
break;
}
drawText(page, l.partNumber, {
x: cols.partNo,
y: cursor.top - 2,
font: fonts.mono,
size: lineSize,
});
for (let i = 0; i < descLines.length; i++) {
drawText(page, descLines[i], {
x: cols.desc,
y: cursor.top - 2 - i * 13,
font: fonts.regular,
size: lineSize,
});
}
drawText(page, String(l.qty), {
x: cols.qty,
y: cursor.top - 2,
font: fonts.regular,
size: lineSize,
});
if (l.unitCost !== null && l.unitCost !== undefined) {
hasAnyCost = true;
const total = l.unitCost * l.qty;
grandTotal += total;
drawText(page, l.unitCost.toFixed(2), {
x: cols.unit,
y: cursor.top - 2,
font: fonts.regular,
size: lineSize,
});
drawText(page, total.toFixed(2), {
x: cols.total,
y: cursor.top - 2,
font: fonts.regular,
size: lineSize,
});
} else {
drawText(page, "—", {
x: cols.unit,
y: cursor.top - 2,
font: fonts.regular,
size: lineSize,
color: rgb(0.55, 0.58, 0.65),
});
}
cursor.top -= rowHeight;
}
// --- Totals --------------------------------------------------------------
if (hasAnyCost) {
cursor.top -= 8;
page.drawLine({
start: { x: cols.unit, y: cursor.top + 10 },
end: { x: PAGE_WIDTH - MARGIN, y: cursor.top + 10 },
thickness: 0.5,
color: rgb(0.82, 0.84, 0.88),
});
drawText(page, "TOTAL", {
x: cols.unit,
y: cursor.top,
font: fonts.bold,
size: 10,
});
drawText(page, grandTotal.toFixed(2), {
x: cols.total,
y: cursor.top,
font: fonts.bold,
size: 10,
});
cursor.top -= 16;
}
// --- Notes --------------------------------------------------------------
drawSection(page, fonts, cursor, "Notes", data.po.notes, contentWidth);
// --- Footer -------------------------------------------------------------
drawText(page, `Generated ${new Date().toISOString().slice(0, 16).replace("T", " ")}Z`, {
x: MARGIN,
y: MARGIN - 10,
font: fonts.regular,
size: 8,
color: rgb(0.55, 0.58, 0.65),
});
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}