import { jsPDF } from 'jspdf' import type { InvoiceTemplateConfig } from './invoiceTemplates' import type { Invoice } from '../stores/invoices' import type { Client } from '../stores/clients' import type { InvoiceItem } from './invoicePdf' import { formatCurrency, formatDate } from './locale' // --------------------------------------------------------------------------- // Exported types // --------------------------------------------------------------------------- export interface BusinessInfo { name: string address: string email: string phone: string logo: string // base64 data URL or empty string } // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- function hexToRgb(hex: string): [number, number, number] { const h = hex.replace('#', '') return [ parseInt(h.substring(0, 2), 16), parseInt(h.substring(2, 4), 16), parseInt(h.substring(4, 6), 16), ] } function setText(doc: jsPDF, hex: string) { const [r, g, b] = hexToRgb(hex) doc.setTextColor(r, g, b) } function setFill(doc: jsPDF, hex: string) { const [r, g, b] = hexToRgb(hex) doc.setFillColor(r, g, b) } function setDraw(doc: jsPDF, hex: string) { const [r, g, b] = hexToRgb(hex) doc.setDrawColor(r, g, b) } function truncateDesc(doc: jsPDF, text: string, maxWidth: number): string { if (doc.getTextWidth(text) <= maxWidth) return text while (doc.getTextWidth(text + '...') > maxWidth && text.length > 0) { text = text.slice(0, -1) } return text + '...' } function drawLogo(doc: jsPDF, logo: string, x: number, y: number, maxH: number): number { if (!logo) return 0 try { doc.addImage(logo, 'PNG', x, y, 0, maxH) return maxH + 2 } catch { return 0 } } // --------------------------------------------------------------------------- // Shared table column geometry // --------------------------------------------------------------------------- type ColLayout = { colX: number[]; colW: number[] } function tableLayout(startX: number, contentW: number): ColLayout { const descW = contentW * 0.48 const qtyW = contentW * 0.12 const rateW = contentW * 0.20 const amtW = contentW * 0.20 return { colW: [descW, qtyW, rateW, amtW], colX: [startX, startX + descW, startX + descW + qtyW, startX + descW + qtyW + rateW], } } // Tracked uppercase helper function trackedText(doc: jsPDF, str: string, x: number, y: number, opts?: { align?: 'left' | 'right' | 'center' }) { doc.setCharSpace(0.3) doc.text(str, x, y, opts) doc.setCharSpace(0) } // Draw header row text (uppercase, tracked) function drawHeaderText( doc: jsPDF, layout: ColLayout, y: number, rowH: number, colorHex: string, padX: number, ) { doc.setFont('helvetica', 'bold') doc.setFontSize(9) setText(doc, colorHex) const textY = y + rowH * 0.65 trackedText(doc, 'DESCRIPTION', layout.colX[0] + padX, textY) trackedText(doc, 'QTY', layout.colX[1] + padX, textY) doc.text('RATE', layout.colX[2] + layout.colW[2] - padX, textY, { align: 'right' }) doc.text('AMOUNT', layout.colX[3] + layout.colW[3] - padX, textY, { align: 'right' }) doc.setCharSpace(0) } // Draw a single item row (returns new y) function drawItemRow( doc: jsPDF, item: InvoiceItem, layout: ColLayout, y: number, rowH: number, textColor: string, padX: number, ): number { doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, textColor) const textY = y + rowH * 0.65 const desc = truncateDesc(doc, item.description, layout.colW[0] - padX * 2) doc.text(desc, layout.colX[0] + padX, textY) doc.text(String(item.quantity), layout.colX[1] + padX, textY) doc.text(formatCurrency(item.rate), layout.colX[2] + layout.colW[2] - padX, textY, { align: 'right' }) doc.text(formatCurrency(item.amount), layout.colX[3] + layout.colW[3] - padX, textY, { align: 'right' }) return y + rowH } // Draw totals block (returns new y) function drawTotals( doc: jsPDF, invoice: Invoice, rightX: number, y: number, textColor: string, totalColor: string, totalSize: number, ): number { doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, textColor) doc.text(`Subtotal`, rightX - 50, y) doc.text(formatCurrency(invoice.subtotal), rightX, y, { align: 'right' }) y += 7 if (invoice.tax_rate > 0) { doc.text(`Tax (${invoice.tax_rate}%)`, rightX - 50, y) doc.text(formatCurrency(invoice.tax_amount), rightX, y, { align: 'right' }) y += 7 } if (invoice.discount > 0) { doc.text(`Discount`, rightX - 50, y) doc.text(`-${formatCurrency(invoice.discount)}`, rightX, y, { align: 'right' }) y += 7 } y += 3 doc.setFont('helvetica', 'bold') doc.setFontSize(totalSize) setText(doc, totalColor) doc.text('TOTAL', rightX - 50, y) doc.text(formatCurrency(invoice.total), rightX, y, { align: 'right' }) return y } // Draw notes section (returns new y) function drawNotes(doc: jsPDF, invoice: Invoice, margin: number, contentW: number, y: number, textColor: string): number { if (!invoice.notes) return y if (y > 260) { doc.addPage(); y = 20 } doc.setFont('helvetica', 'normal') doc.setFontSize(8) setText(doc, textColor) doc.text('Notes:', margin, y) y += 5 const noteLines = doc.splitTextToSize(invoice.notes, contentW) doc.text(noteLines, margin, y) y += noteLines.length * 4 return y } // Draw From column (business info) function drawFromColumn(doc: jsPDF, biz: BusinessInfo, x: number, y: number, textColor: string, labelColor: string, maxW: number): number { doc.setFont('helvetica', 'bold') doc.setFontSize(11) setText(doc, labelColor) trackedText(doc, 'FROM', x, y) y += 6 doc.setFont('helvetica', 'bold') doc.setFontSize(10) setText(doc, textColor) if (biz.name) { doc.text(biz.name, x, y); y += 5 } doc.setFont('helvetica', 'normal') doc.setFontSize(10) if (biz.address) { const lines = doc.splitTextToSize(biz.address, maxW) doc.text(lines, x, y) y += lines.length * 5 } if (biz.email) { doc.text(biz.email, x, y); y += 5 } if (biz.phone) { doc.text(biz.phone, x, y); y += 5 } return y } // Draw To column (client info) function drawToColumn(doc: jsPDF, client: Client, x: number, y: number, textColor: string, labelColor: string, maxW: number): number { doc.setFont('helvetica', 'bold') doc.setFontSize(11) setText(doc, labelColor) trackedText(doc, 'TO', x, y) y += 6 doc.setFont('helvetica', 'bold') doc.setFontSize(10) setText(doc, textColor) if (client.name) { doc.text(client.name, x, y); y += 5 } doc.setFont('helvetica', 'normal') doc.setFontSize(10) if (client.email) { doc.text(client.email, x, y); y += 5 } if (client.address) { const lines = doc.splitTextToSize(client.address, maxW) doc.text(lines, x, y) y += lines.length * 5 } return y } // Page-break guard (returns true if page was added) function pageBreak(doc: jsPDF, y: number, threshold: number = 262): boolean { if (y > threshold) { doc.addPage() return true } return false } // =========================================================================== // 1. CLEAN - Swiss minimalism, single blue accent // =========================================================================== function renderClean( config: InvoiceTemplateConfig, invoice: Invoice, client: Client, items: InvoiceItem[], biz: BusinessInfo, ): jsPDF { const doc = new jsPDF({ unit: 'mm', format: 'a4' }) const margin = 20 const pageW = 210 const contentW = pageW - margin * 2 const c = config.colors // --- Header: logo + biz name --- let y = 20 const logoH = drawLogo(doc, biz.logo, margin, y, 16) y += logoH doc.setFont('helvetica', 'bold') doc.setFontSize(16) setText(doc, c.primary) doc.text(biz.name || '', margin, y) y += 6 doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) if (biz.address) { doc.text(biz.address, margin, y); y += 5 } if (biz.email) { doc.text(biz.email, margin, y); y += 5 } if (biz.phone) { doc.text(biz.phone, margin, y); y += 5 } // --- INVOICE title + accent line --- y += 4 doc.setFont('helvetica', 'bold') doc.setFontSize(28) setText(doc, c.primary) doc.text('INVOICE', margin, y) y += 3 // 30% width accent line in secondary color setFill(doc, c.secondary) doc.rect(margin, y, contentW * 0.3, 0.8, 'F') y += 8 // --- Invoice meta --- doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) doc.text(`# ${invoice.invoice_number}`, margin, y) doc.text(`Date: ${formatDate(invoice.date)}`, margin + 50, y) if (invoice.due_date) { doc.text(`Due: ${formatDate(invoice.due_date)}`, margin + 105, y) } y += 10 // --- From / To columns --- const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) y = Math.max(fromEndY, toEndY) + 10 // --- Table --- const layout = tableLayout(margin, contentW) const rowH = 9 const padX = 4 // Header bg setFill(doc, c.tableHeaderBg) doc.rect(margin, y, contentW, rowH, 'F') drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) y += rowH // Rows for (const item of items) { if (pageBreak(doc, y)) y = 20 // Thin bottom border setDraw(doc, c.tableBorder) doc.setLineWidth(0.3) doc.line(margin, y + rowH, margin + contentW, y + rowH) y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX) } // --- Totals --- y += 10 const totalRightX = margin + contentW y = drawTotals(doc, invoice, totalRightX, y, c.bodyText, c.secondary, 18) // --- Notes --- y += 12 drawNotes(doc, invoice, margin, contentW, y, c.bodyText) return doc } // =========================================================================== // 2. PROFESSIONAL - Navy header band, corporate polish // =========================================================================== function renderProfessional( config: InvoiceTemplateConfig, invoice: Invoice, client: Client, items: InvoiceItem[], biz: BusinessInfo, ): jsPDF { const doc = new jsPDF({ unit: 'mm', format: 'a4' }) const margin = 20 const pageW = 210 const contentW = pageW - margin * 2 const c = config.colors // --- Full-width navy header rect --- setFill(doc, c.headerBg) doc.rect(0, 0, 210, 50, 'F') // "INVOICE" white inside doc.setFont('helvetica', 'bold') doc.setFontSize(28) setText(doc, c.headerText) doc.text('INVOICE', margin, 22) // Biz name white right side doc.setFontSize(14) doc.text(biz.name || '', pageW - margin, 20, { align: 'right' }) // Invoice meta white inside band doc.setFont('helvetica', 'normal') doc.setFontSize(10) doc.text(`# ${invoice.invoice_number}`, margin, 32) doc.text(`Date: ${formatDate(invoice.date)}`, margin, 38) if (invoice.due_date) { doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, 44) } // Biz details right side in header doc.setFontSize(9) let bizY = 28 if (biz.address) { doc.text(biz.address, pageW - margin, bizY, { align: 'right' }); bizY += 5 } if (biz.email) { doc.text(biz.email, pageW - margin, bizY, { align: 'right' }); bizY += 5 } if (biz.phone) { doc.text(biz.phone, pageW - margin, bizY, { align: 'right' }); bizY += 5 } // --- From / To --- let y = 60 const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) y = Math.max(fromEndY, toEndY) + 10 // --- Table --- const layout = tableLayout(margin, contentW) const rowH = 9 const padX = 4 // Navy header row setFill(doc, c.tableHeaderBg) doc.rect(margin, y, contentW, rowH, 'F') drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) y += rowH // Rows with gray zebra for (let i = 0; i < items.length; i++) { if (pageBreak(doc, y)) y = 20 if (i % 2 === 1) { setFill(doc, c.tableRowAlt) doc.rect(margin, y, contentW, rowH, 'F') } y = drawItemRow(doc, items[i], layout, y, rowH, c.bodyText, padX) } // --- Totals --- y += 10 y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, c.totalHighlight, 18) // --- Notes --- y += 12 drawNotes(doc, invoice, margin, contentW, y, c.bodyText) return doc } // =========================================================================== // 3. BOLD - Large indigo block with oversized typography // =========================================================================== function renderBold( config: InvoiceTemplateConfig, invoice: Invoice, client: Client, items: InvoiceItem[], biz: BusinessInfo, ): jsPDF { const doc = new jsPDF({ unit: 'mm', format: 'a4' }) const margin = 20 const pageW = 210 const contentW = pageW - margin * 2 const c = config.colors // --- Large indigo rect --- setFill(doc, c.headerBg) doc.rect(0, 0, 115, 50, 'F') // "INVOICE" 32pt white inside block doc.setFont('helvetica', 'bold') doc.setFontSize(32) setText(doc, c.headerText) doc.text('INVOICE', margin, 25) // Invoice # white inside block doc.setFont('helvetica', 'normal') doc.setFontSize(11) doc.text(`# ${invoice.invoice_number}`, margin, 37) // Biz info outside block to the right let bizY = 14 doc.setFont('helvetica', 'bold') doc.setFontSize(14) setText(doc, c.bodyText) doc.text(biz.name || '', 125, bizY) bizY += 6 doc.setFont('helvetica', 'normal') doc.setFontSize(10) if (biz.address) { doc.text(biz.address, 125, bizY); bizY += 5 } if (biz.email) { doc.text(biz.email, 125, bizY); bizY += 5 } if (biz.phone) { doc.text(biz.phone, 125, bizY); bizY += 5 } // Date and due date outside block setText(doc, c.bodyText) doc.setFontSize(10) doc.text(`Date: ${formatDate(invoice.date)}`, 125, bizY) bizY += 5 if (invoice.due_date) { doc.setFont('helvetica', 'bold') doc.setFontSize(12) setText(doc, c.primary) doc.text(`Due: ${formatDate(invoice.due_date)}`, 125, bizY) } // --- FROM / TO with tracked labels --- let y = 60 const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) y = Math.max(fromEndY, toEndY) + 10 // --- Table --- const layout = tableLayout(margin, contentW) const rowH = 10 const padX = 4 // Indigo header row setFill(doc, c.tableHeaderBg) doc.rect(margin, y, contentW, rowH, 'F') drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) y += rowH // Rows - no borders, no stripes, generous height for (const item of items) { if (pageBreak(doc, y)) y = 20 y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX) } // --- Totals --- y += 10 y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, c.totalHighlight, 18) // --- Notes --- y += 12 drawNotes(doc, invoice, margin, contentW, y, c.bodyText) return doc } // =========================================================================== // 4. MINIMAL - Pure monochrome, everything centered // =========================================================================== function renderMinimal( config: InvoiceTemplateConfig, invoice: Invoice, client: Client, items: InvoiceItem[], biz: BusinessInfo, ): jsPDF { const doc = new jsPDF({ unit: 'mm', format: 'a4' }) const margin = 20 const pageW = 210 const contentW = pageW - margin * 2 const cx = pageW / 2 const c = config.colors // --- "INVOICE" 28pt centered charcoal --- let y = 28 doc.setFont('helvetica', 'bold') doc.setFontSize(28) setText(doc, c.primary) doc.text('INVOICE', cx, y, { align: 'center' }) y += 6 // Biz name centered doc.setFontSize(14) setText(doc, c.headerText) doc.text(biz.name || '', cx, y, { align: 'center' }) y += 5 // Thin rule setDraw(doc, c.tableBorder) doc.setLineWidth(0.3) doc.line(margin + 20, y, pageW - margin - 20, y) y += 6 // Biz details centered doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) if (biz.address) { doc.text(biz.address, cx, y, { align: 'center' }); y += 5 } if (biz.email) { doc.text(biz.email, cx, y, { align: 'center' }); y += 5 } if (biz.phone) { doc.text(biz.phone, cx, y, { align: 'center' }); y += 5 } // Invoice meta centered y += 2 doc.text(`Invoice # ${invoice.invoice_number} | ${formatDate(invoice.date)}${invoice.due_date ? ' | Due: ' + formatDate(invoice.due_date) : ''}`, cx, y, { align: 'center' }) y += 4 // Thin rule doc.line(margin + 20, y, pageW - margin - 20, y) y += 8 // --- From / To centered columns --- const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) y = Math.max(fromEndY, toEndY) + 10 // --- Table: no backgrounds, header bold only --- const layout = tableLayout(margin, contentW) const rowH = 9 const padX = 4 // Header text only (no bg) drawHeaderText(doc, layout, y, rowH, c.primary, padX) y += rowH // Rows - whitespace separation only for (const item of items) { if (pageBreak(doc, y)) y = 20 y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX) } // --- Totals centered-right --- y += 10 // Centered total doc.setFont('helvetica', 'bold') doc.setFontSize(10) setText(doc, c.bodyText) doc.text(`Subtotal: ${formatCurrency(invoice.subtotal)}`, cx, y, { align: 'center' }) y += 7 if (invoice.tax_rate > 0) { doc.setFont('helvetica', 'normal') doc.text(`Tax (${invoice.tax_rate}%): ${formatCurrency(invoice.tax_amount)}`, cx, y, { align: 'center' }) y += 7 } if (invoice.discount > 0) { doc.setFont('helvetica', 'normal') doc.text(`Discount: -${formatCurrency(invoice.discount)}`, cx, y, { align: 'center' }) y += 7 } y += 3 doc.setFont('helvetica', 'bold') doc.setFontSize(18) setText(doc, c.primary) doc.text(formatCurrency(invoice.total), cx, y, { align: 'center' }) // --- Notes --- y += 12 drawNotes(doc, invoice, margin, contentW, y, c.bodyText) return doc } // =========================================================================== // 5. CLASSIC - Traditional layout, burgundy accents, bordered grid // =========================================================================== function renderClassic( config: InvoiceTemplateConfig, invoice: Invoice, client: Client, items: InvoiceItem[], biz: BusinessInfo, ): jsPDF { const doc = new jsPDF({ unit: 'mm', format: 'a4' }) const margin = 20 const pageW = 210 const contentW = pageW - margin * 2 const c = config.colors // --- Two-column header: biz left, INVOICE + meta right --- let y = 20 const logoH = drawLogo(doc, biz.logo, margin, y, 16) y += logoH doc.setFont('helvetica', 'bold') doc.setFontSize(14) setText(doc, c.bodyText) doc.text(biz.name || '', margin, y) y += 5 doc.setFont('helvetica', 'normal') doc.setFontSize(10) if (biz.address) { doc.text(biz.address, margin, y); y += 5 } if (biz.email) { doc.text(biz.email, margin, y); y += 5 } if (biz.phone) { doc.text(biz.phone, margin, y); y += 5 } // Right: INVOICE + meta let ry = 20 doc.setFont('helvetica', 'bold') doc.setFontSize(28) setText(doc, c.primary) doc.text('INVOICE', pageW - margin, ry, { align: 'right' }) ry += 8 doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) doc.text(`# ${invoice.invoice_number}`, pageW - margin, ry, { align: 'right' }) ry += 6 doc.text(`Date: ${formatDate(invoice.date)}`, pageW - margin, ry, { align: 'right' }) ry += 6 if (invoice.due_date) { doc.setFont('helvetica', 'bold') doc.setFontSize(12) setText(doc, c.primary) doc.text(`Due: ${formatDate(invoice.due_date)}`, pageW - margin, ry, { align: 'right' }) } // Thin burgundy rule y = Math.max(y, ry) + 4 setDraw(doc, c.primary) doc.setLineWidth(1) doc.line(margin, y, pageW - margin, y) y += 8 // --- From / To --- const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) y = Math.max(fromEndY, toEndY) + 10 // --- Table: bordered grid, burgundy header --- const layout = tableLayout(margin, contentW) const rowH = 9 const padX = 4 // Header bg setFill(doc, c.tableHeaderBg) doc.rect(margin, y, contentW, rowH, 'F') drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) // Header border setDraw(doc, c.tableBorder) doc.setLineWidth(0.3) doc.rect(margin, y, contentW, rowH, 'S') y += rowH // Rows with bordered grid + alternating warm gray for (let i = 0; i < items.length; i++) { if (pageBreak(doc, y)) y = 20 // Alternating bg if (i % 2 === 1) { setFill(doc, c.tableRowAlt) doc.rect(margin, y, contentW, rowH, 'F') } y = drawItemRow(doc, items[i], layout, y, rowH, c.bodyText, padX) // Cell borders setDraw(doc, c.tableBorder) doc.setLineWidth(0.3) for (let col = 0; col < 4; col++) { doc.rect(layout.colX[col], y - rowH, layout.colW[col], rowH, 'S') } } // --- Totals --- y += 10 y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, c.totalHighlight, 18) // --- Notes --- y += 12 drawNotes(doc, invoice, margin, contentW, y, c.bodyText) return doc } // =========================================================================== // 6. MODERN - Teal accents, borderless, teal header text // =========================================================================== function renderModern( config: InvoiceTemplateConfig, invoice: Invoice, client: Client, items: InvoiceItem[], biz: BusinessInfo, ): jsPDF { const doc = new jsPDF({ unit: 'mm', format: 'a4' }) const margin = 20 const pageW = 210 const contentW = pageW - margin * 2 const c = config.colors // --- "INVOICE" 28pt top-left --- let y = 28 doc.setFont('helvetica', 'bold') doc.setFontSize(28) setText(doc, c.headerText) doc.text('INVOICE', margin, y) y += 8 // Invoice meta below doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) doc.text(`# ${invoice.invoice_number}`, margin, y) y += 5 doc.text(`Date: ${formatDate(invoice.date)}`, margin, y) y += 5 if (invoice.due_date) { doc.setFont('helvetica', 'bold') doc.setFontSize(12) setText(doc, c.primary) doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, y) y += 5 } // Biz info right side let bizY = 22 doc.setFont('helvetica', 'bold') doc.setFontSize(14) setText(doc, c.bodyText) doc.text(biz.name || '', pageW - margin, bizY, { align: 'right' }) bizY += 6 doc.setFont('helvetica', 'normal') doc.setFontSize(10) if (biz.address) { doc.text(biz.address, pageW - margin, bizY, { align: 'right' }); bizY += 5 } if (biz.email) { doc.text(biz.email, pageW - margin, bizY, { align: 'right' }); bizY += 5 } if (biz.phone) { doc.text(biz.phone, pageW - margin, bizY, { align: 'right' }); bizY += 5 } // Thin teal line separator y = Math.max(y, bizY) + 4 setDraw(doc, c.primary) doc.setLineWidth(0.5) doc.line(margin, y, pageW - margin, y) y += 8 // --- From / To --- const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) y = Math.max(fromEndY, toEndY) + 10 // --- Table: borderless, teal bottom borders, teal header TEXT (no bg) --- const layout = tableLayout(margin, contentW) const rowH = 9 const padX = 4 // Header (teal text, no bg) drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) y += rowH // Teal line below header setDraw(doc, c.primary) doc.setLineWidth(0.5) doc.line(margin, y, margin + contentW, y) // Rows with teal bottom borders for (const item of items) { if (pageBreak(doc, y)) y = 20 y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX) setDraw(doc, c.tableBorder) doc.setLineWidth(0.2) doc.line(margin, y, margin + contentW, y) } // --- Totals with subtle teal bg strip --- y += 10 const totalRightX = margin + contentW doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) doc.text('Subtotal', totalRightX - 50, y) doc.text(formatCurrency(invoice.subtotal), totalRightX, y, { align: 'right' }) y += 7 if (invoice.tax_rate > 0) { doc.text(`Tax (${invoice.tax_rate}%)`, totalRightX - 50, y) doc.text(formatCurrency(invoice.tax_amount), totalRightX, y, { align: 'right' }) y += 7 } if (invoice.discount > 0) { doc.text('Discount', totalRightX - 50, y) doc.text(`-${formatCurrency(invoice.discount)}`, totalRightX, y, { align: 'right' }) y += 7 } y += 3 // Subtle teal bg strip setFill(doc, c.tableRowAlt) doc.rect(totalRightX - 65, y - 5, 65, 10, 'F') doc.setFont('helvetica', 'bold') doc.setFontSize(18) setText(doc, c.totalHighlight) doc.text('TOTAL', totalRightX - 60, y) doc.text(formatCurrency(invoice.total), totalRightX, y, { align: 'right' }) // --- Notes --- y += 12 drawNotes(doc, invoice, margin, contentW, y, c.bodyText) return doc } // =========================================================================== // 7. ELEGANT - Gold double-rule accents, centered layout // =========================================================================== function renderElegant( config: InvoiceTemplateConfig, invoice: Invoice, client: Client, items: InvoiceItem[], biz: BusinessInfo, ): jsPDF { const doc = new jsPDF({ unit: 'mm', format: 'a4' }) const margin = 20 const pageW = 210 const contentW = pageW - margin * 2 const cx = pageW / 2 const c = config.colors const gold = c.primary // --- Gold double-rule at top --- setFill(doc, gold) doc.rect(margin, 15, contentW, 0.4, 'F') doc.rect(margin, 16.5, contentW, 0.4, 'F') // "INVOICE" centered let y = 26 doc.setFont('helvetica', 'bold') doc.setFontSize(28) setText(doc, c.headerText) doc.text('INVOICE', cx, y, { align: 'center' }) y += 7 // Biz centered doc.setFontSize(14) doc.text(biz.name || '', cx, y, { align: 'center' }) y += 5 doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) if (biz.address) { doc.text(biz.address, cx, y, { align: 'center' }); y += 5 } if (biz.email) { doc.text(biz.email, cx, y, { align: 'center' }); y += 5 } if (biz.phone) { doc.text(biz.phone, cx, y, { align: 'center' }); y += 5 } // Another double-rule y += 2 setFill(doc, gold) doc.rect(margin, y, contentW, 0.4, 'F') doc.rect(margin, y + 1.5, contentW, 0.4, 'F') y += 8 // Invoice meta left + Bill To right doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) const metaY = y doc.text(`Invoice: ${invoice.invoice_number}`, margin, y); y += 6 doc.text(`Date: ${formatDate(invoice.date)}`, margin, y); y += 6 if (invoice.due_date) { doc.setFont('helvetica', 'bold') doc.setFontSize(12) setText(doc, gold) doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, y) y += 6 } // Bill To right let billY = metaY doc.setFont('helvetica', 'bold') doc.setFontSize(11) setText(doc, gold) trackedText(doc, 'BILL TO', pageW - margin, billY, { align: 'right' }) billY += 6 doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) if (client.name) { doc.text(client.name, pageW - margin, billY, { align: 'right' }); billY += 5 } if (client.email) { doc.text(client.email, pageW - margin, billY, { align: 'right' }); billY += 5 } if (client.address) { doc.text(client.address, pageW - margin, billY, { align: 'right' }); billY += 5 } y = Math.max(y, billY) + 8 // --- Table: gold double-rule above/below header, single gold rules between rows --- const layout = tableLayout(margin, contentW) const rowH = 9 const padX = 4 // Double-rule above header setFill(doc, gold) doc.rect(margin, y, contentW, 0.4, 'F') doc.rect(margin, y + 1.5, contentW, 0.4, 'F') y += 3 drawHeaderText(doc, layout, y, rowH, c.headerText, padX) y += rowH // Double-rule below header setFill(doc, gold) doc.rect(margin, y, contentW, 0.4, 'F') doc.rect(margin, y + 1.5, contentW, 0.4, 'F') y += 3 // Rows with single gold rules for (const item of items) { if (pageBreak(doc, y)) y = 20 y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX) // Single gold line setFill(doc, gold) doc.rect(margin, y, contentW, 0.25, 'F') y += 1 } // --- Totals --- y += 8 y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, gold, 18) // --- Notes --- y += 12 drawNotes(doc, invoice, margin, contentW, y, c.bodyText) return doc } // =========================================================================== // 8. CREATIVE - Purple sidebar, card-style rows // =========================================================================== function renderCreative( config: InvoiceTemplateConfig, invoice: Invoice, client: Client, items: InvoiceItem[], biz: BusinessInfo, ): jsPDF { const doc = new jsPDF({ unit: 'mm', format: 'a4' }) const pageW = 210 const c = config.colors // --- Narrow purple rect full page height --- setFill(doc, c.primary) doc.rect(0, 0, 6, 297, 'F') // Content offset 14mm from left const leftM = 14 const rightM = 20 const contentW = pageW - leftM - rightM // --- "INVOICE" in purple --- let y = 28 doc.setFont('helvetica', 'bold') doc.setFontSize(28) setText(doc, c.primary) doc.text('INVOICE', leftM, y) y += 8 // Invoice meta doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) doc.text(`# ${invoice.invoice_number} | ${formatDate(invoice.date)}${invoice.due_date ? ' | Due: ' + formatDate(invoice.due_date) : ''}`, leftM, y) y += 8 // Biz info right side let bizY = 22 doc.setFont('helvetica', 'bold') doc.setFontSize(14) setText(doc, c.headerText) doc.text(biz.name || '', pageW - rightM, bizY, { align: 'right' }) bizY += 6 doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) if (biz.address) { doc.text(biz.address, pageW - rightM, bizY, { align: 'right' }); bizY += 5 } if (biz.email) { doc.text(biz.email, pageW - rightM, bizY, { align: 'right' }); bizY += 5 } if (biz.phone) { doc.text(biz.phone, pageW - rightM, bizY, { align: 'right' }); bizY += 5 } y = Math.max(y, bizY) + 4 // --- From / To --- const fromEndY = drawFromColumn(doc, biz, leftM, y, c.bodyText, c.primary, 65) const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 65) y = Math.max(fromEndY, toEndY) + 10 // --- Table: card-like rows --- const layout = tableLayout(leftM, contentW) const rowH = 10 const padX = 4 // Header doc.setFont('helvetica', 'bold') doc.setFontSize(9) setText(doc, c.tableHeaderText) drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) y += rowH // Rows as faint purple bg cards for (const item of items) { if (pageBreak(doc, y)) { y = 20 // Redraw sidebar on new page setFill(doc, c.primary) doc.rect(0, 0, 6, 297, 'F') } // Card bg setFill(doc, c.tableRowAlt) doc.rect(leftM, y + 1, contentW, rowH - 2, 'F') y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX) } // --- Totals --- y += 10 y = drawTotals(doc, invoice, leftM + contentW, y, c.bodyText, c.totalHighlight, 18) // --- Notes --- y += 12 drawNotes(doc, invoice, leftM, contentW, y, c.bodyText) return doc } // =========================================================================== // 9. COMPACT - Data-dense layout with tight spacing // =========================================================================== function renderCompact( config: InvoiceTemplateConfig, invoice: Invoice, client: Client, items: InvoiceItem[], biz: BusinessInfo, ): jsPDF { const doc = new jsPDF({ unit: 'mm', format: 'a4' }) const margin = 15 // tighter margins const pageW = 210 const contentW = pageW - margin * 2 const c = config.colors // --- Single-line header: biz name left, INVOICE #XXX right --- let y = 18 doc.setFont('helvetica', 'bold') doc.setFontSize(12) setText(doc, c.headerText) doc.text(biz.name || '', margin, y) doc.text(`INVOICE #${invoice.invoice_number}`, pageW - margin, y, { align: 'right' }) y += 5 // Date and due right below doc.setFont('helvetica', 'normal') doc.setFontSize(9) setText(doc, c.bodyText) if (biz.email) doc.text(biz.email, margin, y) doc.text(`Date: ${formatDate(invoice.date)}${invoice.due_date ? ' | Due: ' + formatDate(invoice.due_date) : ''}`, pageW - margin, y, { align: 'right' }) y += 5 if (biz.phone) doc.text(biz.phone, margin, y) y += 7 // --- Tight From/To --- doc.setFont('helvetica', 'bold') doc.setFontSize(9) setText(doc, c.primary) trackedText(doc, 'FROM', margin, y) trackedText(doc, 'TO', pageW / 2 + 5, y) y += 5 doc.setFont('helvetica', 'normal') doc.setFontSize(9) setText(doc, c.bodyText) let fromY = y if (biz.name) { doc.text(biz.name, margin, fromY); fromY += 4 } if (biz.address) { doc.text(biz.address, margin, fromY); fromY += 4 } let toY = y if (client.name) { doc.text(client.name, pageW / 2 + 5, toY); toY += 4 } if (client.email) { doc.text(client.email, pageW / 2 + 5, toY); toY += 4 } if (client.address) { doc.text(client.address, pageW / 2 + 5, toY); toY += 4 } y = Math.max(fromY, toY) + 6 // --- Table: tight zebra, 7mm rows, no borders --- const layout = tableLayout(margin, contentW) const rowH = 7 const padX = 3 // Header setFill(doc, c.tableHeaderBg) doc.rect(margin, y, contentW, rowH, 'F') doc.setFont('helvetica', 'bold') doc.setFontSize(8) setText(doc, c.tableHeaderText) const headerTextY = y + rowH * 0.65 trackedText(doc, 'DESCRIPTION', layout.colX[0] + padX, headerTextY) trackedText(doc, 'QTY', layout.colX[1] + padX, headerTextY) doc.text('RATE', layout.colX[2] + layout.colW[2] - padX, headerTextY, { align: 'right' }) doc.text('AMOUNT', layout.colX[3] + layout.colW[3] - padX, headerTextY, { align: 'right' }) doc.setCharSpace(0) y += rowH // Rows for (let i = 0; i < items.length; i++) { if (pageBreak(doc, y)) y = 15 if (i % 2 === 0) { setFill(doc, c.tableRowAlt) doc.rect(margin, y, contentW, rowH, 'F') } doc.setFont('helvetica', 'normal') doc.setFontSize(9) setText(doc, c.bodyText) const textY = y + rowH * 0.62 const desc = truncateDesc(doc, items[i].description, layout.colW[0] - padX * 2) doc.text(desc, layout.colX[0] + padX, textY) doc.text(String(items[i].quantity), layout.colX[1] + padX, textY) doc.text(formatCurrency(items[i].rate), layout.colX[2] + layout.colW[2] - padX, textY, { align: 'right' }) doc.text(formatCurrency(items[i].amount), layout.colX[3] + layout.colW[3] - padX, textY, { align: 'right' }) y += rowH } // --- Totals (subtle, right-aligned) --- y += 6 const totalRightX = margin + contentW doc.setFont('helvetica', 'normal') doc.setFontSize(9) setText(doc, c.bodyText) doc.text(`Subtotal: ${formatCurrency(invoice.subtotal)}`, totalRightX, y, { align: 'right' }) y += 5 if (invoice.tax_rate > 0) { doc.text(`Tax (${invoice.tax_rate}%): ${formatCurrency(invoice.tax_amount)}`, totalRightX, y, { align: 'right' }) y += 5 } if (invoice.discount > 0) { doc.text(`Discount: -${formatCurrency(invoice.discount)}`, totalRightX, y, { align: 'right' }) y += 5 } y += 2 doc.setFont('helvetica', 'bold') doc.setFontSize(14) setText(doc, c.totalHighlight) doc.text(`Total: ${formatCurrency(invoice.total)}`, totalRightX, y, { align: 'right' }) // --- Notes --- y += 10 drawNotes(doc, invoice, margin, contentW, y, c.bodyText) return doc } // =========================================================================== // 10. DARK - Full dark background with cyan highlights // =========================================================================== function renderDark( config: InvoiceTemplateConfig, invoice: Invoice, client: Client, items: InvoiceItem[], biz: BusinessInfo, ): jsPDF { const doc = new jsPDF({ unit: 'mm', format: 'a4' }) const margin = 20 const pageW = 210 const contentW = pageW - margin * 2 const c = config.colors // --- Full-page dark bg --- setFill(doc, c.background) doc.rect(0, 0, 210, 297, 'F') // "INVOICE" 28pt cyan let y = 28 doc.setFont('helvetica', 'bold') doc.setFontSize(28) setText(doc, c.primary) doc.text('INVOICE', margin, y) y += 8 // Invoice meta in light text doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) doc.text(`# ${invoice.invoice_number}`, margin, y) y += 5 doc.text(`Date: ${formatDate(invoice.date)}`, margin, y) y += 5 if (invoice.due_date) { doc.setFont('helvetica', 'bold') doc.setFontSize(12) setText(doc, c.primary) doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, y) y += 5 } // Biz info right in light text let bizY = 22 doc.setFont('helvetica', 'bold') doc.setFontSize(14) setText(doc, c.headerText) doc.text(biz.name || '', pageW - margin, bizY, { align: 'right' }) bizY += 6 doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) if (biz.address) { doc.text(biz.address, pageW - margin, bizY, { align: 'right' }); bizY += 5 } if (biz.email) { doc.text(biz.email, pageW - margin, bizY, { align: 'right' }); bizY += 5 } if (biz.phone) { doc.text(biz.phone, pageW - margin, bizY, { align: 'right' }); bizY += 5 } y = Math.max(y, bizY) + 6 // --- From / To in light text --- doc.setFont('helvetica', 'bold') doc.setFontSize(11) setText(doc, c.primary) trackedText(doc, 'FROM', margin, y) trackedText(doc, 'TO', pageW / 2 + 5, y) y += 6 doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) let fromY = y if (biz.name) { doc.text(biz.name, margin, fromY); fromY += 5 } if (biz.address) { doc.text(biz.address, margin, fromY); fromY += 5 } if (biz.email) { doc.text(biz.email, margin, fromY); fromY += 5 } let toY = y setText(doc, c.headerText) if (client.name) { doc.text(client.name, pageW / 2 + 5, toY); toY += 5 } setText(doc, c.bodyText) if (client.email) { doc.text(client.email, pageW / 2 + 5, toY); toY += 5 } if (client.address) { doc.text(client.address, pageW / 2 + 5, toY); toY += 5 } y = Math.max(fromY, toY) + 10 // --- Table: very dark header, cyan names, alternating dark rows --- const layout = tableLayout(margin, contentW) const rowH = 9 const padX = 4 // Very dark header setFill(doc, c.tableHeaderBg) doc.rect(margin, y, contentW, rowH, 'F') drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) y += rowH // Rows with alternating dark for (let i = 0; i < items.length; i++) { if (pageBreak(doc, y)) { y = 20 setFill(doc, c.background) doc.rect(0, 0, 210, 297, 'F') } if (i % 2 === 1) { setFill(doc, c.tableRowAlt) doc.rect(margin, y, contentW, rowH, 'F') } y = drawItemRow(doc, items[i], layout, y, rowH, c.bodyText, padX) } // --- Totals: cyan 18pt --- y += 10 y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, c.totalHighlight, 18) // --- Notes --- y += 12 drawNotes(doc, invoice, margin, contentW, y, c.bodyText) return doc } // =========================================================================== // 11. VIBRANT - Coral header band, warm tones // =========================================================================== function renderVibrant( config: InvoiceTemplateConfig, invoice: Invoice, client: Client, items: InvoiceItem[], biz: BusinessInfo, ): jsPDF { const doc = new jsPDF({ unit: 'mm', format: 'a4' }) const margin = 20 const pageW = 210 const contentW = pageW - margin * 2 const c = config.colors // --- Full-width coral rect --- setFill(doc, c.headerBg) doc.rect(0, 0, 210, 45, 'F') // White "INVOICE" + biz inside doc.setFont('helvetica', 'bold') doc.setFontSize(28) setText(doc, c.headerText) doc.text('INVOICE', margin, 22) // Biz name right in white doc.setFontSize(14) doc.text(biz.name || '', pageW - margin, 18, { align: 'right' }) doc.setFont('helvetica', 'normal') doc.setFontSize(9) let bizY = 25 if (biz.address) { doc.text(biz.address, pageW - margin, bizY, { align: 'right' }); bizY += 5 } if (biz.email) { doc.text(biz.email, pageW - margin, bizY, { align: 'right' }); bizY += 5 } if (biz.phone) { doc.text(biz.phone, pageW - margin, bizY, { align: 'right' }); bizY += 5 } // Invoice meta white inside band doc.setFontSize(10) doc.text(`# ${invoice.invoice_number}`, margin, 32) if (invoice.due_date) { doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, 38) } // --- From / To below band --- let y = 55 const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) y = Math.max(fromEndY, toEndY) + 10 // --- Light warm table --- const layout = tableLayout(margin, contentW) const rowH = 9 const padX = 4 // Header setFill(doc, c.tableHeaderBg) doc.rect(margin, y, contentW, rowH, 'F') drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) y += rowH // Rows for (let i = 0; i < items.length; i++) { if (pageBreak(doc, y)) y = 20 if (i % 2 === 1) { setFill(doc, c.tableRowAlt) doc.rect(margin, y, contentW, rowH, 'F') } y = drawItemRow(doc, items[i], layout, y, rowH, c.bodyText, padX) } // --- Totals: coral 18pt --- y += 10 y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, c.totalHighlight, 18) // --- Notes --- y += 12 drawNotes(doc, invoice, margin, contentW, y, c.bodyText) return doc } // =========================================================================== // 12. CORPORATE - Blue header with info bar below // =========================================================================== function renderCorporate( config: InvoiceTemplateConfig, invoice: Invoice, client: Client, items: InvoiceItem[], biz: BusinessInfo, ): jsPDF { const doc = new jsPDF({ unit: 'mm', format: 'a4' }) const margin = 20 const pageW = 210 const contentW = pageW - margin * 2 const c = config.colors // --- Blue header rect --- setFill(doc, c.headerBg) doc.rect(0, 0, 210, 45, 'F') // White text inside doc.setFont('helvetica', 'bold') doc.setFontSize(28) setText(doc, c.headerText) doc.text('INVOICE', margin, 22) doc.setFontSize(14) doc.text(biz.name || '', pageW - margin, 18, { align: 'right' }) doc.setFont('helvetica', 'normal') doc.setFontSize(9) let bizY = 26 if (biz.address) { doc.text(biz.address, pageW - margin, bizY, { align: 'right' }); bizY += 5 } if (biz.email) { doc.text(biz.email, pageW - margin, bizY, { align: 'right' }); bizY += 5 } if (biz.phone) { doc.text(biz.phone, pageW - margin, bizY, { align: 'right' }); bizY += 5 } // --- Lighter blue info bar below header --- setFill(doc, c.secondary) doc.rect(0, 45, 210, 8, 'F') doc.setFont('helvetica', 'normal') doc.setFontSize(9) setText(doc, '#ffffff') doc.text(`# ${invoice.invoice_number}`, margin, 50) doc.text(`Date: ${formatDate(invoice.date)}`, margin + 45, 50) if (invoice.due_date) { doc.text(`Due: ${formatDate(invoice.due_date)}`, margin + 95, 50) } doc.text(`Status: ${invoice.status}`, pageW - margin, 50, { align: 'right' }) // --- From / To --- let y = 63 const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) y = Math.max(fromEndY, toEndY) + 10 // --- Table: bordered, blue header --- const layout = tableLayout(margin, contentW) const rowH = 9 const padX = 4 // Header setFill(doc, c.tableHeaderBg) doc.rect(margin, y, contentW, rowH, 'F') drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) y += rowH // Rows with thin gray borders for (let i = 0; i < items.length; i++) { if (pageBreak(doc, y)) y = 20 if (i % 2 === 1) { setFill(doc, c.tableRowAlt) doc.rect(margin, y, contentW, rowH, 'F') } // Thin borders setDraw(doc, c.tableBorder) doc.setLineWidth(0.2) doc.rect(margin, y, contentW, rowH, 'S') y = drawItemRow(doc, items[i], layout, y, rowH, c.bodyText, padX) } // --- Totals: blue 18pt --- y += 10 y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, c.totalHighlight, 18) // --- Notes --- y += 12 drawNotes(doc, invoice, margin, contentW, y, c.bodyText) return doc } // =========================================================================== // 13. FRESH - Oversized watermark invoice number // =========================================================================== function renderFresh( config: InvoiceTemplateConfig, invoice: Invoice, client: Client, items: InvoiceItem[], biz: BusinessInfo, ): jsPDF { const doc = new jsPDF({ unit: 'mm', format: 'a4' }) const margin = 20 const pageW = 210 const contentW = pageW - margin * 2 const c = config.colors // --- Logo left --- let y = 20 const logoH = drawLogo(doc, biz.logo, margin, y, 16) if (logoH > 0) y += logoH doc.setFont('helvetica', 'bold') doc.setFontSize(14) setText(doc, c.headerText) doc.text(biz.name || '', margin, y) y += 5 doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) if (biz.address) { doc.text(biz.address, margin, y); y += 5 } if (biz.email) { doc.text(biz.email, margin, y); y += 5 } if (biz.phone) { doc.text(biz.phone, margin, y); y += 5 } // --- Large invoice number right side with light blue bg --- // Light bg rect behind oversized number setFill(doc, c.tableRowAlt) doc.rect(pageW - margin - 70, 16, 70, 28, 'F') // "INVOICE" 14pt above number doc.setFont('helvetica', 'bold') doc.setFontSize(14) setText(doc, c.primary) doc.text('INVOICE', pageW - margin, 24, { align: 'right' }) // Oversized number ~32pt doc.setFontSize(32) setText(doc, c.secondary) doc.text(invoice.invoice_number, pageW - margin, 38, { align: 'right' }) // Date/due doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) doc.text(`Date: ${formatDate(invoice.date)}`, pageW - margin, 48, { align: 'right' }) if (invoice.due_date) { doc.setFont('helvetica', 'bold') doc.setFontSize(12) setText(doc, c.primary) doc.text(`Due: ${formatDate(invoice.due_date)}`, pageW - margin, 55, { align: 'right' }) } // --- From / To --- y = Math.max(y, 60) + 4 const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) y = Math.max(fromEndY, toEndY) + 10 // --- Table: sky blue header, light blue zebra --- const layout = tableLayout(margin, contentW) const rowH = 9 const padX = 4 // Header setFill(doc, c.tableHeaderBg) doc.rect(margin, y, contentW, rowH, 'F') drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) y += rowH // Rows for (let i = 0; i < items.length; i++) { if (pageBreak(doc, y)) y = 20 if (i % 2 === 1) { setFill(doc, c.tableRowAlt) doc.rect(margin, y, contentW, rowH, 'F') } y = drawItemRow(doc, items[i], layout, y, rowH, c.bodyText, padX) } // --- Totals: sky blue 18pt --- y += 10 y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, c.totalHighlight, 18) // --- Notes --- y += 12 drawNotes(doc, invoice, margin, contentW, y, c.bodyText) return doc } // =========================================================================== // 14. NATURAL - Warm beige full-page background, terracotta accents // =========================================================================== function renderNatural( config: InvoiceTemplateConfig, invoice: Invoice, client: Client, items: InvoiceItem[], biz: BusinessInfo, ): jsPDF { const doc = new jsPDF({ unit: 'mm', format: 'a4' }) const margin = 20 const pageW = 210 const contentW = pageW - margin * 2 const c = config.colors // --- Full-page warm beige bg --- setFill(doc, c.background) doc.rect(0, 0, 210, 297, 'F') // "INVOICE" 28pt terracotta let y = 28 doc.setFont('helvetica', 'bold') doc.setFontSize(28) setText(doc, c.primary) doc.text('INVOICE', margin, y) y += 8 // Invoice meta doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) doc.text(`# ${invoice.invoice_number}`, margin, y) y += 5 doc.text(`Date: ${formatDate(invoice.date)}`, margin, y) y += 5 if (invoice.due_date) { doc.setFont('helvetica', 'bold') doc.setFontSize(12) setText(doc, c.primary) doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, y) y += 5 } // Biz info right in warm brown let bizY = 22 doc.setFont('helvetica', 'bold') doc.setFontSize(14) setText(doc, c.headerText) doc.text(biz.name || '', pageW - margin, bizY, { align: 'right' }) bizY += 6 doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) if (biz.address) { doc.text(biz.address, pageW - margin, bizY, { align: 'right' }); bizY += 5 } if (biz.email) { doc.text(biz.email, pageW - margin, bizY, { align: 'right' }); bizY += 5 } if (biz.phone) { doc.text(biz.phone, pageW - margin, bizY, { align: 'right' }); bizY += 5 } y = Math.max(y, bizY) + 6 // --- From / To --- const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) y = Math.max(fromEndY, toEndY) + 10 // --- Table: terracotta header, warm cream alternating on beige --- const layout = tableLayout(margin, contentW) const rowH = 9 const padX = 4 // Terracotta header setFill(doc, c.tableHeaderBg) doc.rect(margin, y, contentW, rowH, 'F') drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) y += rowH // Rows for (let i = 0; i < items.length; i++) { if (pageBreak(doc, y)) { y = 20 setFill(doc, c.background) doc.rect(0, 0, 210, 297, 'F') } if (i % 2 === 1) { setFill(doc, c.tableRowAlt) doc.rect(margin, y, contentW, rowH, 'F') } y = drawItemRow(doc, items[i], layout, y, rowH, c.bodyText, padX) } // --- Totals: terracotta 18pt --- y += 10 y = drawTotals(doc, invoice, margin + contentW, y, c.bodyText, c.totalHighlight, 18) // --- Notes --- y += 12 drawNotes(doc, invoice, margin, contentW, y, c.bodyText) return doc } // =========================================================================== // 15. STATEMENT - Total-forward design, hero amount top-right // =========================================================================== function renderStatement( config: InvoiceTemplateConfig, invoice: Invoice, client: Client, items: InvoiceItem[], biz: BusinessInfo, ): jsPDF { const doc = new jsPDF({ unit: 'mm', format: 'a4' }) const margin = 20 const pageW = 210 const contentW = pageW - margin * 2 const c = config.colors const rose = c.secondary // --- "INVOICE" 16pt normal top-left --- let y = 22 doc.setFont('helvetica', 'normal') doc.setFontSize(16) setText(doc, c.primary) doc.text('INVOICE', margin, y) y += 6 doc.setFontSize(10) setText(doc, c.bodyText) doc.text(`# ${invoice.invoice_number}`, margin, y) y += 5 doc.text(`Date: ${formatDate(invoice.date)}`, margin, y) y += 5 if (invoice.due_date) { doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, y) y += 5 } // Biz info below invoice meta y += 2 doc.setFont('helvetica', 'bold') doc.setFontSize(11) setText(doc, c.bodyText) doc.text(biz.name || '', margin, y) y += 5 doc.setFont('helvetica', 'normal') doc.setFontSize(10) if (biz.address) { doc.text(biz.address, margin, y); y += 5 } if (biz.email) { doc.text(biz.email, margin, y); y += 5 } if (biz.phone) { doc.text(biz.phone, margin, y); y += 5 } // --- TOTAL AMOUNT massive top-right --- doc.setFont('helvetica', 'bold') doc.setFontSize(11) setText(doc, c.bodyText) doc.text('TOTAL DUE', pageW - margin, 20, { align: 'right' }) doc.setFontSize(28) setText(doc, rose) doc.text(formatCurrency(invoice.total), pageW - margin, 32, { align: 'right' }) // Due date below total if present if (invoice.due_date) { doc.setFont('helvetica', 'bold') doc.setFontSize(12) setText(doc, rose) doc.text(`Due: ${formatDate(invoice.due_date)}`, pageW - margin, 40, { align: 'right' }) } // --- From / To below --- y = Math.max(y, 50) + 4 const fromEndY = drawFromColumn(doc, biz, margin, y, c.bodyText, c.primary, 70) const toEndY = drawToColumn(doc, client, pageW / 2 + 5, y, c.bodyText, c.primary, 70) y = Math.max(fromEndY, toEndY) + 10 // --- Table: whitespace-only, no borders, no stripes --- const layout = tableLayout(margin, contentW) const rowH = 9 const padX = 4 // Header text only (no bg) drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX) y += rowH // Thin separator below header setDraw(doc, c.tableBorder) doc.setLineWidth(0.3) doc.line(margin, y, margin + contentW, y) // Rows for (const item of items) { if (pageBreak(doc, y)) y = 20 y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX) } // --- Bottom totals: simple summary --- y += 8 setDraw(doc, c.tableBorder) doc.setLineWidth(0.3) doc.line(margin + contentW - 60, y, margin + contentW, y) y += 6 const totalRightX = margin + contentW doc.setFont('helvetica', 'normal') doc.setFontSize(10) setText(doc, c.bodyText) doc.text('Subtotal', totalRightX - 50, y) doc.text(formatCurrency(invoice.subtotal), totalRightX, y, { align: 'right' }) y += 6 if (invoice.tax_rate > 0) { doc.text(`Tax (${invoice.tax_rate}%)`, totalRightX - 50, y) doc.text(formatCurrency(invoice.tax_amount), totalRightX, y, { align: 'right' }) y += 6 } if (invoice.discount > 0) { doc.text('Discount', totalRightX - 50, y) doc.text(`-${formatCurrency(invoice.discount)}`, totalRightX, y, { align: 'right' }) y += 6 } y += 2 doc.setFont('helvetica', 'bold') doc.setFontSize(18) setText(doc, rose) doc.text('TOTAL', totalRightX - 50, y) doc.text(formatCurrency(invoice.total), totalRightX, y, { align: 'right' }) // --- Notes --- y += 12 drawNotes(doc, invoice, margin, contentW, y, c.bodyText) return doc } // =========================================================================== // Main export // =========================================================================== export function renderInvoicePdf( config: InvoiceTemplateConfig, invoice: Invoice, client: Client, items: InvoiceItem[], businessInfo: BusinessInfo, ): jsPDF { switch (config.layout) { case 'clean': return renderClean(config, invoice, client, items, businessInfo) case 'professional': return renderProfessional(config, invoice, client, items, businessInfo) case 'bold': return renderBold(config, invoice, client, items, businessInfo) case 'minimal': return renderMinimal(config, invoice, client, items, businessInfo) case 'classic': return renderClassic(config, invoice, client, items, businessInfo) case 'modern': return renderModern(config, invoice, client, items, businessInfo) case 'elegant': return renderElegant(config, invoice, client, items, businessInfo) case 'creative': return renderCreative(config, invoice, client, items, businessInfo) case 'compact': return renderCompact(config, invoice, client, items, businessInfo) case 'dark': return renderDark(config, invoice, client, items, businessInfo) case 'vibrant': return renderVibrant(config, invoice, client, items, businessInfo) case 'corporate': return renderCorporate(config, invoice, client, items, businessInfo) case 'fresh': return renderFresh(config, invoice, client, items, businessInfo) case 'natural': return renderNatural(config, invoice, client, items, businessInfo) case 'statement': return renderStatement(config, invoice, client, items, businessInfo) default: return renderClean(config, invoice, client, items, businessInfo) } }