diff --git a/scripts/preview-quote-pdf.mjs b/scripts/preview-quote-pdf.mjs index 5b5281c..3874b22 100644 --- a/scripts/preview-quote-pdf.mjs +++ b/scripts/preview-quote-pdf.mjs @@ -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 [out.pdf] + * node --experimental-strip-types scripts/preview-quote-pdf.mjs [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}"`); +} diff --git a/src/components/quotes/QuoteDataTable.tsx b/src/components/quotes/QuoteDataTable.tsx index f503a23..00b337e 100644 --- a/src/components/quotes/QuoteDataTable.tsx +++ b/src/components/quotes/QuoteDataTable.tsx @@ -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 { 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) { diff --git a/src/components/quotes/quotePdf.ts b/src/components/quotes/quotePdf.ts new file mode 100644 index 0000000..2933345 --- /dev/null +++ b/src/components/quotes/quotePdf.ts @@ -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` +} diff --git a/src/components/quotes/quotePdfCustomer.ts b/src/components/quotes/quotePdfCustomer.ts index 3533583..de1a30f 100644 --- a/src/components/quotes/quotePdfCustomer.ts +++ b/src/components/quotes/quotePdfCustomer.ts @@ -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 }