Files
zeroclock/src/utils/invoicePdfRenderer.ts
2026-02-19 22:43:14 +02:00

1847 lines
56 KiB
TypeScript

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)
}
}