JEISA-WEBAPP/scripts/test-quote-pdf.mjs
Dave d606345de4 fix(quotes,po): repair item storage, totals and PDF line items
Quotes and purchase orders were silently losing their line items.

Quote items field was a multi-relation to stock_items, but PocketBase
stored only the last id as a plain string no matter how it was sent
(JSON string via FormData, raw array, or repeated FormData entries).
Every quote created through the UI had `items: ""` or `items: "<id>"`.
Recreated the field as JSON so an array of stock_item ids round-trips
(companion fields quantities/unitPrices already line up with it).

PO snapshot_items had the same class of bug from the other direction:
String(obj) coerced the snapshot array to "[object Object]", which
PocketBase stored verbatim, rendering the PO as N rows of
"Unknown / £0.00". The form now sends JSON-stringified arrays, and
the reader only trusts a snapshot whose rows actually carry a name.

Also fixes:
- Quote item lines are hydrated from stock_items; the parallel
  quantities/unitPrices arrays are the source of truth, so quotes
  written before the schema fix still render and total correctly.
- Quote form rejects stock rows where no item was actually picked
  from the dropdown, instead of saving an empty stockItemId.
- PO PDF filename is now the plain order number (PO-20261015-001.pdf).

Adds the quotes.items schema migration plus three dev scripts for
creating a quote, re-applying the field fix and rendering a test PDF.
2026-10-20 20:13:46 +02:00

181 lines
6.7 KiB
JavaScript

