This commit is contained in:
+803
@@ -0,0 +1,803 @@
|
||||
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 };
|
||||
assembly: { code: string; name: string };
|
||||
part: { code: string; name: string; material: string | null; qty: number };
|
||||
operation: {
|
||||
id: string;
|
||||
sequence: number;
|
||||
name: 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;
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
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;
|
||||
machineName: string | null;
|
||||
qcRequired: boolean;
|
||||
qrToken: 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. */
|
||||
export async function renderPartTravelers(payload: {
|
||||
cover: PartCoverData;
|
||||
cards: OperationCardData[];
|
||||
}): 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);
|
||||
|
||||
for (const card of payload.cards) {
|
||||
const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
|
||||
await drawOperationCard(doc, page, fonts, card);
|
||||
}
|
||||
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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),
|
||||
});
|
||||
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 partMeta = [
|
||||
`Part ${data.part.code}`,
|
||||
data.part.material ? `${data.part.material}` : null,
|
||||
`qty ${data.part.qty}`,
|
||||
]
|
||||
.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}`]);
|
||||
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 meta = [
|
||||
data.part.material ? `Material: ${data.part.material}` : null,
|
||||
`Quantity: ${data.part.qty}`,
|
||||
`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;
|
||||
drawText(page, `${op.sequence}. ${op.name}`, {
|
||||
x: textX,
|
||||
y: cursor.top - 12,
|
||||
font: fonts.bold,
|
||||
size: 11,
|
||||
});
|
||||
|
||||
const subline = [
|
||||
op.machineName ?? "no machine",
|
||||
op.qcRequired ? "QC" : null,
|
||||
]
|
||||
.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`;
|
||||
}
|
||||
Reference in New Issue
Block a user