1847 lines
56 KiB
TypeScript
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)
|
|
}
|
|
}
|