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:
parent
9d2ba5a430
commit
a30270e77f
4 changed files with 270 additions and 463 deletions
|
|
@ -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}"`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
229
src/components/quotes/quotePdf.ts
Normal file
229
src/components/quotes/quotePdf.ts
Normal 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`
|
||||
}
|
||||
|
|
@ -20,12 +20,20 @@ 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[] = []
|
||||
|
||||
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)
|
||||
|
|
@ -35,6 +43,8 @@ function wrapAddress(address: string, maxChars: number): string[] {
|
|||
}
|
||||
}
|
||||
if (current) lines.push(current)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue