diff --git a/docs/superpowers/specs/2026-09-11-workers-timesheets-qr-design.md b/docs/superpowers/specs/2026-09-11-workers-timesheets-qr-design.md index 41e4c4b..f4c4eb7 100644 --- a/docs/superpowers/specs/2026-09-11-workers-timesheets-qr-design.md +++ b/docs/superpowers/specs/2026-09-11-workers-timesheets-qr-design.md @@ -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 `` 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. --- diff --git a/src/app/globals.css b/src/app/globals.css index 40e2405..22d03ec 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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-*). */ diff --git a/src/components/worker-qr-batch-dialog.tsx b/src/components/worker-qr-batch-dialog.tsx index c51628a..df88fb9 100644 --- a/src/components/worker-qr-batch-dialog.tsx +++ b/src/components/worker-qr-batch-dialog.tsx @@ -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({ Export QR badges - 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. @@ -77,24 +100,56 @@ export function WorkerQrBatchDialog({

- {workers.length} badge{workers.length === 1 ? "" : "s"} will print, ~10 per - A4/Letter sheet. + {workers.length} {workers.length === 1 ? "badge" : "badges"} will be + included.

- - - -
- {workers.map((w) => ( - - ))} +
+ + +
); -} \ No newline at end of file +} + +/** + * 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); +} diff --git a/src/lib/worker-qr-batch.test.ts b/src/lib/worker-qr-batch.test.ts new file mode 100644 index 0000000..0e2dc54 --- /dev/null +++ b/src/lib/worker-qr-batch.test.ts @@ -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-"); + }); +}); diff --git a/src/lib/worker-qr-batch.ts b/src/lib/worker-qr-batch.ts new file mode 100644 index 0000000..9d167bb --- /dev/null +++ b/src/lib/worker-qr-batch.ts @@ -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; + +export async function defaultQrPngFor(employeeNo: string): Promise { + 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 { + 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>>(); + 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; +}