diff --git a/lib/pdf/reports.ts b/lib/pdf/reports.ts index ff26ebf0..7eff6045 100644 --- a/lib/pdf/reports.ts +++ b/lib/pdf/reports.ts @@ -4,6 +4,7 @@ import { drawText, drawTable, moveDown, + ensureSpace, COLORS, type PDFContext, } from "./shared"; @@ -85,7 +86,10 @@ async function customerDelivery(ctx: ReportContext): Promise { const grouped = groupBy(ctx.notes, (n) => n.customer); for (const [customerId, notes] of grouped) { - if (pdf.y < 100) drawHeader(pdf, "Customer Delivery Report", "(continued)"); + // Pass the full "Customer Delivery Report (continued)" as the header + // title — drawHeader will treat it as the title (no subtitle needed + // since ensureSpace only forwards a single string). + ensureSpace(pdf, 120, "Customer Delivery Report (continued)"); const customer = customers.get(customerId); drawText(pdf, customer?.name || customerId, { size: 12, bold: true }); moveDown(pdf, 14); @@ -136,7 +140,7 @@ async function vehicleDelivery(ctx: ReportContext): Promise { drawHeader(pdf, "Vehicle Delivery Report", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`); const grouped = groupBy(ctx.notes, (n) => n.vehicle_registration || "(none)"); for (const [vehicle, notes] of grouped) { - if (pdf.y < 100) drawHeader(pdf, "Vehicle Delivery Report", "(continued)"); + ensureSpace(pdf, 120, "Vehicle Delivery Report (continued)"); drawText(pdf, vehicle, { size: 12, bold: true }); moveDown(pdf, 14); const cols = [ @@ -207,7 +211,7 @@ async function tankDelivery(ctx: ReportContext): Promise { drawHeader(pdf, "Tank Delivery Report", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`); const grouped = groupBy(ctx.notes, (n) => n.plant_dispensing || "(unspecified)"); for (const [tank, notes] of grouped) { - if (pdf.y < 100) drawHeader(pdf, "Tank Delivery Report", "(continued)"); + ensureSpace(pdf, 120, "Tank Delivery Report (continued)"); drawText(pdf, tank, { size: 12, bold: true }); moveDown(pdf, 14); const customers = new Map(ctx.customers.map((c) => [c.id, c])); diff --git a/tmp/diag-customer-report.mjs b/tmp/diag-customer-report.mjs new file mode 100644 index 00000000..f24f8bcf --- /dev/null +++ b/tmp/diag-customer-report.mjs @@ -0,0 +1,110 @@ +/** + * Reproduce the Customer Delivery Report page-overlap bug. + * Builds a synthetic dataset (many customers, 4–14 rows each) that + * overflows onto page 15, then renders each page to PNG. + */ +import { generateReport } from "../lib/pdf/reports.ts"; +import { writeFileSync, mkdirSync } from "fs"; +import { join } from "path"; +import puppeteer from "puppeteer-core"; + +const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + +const OUT_PDF = "/tmp/rjbulk-pdfs/customer-delivery-diag.pdf"; +const OUT_PNG_DIR = "/tmp/rjbulk-pdf-images"; + +mkdirSync("/tmp/rjbulk-pdfs", { recursive: true }); +mkdirSync(OUT_PNG_DIR, { recursive: true }); + +// Build a fake dataset: 60 customers, varying row counts (mostly 2–8 rows), +// simulating a busy month with many small customers. +const customers = []; +const notes = []; +const customerNames = [ + "PJ V Vista", "Blou Investments 273", "Joggie Muller busdienste", "Cash Strydom Trailers", + "Blanview Towing", "Eastvaal Motors", "Pretoria Sand & Stone", "Highveld Logistics", + "Riverside Civils", "Mountainview Plant Hire", "Northcliff Construction", "Sunward Park Quarries", + "Westonaria Mining", "Boksburg Civils", "Kempton Haulage", "Midrand Plant", + "Centurion Earthmovers", "Vanderbijlpulp Steel", "Sasolburg Transport", "Vereeniging Sands", + "Nigel Aggregates", "Springs Heavy Haul", "Benoni Grading", "Brakpan Civils", + "Alrode Civils", "Germiston Plant", "Edenvale Haulage", "Bedfordview Earth", + "Sandton Civils", "Bryanston Plant", "Fourways Construction", "Lanseria Civils", + "Honeydew Plant", "Krugersdorp Mining", "Randfontein Sands", "Carletonville Civils", + "Fochville Earth", "Potchefstroom Agri", "Stilfontein Mining", "Klerksdorp Steel", + "Welkom Civils", "Virginia Mining", "Sasolburg Chemicals", "Secunda Plant", + "Trichardt Civils", "Bethal Mining", "Ermelo Agri", "Morgenzon Plant", + "Standerton Civils", "Volksrust Haulage", "Lichtenburg Agri", "Coligny Plant", + "Delareyville Sands", "Vryburg Civils", "Kuruman Mining", "Hotazel Earth", + "Kathu Steel", "Postmasburg Civils", "Danielskuil Plant", "Douglas Sands", +]; +customerNames.forEach((name, i) => { + const id = `cust_${i + 1}`; + customers.push({ id, name }); +}); + +let noteCounter = 35000; +const vehicles = ["FX37NBQP", "FV76YPGP", "ETX550QP", "KX53NWSW", "DH27NXGP", "KY53GTQP", "JP75NBQP"]; +const baseDate = new Date("2026-07-01"); + +customerNames.forEach((name, i) => { + // mostly 4–10 rows per customer (matches the user's pattern) — yields ~15 pages + const rowCount = 4 + (i % 7); + for (let r = 0; r < rowCount; r++) { + const d = new Date(baseDate); + d.setDate(d.getDate() + r + (i % 3)); + const qty = 50 + Math.round(Math.random() * 950); + notes.push({ + id: `note_${noteCounter}`, + customer: `cust_${i + 1}`, + number: String(noteCounter++), + date: d.toISOString().slice(0, 10), + qty_litres_supplied: qty, + price_per_litre: 22.5, + total_value: qty * 22.5, + vehicle_registration: vehicles[r % vehicles.length], + }); + } +}); + +console.log(`Built ${customers.length} customers, ${notes.length} notes`); + +const pdf = await generateReport("customer-delivery", { + startDate: "2026-07-01", + endDate: "2026-07-31", + includePricing: true, + notes, + purchases: [], + customers, + suppliers: [], +}); + +writeFileSync(OUT_PDF, pdf); +console.log(`Wrote ${OUT_PDF} (${pdf.length} bytes)`); + +// Render every page to PNG using pdf-lib + an image lib is overkill. +// Instead use Playwright/Chromium to display the PDF and screenshot each page. +const browser = await puppeteer.launch({ + executablePath: CHROME, + headless: "new", + args: ["--no-sandbox", "--disable-gpu"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 900, height: 1200 }); +await page.goto(`file://${OUT_PDF}`, { waitUntil: "networkidle2", timeout: 30000 }); +await new Promise((r) => setTimeout(r, 800)); + +const totalPages = await page.evaluate(() => { + const m = document.body.innerText.match(/(\d+)\s*\/\s*(\d+)/); + return m ? parseInt(m[2], 10) : 1; +}); +console.log(`Total pages: ${totalPages}`); + +for (let i = 1; i <= Math.min(totalPages, 25); i++) { + await page.goto(`file://${OUT_PDF}#page=${i}`, { waitUntil: "networkidle2" }); + await new Promise((r) => setTimeout(r, 400)); + await page.screenshot({ path: join(OUT_PNG_DIR, `diag-page-${String(i).padStart(2, "0")}.png`) }); + console.log(` ✓ page ${i}`); +} + +await browser.close(); +console.log("Done"); diff --git a/tmp/render-all-pages.mjs b/tmp/render-all-pages.mjs new file mode 100644 index 00000000..a476598c --- /dev/null +++ b/tmp/render-all-pages.mjs @@ -0,0 +1,49 @@ +/** + * Render every page of the diagnostic PDF using Chromium's built-in PDF viewer + * and the pdf.js `applicationOptions` to size the viewport properly. + */ +import puppeteer from "puppeteer-core"; +import { readFileSync, mkdirSync, readdirSync } from "fs"; +import { join } from "path"; + +const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; +const PDF = "/tmp/rjbulk-pdfs/customer-delivery-diag.pdf"; +const OUT_DIR = "/tmp/rjbulk-pdf-images"; + +mkdirSync(OUT_DIR, { recursive: true }); + +const browser = await puppeteer.launch({ + executablePath: CHROME, + headless: "new", + args: ["--no-sandbox", "--disable-gpu"], +}); + +// We embed the PDF in an on a blank HTML page so we can use the +// `#page=N` URL fragment which Chromium's PDF viewer honours. +const html = ` + +`; +const hostPath = "/tmp/rjbulk-pdfs/_host.html"; +const { writeFileSync } = await import("fs"); +writeFileSync(hostPath, html); + +const page = await browser.newPage(); +await page.setViewport({ width: 900, height: 1300 }); +await page.goto(`file://${hostPath}`, { waitUntil: "networkidle2", timeout: 30000 }); +await new Promise((r) => setTimeout(r, 1500)); + +// 1/6 means 6 pages — read it from the toolbar +const totalPages = await page.evaluate(() => { + const m = document.body.innerText.match(/\/\s*(\d+)/); + return m ? parseInt(m[1], 10) : 1; +}); +console.log(`Total pages: ${totalPages}`); + +for (let i = 1; i <= totalPages; i++) { + await page.goto(`file://${PDF}#page=${i}&zoom=100`, { waitUntil: "networkidle2" }); + await new Promise((r) => setTimeout(r, 600)); + await page.screenshot({ path: join(OUT_DIR, `diag-page-${String(i).padStart(2, "0")}.png`) }); + console.log(` ✓ page ${i}`); +} + +await browser.close();