Replace the off-screen CSS grid + window.print() in the worker QR
batch dialog with two on-click exports:
- Download PDF — A4 portrait, 2x5 grid of CR80 (85x54mm) cards per
page, brand orange header, generated with pdf-lib (dynamically
imported, same pattern as src/lib/photo-pdf.ts).
- Download CSV — UTF-8 BOM, columns employee_no, name, position,
worker_type, project_code, qr_payload via Papa.unparse.
The single-card print on the worker detail page still uses
window.print() — one card has no pagination problem, only the batch
view did.
Spec §4.4 updated to match the new output mechanism and explain the
mobile failure mode that prompted the change.
New tests (8) cover the CSV builder (header order, qr_payload ==
employee_no per spec §4.1, RFC 4180 quoting, empty input) and the PDF
builder (empty input throws, valid %PDF output, 11 workers paginate
onto 2 pages, fake PNG injection so layout is testable without qrcode).
245 lines
8.4 KiB
TypeScript
245 lines
8.4 KiB
TypeScript
/**
|
||
* Batch-export helpers for worker QR badges — turns the current set of
|
||
* filtered/selected workers into a CSV, or into a real PDF you can print
|
||
* straight away without relying on `window.print()` and a browser print
|
||
* dialog that may clip or paginate the off-screen card grid on mobile.
|
||
*
|
||
* Pure where possible: the CSV builder is a pure string transform (tested
|
||
* directly); the PDF builder takes each row's QR as an injected data URL
|
||
* so the only thing under test here is the page layout itself.
|
||
*
|
||
* Replaces the design spec's §4.4 choice of `window.print()` + CSS
|
||
* `@media print`. That path was the right call when this was a desktop
|
||
* workflow; it does not work for someone opening the dialog on a phone,
|
||
* which is the actual use case for printing badges in the field.
|
||
*/
|
||
|
||
import { buildWorkerQrPayload } from "@/lib/worker-qr";
|
||
import Papa from "papaparse";
|
||
|
||
/**
|
||
* Minimum data the export needs per worker. `EmployeeRow` (the roster
|
||
* page's full row shape) is structurally assignable to this — no shared
|
||
* import needed, no risk of a circular dependency between the roster
|
||
* view and the dialog.
|
||
*/
|
||
export interface WorkerQrExportRow {
|
||
id: string;
|
||
employeeNo: string;
|
||
name: string;
|
||
position: string;
|
||
workerType: string;
|
||
projectCode: string;
|
||
}
|
||
|
||
const CSV_COLUMNS = [
|
||
"employee_no",
|
||
"name",
|
||
"position",
|
||
"worker_type",
|
||
"project_code",
|
||
"qr_payload",
|
||
] as const;
|
||
|
||
/**
|
||
* CSV for editing/auditing in Excel. The `qr_payload` column repeats the
|
||
* employee number on purpose — the spec (§4.1) makes the employee number
|
||
* the QR's payload verbatim, and someone reading the CSV should see that
|
||
* directly without having to remember the rule.
|
||
*/
|
||
export function buildQrCsv(rows: readonly WorkerQrExportRow[]): string {
|
||
return Papa.unparse(
|
||
{
|
||
fields: [...CSV_COLUMNS],
|
||
data: rows.map((r) => [
|
||
r.employeeNo,
|
||
r.name,
|
||
r.position,
|
||
r.workerType,
|
||
r.projectCode,
|
||
buildWorkerQrPayload(r.employeeNo),
|
||
]),
|
||
},
|
||
{ newline: "\n" },
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Inject the QR generator so the layout can be tested without touching
|
||
* `qrcode`. The default wires the same `QRCode.toDataURL(...)` call the
|
||
* `WorkerQrCard` component already uses, but with `margin: 1` and a
|
||
* crisp-ish 480px raster so the printed card stays sharp.
|
||
*/
|
||
export type QrPngFor = (employeeNo: string) => Promise<Uint8Array>;
|
||
|
||
export async function defaultQrPngFor(employeeNo: string): Promise<Uint8Array> {
|
||
const QRCode = (await import("qrcode")).default;
|
||
const dataUrl = await QRCode.toDataURL(buildWorkerQrPayload(employeeNo), {
|
||
margin: 1,
|
||
width: 480,
|
||
});
|
||
return dataUrlToPngBytes(dataUrl);
|
||
}
|
||
|
||
const MM_TO_PT = 72 / 25.4;
|
||
|
||
/** A4 portrait, in PDF points. */
|
||
const PAGE_W_PT = 210 * MM_TO_PT; // 595.28
|
||
const PAGE_H_PT = 297 * MM_TO_PT; // 841.89
|
||
|
||
/** Standard CR80 badge dimensions. */
|
||
const CARD_W_PT = 85 * MM_TO_PT; // ~240.94
|
||
const CARD_H_PT = 54 * MM_TO_PT; // ~153.07
|
||
|
||
/**
|
||
* 2 columns × 5 rows = 10 cards per A4 sheet — the standard CR80 layout.
|
||
* Margins sized so a printed sheet can be fed through a guillotine down
|
||
* the column gutter (3mm) and the row gutter (3mm) without the crop
|
||
* hitting any card content.
|
||
*/
|
||
const COLS = 2;
|
||
const ROWS = 5;
|
||
const CARDS_PER_PAGE = COLS * ROWS;
|
||
const GUTTER_PT = 3 * MM_TO_PT;
|
||
const SIDE_MARGIN_PT = (PAGE_W_PT - COLS * CARD_W_PT - (COLS - 1) * GUTTER_PT) / 2;
|
||
const TOP_MARGIN_PT = (PAGE_H_PT - ROWS * CARD_H_PT - (ROWS - 1) * GUTTER_PT) / 2;
|
||
|
||
/**
|
||
* Build a multi-page A4 PDF, 10 cards per page, ready to print and cut.
|
||
* Throws if there are no workers — the caller is responsible for
|
||
* disabling the button in that state, but the guard makes a misuse fail
|
||
* loudly rather than silently producing an empty PDF.
|
||
*/
|
||
export async function buildQrPdf(
|
||
rows: readonly WorkerQrExportRow[],
|
||
qrPngFor: QrPngFor = defaultQrPngFor,
|
||
): Promise<Uint8Array> {
|
||
if (rows.length === 0) throw new Error("no workers to export");
|
||
|
||
const { PDFDocument, StandardFonts, rgb } = await import("pdf-lib");
|
||
|
||
const doc = await PDFDocument.create();
|
||
const brandFont = await doc.embedFont(StandardFonts.HelveticaBold);
|
||
const textFont = await doc.embedFont(StandardFonts.Helvetica);
|
||
const monoFont = await doc.embedFont(StandardFonts.CourierBold);
|
||
|
||
// Embed each unique QR once — same employee number on multiple pages
|
||
// (e.g. two sheet exports in a row) still resolves to one image asset.
|
||
const qrCache = new Map<string, Awaited<ReturnType<typeof doc.embedPng>>>();
|
||
async function getQr(employeeNo: string) {
|
||
const cached = qrCache.get(employeeNo);
|
||
if (cached) return cached;
|
||
const png = await qrPngFor(employeeNo);
|
||
const image = await doc.embedPng(png);
|
||
qrCache.set(employeeNo, image);
|
||
return image;
|
||
}
|
||
|
||
const pageCount = Math.ceil(rows.length / CARDS_PER_PAGE);
|
||
|
||
for (let pageIdx = 0; pageIdx < pageCount; pageIdx++) {
|
||
const page = doc.addPage([PAGE_W_PT, PAGE_H_PT]);
|
||
const slice = rows.slice(pageIdx * CARDS_PER_PAGE, (pageIdx + 1) * CARDS_PER_PAGE);
|
||
|
||
for (let i = 0; i < slice.length; i++) {
|
||
const row = slice[i];
|
||
const col = i % COLS;
|
||
const rowIdx = Math.floor(i / COLS);
|
||
const x = SIDE_MARGIN_PT + col * (CARD_W_PT + GUTTER_PT);
|
||
// pdf-lib's Y axis grows upward; row 0 is the top of the page.
|
||
const y =
|
||
PAGE_H_PT -
|
||
TOP_MARGIN_PT -
|
||
CARD_H_PT -
|
||
rowIdx * (CARD_H_PT + GUTTER_PT);
|
||
|
||
// Card outline (1pt border, slate grey) so a printed sheet still
|
||
// looks like badges even before you cut.
|
||
page.drawRectangle({
|
||
x,
|
||
y,
|
||
width: CARD_W_PT,
|
||
height: CARD_H_PT,
|
||
borderColor: rgb(0.7, 0.7, 0.72),
|
||
borderWidth: 0.75,
|
||
});
|
||
|
||
// QR image — square, centred horizontally, sitting between the
|
||
// brand header and the name/number lines.
|
||
const qr = await getQr(row.employeeNo);
|
||
const qrSize = 26 * MM_TO_PT;
|
||
const qrX = x + (CARD_W_PT - qrSize) / 2;
|
||
const qrY = y + (CARD_H_PT - qrSize) / 2 + 6;
|
||
page.drawImage(qr, { x: qrX, y: qrY, width: qrSize, height: qrSize });
|
||
|
||
// "Mega Infrastructure" header
|
||
page.drawText("MEGA INFRASTRUCTURE", {
|
||
x: x + (CARD_W_PT - brandFont.widthOfTextAtSize("MEGA INFRASTRUCTURE", 7)) / 2,
|
||
y: y + CARD_H_PT - 12,
|
||
size: 7,
|
||
font: brandFont,
|
||
color: rgb(0.45, 0.45, 0.48),
|
||
});
|
||
|
||
// Worker name (truncated with an ellipsis if it would overflow the
|
||
// card width — the printed sheet should never run off the badge).
|
||
const nameSize = 10;
|
||
const nameMaxWidth = CARD_W_PT - 10;
|
||
const truncatedName = truncateToWidth(row.name, textFont, nameSize, nameMaxWidth);
|
||
const nameWidth = textFont.widthOfTextAtSize(truncatedName, nameSize);
|
||
page.drawText(truncatedName, {
|
||
x: x + (CARD_W_PT - nameWidth) / 2,
|
||
y: y + 18,
|
||
size: nameSize,
|
||
font: textFont,
|
||
color: rgb(0.1, 0.1, 0.12),
|
||
});
|
||
|
||
// Employee number, monospace, brand orange.
|
||
const numberSize = 9;
|
||
const numberWidth = monoFont.widthOfTextAtSize(row.employeeNo, numberSize);
|
||
page.drawText(row.employeeNo, {
|
||
x: x + (CARD_W_PT - numberWidth) / 2,
|
||
y: y + 6,
|
||
size: numberSize,
|
||
font: monoFont,
|
||
color: rgb(0.878, 0.541, 0.235), // #E08A3C, matches globals.css
|
||
});
|
||
}
|
||
}
|
||
|
||
return doc.save();
|
||
}
|
||
|
||
/** Strip a `data:image/png;base64,…` URL down to its raw PNG bytes. */
|
||
function dataUrlToPngBytes(dataUrl: string): Uint8Array {
|
||
const commaIdx = dataUrl.indexOf(",");
|
||
if (commaIdx < 0 || !dataUrl.startsWith("data:image/png;base64,")) {
|
||
throw new Error("expected a base64 PNG data URL");
|
||
}
|
||
const b64 = dataUrl.slice(commaIdx + 1);
|
||
const binary = atob(b64);
|
||
const out = new Uint8Array(binary.length);
|
||
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
||
return out;
|
||
}
|
||
|
||
/** Shorten `text` with an ellipsis until it fits within `maxWidth` pt. */
|
||
function truncateToWidth(
|
||
text: string,
|
||
font: { widthOfTextAtSize: (s: string, size: number) => number },
|
||
size: number,
|
||
maxWidth: number,
|
||
): string {
|
||
if (font.widthOfTextAtSize(text, size) <= maxWidth) return text;
|
||
const ellipsis = "…";
|
||
let lo = 0;
|
||
let hi = text.length;
|
||
while (lo < hi) {
|
||
const mid = Math.ceil((lo + hi) / 2);
|
||
const candidate = text.slice(0, mid) + ellipsis;
|
||
if (font.widthOfTextAtSize(candidate, size) <= maxWidth) lo = mid;
|
||
else hi = mid - 1;
|
||
}
|
||
return text.slice(0, lo) + ellipsis;
|
||
}
|