mega-plant/lib/qr-pdf.ts
David df53130861 Add Workforce pages, Plant Clock tab bar and oil & diesel book-out
- Workforce: Employees register, per-employee dashboards, Attendance table
- Plant Clock: scan-only clock, bottom tab bar (Employees / Oil & Diesel / Job cards)
- Oil & Diesel stock: diesel tank (650 L, opening 589 L), level overwrite dialog,
  QR on each card, book-out screen with searchable vehicle picker,
  filterable "Booked out" table (today/week/month/all/range)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 11:39:18 +02:00

247 lines
8.3 KiB
TypeScript

import type { Mechanic } from "./data";
import { mechanicQrPayload, isBadgeReady } from "./mechanics";
import { qrMatrix, isDark } from "./qr";
/**
* Builds the printable badge sheet as a REAL PDF — vector, not a screenshot of the
* page and not a browser print job.
*
* The QR is drawn module by module as a filled rectangle, so it stays perfectly sharp
* at any printer resolution or zoom level. Rasterising it into a PNG first would look
* fine on screen and go fuzzy on a badge somebody scans with a phone off a lanyard.
*
* A4 portrait, four badges per row, cut lines included so the sheet can be guillotined.
*/
export async function buildBadgesPdf(mechanics: Pick<Mechanic, "name" | "employee_code" | "role">[], title = "Mega Plant — Mechanic Badges"): Promise<Uint8Array> {
const { PDFDocument, StandardFonts, rgb } = await import("pdf-lib");
const doc = await PDFDocument.create();
const page = doc.addPage([595.28, 841.89]); // A4 portrait, points
const regular = await doc.embedFont(StandardFonts.Helvetica);
const bold = await doc.embedFont(StandardFonts.HelveticaBold);
const INK = rgb(0, 0, 0);
const GREY = rgb(0.38, 0.42, 0.45);
const HAIRLINE = rgb(0.82, 0.85, 0.87);
const printables = mechanics.filter(isBadgeReady);
const margin = 36;
const cols = 3;
const gutter = 14;
const cellW = (page.getWidth() - margin * 2 - gutter * (cols - 1)) / cols;
const cellH = 232;
page.drawText(title, {
x: margin,
y: page.getHeight() - margin + 2,
size: 13,
font: bold,
color: INK,
});
page.drawText(
`${printables.length} badge${printables.length !== 1 ? "s" : ""} · each QR scans to the name and employee number`,
{ x: margin, y: page.getHeight() - margin - 12, size: 8.5, font: regular, color: GREY },
);
const top = page.getHeight() - margin - 28;
printables.forEach((m, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const x = margin + col * (cellW + gutter);
const y = top - row * (cellH + gutter) - cellH;
// Cell border doubles as the cut line.
page.drawRectangle({
x,
y,
width: cellW,
height: cellH,
borderColor: HAIRLINE,
borderWidth: 0.5,
});
const matrix = qrMatrix(mechanicQrPayload(m));
const qrSize = 104;
const qrX = x + (cellW - qrSize) / 2;
const qrY = y + cellH - 20 - qrSize;
const unit = qrSize / matrix.size;
// White backing first: a PDF page is transparent by default, and a transparent QR
// prints as nothing on most printers.
page.drawRectangle({ x: qrX, y: qrY, width: qrSize, height: qrSize, color: rgb(1, 1, 1) });
// Merge horizontally adjacent dark modules into one rect — same trick as the SVG,
// so the file stays small.
for (let my = 0; my < matrix.size; my++) {
let run = 0;
for (let mx = 0; mx <= matrix.size; mx++) {
const dark = mx < matrix.size && isDark(matrix, mx, my);
if (dark) {
run++;
} else if (run > 0) {
page.drawRectangle({
x: qrX + (mx - run) * unit,
y: qrY + (matrix.size - my - 1) * unit,
width: run * unit,
height: unit,
color: INK,
});
run = 0;
}
}
}
let cursor = qrY - 18;
const nameSize = Math.min(11, (cellW - 12) / (m.name.length * 0.58));
page.drawText(m.name, {
x: x + (cellW - bold.widthOfTextAtSize(m.name, nameSize)) / 2,
y: cursor,
size: nameSize,
font: bold,
color: INK,
});
cursor -= 13;
page.drawText(m.employee_code, {
x: x + (cellW - regular.widthOfTextAtSize(m.employee_code, 10)) / 2,
y: cursor,
size: 10,
font: regular,
color: GREY,
});
cursor -= 12;
const role = m.role || "Mechanic";
page.drawText(role.toUpperCase(), {
x: x + (cellW - regular.widthOfTextAtSize(role.toUpperCase(), 7)) / 2,
y: cursor,
size: 7,
font: regular,
color: GREY,
});
});
if (printables.length === 0) {
page.drawText("No mechanic has an employee number yet — there is nothing to print.", {
x: margin,
y: page.getHeight() / 2,
size: 11,
font: regular,
color: INK,
});
}
return doc.save();
}
/** Trigger a browser download for the generated bytes. */
export function downloadPdf(bytes: Uint8Array, filename: string) {
// Copy into a fresh ArrayBuffer: pdf-lib's Uint8Array can be a view onto a larger
// buffer, and Blob would then save the padding too.
const blob = new Blob([new Uint8Array(bytes)], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// Revoke on the next tick so the click has definitely been handled.
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/**
* The oil drum labels: one QR per tracked oil, printed as a label for the drum or
* dispenser. Same vector drawing and the same encoder as the badge sheet, so a label
* can never scan to a different oil than the one on screen.
*/
export async function buildOilLabelsPdf(
items: { name: string; code: string; colour: string; drum_litres: number }[],
payloadOf: (i: { name: string; code: string }) => string,
): Promise<Uint8Array> {
const { PDFDocument, StandardFonts, rgb } = await import("pdf-lib");
const doc = await PDFDocument.create();
const page = doc.addPage([595.28, 841.89]);
const regular = await doc.embedFont(StandardFonts.Helvetica);
const bold = await doc.embedFont(StandardFonts.HelveticaBold);
const INK = rgb(0, 0, 0);
const GREY = rgb(0.38, 0.42, 0.45);
const HAIRLINE = rgb(0.82, 0.85, 0.87);
const hex = (c: string) => {
const m = /^#?([0-9a-f]{6})$/i.exec(c || "");
if (!m) return rgb(0, 0, 0);
const n = parseInt(m[1], 16);
return rgb(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255);
};
const margin = 36;
page.drawText("Mega Plant — Oil & Diesel Labels", {
x: margin, y: page.getHeight() - margin + 2, size: 13, font: bold, color: INK,
});
page.drawText("Cut and stick each label on its drum or tank. The QR identifies the product.", {
x: margin, y: page.getHeight() - margin - 12, size: 8.5, font: regular, color: GREY,
});
const cellW = (page.getWidth() - margin * 2) / 2;
const cellH = 250;
const top = page.getHeight() - margin - 28;
items.forEach((item, i) => {
const x = margin + (i % 2) * cellW;
const y = top - Math.floor(i / 2) * cellH - cellH;
page.drawRectangle({
x: x + 6, y, width: cellW - 12, height: cellH - 6,
borderColor: HAIRLINE, borderWidth: 0.5,
});
// A colour bar, so the two labels are told apart at a glance on the drum.
page.drawRectangle({ x: x + 6, y: y + cellH - 22, width: cellW - 12, height: 10, color: hex(item.colour) });
const matrix = qrMatrix(payloadOf(item));
const qrSize = 120;
const qrX = x + (cellW - qrSize) / 2;
const qrY = y + cellH - 34 - qrSize;
const unit = qrSize / matrix.size;
// Transparent pages print as nothing, so the backing plate is not optional.
page.drawRectangle({ x: qrX, y: qrY, width: qrSize, height: qrSize, color: rgb(1, 1, 1) });
for (let my = 0; my < matrix.size; my++) {
let run = 0;
for (let mx = 0; mx <= matrix.size; mx++) {
const dark = mx < matrix.size && isDark(matrix, mx, my);
if (dark) {
run++;
} else if (run > 0) {
page.drawRectangle({
x: qrX + (mx - run) * unit,
y: qrY + (matrix.size - my - 1) * unit,
width: run * unit, height: unit, color: INK,
});
run = 0;
}
}
}
let cursor = qrY - 20;
page.drawText(item.name, {
x: x + (cellW - bold.widthOfTextAtSize(item.name, 14)) / 2,
y: cursor, size: 14, font: bold, color: INK,
});
cursor -= 15;
page.drawText(item.code, {
x: x + (cellW - regular.widthOfTextAtSize(item.code, 11)) / 2,
y: cursor, size: 11, font: regular, color: GREY,
});
cursor -= 13;
const drum = `${item.drum_litres || 210} L ${item.code.startsWith("FUEL") ? "tank" : "drum"}`;
page.drawText(drum, {
x: x + (cellW - regular.widthOfTextAtSize(drum, 9)) / 2,
y: cursor, size: 9, font: regular, color: GREY,
});
});
return doc.save();
}