feat(worker-qr): real PDF + CSV batch export

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).
This commit is contained in:
DEls 2026-09-20 20:43:10 +02:00
parent 3a0aedec0d
commit da756f5224
5 changed files with 463 additions and 60 deletions

View file

@ -135,16 +135,27 @@ npm, ~20kb) — nothing like this exists in the project today, so this is
a new dependency either way, and it's the standard, minimal choice for
generating a QR as an SVG/canvas/data-URL in the browser.
Output mechanism: **the browser's own print dialog** (`window.print()`
with `@media print` CSS), not a PDF-generation library. The single-card
and batch-sheet views are two different CSS layouts (one large centered
card vs. a CSS grid of many cards at real-world mm dimensions) behind the
same print trigger. The browser's print dialog already offers "Save as
PDF," so this produces a PDF without adding `jsPDF`/`html2canvas` as
dependencies or fighting a PDF library's coordinate system to get
physical card dimensions right — CSS `@media print` with `mm`/`in` units
is the more reliable tool for that than a PDF library's own layout
engine.
Output mechanism: split per export.
- **Single-card export** (worker detail page): still `window.print()` on
the centred `<WorkerQrCard>` with `@media print` CSS. One card is
trivially printable from the browser's own dialog — no need to bring in
a PDF library for it.
- **Batch export** (Workers list → "Export QR badges"): generates a real
PDF in the browser via `pdf-lib` (already in `package.json` for the
delivery-note bundler in `src/lib/photo-pdf.ts`). A4 portrait, 2 cols
× 5 rows = 10 cards per page, 85 × 54 mm per card — standard CR80 ID
layout, gutters sized for a guillotine cut. A CSV of the same workers
is downloaded alongside, columns `employee_no, name, position,
worker_type, project_code, qr_payload` (the last column repeats the
employee number on purpose — see §4.1). The `pdf-lib` import stays
dynamic so users who never open the export dialog don't pay for it.
The original choice (`window.print()` for both) was right on the desktop
workflow this was first specced against; it does not hold up on mobile,
where the off-screen CSS grid silently fails to paginate and the user
sees a partial or empty print preview. The spec is updated to reflect
that.
---

View file

@ -258,10 +258,11 @@
}
}
/* Worker QR badge cards — used by both the single-print button on a
worker's detail page and the batch export dialog on the Workers list.
Sized to standard ID-badge/credit-card dimensions so a printed sheet
can be cut and laminated directly. */
/* Worker QR badge cards — rendered on-screen on a worker's detail page
(next to the single "Print badge" button). Sized to standard
ID-badge/credit-card dimensions so the printed card laminates cleanly.
The batch export on the Workers list no longer uses these classes —
it builds a real PDF via pdf-lib instead. */
.worker-qr-card {
display: flex;
flex-direction: column;
@ -299,24 +300,6 @@
color: #E08A3C;
}
/* Batch sheet: 2 cards per row, as many rows as fit. */
.worker-qr-badge-grid {
display: grid;
grid-template-columns: repeat(2, 85mm);
grid-auto-rows: 54mm;
gap: 4mm;
}
/* The batch grid isn't part of the normal page layout — it exists only
to be printed. Keep it off-canvas on screen; the .print-target rule
in the printing-answer block above (position: absolute; inset: 0)
overrides this the instant printing starts. */
.worker-qr-badges-offscreen {
position: fixed;
left: -10000px;
top: 0;
}
/* MEGA Clock's footer tab bar sits flush with the bottom edge in iOS
standalone mode, where the home-indicator area needs real padding —
nothing in this file already accounts for env(safe-area-inset-*). */

View file