#!/usr/bin/env node
/**
* Generates a real PDF for a given quote and saves it locally.
* Used to verify the PDF rendering works against live data.
*
* Usage: node scripts/test-quote-pdf.mjs <quote_number>
*/
import PocketBase from "pocketbase";
import { jsPDF } from "jspdf";
import fs from "node:fs/promises";
import path from "node:path";
const PB_URL = "https://pb.jeistk.apphub.co.za";
const PB_EMAIL = "david@wfsales.co.za";
const PB_PASSWORD = "1234567890";
const QUOTE_NUMBER = process.argv[2] || "QT-20260914-001";
const pb = new PocketBase(PB_URL);
pb.autoCancellation(false);
await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD);
// Mirror the Quotes.tsx hydration logic
const record = await pb.collection("quotes").getFirstListItem(
`quote_number = "${QUOTE_NUMBER}"`,
{ expand: "customerName" }
);
const itemIds = Array.isArray(record.items) ? record.items : [];
const items = itemIds.map((id, i) => ({
stockItemId: id,
itemName: "",
quantity: record.quantities?.[i] ?? 0,
sku: "",
sellingPrice: record.unitPrices?.[i] ?? 0,
}));
if (itemIds.length > 0) {
const filter = itemIds.map((id) => `id = "${id}"`).join(" || ");
const stockRecs = await pb.collection("stock_items").getList(1, 200, { filter });
const byId = new Map(stockRecs.items.map((r) => [r.id, r]));
for (const it of items) {
const s = byId.get(it.stockItemId);
if (s) { it.itemName = s.name; it.sku = s.sku; }
}
}
const customer = record.expand?.customerName;
const quote = {
id: record.id,
quote_number: record.quote_number,
customerName: customer?.comapny_name ?? "",
quoteDate: record.quoteDate,
validUntil: record.validUntil,
status: record.status,
quantities: record.quantities ?? [],
unitPrices: record.unitPrices ?? [],
items,
customer: customer ? { name: customer.comapny_name, address: "", email: "", phone: "" } : null,
notes: record.notes ?? "",
attachments: [],
};
console.log("Quote for PDF:", JSON.stringify({ n: quote.quote_number, items: quote.items.length, qty: quote.quantities }, null, 2));
// ---- Mirror the PDF generation from QuoteDataTable.tsx ----
const doc = new jsPDF();
const brandBlack = "#000000";
const accentYellow = "#ffc905";
const darkText = "#1a1a1a";
const lightGray = "#f5f5f5";
const mediumGray = "#666666";
doc.setFillColor(brandBlack);
doc.rect(0, 0, 210, 45, "F");
doc.setFillColor(accentYellow);
doc.rect(0, 45, 210, 2, "F");
doc.setFont("helvetica", "bold"); doc.setFontSize(20); doc.setTextColor(255, 255, 255);
doc.text("JEI SA", 14, 20);
doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(220, 220, 220);
doc.text("47 Station St, Oberholzer, Carletonville, 2499", 14, 26);
doc.text("Tel: 018 786 2897", 14, 31);
doc.setFont("helvetica", "bold"); doc.setFontSize(24); doc.setTextColor(255, 255, 255);
doc.text("QUOTATION", 120, 25);
doc.setFillColor(250, 250, 250);
doc.roundedRect(125, 55, 71, 35, 2, 2, "FD");
doc.setFont("helvetica", "bold"); doc.setFontSize(9); doc.setTextColor(mediumGray);
doc.text("Quote No:", 128, 61);
doc.text("Date:", 128, 69);
doc.text("Valid Until:", 128, 77);
doc.setFont("helvetica", "normal"); doc.setTextColor(darkText);
doc.text(quote.quote_number || quote.id, 193, 61, { align: "right" });
doc.text(new Date(quote.quoteDate).toLocaleDateString(), 193, 69, { align: "right" });
doc.text(quote.validUntil ? new Date(quote.validUntil).toLocaleDateString() : "N/A", 193, 77, { align: "right" });
let y = 105;
doc.setFont("helvetica", "bold"); doc.setFontSize(11); doc.setTextColor(brandBlack);
doc.text("CUSTOMER INFORMATION", 14, y);
doc.setDrawColor(accentYellow); doc.setLineWidth(1.5);
doc.line(14, y + 2, 75, y + 2);
y += 10;
doc.setFillColor(lightGray);
doc.roundedRect(14, y, 182, 32, 2, 2, "F");
y += 6;
doc.setFont("helvetica", "bold"); doc.setFontSize(10); doc.setTextColor(darkText);
doc.text(quote.customer?.name || quote.customerName || "N/A", 18, y);
y += 14;
doc.setFont("helvetica", "bold"); doc.setFontSize(11); doc.setTextColor(brandBlack);
doc.text("QUOTE ITEMS", 14, y);
doc.setDrawColor(accentYellow);
doc.line(14, y + 2, 55, y + 2);
y += 8;
doc.setFillColor(brandBlack);
doc.roundedRect(14, y, 182, 10, 1, 1, "F");
doc.setFont("helvetica", "bold"); doc.setFontSize(9); doc.setTextColor(255, 255, 255);
doc.text("#", 17, y + 6);
doc.text("Description", 28, y + 6);
doc.text("SKU", 100, y + 6);
doc.text("Qty", 130, y + 6, { align: "center" });
doc.text("Unit Price", 155, y + 6, { align: "right" });
doc.text("Total", 188, y + 6, { align: "right" });
y += 10;
doc.setFont("helvetica", "normal"); doc.setFontSize(8);
let subtotal = 0;
const lineCount = Math.max(quote.quantities?.length ?? 0, quote.items?.length ?? 0, quote.unitPrices?.length ?? 0);
for (let i = 0; i < lineCount; i++) {
const item = quote.items?.[i] || { itemName: "(item)", sku: "", sellingPrice: 0 };
const qty = quote.quantities?.[i] || 0;
const price = quote.unitPrices?.[i] || item.sellingPrice || 0;
const total = qty * price;
subtotal += total;
const rowH = 10;
if (i % 2 === 0) {
doc.setFillColor(250, 250, 250);
doc.rect(14, y, 182, rowH, "F");
}
doc.setTextColor(darkText);
doc.text(String(i + 1), 17, y + 5);
doc.text(String(item.itemName || "(item)").substring(0, 40), 28, y + 5);
doc.setTextColor(mediumGray); doc.setFontSize(7);
doc.text(item.sku || "—", 100, y + 5);
doc.setFontSize(8); doc.setTextColor(darkText);
doc.text(String(qty), 130, y + 5, { align: "center" });
doc.text(`R${price.toFixed(2)}`, 155, y + 5, { align: "right" });
doc.text(`R${total.toFixed(2)}`, 188, y + 5, { align: "right" });
y += rowH;
}
const vatAmount = subtotal * 0.15;
const totalWithVat = subtotal + vatAmount;
y += 5;
doc.setFillColor(lightGray);
doc.roundedRect(130, y, 66, 30, 2, 2, "F");
y += 7;
doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(mediumGray);
doc.text("Subtotal:", 135, y);
doc.setTextColor(darkText);
doc.text(`R${subtotal.toFixed(2)}`, 188, y, { align: "right" });
y += 8;
doc.setTextColor(mediumGray);
doc.text("Tax (VAT 15%):", 135, y);
doc.setTextColor(darkText);
doc.text(`R${vatAmount.toFixed(2)}`, 188, y, { align: "right" });
y += 8;
doc.setFillColor(brandBlack);
doc.roundedRect(130, y - 3, 66, 10, 2, 2, "F");
doc.setFont("helvetica", "bold"); doc.setFontSize(11); doc.setTextColor(255, 255, 255);
doc.text("TOTAL:", 135, y + 4);
doc.text(`R${totalWithVat.toFixed(2)}`, 188, y + 4, { align: "right" });
const out = `/tmp/${QUOTE_NUMBER.replace(/[^A-Za-z0-9_-]/g, "_")}.pdf`;
const bytes = Buffer.from(doc.output("arraybuffer"));
await fs.writeFile(out, bytes);
console.log(`\n✅ PDF written: ${out} (${bytes.length} bytes)`);
console.log(` Subtotal: R ${subtotal.toFixed(2)}`);
console.log(` VAT: R ${vatAmount.toFixed(2)}`);
console.log(` Total: R ${totalWithVat.toFixed(2)}`);
console.log(` Lines rendered: ${lineCount}`);