fix(quotes): wrap item descriptions on the quote PDF

Item descriptions were cut off with an ellipsis. Long names like
"PRO132T-C24 - CARBON BRUSH EACH, PRO152T" lost their tail, which on a
quotation reads as a mistake.

Descriptions now wrap onto as many lines as they need and the row grows
to fit, so the full name is always printed. SKU, quantity, price and
total stay on the first line of the row.

While wrapping, the quote PDF gained the page handling the purchase
order PDF already had: a row that no longer fits moves to a new page
with the column headers repeated, and the totals and notes blocks
break to a new page rather than running off the bottom. Before this
the quote PDF had no page breaks at all, so a long quote simply ran
off the page.

Addresses also keep their line breaks now instead of being flattened
into one line.

The two quote PDF builders - "Download PDF" and the copy stored in
PocketBase - were near-identical copies of ~275 lines. They now share
one builder (quotePdf.ts), so a layout fix can no longer land in one
and miss the other.
This commit is contained in:
Dave 2026-10-21 08:40:39 +02:00
parent 9d2ba5a430
commit a30270e77f
4 changed files with 270 additions and 463 deletions

View file

@ -1,18 +1,17 @@
#!/usr/bin/env node
/**
* Renders a quote PDF against live data so the customer block can be
* checked by eye.
* Renders a quote PDF against live data so the layout can be checked by
* eye - especially the wrapped item descriptions.
*
* Usage:
* node scripts/preview-quote-pdf.mjs <quote_number> [out.pdf]
* node --experimental-strip-types scripts/preview-quote-pdf.mjs <quote_number> [out.pdf]
*
* Example:
* node scripts/preview-quote-pdf.mjs QT-20260914-001 /tmp/quote.pdf
* node --experimental-strip-types scripts/preview-quote-pdf.mjs QT-20260914-001 /tmp/quote.pdf
*/
import PocketBase from "pocketbase";
import { jsPDF } from "jspdf";
import fs from "node:fs/promises";
import { truncateToWidth } from "../src/components/purchase-orders/pdfText.ts";
import { buildQuotePdf } from "../src/components/quotes/quotePdf.ts";
const PB_URL = process.env.POCKETBASE_URL || "https://pb.jeistk.apphub.co.za";
const PB_EMAIL = process.env.POCKETBASE_ADMIN_EMAIL || "david@wfsales.co.za";
@ -69,153 +68,12 @@ const quote = {
notes: rec.notes || "",
};
// ── render (mirrors QuoteDataTable) ────────────────────────────────────────
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, 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" },
);
// Customer block (same helper the app uses)
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;
const name = quote.customer.name || quote.customerName || "N/A";
const addressLines = quote.customer.address
? quote.customer.address.replace(/\s+/g, " ").trim().split(" ")
: [];
const wrapped = [];
let cur = "";
for (const w of addressLines) {
const cand = cur ? `${cur} ${w}` : w;
if (cand.length > 62 && cur) { wrapped.push(cur); cur = w; } else { cur = cand; }
}
if (cur) wrapped.push(cur);
const vatLine = quote.customer.vatNumber ? `VAT No: ${quote.customer.vatNumber}` : "";
const contactLine = [quote.customer.email, quote.customer.phone].filter(Boolean).join(" | ");
const contentLines = 1 + wrapped.length + (vatLine ? 1 : 0) + (contactLine ? 1 : 0);
const boxHeight = 6 + contentLines * 5 + 2;
doc.setFillColor(lightGray);
doc.roundedRect(14, y, 182, boxHeight, 2, 2, "F");
y += 6;
doc.setFont("helvetica", "bold");
doc.setFontSize(10);
doc.setTextColor(darkText);
doc.text(name, 18, y);
y += 5;
doc.setFont("helvetica", "normal");
doc.setFontSize(8);
doc.setTextColor(mediumGray);
for (const line of wrapped) { doc.text(line, 18, y); y += 5; }
if (vatLine) { doc.text(vatLine, 18, y); y += 5; }
if (contactLine) { doc.text(contactLine, 18, y); y += 5; }
y += 8;
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;
for (let i = 0; i < quantities.length; i++) {
const item = items[i] || { itemName: "(item)", sku: "" };
const qty = quantities[i] || 0;
const price = unitPrices[i] || 0;
subtotal += qty * price;
if (i % 2 === 0) { doc.setFillColor(250, 250, 250); doc.rect(14, y, 182, 10, "F"); }
doc.setTextColor(darkText);
doc.text(String(i + 1), 17, y + 5);
doc.text(truncateToWidth(doc, String(item.itemName), 54), 28, y + 5);
doc.setTextColor(mediumGray);
doc.setFontSize(7);
doc.text(truncateToWidth(doc, item.sku || "—", 38), 86, 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${(qty * price).toFixed(2)}`, 188, y + 5, { align: "right" });
y += 10;
}
const vat = subtotal * 0.15;
y += 8;
doc.setFont("helvetica", "bold");
doc.setFontSize(9);
doc.setTextColor(darkText);
doc.text("Subtotal:", 135, y, { align: "right" });
doc.text(`R${subtotal.toFixed(2)}`, 188, y, { align: "right" });
y += 6;
doc.text("Tax (VAT 15%):", 135, y, { align: "right" });
doc.text(`R${vat.toFixed(2)}`, 188, y, { align: "right" });
y += 6;
doc.setFontSize(11);
doc.text("Total:", 135, y, { align: "right" });
doc.text(`R${(subtotal + vat).toFixed(2)}`, 188, y, { align: "right" });
const doc = buildQuotePdf(quote);
await fs.writeFile(OUT, Buffer.from(doc.output("arraybuffer")));
console.log(`Wrote ${OUT}`);
console.log(` customer: ${quote.customer.name}`);
console.log(` address : ${quote.customer.address || "(none)"}`);
console.log(` vat : ${quote.customer.vatNumber || "(none)"}`);
console.log(` contact : ${[quote.customer.email, quote.customer.phone].filter(Boolean).join(" | ") || "(none)"}`);
console.log(` items : ${items.length}`);
for (const it of items) {
console.log(` - "${it.itemName}"`);
}

View file

@ -24,8 +24,7 @@ import { jsPDF } from "jspdf"
import pb from "@/lib/pocketbase"
import { showSuccess, showError } from "@/utils/toast"
import { createQuoteColumns, Quote } from "./QuoteColumns"
import { drawCustomerBlock } from "./quotePdfCustomer"
import { truncateToWidth } from "../purchase-orders/pdfText"
import { buildQuotePdf, quotePdfFilename } from "./quotePdf"
import { AttachmentUpload, AttachmentList } from "../purchase-orders/AttachmentUpload"
interface QuoteDataTableProps {
@ -285,317 +284,28 @@ function QuoteAttachmentsDialog({ open, onOpenChange, quote, refreshData }: {
async function generateQuotePDF(quote: Quote) {
try {
const doc = new jsPDF()
const brandBlack = "#000000"
const accentYellow = "#ffc905"
const darkText = "#1a1a1a"
const lightGray = "#f5f5f5"
const mediumGray = "#666666"
// Header
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)
// Quote details box
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" })
// Customer box
let y = drawCustomerBlock(doc, quote.customer, quote.customerName, 105)
// Items table
y += 6
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
// Header row
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", 86, 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
// quantities is the source of truth — old quotes have empty items arrays
// because the previous schema truncated them, but quantities/unitPrices are intact.
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(truncateToWidth(doc, String(item.itemName || '(item)'), 54), 28, y + 5)
doc.setTextColor(mediumGray)
doc.setFontSize(7)
doc.text(truncateToWidth(doc, item.sku || '—', 38), 86, 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
}
// Totals
const vatRate = 0.15
const vatAmount = subtotal * vatRate
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" })
// Notes
if (quote.notes) {
y += 20
doc.setFont("helvetica", "bold")
doc.setFontSize(10)
doc.setTextColor(brandBlack)
doc.text("Notes:", 14, y)
y += 5
doc.setFont("helvetica", "normal")
doc.setFontSize(8)
doc.setTextColor(mediumGray)
doc.text(quote.notes, 14, y)
}
doc.save(`QT_${(quote.quote_number || quote.id).replace(/[^A-Za-z0-9_-]/g, "_")}.pdf`)
// Return the generated filename for upload
return `QT_${(quote.quote_number || quote.id).replace(/[^A-Za-z0-9_-]/g, "_")}.pdf`
const doc = buildQuotePdf(quote)
const filename = quotePdfFilename(quote)
doc.save(filename)
return filename
} catch (err) {
console.error("PDF error:", err)
return null
}
}
// Separate function to save PDF to PocketBase
export async function saveQuotePdfToPocketbase(quote: Quote, pdfFilename: string): Promise<string | null> {
try {
const doc = new jsPDF()
const brandBlack = "#000000"
const accentYellow = "#ffc905"
const darkText = "#1a1a1a"
const lightGray = "#f5f5f5"
const mediumGray = "#666666"
// Header
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)
// Quote details box
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" })
// Customer box
let y = drawCustomerBlock(doc, quote.customer, quote.customerName, 105)
// Items table
y += 6
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
// Header row
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", 86, 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
// quantities is the source of truth — old quotes have empty items arrays
// because the previous schema truncated them, but quantities/unitPrices are intact.
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(truncateToWidth(doc, String(item.itemName || '(item)'), 54), 28, y + 5)
doc.setTextColor(mediumGray)
doc.setFontSize(7)
doc.text(truncateToWidth(doc, item.sku || '—', 38), 86, 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
}
// Totals
const vatRate = 0.15
const vatAmount = subtotal * vatRate
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" })
// Notes
if (quote.notes) {
y += 20
doc.setFont("helvetica", "bold")
doc.setFontSize(10)
doc.setTextColor(brandBlack)
doc.text("Notes:", 14, y)
y += 5
doc.setFont("helvetica", "normal")
doc.setFontSize(8)
doc.setTextColor(mediumGray)
doc.text(quote.notes, 14, y)
}
const doc = buildQuotePdf(quote)
// Get PDF as blob
const pdfBlob = doc.output('blob')
const pdfBlob = doc.output("blob")
// Upload to PocketBase using existing attachments field
const formData = new FormData()
formData.append('attachments', pdfBlob, pdfFilename)
formData.append("attachments", pdfBlob, pdfFilename)
await pb.collection('quotes').update(quote.id, formData)
await pb.collection("quotes").update(quote.id, formData)
return pdfFilename
} catch (err) {

View file

@ -0,0 +1,229 @@
import { jsPDF } from "jspdf"
import type { Quote } from "./QuoteColumns"
import { drawCustomerBlock } from "./quotePdfCustomer"
import { truncateToWidth } from "../purchase-orders/pdfText"
const BRAND_BLACK = "#000000"
const ACCENT_YELLOW = "#ffc905"
const DARK_TEXT = "#1a1a1a"
const LIGHT_GRAY = "#f5f5f5"
const MEDIUM_GRAY = "#666666"
const ROW_SHADE = "#fafafa"
const PAGE_BOTTOM_MARGIN = 20
const ITEMS_TOP_Y = 20
/** Description column: x 28 -> 86, so 54pt of usable width. */
const DESC_WIDTH = 54
/** SKU column: x 86 -> 126, so 38pt of usable width. */
const SKU_WIDTH = 38
/** Line height used for wrapped description text. */
const LINE_HEIGHT = 5
function pageHeight(doc: jsPDF) {
const size = (doc as unknown as {
internal: { pageSize: { getHeight?: () => number; height: number } };
}).internal.pageSize;
return size.getHeight ? size.getHeight() : size.height
}
function renderItemsHeader(doc: jsPDF, startY: number) {
doc.setFillColor(BRAND_BLACK)
doc.roundedRect(14, startY, 182, 10, 1, 1, "F")
doc.setFont("helvetica", "bold")
doc.setFontSize(9)
doc.setTextColor(255, 255, 255)
doc.text("#", 17, startY + 6)
doc.text("Description", 28, startY + 6)
doc.text("SKU", 86, startY + 6)
doc.text("Qty", 130, startY + 6, { align: "center" })
doc.text("Unit Price", 155, startY + 6, { align: "right" })
doc.text("Total", 188, startY + 6, { align: "right" })
}
/**
* Builds the quote PDF.
*
* Item descriptions wrap onto as many lines as they need instead of being
* cut off, so the row grows to fit and the other columns stay put. Rows
* that no longer fit on the page move to a new page with the column
* headers repeated.
*/
export function buildQuotePdf(quote: Quote): jsPDF {
const doc = new jsPDF()
// ── Header ────────────────────────────────────────────────────────────
doc.setFillColor(BRAND_BLACK)
doc.rect(0, 0, 210, 45, "F")
doc.setFillColor(ACCENT_YELLOW)
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)
// ── Quote details box ─────────────────────────────────────────────────
doc.setFillColor(250, 250, 250)
doc.roundedRect(125, 55, 71, 35, 2, 2, "FD")
doc.setFont("helvetica", "bold")
doc.setFontSize(9)
doc.setTextColor(MEDIUM_GRAY)
doc.text("Quote No:", 128, 61)
doc.text("Date:", 128, 69)
doc.text("Valid Until:", 128, 77)
doc.setFont("helvetica", "normal")
doc.setTextColor(DARK_TEXT)
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" },
)
// ── Customer block ────────────────────────────────────────────────────
let y = drawCustomerBlock(doc, quote.customer, quote.customerName, 105)
// ── Items table ───────────────────────────────────────────────────────
y += 6
doc.setFont("helvetica", "bold")
doc.setFontSize(11)
doc.setTextColor(BRAND_BLACK)
doc.text("QUOTE ITEMS", 14, y)
doc.setDrawColor(ACCENT_YELLOW)
doc.line(14, y + 2, 55, y + 2)
y += 8
renderItemsHeader(doc, y)
y += 10
doc.setFont("helvetica", "normal")
doc.setFontSize(8)
let subtotal = 0
// quantities is the source of truth — old quotes have empty items arrays
// because the previous schema truncated them, but quantities/unitPrices are intact.
const lineCount = Math.max(
quote.quantities?.length ?? 0,
quote.items?.length ?? 0,
quote.unitPrices?.length ?? 0,
)
const ph = pageHeight(doc)
const bottomLimit = ph - PAGE_BOTTOM_MARGIN
let rowColor = true
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
// Wrap the description instead of cutting it off. The SKU is a short
// code, so it stays on one line.
doc.setFont("helvetica", "normal")
doc.setFontSize(8)
const descLines = doc.splitTextToSize(String(item.itemName || "(item)"), DESC_WIDTH)
const rowH = Math.max(10, descLines.length * LINE_HEIGHT + 5)
// Move the row to a new page when it no longer fits.
if (y + rowH > bottomLimit) {
doc.addPage()
y = ITEMS_TOP_Y
renderItemsHeader(doc, y)
y += 10
rowColor = true
}
if (rowColor) {
doc.setFillColor(ROW_SHADE)
doc.rect(14, y, 182, rowH, "F")
}
rowColor = !rowColor
doc.setFont("helvetica", "normal")
doc.setFontSize(8)
doc.setTextColor(DARK_TEXT)
doc.text(String(i + 1), 17, y + 5)
doc.text(descLines, 28, y + 5)
doc.setTextColor(MEDIUM_GRAY)
doc.setFontSize(7)
doc.text(truncateToWidth(doc, item.sku || "—", SKU_WIDTH), 86, y + 5)
doc.setFontSize(8)
doc.setTextColor(DARK_TEXT)
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
}
// ── Totals ────────────────────────────────────────────────────────────
const vatRate = 0.15
const vatAmount = subtotal * vatRate
const totalWithVat = subtotal + vatAmount
y += 5
if (y + 48 > bottomLimit) {
doc.addPage()
y = ITEMS_TOP_Y
}
doc.setFillColor(LIGHT_GRAY)
doc.roundedRect(130, y, 66, 30, 2, 2, "F")
y += 7
doc.setFont("helvetica", "normal")
doc.setFontSize(9)
doc.setTextColor(MEDIUM_GRAY)
doc.text("Subtotal:", 135, y)
doc.setTextColor(DARK_TEXT)
doc.text(`R${subtotal.toFixed(2)}`, 188, y, { align: "right" })
y += 8
doc.setTextColor(MEDIUM_GRAY)
doc.text("Tax (VAT 15%):", 135, y)
doc.setTextColor(DARK_TEXT)
doc.text(`R${vatAmount.toFixed(2)}`, 188, y, { align: "right" })
y += 8
doc.setFillColor(BRAND_BLACK)
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" })
// ── Notes ─────────────────────────────────────────────────────────────
if (quote.notes) {
y += 20
if (y + 10 > bottomLimit) {
doc.addPage()
y = ITEMS_TOP_Y
}
doc.setFont("helvetica", "bold")
doc.setFontSize(10)
doc.setTextColor(BRAND_BLACK)
doc.text("Notes:", 14, y)
y += 5
doc.setFont("helvetica", "normal")
doc.setFontSize(8)
doc.setTextColor(MEDIUM_GRAY)
doc.text(doc.splitTextToSize(quote.notes, 182), 14, y)
}
return doc
}
/** Filename used for the downloaded and the stored copy of a quote PDF. */
export function quotePdfFilename(quote: Quote): string {
const raw = quote.quote_number || quote.id
return `QT_${String(raw).replace(/[^A-Za-z0-9_-]/g, "_")}.pdf`
}

View file

@ -20,21 +20,31 @@ const BOX_WIDTH = 182
const TEXT_LEFT = 18
const LINE_HEIGHT = 5
/** Splits a long address into lines that fit the box width. */
/** Splits a long address into lines that fit the box width.
*
* Explicit line breaks in the address are preserved - an address typed as
* "1 Long Street\nSuburb\nCity" should print on three lines, not be
* flattened into one.
*/
function wrapAddress(address: string, maxChars: number): string[] {
const words = address.replace(/\s+/g, " ").trim().split(" ")
const lines: string[] = []
let current = ""
for (const word of words) {
const candidate = current ? `${current} ${word}` : word
if (candidate.length > maxChars && current) {
lines.push(current)
current = word
} else {
current = candidate
for (const paragraph of String(address).split(/\r?\n/)) {
const words = paragraph.replace(/\s+/g, " ").trim().split(" ")
let current = ""
for (const word of words) {
if (!word) continue
const candidate = current ? `${current} ${word}` : word
if (candidate.length > maxChars && current) {
lines.push(current)
current = word
} else {
current = candidate
}
}
if (current) lines.push(current)
}
if (current) lines.push(current)
return lines
}