diff --git a/src/utils/invoicePdf.ts b/src/utils/invoicePdf.ts new file mode 100644 index 0000000..85cb39d --- /dev/null +++ b/src/utils/invoicePdf.ts @@ -0,0 +1,37 @@ +import { jsPDF } from 'jspdf' +import type { Invoice } from '../stores/invoices' +import type { Client } from '../stores/clients' +import { renderInvoicePdf, type BusinessInfo } from './invoicePdfRenderer' +import { getTemplateById } from './invoiceTemplates' + +/** + * Invoice item interface for line items on the invoice + */ +export interface InvoiceItem { + id?: number + description: string + quantity: number + rate: number + amount: number +} + +/** + * Generate a PDF invoice using a template + * @param invoice - The invoice data + * @param client - The client data + * @param items - The invoice line items + * @param templateId - Template ID to use (defaults to 'clean-minimal') + * @param businessInfo - Optional business identity info + * @returns The generated jsPDF document + */ +export function generateInvoicePdf( + invoice: Invoice, + client: Client, + items: InvoiceItem[], + templateId: string = 'clean-minimal', + businessInfo?: BusinessInfo, +): jsPDF { + const config = getTemplateById(templateId) + const info: BusinessInfo = businessInfo || { name: '', address: '', email: '', phone: '', logo: '' } + return renderInvoicePdf(config, invoice, client, items, info) +} diff --git a/src/utils/invoicePdfRenderer.ts b/src/utils/invoicePdfRenderer.ts new file mode 100644 index 0000000..bfcb14e --- /dev/null +++ b/src/utils/invoicePdfRenderer.ts @@ -0,0 +1,1121 @@ +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' + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface BusinessInfo { + name: string + address: string + email: string + phone: string + logo: string // base64 data URL or empty string +} + +// --------------------------------------------------------------------------- +// Color helpers +// --------------------------------------------------------------------------- + +function hexToRgb(hex: string): [number, number, number] { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return [r, g, b] +} + +function interpolateColor(hex1: string, hex2: string, t: number): string { + const [r1, g1, b1] = hexToRgb(hex1) + const [r2, g2, b2] = hexToRgb(hex2) + return '#' + [ + Math.round(r1 + (r2 - r1) * t), + Math.round(g1 + (g2 - g1) * t), + Math.round(b1 + (b2 - b1) * t), + ].map(v => v.toString(16).padStart(2, '0')).join('') +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function setFill(doc: jsPDF, hex: string): void { + doc.setFillColor(hex) +} + +function setText(doc: jsPDF, hex: string): void { + doc.setTextColor(hex) +} + +function setDraw(doc: jsPDF, hex: string): void { + doc.setDrawColor(hex) +} + +/** Draw a new-page background + sidebar if needed. */ +function drawPageBackground( + doc: jsPDF, + config: InvoiceTemplateConfig, + pageWidth: number, + pageHeight: number, +): void { + if (config.colors.background.toUpperCase() !== '#FFFFFF') { + setFill(doc, config.colors.background) + doc.rect(0, 0, pageWidth, pageHeight, 'F') + } + if (config.decorative.backgroundTint && config.decorative.backgroundTintColor) { + setFill(doc, config.decorative.backgroundTintColor) + doc.rect(0, 0, pageWidth, pageHeight, 'F') + } + if (config.decorative.sidebarWidth > 0) { + setFill(doc, config.decorative.sidebarColor) + doc.rect(0, 0, config.decorative.sidebarWidth, pageHeight, 'F') + } +} + +// --------------------------------------------------------------------------- +// Decorative elements +// --------------------------------------------------------------------------- + +function renderDecorative( + doc: jsPDF, + config: InvoiceTemplateConfig, + pageWidth: number, + pageHeight: number, +): void { + // Background tint (full page wash) + if (config.decorative.backgroundTint && config.decorative.backgroundTintColor) { + setFill(doc, config.decorative.backgroundTintColor) + doc.rect(0, 0, pageWidth, pageHeight, 'F') + } + + // Sidebar + if (config.decorative.sidebarWidth > 0) { + setFill(doc, config.decorative.sidebarColor) + doc.rect(0, 0, config.decorative.sidebarWidth, pageHeight, 'F') + } + + // Corner shapes + if (config.decorative.cornerShape === 'colored-block') { + setFill(doc, config.colors.primary) + doc.rect(0, 0, 60, 60, 'F') + } else if (config.decorative.cornerShape === 'triangle') { + setFill(doc, config.colors.primary) + doc.triangle(pageWidth, 0, pageWidth, 80, pageWidth - 80, 0, 'F') + } else if (config.decorative.cornerShape === 'diagonal') { + setFill(doc, config.colors.primary) + doc.triangle(0, 0, pageWidth, 0, 0, 60, 'F') + } +} + +// --------------------------------------------------------------------------- +// Logo rendering +// --------------------------------------------------------------------------- + +function renderLogo( + doc: jsPDF, + config: InvoiceTemplateConfig, + businessInfo: BusinessInfo, + y: number, + margin: number, + pageWidth: number, +): void { + if (!businessInfo.logo) return + try { + let logoX: number + if (config.layout.logoPosition === 'top-right') { + logoX = pageWidth - margin - 40 + } else if (config.layout.logoPosition === 'top-center') { + logoX = pageWidth / 2 - 20 + } else { + logoX = margin + } + doc.addImage(businessInfo.logo, 'AUTO', logoX, y, 40, 16) + } catch { + // silently skip if logo data is invalid + } +} + +// --------------------------------------------------------------------------- +// Header styles +// --------------------------------------------------------------------------- + +function renderHeader( + doc: jsPDF, + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + businessInfo: BusinessInfo, + startY: number, + margin: number, + pageWidth: number, + contentWidth: number, +): number { + switch (config.layout.headerStyle) { + case 'minimal': + return renderHeaderMinimal(doc, config, invoice, client, businessInfo, startY, margin, pageWidth, contentWidth) + case 'full-width': + return renderHeaderFullWidth(doc, config, invoice, client, businessInfo, margin, pageWidth, contentWidth) + case 'split': + return renderHeaderSplit(doc, config, invoice, client, businessInfo, margin, pageWidth, contentWidth) + case 'sidebar': + return renderHeaderSidebar(doc, config, invoice, client, businessInfo, startY, margin, pageWidth, contentWidth) + case 'gradient': + return renderHeaderGradient(doc, config, invoice, client, businessInfo, margin, pageWidth, contentWidth) + case 'geometric': + return renderHeaderGeometric(doc, config, invoice, client, businessInfo, startY, margin, pageWidth, contentWidth) + case 'centered': + return renderHeaderCentered(doc, config, invoice, client, businessInfo, startY, margin, pageWidth, contentWidth) + default: + return renderHeaderMinimal(doc, config, invoice, client, businessInfo, startY, margin, pageWidth, contentWidth) + } +} + +// --- 1. Minimal header --- +function renderHeaderMinimal( + doc: jsPDF, + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + businessInfo: BusinessInfo, + startY: number, + margin: number, + pageWidth: number, + contentWidth: number, +): number { + let y = startY + + // Logo + renderLogo(doc, config, businessInfo, y, margin, pageWidth) + if (businessInfo.logo) y += 20 + + // Business info + if (businessInfo.name) { + doc.setFont('helvetica', 'bold') + doc.setFontSize(config.typography.headerSize) + setText(doc, config.colors.headerText) + doc.text(businessInfo.name, margin, y) + y += 5 + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + if (businessInfo.address) { + const addrLines = businessInfo.address.split('\n') + for (const line of addrLines) { + doc.text(line, margin, y) + y += 4 + } + } + if (businessInfo.email) { doc.text(businessInfo.email, margin, y); y += 4 } + if (businessInfo.phone) { doc.text(businessInfo.phone, margin, y); y += 4 } + y += 4 + } + + // Title "INVOICE" + doc.setFont('helvetica', config.typography.titleWeight) + doc.setFontSize(config.typography.titleSize) + setText(doc, config.colors.primary) + doc.text('INVOICE', margin, y + 8) + y += 12 + + // Accent line + setFill(doc, config.colors.primary) + doc.rect(margin, y, 40, 1, 'F') + y += 6 + + // Invoice details (left) and Bill To (right) + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + setText(doc, config.colors.bodyText) + + const detailsY = y + doc.text(`Invoice #: ${invoice.invoice_number}`, margin, y); y += 5 + doc.text(`Date: ${formatDate(invoice.date)}`, margin, y); y += 5 + if (invoice.due_date) { doc.text(`Due Date: ${formatDate(invoice.due_date)}`, margin, y); y += 5 } + doc.text(`Status: ${invoice.status}`, margin, y); y += 5 + + // Bill To (right side) + const rightX = pageWidth - margin - 60 + let clientY = detailsY + doc.setFont('helvetica', 'bold') + doc.setFontSize(config.typography.bodySize + 1) + doc.text('Bill To:', rightX, clientY); clientY += 5 + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + doc.text(client.name || 'N/A', rightX, clientY); clientY += 4 + if (client.email) { doc.text(client.email, rightX, clientY); clientY += 4 } + if (client.address) { + const lines = client.address.split('\n') + for (const line of lines) { doc.text(line, rightX, clientY); clientY += 4 } + } + + y = Math.max(y, clientY) + 10 + + // Divider + if (config.layout.showDividers && config.layout.dividerStyle !== 'none') { + drawDivider(doc, config, margin, y, contentWidth) + y += 6 + } + + return y +} + +// --- 2. Full-width header --- +function renderHeaderFullWidth( + doc: jsPDF, + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + businessInfo: BusinessInfo, + margin: number, + pageWidth: number, + _contentWidth: number, +): number { + const headerHeight = 45 + + // Full-width colored band + setFill(doc, config.colors.headerBg) + doc.rect(0, 0, pageWidth, headerHeight, 'F') + + // Logo in the header + if (businessInfo.logo) { + try { + let logoX: number + if (config.layout.logoPosition === 'top-right') { + logoX = pageWidth - margin - 40 + } else if (config.layout.logoPosition === 'top-center') { + logoX = pageWidth / 2 - 20 + } else { + logoX = margin + } + doc.addImage(businessInfo.logo, 'AUTO', logoX, 4, 40, 16) + } catch { /* skip */ } + } + + let y = 14 + + // Title + doc.setFont('helvetica', config.typography.titleWeight) + doc.setFontSize(config.typography.titleSize) + setText(doc, config.colors.headerText) + doc.text('INVOICE', margin, y) + + // Invoice number below title + y += 8 + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + doc.text(`#${invoice.invoice_number}`, margin, y) + y += 4 + doc.text(`Date: ${formatDate(invoice.date)}`, margin, y) + y += 4 + if (invoice.due_date) { + doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, y) + y += 4 + } + doc.text(`Status: ${invoice.status}`, margin, y) + + // Business info (top right inside header) + if (businessInfo.name && config.layout.logoPosition !== 'top-right') { + const bx = pageWidth - margin + doc.setFont('helvetica', 'bold') + doc.setFontSize(config.typography.bodySize + 1) + doc.text(businessInfo.name, bx, 14, { align: 'right' }) + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize - 1) + let by = 19 + if (businessInfo.email) { doc.text(businessInfo.email, bx, by, { align: 'right' }); by += 4 } + if (businessInfo.phone) { doc.text(businessInfo.phone, bx, by, { align: 'right' }); by += 4 } + if (businessInfo.address) { + const addrLines = businessInfo.address.split('\n') + for (const line of addrLines) { doc.text(line, bx, by, { align: 'right' }); by += 4 } + } + } + + // Bill To section below the header band + let billY = headerHeight + 10 + doc.setFont('helvetica', 'bold') + doc.setFontSize(config.typography.bodySize + 1) + setText(doc, config.colors.bodyText) + doc.text('Bill To:', margin, billY); billY += 5 + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + doc.text(client.name || 'N/A', margin, billY); billY += 4 + if (client.email) { doc.text(client.email, margin, billY); billY += 4 } + if (client.address) { + const addrLines = client.address.split('\n') + for (const line of addrLines) { doc.text(line, margin, billY); billY += 4 } + } + + return billY + 10 +} + +// --- 3. Split header --- +function renderHeaderSplit( + doc: jsPDF, + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + businessInfo: BusinessInfo, + margin: number, + pageWidth: number, + _contentWidth: number, +): number { + const headerHeight = 50 + const halfWidth = pageWidth / 2 + + // Left half: dark background + setFill(doc, config.colors.headerBg) + doc.rect(0, 0, halfWidth, headerHeight, 'F') + + // Left side: white text with business info + title + setText(doc, config.colors.headerText) + doc.setFont('helvetica', config.typography.titleWeight) + doc.setFontSize(config.typography.titleSize) + doc.text('INVOICE', margin, 18) + + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + let ly = 26 + if (businessInfo.name) { doc.text(businessInfo.name, margin, ly); ly += 4 } + if (businessInfo.email) { doc.text(businessInfo.email, margin, ly); ly += 4 } + if (businessInfo.phone) { doc.text(businessInfo.phone, margin, ly); ly += 4 } + if (businessInfo.address) { + const addrLines = businessInfo.address.split('\n') + for (const line of addrLines) { doc.text(line, margin, ly); ly += 4 } + } + + // Logo inside left half + if (businessInfo.logo) { + try { + doc.addImage(businessInfo.logo, 'AUTO', margin, headerHeight - 20, 36, 14) + } catch { /* skip */ } + } + + // Right side: dark text with invoice details + const rightMargin = halfWidth + 10 + setText(doc, config.colors.bodyText) + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + let ry = 14 + doc.text(`Invoice #: ${invoice.invoice_number}`, rightMargin, ry); ry += 5 + doc.text(`Date: ${formatDate(invoice.date)}`, rightMargin, ry); ry += 5 + if (invoice.due_date) { doc.text(`Due Date: ${formatDate(invoice.due_date)}`, rightMargin, ry); ry += 5 } + doc.text(`Status: ${invoice.status}`, rightMargin, ry); ry += 5 + + // Bill To below header + let billY = headerHeight + 10 + doc.setFont('helvetica', 'bold') + doc.setFontSize(config.typography.bodySize + 1) + setText(doc, config.colors.bodyText) + doc.text('Bill To:', margin, billY); billY += 5 + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + doc.text(client.name || 'N/A', margin, billY); billY += 4 + if (client.email) { doc.text(client.email, margin, billY); billY += 4 } + if (client.address) { + const addrLines = client.address.split('\n') + for (const line of addrLines) { doc.text(line, margin, billY); billY += 4 } + } + + return billY + 10 +} + +// --- 4. Sidebar header --- +function renderHeaderSidebar( + doc: jsPDF, + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + businessInfo: BusinessInfo, + startY: number, + margin: number, + pageWidth: number, + contentWidth: number, +): number { + const sideOffset = config.decorative.sidebarWidth + 6 + const leftX = sideOffset + margin + let y = startY + + // Logo + if (businessInfo.logo) { + try { + doc.addImage(businessInfo.logo, 'AUTO', leftX, y, 40, 16) + } catch { /* skip */ } + y += 20 + } + + // Business info + if (businessInfo.name) { + doc.setFont('helvetica', 'bold') + doc.setFontSize(config.typography.headerSize) + setText(doc, config.colors.headerText) + doc.text(businessInfo.name, leftX, y); y += 5 + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + if (businessInfo.address) { + const addrLines = businessInfo.address.split('\n') + for (const line of addrLines) { doc.text(line, leftX, y); y += 4 } + } + if (businessInfo.email) { doc.text(businessInfo.email, leftX, y); y += 4 } + if (businessInfo.phone) { doc.text(businessInfo.phone, leftX, y); y += 4 } + y += 4 + } + + // Title + doc.setFont('helvetica', config.typography.titleWeight) + doc.setFontSize(config.typography.titleSize) + setText(doc, config.colors.primary) + doc.text('INVOICE', leftX, y + 8); y += 14 + + // Invoice details + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + setText(doc, config.colors.bodyText) + const detailsY = y + doc.text(`Invoice #: ${invoice.invoice_number}`, leftX, y); y += 5 + doc.text(`Date: ${formatDate(invoice.date)}`, leftX, y); y += 5 + if (invoice.due_date) { doc.text(`Due Date: ${formatDate(invoice.due_date)}`, leftX, y); y += 5 } + doc.text(`Status: ${invoice.status}`, leftX, y); y += 5 + + // Bill To (right) + const rightX = pageWidth - margin - 60 + let clientY = detailsY + doc.setFont('helvetica', 'bold') + doc.setFontSize(config.typography.bodySize + 1) + doc.text('Bill To:', rightX, clientY); clientY += 5 + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + doc.text(client.name || 'N/A', rightX, clientY); clientY += 4 + if (client.email) { doc.text(client.email, rightX, clientY); clientY += 4 } + if (client.address) { + const lines = client.address.split('\n') + for (const line of lines) { doc.text(line, rightX, clientY); clientY += 4 } + } + + y = Math.max(y, clientY) + 10 + + // Divider + if (config.layout.showDividers && config.layout.dividerStyle !== 'none') { + drawDivider(doc, config, leftX, y, contentWidth - sideOffset) + y += 6 + } + + return y +} + +// --- 5. Gradient header --- +function renderHeaderGradient( + doc: jsPDF, + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + businessInfo: BusinessInfo, + margin: number, + pageWidth: number, + _contentWidth: number, +): number { + const gradientHeight = 45 + const from = config.decorative.gradientFrom || config.colors.headerBg + const to = config.decorative.gradientTo || config.colors.primary + + // Draw gradient with 30 strips + const steps = 30 + const stripH = gradientHeight / steps + for (let i = 0; i < steps; i++) { + const t = i / steps + setFill(doc, interpolateColor(from, to, t)) + doc.rect(0, stripH * i, pageWidth, stripH + 0.5, 'F') + } + + // Logo + if (businessInfo.logo) { + try { + let logoX: number + if (config.layout.logoPosition === 'top-right') { + logoX = pageWidth - margin - 40 + } else if (config.layout.logoPosition === 'top-center') { + logoX = pageWidth / 2 - 20 + } else { + logoX = margin + } + doc.addImage(businessInfo.logo, 'AUTO', logoX, 4, 40, 16) + } catch { /* skip */ } + } + + // White text overlay + setText(doc, config.colors.headerText) + doc.setFont('helvetica', config.typography.titleWeight) + doc.setFontSize(config.typography.titleSize) + doc.text('INVOICE', margin, 18) + + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + let y = 26 + doc.text(`#${invoice.invoice_number}`, margin, y); y += 4 + doc.text(`Date: ${formatDate(invoice.date)}`, margin, y); y += 4 + if (invoice.due_date) { doc.text(`Due: ${formatDate(invoice.due_date)}`, margin, y); y += 4 } + doc.text(`Status: ${invoice.status}`, margin, y) + + // Business info on right inside gradient + if (businessInfo.name && config.layout.logoPosition !== 'top-right') { + const bx = pageWidth - margin + doc.setFont('helvetica', 'bold') + doc.setFontSize(config.typography.bodySize + 1) + doc.text(businessInfo.name, bx, 14, { align: 'right' }) + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize - 1) + let by = 19 + if (businessInfo.email) { doc.text(businessInfo.email, bx, by, { align: 'right' }); by += 4 } + if (businessInfo.phone) { doc.text(businessInfo.phone, bx, by, { align: 'right' }); by += 4 } + } + + // Bill To below gradient + let billY = gradientHeight + 10 + doc.setFont('helvetica', 'bold') + doc.setFontSize(config.typography.bodySize + 1) + setText(doc, config.colors.bodyText) + doc.text('Bill To:', margin, billY); billY += 5 + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + doc.text(client.name || 'N/A', margin, billY); billY += 4 + if (client.email) { doc.text(client.email, margin, billY); billY += 4 } + if (client.address) { + const addrLines = client.address.split('\n') + for (const line of addrLines) { doc.text(line, margin, billY); billY += 4 } + } + + return billY + 10 +} + +// --- 6. Geometric header --- +function renderHeaderGeometric( + doc: jsPDF, + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + businessInfo: BusinessInfo, + startY: number, + margin: number, + pageWidth: number, + contentWidth: number, +): number { + // Triangle is already drawn by renderDecorative. + // Content placed avoiding the top-right triangle area. + let y = startY + + // Logo (top-left to avoid triangle) + if (businessInfo.logo) { + try { + doc.addImage(businessInfo.logo, 'AUTO', margin, y, 40, 16) + } catch { /* skip */ } + y += 20 + } + + // Business info + if (businessInfo.name) { + doc.setFont('helvetica', 'bold') + doc.setFontSize(config.typography.headerSize) + setText(doc, config.colors.headerText) + doc.text(businessInfo.name, margin, y); y += 5 + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + if (businessInfo.email) { doc.text(businessInfo.email, margin, y); y += 4 } + if (businessInfo.phone) { doc.text(businessInfo.phone, margin, y); y += 4 } + y += 4 + } + + // Title below triangle area + doc.setFont('helvetica', config.typography.titleWeight) + doc.setFontSize(config.typography.titleSize) + setText(doc, config.colors.primary) + doc.text('INVOICE', margin, y + 8); y += 14 + + // Invoice details + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + setText(doc, config.colors.bodyText) + const detailsY = y + doc.text(`Invoice #: ${invoice.invoice_number}`, margin, y); y += 5 + doc.text(`Date: ${formatDate(invoice.date)}`, margin, y); y += 5 + if (invoice.due_date) { doc.text(`Due Date: ${formatDate(invoice.due_date)}`, margin, y); y += 5 } + doc.text(`Status: ${invoice.status}`, margin, y); y += 5 + + // Bill To (right, offset down to avoid triangle) + const rightX = pageWidth - margin - 60 + let clientY = Math.max(detailsY, 50) // stay below the triangle + doc.setFont('helvetica', 'bold') + doc.setFontSize(config.typography.bodySize + 1) + doc.text('Bill To:', rightX, clientY); clientY += 5 + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + doc.text(client.name || 'N/A', rightX, clientY); clientY += 4 + if (client.email) { doc.text(client.email, rightX, clientY); clientY += 4 } + if (client.address) { + const lines = client.address.split('\n') + for (const line of lines) { doc.text(line, rightX, clientY); clientY += 4 } + } + + y = Math.max(y, clientY) + 10 + + // Divider + if (config.layout.showDividers && config.layout.dividerStyle !== 'none') { + drawDivider(doc, config, margin, y, contentWidth) + y += 6 + } + + return y +} + +// --- 7. Centered header --- +function renderHeaderCentered( + doc: jsPDF, + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + businessInfo: BusinessInfo, + startY: number, + margin: number, + pageWidth: number, + contentWidth: number, +): number { + const centerX = pageWidth / 2 + let y = startY + + // Logo centered + if (businessInfo.logo) { + try { + doc.addImage(businessInfo.logo, 'AUTO', centerX - 20, y, 40, 16) + } catch { /* skip */ } + y += 20 + } + + // Top thin rule + setDraw(doc, config.colors.tableBorder) + doc.setLineWidth(0.3) + doc.line(margin, y, margin + contentWidth, y) + y += 6 + + // Title centered + doc.setFont('helvetica', config.typography.titleWeight) + doc.setFontSize(config.typography.titleSize) + setText(doc, config.colors.primary) + doc.text('INVOICE', centerX, y + 6, { align: 'center' }) + y += 12 + + // Business name centered + if (businessInfo.name) { + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.headerSize) + setText(doc, config.colors.headerText) + doc.text(businessInfo.name, centerX, y, { align: 'center' }) + y += 5 + doc.setFontSize(config.typography.bodySize) + if (businessInfo.address) { + const addrLines = businessInfo.address.split('\n') + for (const line of addrLines) { + doc.text(line, centerX, y, { align: 'center' }) + y += 4 + } + } + if (businessInfo.email) { doc.text(businessInfo.email, centerX, y, { align: 'center' }); y += 4 } + if (businessInfo.phone) { doc.text(businessInfo.phone, centerX, y, { align: 'center' }); y += 4 } + y += 2 + } + + // Bottom thin rule + setDraw(doc, config.colors.tableBorder) + doc.setLineWidth(0.3) + doc.line(margin, y, margin + contentWidth, y) + y += 8 + + // Invoice details (left) and Bill To (right) + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + setText(doc, config.colors.bodyText) + + const detailsY = y + doc.text(`Invoice #: ${invoice.invoice_number}`, margin, y); y += 5 + doc.text(`Date: ${formatDate(invoice.date)}`, margin, y); y += 5 + if (invoice.due_date) { doc.text(`Due Date: ${formatDate(invoice.due_date)}`, margin, y); y += 5 } + doc.text(`Status: ${invoice.status}`, margin, y); y += 5 + + const rightX = pageWidth - margin - 60 + let clientY = detailsY + doc.setFont('helvetica', 'bold') + doc.setFontSize(config.typography.bodySize + 1) + doc.text('Bill To:', rightX, clientY); clientY += 5 + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + doc.text(client.name || 'N/A', rightX, clientY); clientY += 4 + if (client.email) { doc.text(client.email, rightX, clientY); clientY += 4 } + if (client.address) { + const lines = client.address.split('\n') + for (const line of lines) { doc.text(line, rightX, clientY); clientY += 4 } + } + + y = Math.max(y, clientY) + 10 + + // Divider (double style for centered template) + if (config.layout.showDividers && config.layout.dividerStyle !== 'none') { + drawDivider(doc, config, margin, y, contentWidth) + y += 6 + } + + return y +} + +// --------------------------------------------------------------------------- +// Divider helper +// --------------------------------------------------------------------------- + +function drawDivider( + doc: jsPDF, + config: InvoiceTemplateConfig, + x: number, + y: number, + width: number, +): void { + setDraw(doc, config.colors.tableBorder) + switch (config.layout.dividerStyle) { + case 'thin': + doc.setLineWidth(0.3) + doc.line(x, y, x + width, y) + break + case 'double': + doc.setLineWidth(0.3) + doc.line(x, y, x + width, y) + doc.line(x, y + 1.5, x + width, y + 1.5) + break + case 'thick': + doc.setLineWidth(1) + doc.line(x, y, x + width, y) + break + case 'none': + break + } + doc.setLineWidth(0.2) // reset +} + +// --------------------------------------------------------------------------- +// Line items table +// --------------------------------------------------------------------------- + +function renderTable( + doc: jsPDF, + config: InvoiceTemplateConfig, + items: InvoiceItem[], + startY: number, + margin: number, + contentWidth: number, + pageWidth: number, + pageHeight: number, +): number { + const tableStyle = config.layout.tableStyle + const typography = config.typography + + // Compute left offset for sidebar layouts + const sideOffset = config.layout.headerStyle === 'sidebar' ? config.decorative.sidebarWidth + 6 : 0 + const tableX = margin + sideOffset + const tableW = contentWidth - sideOffset + + // Column widths (Description 50%, Qty 15%, Rate 17.5%, Amount 17.5%) + const colDesc = tableW * 0.50 + const colQty = tableW * 0.15 + const colRate = tableW * 0.175 + // colAmt = tableW * 0.175 (last column, no offset needed) + const rowHeight = 8 + + let y = startY + + // ---- Table header row ---- + setFill(doc, config.colors.tableHeaderBg) + doc.rect(tableX, y, tableW, rowHeight, 'F') + + doc.setFont('helvetica', 'bold') + doc.setFontSize(typography.bodySize) + setText(doc, config.colors.tableHeaderText) + + let hx = tableX + 2 + doc.text('Description', hx, y + 5.5) + hx += colDesc + + // Monospace font for number columns if configured + if (typography.numberStyle === 'monospace-feel') doc.setFont('courier', 'bold') + doc.text('Qty', hx, y + 5.5) + hx += colQty + doc.text('Rate', hx, y + 5.5) + hx += colRate + doc.text('Amount', hx, y + 5.5) + + // Draw header borders for bordered style + if (tableStyle === 'bordered') { + setDraw(doc, config.colors.tableBorder) + doc.setLineWidth(0.3) + doc.rect(tableX, y, tableW, rowHeight, 'S') + // Vertical lines + let vx = tableX + colDesc + doc.line(vx, y, vx, y + rowHeight) + vx += colQty + doc.line(vx, y, vx, y + rowHeight) + vx += colRate + doc.line(vx, y, vx, y + rowHeight) + } + + y += rowHeight + + // ---- Table data rows ---- + for (let idx = 0; idx < items.length; idx++) { + // Page break check + if (y > 260) { + doc.addPage() + drawPageBackground(doc, config, pageWidth, pageHeight) + y = margin + } + + const item = items[idx] + const isAlt = idx % 2 === 1 + + // Row background based on table style + if (tableStyle === 'striped' && isAlt) { + setFill(doc, config.colors.tableRowAlt) + doc.rect(tableX, y, tableW, rowHeight, 'F') + } else if (tableStyle === 'colored-sections') { + setFill(doc, isAlt ? config.colors.tableRowAlt : config.colors.background) + doc.rect(tableX, y, tableW, rowHeight, 'F') + } else if (tableStyle === 'bordered') { + // Draw cell borders + setDraw(doc, config.colors.tableBorder) + doc.setLineWidth(0.3) + doc.rect(tableX, y, tableW, rowHeight, 'S') + let vx = tableX + colDesc + doc.line(vx, y, vx, y + rowHeight) + vx += colQty + doc.line(vx, y, vx, y + rowHeight) + vx += colRate + doc.line(vx, y, vx, y + rowHeight) + } + + // Row text + doc.setFont('helvetica', 'normal') + doc.setFontSize(typography.bodySize) + setText(doc, config.colors.bodyText) + + let rx = tableX + 2 + // Truncate description to fit + const descText = item.description.length > 50 ? item.description.substring(0, 47) + '...' : item.description + doc.text(descText, rx, y + 5.5) + rx += colDesc + + // Number columns: optionally monospace + if (typography.numberStyle === 'monospace-feel') doc.setFont('courier', 'normal') + doc.text(item.quantity.toString(), rx, y + 5.5) + rx += colQty + doc.text(formatCurrency(item.rate), rx, y + 5.5) + rx += colRate + doc.text(formatCurrency(item.amount), rx, y + 5.5) + + // Reset font + doc.setFont('helvetica', 'normal') + + // Minimal-lines: draw horizontal line after row + if (tableStyle === 'minimal-lines') { + setDraw(doc, config.colors.tableBorder) + doc.setLineWidth(0.2) + doc.line(tableX, y + rowHeight, tableX + tableW, y + rowHeight) + } + + y += rowHeight + } + + return y + 10 +} + +// --------------------------------------------------------------------------- +// Totals section +// --------------------------------------------------------------------------- + +function renderTotals( + doc: jsPDF, + config: InvoiceTemplateConfig, + invoice: Invoice, + startY: number, + margin: number, + pageWidth: number, + contentWidth: number, + pageHeight: number, +): number { + let y = startY + + // Page break check + if (y > 250) { + doc.addPage() + drawPageBackground(doc, config, pageWidth, pageHeight) + y = margin + } + + const blockWidth = 70 + + let labelX: number + let valueX: number + + if (config.layout.totalsPosition === 'center') { + const cx = pageWidth / 2 + labelX = cx - blockWidth / 2 + valueX = cx + blockWidth / 2 + } else { + // Right-aligned + const sideOffset = config.layout.headerStyle === 'sidebar' ? config.decorative.sidebarWidth + 6 : 0 + labelX = margin + sideOffset + contentWidth - sideOffset - blockWidth + valueX = margin + sideOffset + contentWidth - sideOffset + } + + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize + 1) + setText(doc, config.colors.bodyText) + + const numFont = config.typography.numberStyle === 'monospace-feel' ? 'courier' : 'helvetica' + + // Subtotal + doc.text('Subtotal:', labelX, y) + doc.setFont(numFont, 'normal') + doc.text(formatCurrency(invoice.subtotal), valueX, y, { align: 'right' }) + doc.setFont('helvetica', 'normal') + y += 6 + + // Tax + if (invoice.tax_rate > 0) { + doc.text(`Tax (${invoice.tax_rate}%):`, labelX, y) + doc.setFont(numFont, 'normal') + doc.text(formatCurrency(invoice.tax_amount), valueX, y, { align: 'right' }) + doc.setFont('helvetica', 'normal') + y += 6 + } + + // Discount + if (invoice.discount > 0) { + doc.text('Discount:', labelX, y) + doc.setFont(numFont, 'normal') + doc.text(`-${formatCurrency(invoice.discount)}`, valueX, y, { align: 'right' }) + doc.setFont('helvetica', 'normal') + y += 6 + } + + // Separator line + y += 2 + setDraw(doc, config.colors.tableBorder) + doc.setLineWidth(0.4) + doc.line(labelX - 2, y, valueX, y) + y += 6 + + // Total + doc.setFont('helvetica', 'bold') + doc.setFontSize(config.typography.bodySize + 3) + setText(doc, config.colors.totalHighlight) + doc.text('Total:', labelX, y) + doc.setFont(numFont, 'bold') + doc.text(formatCurrency(invoice.total), valueX, y, { align: 'right' }) + + y += 12 + return y +} + +// --------------------------------------------------------------------------- +// Notes section +// --------------------------------------------------------------------------- + +function renderNotes( + doc: jsPDF, + config: InvoiceTemplateConfig, + notes: string, + startY: number, + margin: number, + contentWidth: number, + pageWidth: number, + pageHeight: number, +): number { + let y = startY + + // Page break check + if (y > 240) { + doc.addPage() + drawPageBackground(doc, config, pageWidth, pageHeight) + y = margin + } + + const sideOffset = config.layout.headerStyle === 'sidebar' ? config.decorative.sidebarWidth + 6 : 0 + const leftX = margin + sideOffset + const noteWidth = contentWidth - sideOffset + + // Divider before notes + setDraw(doc, config.colors.tableBorder) + doc.setLineWidth(0.2) + doc.line(leftX, y, leftX + noteWidth, y) + y += 6 + + // Label + doc.setFont('helvetica', 'bold') + doc.setFontSize(config.typography.bodySize + 1) + setText(doc, config.colors.bodyText) + doc.text('Notes:', leftX, y) + y += 5 + + // Wrapped note text + doc.setFont('helvetica', 'normal') + doc.setFontSize(config.typography.bodySize) + const noteLines: string[] = doc.splitTextToSize(notes, noteWidth) + for (const line of noteLines) { + if (y > 280) { + doc.addPage() + drawPageBackground(doc, config, pageWidth, pageHeight) + y = margin + } + doc.text(line, leftX, y) + y += 4.5 + } + + return y + 5 +} + +// --------------------------------------------------------------------------- +// Main render function +// --------------------------------------------------------------------------- + +export function renderInvoicePdf( + config: InvoiceTemplateConfig, + invoice: Invoice, + client: Client, + items: InvoiceItem[], + businessInfo: BusinessInfo, +): jsPDF { + const doc = new jsPDF() + const pageWidth = doc.internal.pageSize.getWidth() // 210 + const pageHeight = doc.internal.pageSize.getHeight() // 297 + const margin = 20 + const contentWidth = pageWidth - margin * 2 + + // Draw page background + if (config.colors.background.toUpperCase() !== '#FFFFFF') { + setFill(doc, config.colors.background) + doc.rect(0, 0, pageWidth, pageHeight, 'F') + } + + // Draw decorative elements + renderDecorative(doc, config, pageWidth, pageHeight) + + // Draw header + let y = renderHeader(doc, config, invoice, client, businessInfo, margin, margin, pageWidth, contentWidth) + + // Draw line items table + y = renderTable(doc, config, items, y, margin, contentWidth, pageWidth, pageHeight) + + // Draw totals + y = renderTotals(doc, config, invoice, y, margin, pageWidth, contentWidth, pageHeight) + + // Draw notes + if (invoice.notes) { + renderNotes(doc, config, invoice.notes, y, margin, contentWidth, pageWidth, pageHeight) + } + + return doc +}