@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import { FileText, Loader2, QrCode } from "lucide-react";
import {
Dialog,
DialogContent,
@ -9,13 +10,17 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { WorkerQrCard } from "@/components/worker-qr-card";
import {
buildQrCsv,
buildQrPdf,
type WorkerQrExportRow,
} from "@/lib/worker-qr-batch";
export interface WorkerQrBatchDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
filteredWorkers: { id: string; name: string; employeeNo: string }[];
selectedWorkers: { id: string; name: string; employeeNo: string }[];
filteredWorkers: WorkerQrExportRow[];
selectedWorkers: WorkerQrExportRow[];
}
export function WorkerQrBatchDialog({
@ -25,25 +30,42 @@ export function WorkerQrBatchDialog({
selectedWorkers,
}: WorkerQrBatchDialogProps) {
const [mode, setMode] = React.useState<"filtered" | "selected">("filtered");
const [busy, setBusy] = React.useState<"pdf" | "csv" | null>(null);
// Default to "selected" whenever the dialog opens with a selection
// already made — the more specific choice wins over "everything visible".
React.useEffect(() => {
if (open) setMode(selectedWorkers.length > 0 ? "selected" : "filtered");
if (open) {
setMode(selectedWorkers.length > 0 ? "selected" : "filtered");
setBusy(null);
}
}, [open, selectedWorkers.length]);
const workers = mode === "selected" ? selectedWorkers : filteredWorkers;
const dateStamp = () => new Date().toISOString().slice(0, 10);
function printBadges() {
const el = document.getElementById("worker-badges-print-target");
if (!el) return;
el.classList.add("print-target");
document.body.classList.add("printing-answer");
async function downloadPdf() {
if (workers.length === 0) return;
setBusy("pdf");
try {
window.print();
const bytes = await buildQrPdf(workers);
saveBlob(bytes as BlobPart, `qr-badges-${dateStamp()}.pdf`, "application/pdf");
} finally {
document.body.classList.remove("printing-answer");
el.classList.remove("print-target");
setBusy(null);
}
}
function downloadCsv() {
if (workers.length === 0) return;
setBusy("csv");
try {
const csv = buildQrCsv(workers);
// Prefix with a UTF-8 BOM so Excel opens it with the right encoding
// by default — the roster has accented names.
const blob = new Blob(["\uFEFF", csv], { type: "text/csv;charset=utf-8" });
saveBlob(blob, `qr-badges-${dateStamp()}.csv`);
} finally {
setBusy(null);
}
}
@ -53,7 +75,8 @@ export function WorkerQrBatchDialog({
<DialogHeader>
<DialogTitle>Export QR badges</DialogTitle>
<DialogDescription>
Prints a multi-card sheet you can cut and laminate.
Downloads a printable PDF (10 cards per A4 sheet) and a CSV of
the same workers for editing or auditing.
</DialogDescription>
</DialogHeader>
@ -77,24 +100,56 @@ export function WorkerQrBatchDialog({
</div>
<p className="text-xs text-muted-foreground">
{workers.length} badge{workers.length === 1 ? "" : "s"} will print, ~10 per
A4/Letter sheet.
{workers.length} {workers.length === 1 ? "badge" : "badges"} will be
included.
</p>
<Button onClick={printBadges} disabled={workers.length === 0} className="w-full">
Print badges
</Button>
</div>
<div
id="worker-badges-print-target"
className="worker-qr-badges-offscreen worker-qr-badge-grid"
>
{workers.map((w) => (
<WorkerQrCard key={w.id} name={w.name} employeeNo={w.employeeNo} />
))}
<div className="flex flex-col gap-2 sm:flex-row">
<Button
onClick={downloadPdf}
disabled={workers.length === 0 || busy !== null}
className="flex-1 gap-1.5"
>
{busy === "pdf" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<QrCode className="h-4 w-4" />
)}
{busy === "pdf" ? "Generating PDF…" : "Download PDF"}
</Button>
<Button
onClick={downloadCsv}
disabled={workers.length === 0 || busy !== null}
variant="outline"
className="flex-1 gap-1.5"
>
{busy === "csv" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileText className="h-4 w-4" />
)}
{busy === "csv" ? "Building CSV…" : "Download CSV"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
}
/**
* Save bytes (or a Blob) as a file download. Works for both a Uint8Array
* (from pdf-lib) and a Blob (from the CSV builder).
*/
function saveBlob(payload: BlobPart, filename: string, mime?: string) {
const blob = payload instanceof Blob ? payload : new Blob([payload], { type: mime ?? "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke on the next tick so the click has time to start the download.
setTimeout(() => URL.revokeObjectURL(url), 0);
}

View file

@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { PDFDocument } from "pdf-lib";
import { buildQrCsv, buildQrPdf, type WorkerQrExportRow } from "./worker-qr-batch";
const SAMPLE: WorkerQrExportRow[] = [
{
id: "a",
employeeNo: "MCW058",
name: "Alice Anderson",
position: "Foreman",
workerType: "permanent",
projectCode: "PRJ-01",
},
{
id: "b",
employeeNo: "MCW059",
name: "Bob, Builder",
position: "Labourer",
workerType: "casual",
projectCode: "PRJ-02",
},
{
id: "c",
employeeNo: "MCW060",
name: 'Cara "Crane" Carter',
position: "Operator",
workerType: "permanent",
projectCode: "PRJ-01",
},
];
describe("buildQrCsv", () => {
it("emits the spec'd column header order, one row per worker", () => {
const csv = buildQrCsv(SAMPLE);
const lines = csv.split("\n");
expect(lines[0]).toBe(
"employee_no,name,position,worker_type,project_code,qr_payload",
);
expect(lines).toHaveLength(4); // header + 3 rows
});
it("repeats the employee number in qr_payload (spec §4.1: payload IS the employee number)", () => {
const csv = buildQrCsv([SAMPLE[0]]);
const [, dataLine] = csv.split("\n");
// Last column is qr_payload — same as employee_no.
expect(dataLine.endsWith(",MCW058")).toBe(true);
});
it("quotes commas, quotes, and embedded newlines correctly", () => {
const csv = buildQrCsv([SAMPLE[1], SAMPLE[2]]);
// "Bob, Builder" has a comma → must be wrapped in quotes.
expect(csv).toContain('"Bob, Builder"');
// Cara "Crane" Carter has literal quotes → must double them (RFC 4180).
expect(csv).toContain('"Cara ""Crane"" Carter"');
});
it("returns just the header for an empty list (still a valid CSV)", () => {
// Papa.unparse appends a trailing newline — keep it; many tools
// (including Excel) expect a CSV to end with one.
expect(buildQrCsv([])).toBe(
"employee_no,name,position,worker_type,project_code,qr_payload\n",
);
});
});
describe("buildQrPdf", () => {
it("throws on empty input rather than producing an empty document", async () => {
await expect(buildQrPdf([])).rejects.toThrow(/no workers/i);
});
it("produces a valid PDF for at least one worker", async () => {
const bytes = await buildQrPdf([SAMPLE[0]]);
// PDF magic header
expect(new TextDecoder().decode(bytes.slice(0, 5))).toBe("%PDF-");
// Should have at least one page worth of content (not the empty header alone).
expect(bytes.byteLength).toBeGreaterThan(1000);
});
it("pages 11+ workers onto a second page (10 per sheet)", async () => {
const rows: WorkerQrExportRow[] = Array.from({ length: 11 }, (_, i) => ({
id: String(i),
employeeNo: `MCW${String(i + 1).padStart(3, "0")}`,
name: `Worker ${i + 1}`,
position: "Operator",
workerType: "permanent",
projectCode: "PRJ-01",
}));
const bytes = await buildQrPdf(rows);
// pdf-lib compresses content streams by default, so the raw bytes
// don't contain a literal "/Type /Page" token. Re-parse the output
// and ask pdf-lib how many pages it sees — that's the contract that
// actually matters.
const reparsed = await PDFDocument.load(bytes);
expect(reparsed.getPageCount()).toBe(2);
});
it("accepts an injected QR generator so tests don't depend on qrcode", async () => {
// 1×1 transparent PNG, decoded from base64. pdf-lib only cares that
// the bytes form a parseable PNG, not what they look like.
const tinyPng = Uint8Array.from(
atob(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
),
(c) => c.charCodeAt(0),
);
const bytes = await buildQrPdf([SAMPLE[0]], async () => tinyPng);
expect(new TextDecoder().decode(bytes.slice(0, 5))).toBe("%PDF-");
});
});

245
src/lib/worker-qr-batch.ts Normal file
View file

@ -0,0 +1,245 @@
/**
* 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;
}