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 #!/usr/bin/env node
/** /**
* Renders a quote PDF against live data so the customer block can be * Renders a quote PDF against live data so the layout can be checked by
* checked by eye. * eye - especially the wrapped item descriptions.
* *
* Usage: * 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: * 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 PocketBase from "pocketbase";
import { jsPDF } from "jspdf";
import fs from "node:fs/promises"; 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_URL = process.env.POCKETBASE_URL || "https://pb.jeistk.apphub.co.za";
const PB_EMAIL = process.env.POCKETBASE_ADMIN_EMAIL || "david@wfsales.co.za"; const PB_EMAIL = process.env.POCKETBASE_ADMIN_EMAIL || "david@wfsales.co.za";
@ -69,153 +68,12 @@ const quote = {
notes: rec.notes || "", notes: rec.notes || "",
}; };
// ── render (mirrors QuoteDataTable) ──────────────────────────────────────── const doc = buildQuotePdf(quote);
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" });
await fs.writeFile(OUT, Buffer.from(doc.output("arraybuffer"))); await fs.writeFile(OUT, Buffer.from(doc.output("arraybuffer")));
console.log(`Wrote ${OUT}`); console.log(`Wrote ${OUT}`);
console.log(` customer: ${quote.customer.name}`); console.log(` customer: ${quote.customer.name}`);
console.log(` address : ${quote.customer.address || "(none)"}`); console.log(` items : ${items.length}`);
console.log(` vat : ${quote.customer.vatNumber || "(none)"}`); for (const it of items) {
console.log(` contact : ${[quote.customer.email, quote.customer.phone].filter(Boolean).join(" | ") || "(none)"}`); console.log(` - "${it.itemName}"`);
}

View file

@ -24,8 +24,7 @@ import { jsPDF } from "jspdf"
import pb from "@/lib/pocketbase" import pb from "@/lib/pocketbase"
import { showSuccess, showError } from "@/utils/toast" import { showSuccess, showError } from "@/utils/toast"
import { createQuoteColumns, Quote } from "./QuoteColumns" import { createQuoteColumns, Quote } from "./QuoteColumns"
import { drawCustomerBlock } from "./quotePdfCustomer" import { buildQuotePdf, quotePdfFilename } from "./quotePdf"
import { truncateToWidth } from "../purchase-orders/pdfText"
import { AttachmentUpload, AttachmentList } from "../purchase-orders/AttachmentUpload" import { AttachmentUpload, AttachmentList } from "../purchase-orders/AttachmentUpload"
interface QuoteDataTableProps { interface QuoteDataTableProps {
@ -285,317 +284,28 @@ function QuoteAttachmentsDialog({ open, onOpenChange, quote, refreshData }: {
async function generateQuotePDF(quote: Quote) { async function generateQuotePDF(quote: Quote) {
try { try {
const doc = new jsPDF() const doc = buildQuotePdf(quote)
const brandBlack = "#000000" const filename = quotePdfFilename(quote)
const accentYellow = "#ffc905" doc.save(filename)
const darkText = "#1a1a1a" return filename
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`
} catch (err) { } catch (err) {
console.error("PDF error:", err) console.error("PDF error:", err)
return null return null
} }
} }
// Separate function to save PDF to PocketBase
export async function saveQuotePdfToPocketbase(quote: Quote, pdfFilename: string): Promise<string | null> { export async function saveQuotePdfToPocketbase(quote: Quote, pdfFilename: string): Promise<string | null> {
try { try {
const doc = new jsPDF() const doc = buildQuotePdf(quote)
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)
}
// Get PDF as blob // Get PDF as blob
const pdfBlob = doc.output('blob') const pdfBlob = doc.output("blob")
// Upload to PocketBase using existing attachments field // Upload to PocketBase using existing attachments field
const formData = new FormData() 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 return pdfFilename
} catch (err) { } 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 TEXT_LEFT = 18
const LINE_HEIGHT = 5 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[] { function wrapAddress(address: string, maxChars: number): string[] {
const words = address.replace(/\s+/g, " ").trim().split(" ")
const lines: string[] = [] const lines: string[] = []
let current = ""
for (const word of words) { for (const paragraph of String(address).split(/\r?\n/)) {
const candidate = current ? `${current} ${word}` : word const words = paragraph.replace(/\s+/g, " ").trim().split(" ")
if (candidate.length > maxChars && current) { let current = ""
lines.push(current) for (const word of words) {
current = word if (!word) continue
} else { const candidate = current ? `${current} ${word}` : word
current = candidate 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 return lines
} }