955 lines
29 KiB
TypeScript
955 lines
29 KiB
TypeScript
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);
|
||
|
||
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);
|
||
}
|
||
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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`;
|
||
}
|