- 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>
247 lines
8.3 KiB
TypeScript
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();
|
|
}
|