Files
zeroclock/docs/plans/2026-02-18-invoice-templates-implementation.md
Your Name 50734dee03 docs: add invoice templates implementation plan
9-task plan covering template config types, jsPDF renderer,
HTML preview component, template picker UI, Invoices.vue
integration, business identity settings, and polish passes.
2026-02-18 13:12:37 +02:00

60 KiB

Invoice Templates Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace the single hardcoded invoice layout with 15 visually distinct templates, a template picker with live preview, and business identity support.

Architecture: Template Config Objects approach — 15 config objects define colors, layout, typography, and decorative elements. A shared jsPDF renderer reads the config to generate PDFs. A Vue component reads the same config to render HTML previews. The template picker is a split-pane UI with template list on the left and live preview on the right.

Tech Stack: Vue 3 Composition API, jsPDF (already installed), Tailwind CSS v4, Pinia stores, Tauri v2 IPC


Task 1: Create template config types and registry

Files:

  • Create: src/utils/invoiceTemplates.ts

Context: This is the foundation file. It defines the TypeScript interface for template configs and exports all 15 template config objects plus a registry. The existing InvoiceItem interface in src/utils/invoicePdf.ts:9-15 will be reused.

Step 1: Create the template config types and all 15 templates

Create src/utils/invoiceTemplates.ts with:

export interface InvoiceTemplateColors {
  primary: string
  secondary: string
  background: string
  headerBg: string
  headerText: string
  bodyText: string
  tableHeaderBg: string
  tableHeaderText: string
  tableRowAlt: string
  tableBorder: string
  totalHighlight: string
}

export interface InvoiceTemplateLayout {
  logoPosition: 'top-left' | 'top-center' | 'top-right'
  headerStyle: 'full-width' | 'split' | 'minimal' | 'sidebar' | 'gradient' | 'geometric' | 'centered'
  tableStyle: 'bordered' | 'striped' | 'borderless' | 'minimal-lines' | 'colored-sections'
  totalsPosition: 'right' | 'center'
  showDividers: boolean
  dividerStyle: 'thin' | 'double' | 'thick' | 'none'
}

export interface InvoiceTemplateTypography {
  titleSize: number
  titleWeight: 'bold' | 'normal'
  headerSize: number
  bodySize: number
  numberStyle: 'normal' | 'monospace-feel'
}

export interface InvoiceTemplateDecorative {
  cornerShape: 'none' | 'colored-block' | 'triangle' | 'diagonal'
  sidebarWidth: number
  sidebarColor: string
  useGradientHeader: boolean
  gradientFrom?: string
  gradientTo?: string
  backgroundTint: boolean
  backgroundTintColor?: string
}

export interface InvoiceTemplateConfig {
  id: string
  name: string
  category: 'essential' | 'creative' | 'warm' | 'premium'
  description: string
  colors: InvoiceTemplateColors
  layout: InvoiceTemplateLayout
  typography: InvoiceTemplateTypography
  decorative: InvoiceTemplateDecorative
}

// --- Template definitions ---
// Define all 15 templates. Each has unique colors, layout, typography, decorative.

const cleanMinimal: InvoiceTemplateConfig = {
  id: 'clean-minimal',
  name: 'Clean Minimal',
  category: 'essential',
  description: 'Swiss-inspired simplicity with a single accent line',
  colors: {
    primary: '#3B82F6',
    secondary: '#93C5FD',
    background: '#FFFFFF',
    headerBg: '#FFFFFF',
    headerText: '#111827',
    bodyText: '#374151',
    tableHeaderBg: '#F9FAFB',
    tableHeaderText: '#374151',
    tableRowAlt: '#F9FAFB',
    tableBorder: '#E5E7EB',
    totalHighlight: '#3B82F6',
  },
  layout: {
    logoPosition: 'top-left',
    headerStyle: 'minimal',
    tableStyle: 'bordered',
    totalsPosition: 'right',
    showDividers: true,
    dividerStyle: 'thin',
  },
  typography: { titleSize: 24, titleWeight: 'bold', headerSize: 11, bodySize: 9, numberStyle: 'normal' },
  decorative: { cornerShape: 'none', sidebarWidth: 0, sidebarColor: '', useGradientHeader: false, backgroundTint: false },
}

const corporateClassic: InvoiceTemplateConfig = {
  id: 'corporate-classic',
  name: 'Corporate Classic',
  category: 'essential',
  description: 'Navy header band with professional table layout',
  colors: {
    primary: '#1E3A5F',
    secondary: '#2563EB',
    background: '#FFFFFF',
    headerBg: '#1E3A5F',
    headerText: '#FFFFFF',
    bodyText: '#374151',
    tableHeaderBg: '#1E3A5F',
    tableHeaderText: '#FFFFFF',
    tableRowAlt: '#F3F4F6',
    tableBorder: '#D1D5DB',
    totalHighlight: '#1E3A5F',
  },
  layout: {
    logoPosition: 'top-left',
    headerStyle: 'full-width',
    tableStyle: 'striped',
    totalsPosition: 'right',
    showDividers: false,
    dividerStyle: 'none',
  },
  typography: { titleSize: 28, titleWeight: 'bold', headerSize: 11, bodySize: 9, numberStyle: 'normal' },
  decorative: { cornerShape: 'none', sidebarWidth: 0, sidebarColor: '', useGradientHeader: false, backgroundTint: false },
}

const modernBold: InvoiceTemplateConfig = {
  id: 'modern-bold',
  name: 'Modern Bold',
  category: 'essential',
  description: 'Large accent block with oversized typography',
  colors: {
    primary: '#6366F1',
    secondary: '#A5B4FC',
    background: '#FFFFFF',
    headerBg: '#6366F1',
    headerText: '#FFFFFF',
    bodyText: '#1F2937',
    tableHeaderBg: '#6366F1',
    tableHeaderText: '#FFFFFF',
    tableRowAlt: '#F5F3FF',
    tableBorder: '#E0E7FF',
    totalHighlight: '#6366F1',
  },
  layout: {
    logoPosition: 'top-left',
    headerStyle: 'full-width',
    tableStyle: 'borderless',
    totalsPosition: 'right',
    showDividers: false,
    dividerStyle: 'none',
  },
  typography: { titleSize: 32, titleWeight: 'bold', headerSize: 12, bodySize: 10, numberStyle: 'normal' },
  decorative: { cornerShape: 'colored-block', sidebarWidth: 0, sidebarColor: '', useGradientHeader: false, backgroundTint: false },
}

const elegantSerif: InvoiceTemplateConfig = {
  id: 'elegant-serif',
  name: 'Elegant Serif',
  category: 'essential',
  description: 'Refined charcoal and gold with centered layout',
  colors: {
    primary: '#374151',
    secondary: '#B8860B',
    background: '#FFFFFF',
    headerBg: '#FFFFFF',
    headerText: '#374151',
    bodyText: '#4B5563',
    tableHeaderBg: '#374151',
    tableHeaderText: '#FFFFFF',
    tableRowAlt: '#FAFAFA',
    tableBorder: '#D1D5DB',
    totalHighlight: '#B8860B',
  },
  layout: {
    logoPosition: 'top-center',
    headerStyle: 'centered',
    tableStyle: 'minimal-lines',
    totalsPosition: 'right',
    showDividers: true,
    dividerStyle: 'double',
  },
  typography: { titleSize: 26, titleWeight: 'normal', headerSize: 11, bodySize: 9, numberStyle: 'normal' },
  decorative: { cornerShape: 'none', sidebarWidth: 0, sidebarColor: '', useGradientHeader: false, backgroundTint: false },
}

const simpleTwoTone: InvoiceTemplateConfig = {
  id: 'simple-two-tone',
  name: 'Simple Two-Tone',
  category: 'essential',
  description: 'Dark/light split header with emerald accents',
  colors: {
    primary: '#1F2937',
    secondary: '#10B981',
    background: '#FFFFFF',
    headerBg: '#1F2937',
    headerText: '#FFFFFF',
    bodyText: '#374151',
    tableHeaderBg: '#F3F4F6',
    tableHeaderText: '#1F2937',
    tableRowAlt: '#FFFFFF',
    tableBorder: '#E5E7EB',
    totalHighlight: '#10B981',
  },
  layout: {
    logoPosition: 'top-left',
    headerStyle: 'split',
    tableStyle: 'minimal-lines',
    totalsPosition: 'right',
    showDividers: true,
    dividerStyle: 'thin',
  },
  typography: { titleSize: 24, titleWeight: 'bold', headerSize: 11, bodySize: 9, numberStyle: 'normal' },
  decorative: { cornerShape: 'none', sidebarWidth: 0, sidebarColor: '', useGradientHeader: false, backgroundTint: false },
}

const gradientHeader: InvoiceTemplateConfig = {
  id: 'gradient-header',
  name: 'Gradient Header',
  category: 'creative',
  description: 'Full-width gradient header with modern feel',
  colors: {
    primary: '#667EEA',
    secondary: '#764BA2',
    background: '#FFFFFF',
    headerBg: '#667EEA',
    headerText: '#FFFFFF',
    bodyText: '#374151',
    tableHeaderBg: '#667EEA',
    tableHeaderText: '#FFFFFF',
    tableRowAlt: '#F5F3FF',
    tableBorder: '#E0E7FF',
    totalHighlight: '#667EEA',
  },
  layout: {
    logoPosition: 'top-left',
    headerStyle: 'gradient',
    tableStyle: 'striped',
    totalsPosition: 'right',
    showDividers: false,
    dividerStyle: 'none',
  },
  typography: { titleSize: 28, titleWeight: 'bold', headerSize: 11, bodySize: 9, numberStyle: 'normal' },
  decorative: { cornerShape: 'none', sidebarWidth: 0, sidebarColor: '', useGradientHeader: true, gradientFrom: '#667EEA', gradientTo: '#764BA2', backgroundTint: false },
}

const sidebarAccent: InvoiceTemplateConfig = {
  id: 'sidebar-accent',
  name: 'Sidebar Accent',
  category: 'creative',
  description: 'Rose vertical bar with asymmetric layout',
  colors: {
    primary: '#E11D48',
    secondary: '#FB7185',
    background: '#FFFFFF',
    headerBg: '#FFFFFF',
    headerText: '#111827',
    bodyText: '#374151',
    tableHeaderBg: '#FFF1F2',
    tableHeaderText: '#E11D48',
    tableRowAlt: '#FFF1F2',
    tableBorder: '#FECDD3',
    totalHighlight: '#E11D48',
  },
  layout: {
    logoPosition: 'top-left',
    headerStyle: 'sidebar',
    tableStyle: 'borderless',
    totalsPosition: 'right',
    showDividers: true,
    dividerStyle: 'thin',
  },
  typography: { titleSize: 24, titleWeight: 'bold', headerSize: 11, bodySize: 9, numberStyle: 'normal' },
  decorative: { cornerShape: 'none', sidebarWidth: 4, sidebarColor: '#E11D48', useGradientHeader: false, backgroundTint: false },
}

const geometricModern: InvoiceTemplateConfig = {
  id: 'geometric-modern',
  name: 'Geometric Modern',
  category: 'creative',
  description: 'Violet triangle accent with diagonal elements',
  colors: {
    primary: '#8B5CF6',
    secondary: '#C4B5FD',
    background: '#FFFFFF',
    headerBg: '#FFFFFF',
    headerText: '#1F2937',
    bodyText: '#374151',
    tableHeaderBg: '#8B5CF6',
    tableHeaderText: '#FFFFFF',
    tableRowAlt: '#F5F3FF',
    tableBorder: '#DDD6FE',
    totalHighlight: '#8B5CF6',
  },
  layout: {
    logoPosition: 'top-left',
    headerStyle: 'geometric',
    tableStyle: 'striped',
    totalsPosition: 'right',
    showDividers: false,
    dividerStyle: 'none',
  },
  typography: { titleSize: 26, titleWeight: 'bold', headerSize: 11, bodySize: 9, numberStyle: 'normal' },
  decorative: { cornerShape: 'triangle', sidebarWidth: 0, sidebarColor: '', useGradientHeader: false, backgroundTint: false },
}

const darkMode: InvoiceTemplateConfig = {
  id: 'dark-mode',
  name: 'Dark Mode',
  category: 'creative',
  description: 'Near-black background with cyan highlights',
  colors: {
    primary: '#06B6D4',
    secondary: '#22D3EE',
    background: '#1A1A2E',
    headerBg: '#1A1A2E',
    headerText: '#E2E8F0',
    bodyText: '#CBD5E1',
    tableHeaderBg: '#0F172A',
    tableHeaderText: '#06B6D4',
    tableRowAlt: '#16213E',
    tableBorder: '#334155',
    totalHighlight: '#06B6D4',
  },
  layout: {
    logoPosition: 'top-left',
    headerStyle: 'minimal',
    tableStyle: 'striped',
    totalsPosition: 'right',
    showDividers: true,
    dividerStyle: 'thin',
  },
  typography: { titleSize: 26, titleWeight: 'bold', headerSize: 11, bodySize: 9, numberStyle: 'monospace-feel' },
  decorative: { cornerShape: 'none', sidebarWidth: 0, sidebarColor: '', useGradientHeader: false, backgroundTint: false },
}

const warmNatural: InvoiceTemplateConfig = {
  id: 'warm-natural',
  name: 'Warm Natural',
  category: 'warm',
  description: 'Earth tones on warm beige background',
  colors: {
    primary: '#C2703E',
    secondary: '#6B7A3D',
    background: '#FDF6EC',
    headerBg: '#FDF6EC',
    headerText: '#78350F',
    bodyText: '#57534E',
    tableHeaderBg: '#C2703E',
    tableHeaderText: '#FFFFFF',
    tableRowAlt: '#FEF3C7',
    tableBorder: '#D6D3D1',
    totalHighlight: '#C2703E',
  },
  layout: {
    logoPosition: 'top-left',
    headerStyle: 'minimal',
    tableStyle: 'striped',
    totalsPosition: 'right',
    showDividers: true,
    dividerStyle: 'thin',
  },
  typography: { titleSize: 26, titleWeight: 'bold', headerSize: 11, bodySize: 9, numberStyle: 'normal' },
  decorative: { cornerShape: 'none', sidebarWidth: 0, sidebarColor: '', useGradientHeader: false, backgroundTint: true, backgroundTintColor: '#FDF6EC' },
}

const playfulColorBlock: InvoiceTemplateConfig = {
  id: 'playful-color-block',
  name: 'Playful Color Block',
  category: 'warm',
  description: 'Teal and coral blocks for a fun professional feel',
  colors: {
    primary: '#0D9488',
    secondary: '#F97316',
    background: '#FFFFFF',
    headerBg: '#0D9488',
    headerText: '#FFFFFF',
    bodyText: '#1F2937',
    tableHeaderBg: '#F97316',
    tableHeaderText: '#FFFFFF',
    tableRowAlt: '#FFF7ED',
    tableBorder: '#FDBA74',
    totalHighlight: '#0D9488',
  },
  layout: {
    logoPosition: 'top-left',
    headerStyle: 'full-width',
    tableStyle: 'colored-sections',
    totalsPosition: 'right',
    showDividers: false,
    dividerStyle: 'none',
  },
  typography: { titleSize: 28, titleWeight: 'bold', headerSize: 11, bodySize: 9, numberStyle: 'normal' },
  decorative: { cornerShape: 'colored-block', sidebarWidth: 0, sidebarColor: '', useGradientHeader: false, backgroundTint: false },
}

const retroProfessional: InvoiceTemplateConfig = {
  id: 'retro-professional',
  name: 'Retro Professional',
  category: 'warm',
  description: 'Vintage double-rule lines with warm brown palette',
  colors: {
    primary: '#78350F',
    secondary: '#92400E',
    background: '#FFFBEB',
    headerBg: '#FFFBEB',
    headerText: '#78350F',
    bodyText: '#57534E',
    tableHeaderBg: '#78350F',
    tableHeaderText: '#FFFFFF',
    tableRowAlt: '#FEF3C7',
    tableBorder: '#92400E',
    totalHighlight: '#78350F',
  },
  layout: {
    logoPosition: 'top-left',
    headerStyle: 'minimal',
    tableStyle: 'bordered',
    totalsPosition: 'right',
    showDividers: true,
    dividerStyle: 'double',
  },
  typography: { titleSize: 24, titleWeight: 'bold', headerSize: 11, bodySize: 9, numberStyle: 'normal' },
  decorative: { cornerShape: 'none', sidebarWidth: 0, sidebarColor: '', useGradientHeader: false, backgroundTint: true, backgroundTintColor: '#FFFBEB' },
}

const techMinimal: InvoiceTemplateConfig = {
  id: 'tech-minimal',
  name: 'Tech Minimal',
  category: 'premium',
  description: 'Slate and electric green with code-editor aesthetic',
  colors: {
    primary: '#475569',
    secondary: '#22C55E',
    background: '#FFFFFF',
    headerBg: '#FFFFFF',
    headerText: '#0F172A',
    bodyText: '#475569',
    tableHeaderBg: '#0F172A',
    tableHeaderText: '#22C55E',
    tableRowAlt: '#F8FAFC',
    tableBorder: '#E2E8F0',
    totalHighlight: '#22C55E',
  },
  layout: {
    logoPosition: 'top-left',
    headerStyle: 'minimal',
    tableStyle: 'minimal-lines',
    totalsPosition: 'right',
    showDividers: true,
    dividerStyle: 'thin',
  },
  typography: { titleSize: 22, titleWeight: 'bold', headerSize: 10, bodySize: 9, numberStyle: 'monospace-feel' },
  decorative: { cornerShape: 'none', sidebarWidth: 0, sidebarColor: '', useGradientHeader: false, backgroundTint: false },
}

const executivePremium: InvoiceTemplateConfig = {
  id: 'executive-premium',
  name: 'Executive Premium',
  category: 'premium',
  description: 'Black and gold luxury with generous spacing',
  colors: {
    primary: '#111827',
    secondary: '#D4AF37',
    background: '#FFFFFF',
    headerBg: '#111827',
    headerText: '#D4AF37',
    bodyText: '#374151',
    tableHeaderBg: '#111827',
    tableHeaderText: '#D4AF37',
    tableRowAlt: '#F9FAFB',
    tableBorder: '#D1D5DB',
    totalHighlight: '#D4AF37',
  },
  layout: {
    logoPosition: 'top-left',
    headerStyle: 'full-width',
    tableStyle: 'minimal-lines',
    totalsPosition: 'right',
    showDividers: true,
    dividerStyle: 'thin',
  },
  typography: { titleSize: 28, titleWeight: 'normal', headerSize: 11, bodySize: 10, numberStyle: 'normal' },
  decorative: { cornerShape: 'none', sidebarWidth: 0, sidebarColor: '', useGradientHeader: false, backgroundTint: false },
}

const dataDrivenClean: InvoiceTemplateConfig = {
  id: 'data-driven-clean',
  name: 'Data-Driven Clean',
  category: 'premium',
  description: 'Deep blue with emphasis on data hierarchy',
  colors: {
    primary: '#1E40AF',
    secondary: '#3B82F6',
    background: '#FFFFFF',
    headerBg: '#1E40AF',
    headerText: '#FFFFFF',
    bodyText: '#1F2937',
    tableHeaderBg: '#1E40AF',
    tableHeaderText: '#FFFFFF',
    tableRowAlt: '#EFF6FF',
    tableBorder: '#BFDBFE',
    totalHighlight: '#1E40AF',
  },
  layout: {
    logoPosition: 'top-right',
    headerStyle: 'full-width',
    tableStyle: 'striped',
    totalsPosition: 'center',
    showDividers: false,
    dividerStyle: 'none',
  },
  typography: { titleSize: 24, titleWeight: 'bold', headerSize: 12, bodySize: 10, numberStyle: 'monospace-feel' },
  decorative: { cornerShape: 'none', sidebarWidth: 0, sidebarColor: '', useGradientHeader: false, backgroundTint: false },
}

// --- Registry ---
export const INVOICE_TEMPLATES: InvoiceTemplateConfig[] = [
  cleanMinimal,
  corporateClassic,
  modernBold,
  elegantSerif,
  simpleTwoTone,
  gradientHeader,
  sidebarAccent,
  geometricModern,
  darkMode,
  warmNatural,
  playfulColorBlock,
  retroProfessional,
  techMinimal,
  executivePremium,
  dataDrivenClean,
]

export const TEMPLATE_CATEGORIES = [
  { id: 'essential', label: 'Professional Essentials' },
  { id: 'creative', label: 'Creative & Modern' },
  { id: 'warm', label: 'Warm & Distinctive' },
  { id: 'premium', label: 'Premium & Specialized' },
] as const

export function getTemplateById(id: string): InvoiceTemplateConfig {
  return INVOICE_TEMPLATES.find(t => t.id === id) || INVOICE_TEMPLATES[0]
}

export function getTemplatesByCategory(category: string): InvoiceTemplateConfig[] {
  return INVOICE_TEMPLATES.filter(t => t.category === category)
}

Step 2: Verify the file has no TypeScript errors

Run: npx tsc --noEmit --pretty 2>&1 | head -20

If there are import errors, fix them. The file should have zero imports (it's self-contained types + data).

Step 3: Commit

git add src/utils/invoiceTemplates.ts
git commit -m "feat: add 15 invoice template configs and registry"

Task 2: Create the jsPDF template renderer

Files:

  • Create: src/utils/invoicePdfRenderer.ts
  • Modify: src/utils/invoicePdf.ts (will become a thin wrapper)

Context: The current src/utils/invoicePdf.ts has a single generateInvoicePdf() function with hardcoded amber theme. We need a new renderer that reads template config. The old file's InvoiceItem interface and generateInvoicePdf signature stay the same (they're used by Invoices.vue:545,755), but internally it delegates to the new renderer.

Step 1: Create the config-driven renderer

Create src/utils/invoicePdfRenderer.ts. This file exports renderInvoicePdf(doc, config, invoice, client, items, businessInfo) which draws onto an existing jsPDF document using the template config.

The renderer must handle:

  • Page background: Fill entire page with config.colors.background (skip if white)
  • Decorative elements: sidebar bar, corner shapes, gradients, background tints
  • Header section: 7 header styles — each is a separate function:
    • minimal: Title left, invoice details below, client info right. Simple accent line.
    • full-width: Full-width colored header band with white text. Business name + invoice number.
    • split: Left half dark background (business info), right half light (invoice details).
    • sidebar: Narrow colored bar on left edge, content offset. Business info near sidebar.
    • gradient: Simulated gradient via multiple thin colored rect strips across header width.
    • geometric: Triangle in top-right corner drawn via path, invoice details below.
    • centered: Centered title, centered business name, dividers above and below.
  • Bill To section: Client name, email, address. Position depends on header style.
  • Line items table: 5 table styles:
    • bordered: Full cell borders
    • striped: Alternating row backgrounds, no vertical borders
    • borderless: No borders at all, just spacing
    • minimal-lines: Horizontal lines only between rows
    • colored-sections: Colored background for header, alternating colored rows
  • Totals section: Subtotal, tax, discount, total. Right-aligned or centered per config.
  • Notes section: If invoice has notes, render below totals.
  • Logo rendering: If businessInfo.logo is set, render via doc.addImage() at the position specified by config.layout.logoPosition.
  • Page breaks: Check yPosition > 270 before each row, add new page if needed.

Key technical notes:

  • jsPDF gradient: Simulate by drawing 20+ thin horizontal rect() strips with interpolated colors from gradientFrom to gradientTo.
  • jsPDF triangle: Use doc.triangle(x1,y1, x2,y2, x3,y3, 'F').
  • jsPDF sidebar: doc.rect(0, 0, sidebarWidth, pageHeight, 'F').
  • Font: jsPDF only has helvetica, courier, times. Use helvetica for all. For monospace-feel numbers, use courier for amount columns only.
  • Color parsing: The config uses hex strings. jsPDF setFillColor/setTextColor accept hex strings directly.
  • Business info comes from settings store: { name, address, email, phone, logo }.
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'

export interface BusinessInfo {
  name: string
  address: string
  email: string
  phone: string
  logo: string // base64 data URL or empty
}

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 !== '#FFFFFF') {
    doc.setFillColor(config.colors.background)
    doc.rect(0, 0, pageWidth, pageHeight, 'F')
  }

  // Draw decorative elements
  renderDecorative(doc, config, pageWidth, pageHeight, margin)

  let y = margin

  // Draw header
  y = renderHeader(doc, config, invoice, client, businessInfo, y, margin, pageWidth, contentWidth)

  // Draw line items table
  y = renderTable(doc, config, items, y, margin, contentWidth)

  // Draw totals
  y = renderTotals(doc, config, invoice, y, margin, pageWidth, contentWidth)

  // Draw notes
  if (invoice.notes) {
    y = renderNotes(doc, config, invoice.notes, y, margin, contentWidth, pageHeight)
  }

  return doc
}

// ... implement each render function following the patterns above.
// Full implementation provided in code — each function uses doc.setFillColor,
// doc.setTextColor, doc.setFont, doc.text, doc.rect, doc.line, doc.triangle etc.

Important implementation details for each render function:

renderDecorative(doc, config, pageWidth, pageHeight, margin):

  • If config.decorative.sidebarWidth > 0: draw doc.rect(0, 0, config.decorative.sidebarWidth, pageHeight, 'F') with sidebarColor.
  • If config.decorative.cornerShape === 'colored-block': draw doc.rect(0, 0, 60, 60, 'F') with config.colors.primary.
  • If config.decorative.cornerShape === 'triangle': draw doc.triangle(pageWidth, 0, pageWidth, 80, pageWidth - 80, 0, 'F') with config.colors.primary.
  • If config.decorative.cornerShape === 'diagonal': draw a filled triangle across top.
  • If config.decorative.backgroundTint && config.decorative.backgroundTintColor: fill full page.

renderHeader(doc, config, invoice, client, businessInfo, y, margin, pageWidth, contentWidth):

  • Switch on config.layout.headerStyle.
  • For 'gradient': draw 30 thin rect strips from y=0 to y=45, interpolating color from gradientFrom to gradientTo. Then overlay white text.
  • For 'split': draw left half rect with headerBg color, right half stays white.
  • For 'full-width': draw full-width rect with headerBg color.
  • For 'sidebar': offset all content by sidebarWidth + 6.
  • After header style-specific rendering, always draw:
    • Invoice title "INVOICE" in config.colors.primary at config.typography.titleSize
    • Invoice number, date, due date, status
    • "Bill To:" section with client info
    • Logo if available
  • Return new y position after header.

renderTable(doc, config, items, y, margin, contentWidth):

  • Column widths: Description 50%, Qty 15%, Rate 17.5%, Amount 17.5%
  • Table header row: background config.colors.tableHeaderBg, text config.colors.tableHeaderText
  • For each item row: apply table style
    • 'bordered': draw rect border around each cell
    • 'striped': draw alternating config.colors.tableRowAlt backgrounds
    • 'borderless': no borders, just text
    • 'minimal-lines': draw horizontal line after each row
    • 'colored-sections': draw colored background sections
  • Font for amounts: if config.typography.numberStyle === 'monospace-feel', use courier font for Qty/Rate/Amount columns.
  • Page break check: if y > 260, doc.addPage(), redraw background if needed, reset y.
  • Return new y position.

renderTotals(doc, config, invoice, y, margin, pageWidth, contentWidth):

  • If config.layout.totalsPosition === 'center': center the totals block
  • Else: right-align at pageWidth - margin - 60
  • Draw Subtotal, Tax (if > 0), Discount (if > 0)
  • Draw separator line
  • Draw Total in config.colors.totalHighlight at larger font size
  • Return new y position.

renderNotes(doc, config, notes, y, margin, contentWidth, pageHeight):

  • If y > 240, add new page
  • Draw "Notes:" label and wrapped text
  • Return new y position.

Step 2: Update the old invoicePdf.ts to delegate

Modify src/utils/invoicePdf.ts to keep the same generateInvoicePdf signature but delegate to the new renderer with a default template:

import type { Invoice } from '../stores/invoices'
import type { Client } from '../stores/clients'
import { renderInvoicePdf, type BusinessInfo } from './invoicePdfRenderer'
import { getTemplateById } from './invoiceTemplates'

export interface InvoiceItem {
  id?: number
  description: string
  quantity: number
  rate: number
  amount: number
}

/**
 * Generate a PDF invoice using a template
 */
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)
}

Step 3: Update the Invoices.vue call site

In src/views/Invoices.vue:755, the current call is:

const doc = generateInvoicePdf(invoice, client, items)

This still works because templateId has a default value. No changes needed yet — template selection will be added in Task 5.

Step 4: Verify build

Run: npm run build 2>&1 | tail -20 Expected: Build succeeds.

Step 5: Commit

git add src/utils/invoicePdfRenderer.ts src/utils/invoicePdf.ts
git commit -m "feat: add config-driven jsPDF invoice renderer with 7 header styles and 5 table styles"

Task 3: Create InvoicePreview.vue HTML component

Files:

  • Create: src/components/InvoicePreview.vue

Context: This component renders an HTML preview of an invoice using the template config. It replicates the jsPDF renderer's visual output in HTML/CSS so the user sees exactly what the PDF will look like. It replaces the hardcoded amber preview in Invoices.vue:417-497.

The component receives props: template (InvoiceTemplateConfig), invoice (Invoice), client (Client | null), items (InvoiceItem[]), businessInfo (BusinessInfo).

It renders a scaled-down A4-ratio div (8.5in x 11in) with:

  • Page background color from config.colors.background
  • All decorative elements (sidebar, corners, gradient header) via CSS
  • Header section matching the template's header style
  • Client "Bill To" section
  • Line items table with the template's table style
  • Totals section
  • Notes section

Step 1: Create the component

<template>
  <div
    class="bg-white shadow-lg rounded overflow-hidden"
    :style="{
      fontFamily: '\'Helvetica Neue\', Helvetica, Arial, sans-serif',
      backgroundColor: template.colors.background,
      color: template.colors.bodyText,
      position: 'relative',
      aspectRatio: '8.5 / 11',
      width: '100%',
    }"
  >
    <!-- Decorative: Sidebar -->
    <div
      v-if="template.decorative.sidebarWidth > 0"
      :style="{
        position: 'absolute', left: 0, top: 0, bottom: 0,
        width: template.decorative.sidebarWidth * 4 + 'px',
        backgroundColor: template.decorative.sidebarColor,
      }"
    />

    <!-- Decorative: Corner block -->
    <div
      v-if="template.decorative.cornerShape === 'colored-block'"
      :style="{
        position: 'absolute', left: 0, top: 0,
        width: '80px', height: '80px',
        backgroundColor: template.colors.primary,
      }"
    />

    <!-- Decorative: Triangle -->
    <!-- Use SVG for triangle shape in top-right -->
    <svg
      v-if="template.decorative.cornerShape === 'triangle'"
      :style="{ position: 'absolute', right: 0, top: 0 }"
      width="100" height="100"
      viewBox="0 0 100 100"
    >
      <polygon points="100,0 100,100 0,0" :fill="template.colors.primary" />
    </svg>

    <!-- Content with padding -->
    <div :style="{ padding: '32px', paddingLeft: template.decorative.sidebarWidth > 0 ? (template.decorative.sidebarWidth * 4 + 20) + 'px' : '32px', position: 'relative', zIndex: 1 }">

      <!-- Header section  render based on headerStyle -->
      <!-- ... 7 header variants using v-if/v-else-if -->
      <!-- Each variant renders: title, invoice details, bill-to, logo position -->

      <!-- Full-width header -->
      <div v-if="template.layout.headerStyle === 'full-width'" :style="{ margin: '-32px -32px 24px', padding: '24px 32px', backgroundColor: template.colors.headerBg, color: template.colors.headerText }">
        <div class="flex justify-between items-start">
          <div>
            <h1 :style="{ fontSize: template.typography.titleSize * 0.7 + 'px', fontWeight: template.typography.titleWeight, color: template.colors.headerText }">INVOICE</h1>
            <div :style="{ fontSize: '8px', marginTop: '6px', opacity: 0.85 }">
              <p>{{ invoice.invoice_number }}</p>
              <p>{{ formatDate(invoice.date) }}</p>
            </div>
          </div>
          <div :style="{ textAlign: 'right', fontSize: '8px' }">
            <p style="font-weight: 600; margin-bottom: 2px;">Bill To:</p>
            <p>{{ client?.name || 'N/A' }}</p>
            <p v-if="client?.email">{{ client.email }}</p>
          </div>
        </div>
      </div>

      <!-- ... similar blocks for other header styles ... -->

      <!-- Minimal header (default) -->
      <div v-else>
        <h1 :style="{ fontSize: template.typography.titleSize * 0.7 + 'px', fontWeight: template.typography.titleWeight, color: template.colors.primary, marginBottom: '8px' }">INVOICE</h1>
        <!-- Accent line for minimal -->
        <div :style="{ height: '2px', width: '40px', backgroundColor: template.colors.primary, marginBottom: '12px' }" />
        <div class="flex justify-between" :style="{ fontSize: '8px' }">
          <div>
            <p>Invoice #: {{ invoice.invoice_number }}</p>
            <p>Date: {{ formatDate(invoice.date) }}</p>
            <p v-if="invoice.due_date">Due: {{ formatDate(invoice.due_date) }}</p>
            <p>Status: {{ invoice.status }}</p>
          </div>
          <div :style="{ textAlign: 'right' }">
            <p style="font-weight: 600;">Bill To:</p>
            <p>{{ client?.name || 'N/A' }}</p>
            <p v-if="client?.email">{{ client.email }}</p>
          </div>
        </div>
      </div>

      <!-- Divider if configured -->
      <div v-if="template.layout.showDividers && template.layout.dividerStyle !== 'none'" :style="dividerStyle" />

      <!-- Line items table -->
      <table :style="{ width: '100%', fontSize: '7px', borderCollapse: 'collapse', margin: '12px 0' }">
        <thead>
          <tr :style="{ backgroundColor: template.colors.tableHeaderBg, color: template.colors.tableHeaderText }">
            <th :style="{ padding: '4px 6px', textAlign: 'left', fontWeight: 600, fontSize: '7px' }">Description</th>
            <th :style="{ padding: '4px 6px', textAlign: 'right', fontWeight: 600, fontSize: '7px', width: '40px' }">Qty</th>
            <th :style="{ padding: '4px 6px', textAlign: 'right', fontWeight: 600, fontSize: '7px', width: '56px' }">Rate</th>
            <th :style="{ padding: '4px 6px', textAlign: 'right', fontWeight: 600, fontSize: '7px', width: '56px' }">Amount</th>
          </tr>
        </thead>
        <tbody>
          <tr
            v-for="(item, i) in displayItems"
            :key="i"
            :style="rowStyle(i)"
          >
            <td :style="{ padding: '3px 6px' }">{{ item.description }}</td>
            <td :style="{ padding: '3px 6px', textAlign: 'right', fontFamily: numberFont }">{{ item.quantity }}</td>
            <td :style="{ padding: '3px 6px', textAlign: 'right', fontFamily: numberFont }">{{ formatCurrency(item.rate) }}</td>
            <td :style="{ padding: '3px 6px', textAlign: 'right', fontFamily: numberFont }">{{ formatCurrency(item.amount) }}</td>
          </tr>
          <tr v-if="displayItems.length === 0">
            <td colspan="4" :style="{ padding: '12px', textAlign: 'center', opacity: 0.5, fontSize: '7px' }">No line items</td>
          </tr>
        </tbody>
      </table>

      <!-- Totals -->
      <div :style="{ display: 'flex', justifyContent: template.layout.totalsPosition === 'center' ? 'center' : 'flex-end' }">
        <div :style="{ width: '140px', fontSize: '8px' }">
          <div :style="{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px', color: template.colors.bodyText }">
            <span>Subtotal:</span>
            <span :style="{ fontFamily: numberFont }">{{ formatCurrency(invoice.subtotal) }}</span>
          </div>
          <div v-if="invoice.tax_rate > 0" :style="{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }">
            <span>Tax ({{ invoice.tax_rate }}%):</span>
            <span :style="{ fontFamily: numberFont }">{{ formatCurrency(invoice.tax_amount) }}</span>
          </div>
          <div v-if="invoice.discount > 0" :style="{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }">
            <span>Discount:</span>
            <span :style="{ fontFamily: numberFont }">-{{ formatCurrency(invoice.discount) }}</span>
          </div>
          <div :style="{ borderTop: '1px solid ' + template.colors.tableBorder, paddingTop: '4px', marginTop: '4px', display: 'flex', justifyContent: 'space-between', fontWeight: 700, fontSize: '10px', color: template.colors.totalHighlight }">
            <span>Total:</span>
            <span :style="{ fontFamily: numberFont }">{{ formatCurrency(invoice.total) }}</span>
          </div>
        </div>
      </div>

      <!-- Notes -->
      <div v-if="invoice.notes" :style="{ marginTop: '16px', borderTop: '1px solid ' + template.colors.tableBorder, paddingTop: '8px' }">
        <p :style="{ fontSize: '7px', fontWeight: 600, marginBottom: '2px' }">Notes:</p>
        <p :style="{ fontSize: '6px', whiteSpace: 'pre-line', opacity: 0.8 }">{{ invoice.notes }}</p>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import type { InvoiceTemplateConfig } from '../utils/invoiceTemplates'
import type { Invoice } from '../stores/invoices'
import type { Client } from '../stores/clients'
import type { InvoiceItem } from '../utils/invoicePdf'
import type { BusinessInfo } from '../utils/invoicePdfRenderer'
import { formatCurrency, formatDate } from '../utils/locale'

const props = defineProps<{
  template: InvoiceTemplateConfig
  invoice: Invoice
  client: Client | null
  items: InvoiceItem[]
  businessInfo?: BusinessInfo
}>()

// Use sample items if none provided
const sampleItems: InvoiceItem[] = [
  { description: 'Web Design & Development', quantity: 40, rate: 150, amount: 6000 },
  { description: 'UI/UX Consultation', quantity: 8, rate: 200, amount: 1600 },
  { description: 'Project Management', quantity: 12, rate: 100, amount: 1200 },
]

const displayItems = computed(() => props.items.length > 0 ? props.items : sampleItems)

const numberFont = computed(() =>
  props.template.typography.numberStyle === 'monospace-feel'
    ? "'Courier New', Courier, monospace"
    : 'inherit'
)

const dividerStyle = computed(() => {
  const base = { margin: '12px 0' }
  switch (props.template.layout.dividerStyle) {
    case 'thin': return { ...base, borderBottom: '1px solid ' + props.template.colors.tableBorder }
    case 'double': return { ...base, borderBottom: '3px double ' + props.template.colors.tableBorder }
    case 'thick': return { ...base, borderBottom: '2px solid ' + props.template.colors.primary }
    default: return base
  }
})

function rowStyle(index: number) {
  const style = props.template.layout.tableStyle
  const base: Record<string, string> = {}

  if (style === 'bordered') {
    base.border = '1px solid ' + props.template.colors.tableBorder
  } else if (style === 'striped' || style === 'colored-sections') {
    if (index % 2 === 1) base.backgroundColor = props.template.colors.tableRowAlt
  } else if (style === 'minimal-lines') {
    base.borderBottom = '1px solid ' + props.template.colors.tableBorder
  }
  // 'borderless' = no styling

  return base
}
</script>

Step 2: Verify build

Run: npm run build 2>&1 | tail -20

Step 3: Commit

git add src/components/InvoicePreview.vue
git commit -m "feat: add InvoicePreview.vue HTML template preview component"

Task 4: Create InvoiceTemplatePicker.vue

Files:

  • Create: src/components/InvoiceTemplatePicker.vue

Context: Split-pane template picker. Left panel (30%) has a scrollable list of template names grouped by category with small color dots. Right panel (70%) shows a live preview of the selected template. The component emits update:modelValue with the selected template ID.

<template>
  <div class="flex border border-border-subtle rounded-lg overflow-hidden" style="height: 480px;">
    <!-- Left: Template list -->
    <div class="w-[30%] border-r border-border-subtle overflow-y-auto bg-bg-surface">
      <div v-for="cat in TEMPLATE_CATEGORIES" :key="cat.id" class="mb-1">
        <p class="px-3 pt-3 pb-1 text-[0.5625rem] text-text-tertiary uppercase tracking-[0.1em] font-medium">{{ cat.label }}</p>
        <button
          v-for="tmpl in getTemplatesByCategory(cat.id)"
          :key="tmpl.id"
          @click="$emit('update:modelValue', tmpl.id)"
          class="w-full flex items-center gap-2 px-3 py-1.5 text-[0.75rem] transition-colors duration-100"
          :class="modelValue === tmpl.id
            ? 'bg-accent/10 text-accent-text'
            : 'text-text-secondary hover:bg-bg-elevated hover:text-text-primary'"
        >
          <span
            class="w-2.5 h-2.5 rounded-full shrink-0 border border-black/10"
            :style="{ backgroundColor: tmpl.colors.primary }"
          />
          <span class="truncate">{{ tmpl.name }}</span>
        </button>
      </div>
    </div>

    <!-- Right: Preview -->
    <div class="w-[70%] bg-bg-inset p-4 flex items-start justify-center overflow-y-auto">
      <div class="w-full max-w-sm">
        <InvoicePreview
          :template="selectedTemplate"
          :invoice="previewInvoice"
          :client="previewClient"
          :items="previewItems"
          :business-info="businessInfo"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import InvoicePreview from './InvoicePreview.vue'
import { TEMPLATE_CATEGORIES, getTemplatesByCategory, getTemplateById } from '../utils/invoiceTemplates'
import type { Invoice } from '../stores/invoices'
import type { Client } from '../stores/clients'
import type { InvoiceItem } from '../utils/invoicePdf'
import type { BusinessInfo } from '../utils/invoicePdfRenderer'

const props = withDefaults(defineProps<{
  modelValue: string
  invoice?: Invoice
  client?: Client | null
  items?: InvoiceItem[]
  businessInfo?: BusinessInfo
}>(), {
  invoice: () => ({
    client_id: 0,
    invoice_number: 'INV-2026-001',
    date: '2026-02-18',
    due_date: '2026-03-18',
    subtotal: 8800,
    tax_rate: 10,
    tax_amount: 880,
    discount: 0,
    total: 9680,
    notes: 'Payment due within 30 days. Thank you for your business!',
    status: 'pending',
  }),
  client: () => ({
    id: 1,
    name: 'Acme Corporation',
    email: 'billing@acme.com',
    address: '123 Business Ave\nSuite 100\nNew York, NY 10001',
  }),
  items: () => [],
})

defineEmits<{
  'update:modelValue': [value: string]
}>()

const selectedTemplate = computed(() => getTemplateById(props.modelValue))

const previewInvoice = computed(() => props.invoice as Invoice)
const previewClient = computed(() => props.client as Client)
const previewItems = computed(() => props.items || [])

const businessInfo = computed<BusinessInfo>(() => props.businessInfo || {
  name: 'Your Business Name',
  address: '456 Creative St, Design City',
  email: 'hello@business.com',
  phone: '(555) 123-4567',
  logo: '',
})
</script>

Step 2: Verify build

Run: npm run build 2>&1 | tail -20

Step 3: Commit

git add src/components/InvoiceTemplatePicker.vue
git commit -m "feat: add InvoiceTemplatePicker split-pane component"

Task 5: Integrate template picker into Invoices.vue

Files:

  • Modify: src/views/Invoices.vue

Context: Three integration points:

  1. Add template picker to the Create view (between the top form section and line items)
  2. Add template selector dropdown to the Preview dialog toolbar
  3. Pass selected template ID to generateInvoicePdf() and to InvoicePreview in the preview dialog
  4. Replace the hardcoded amber preview in the dialog with InvoicePreview component

Step 1: Add imports and state

At the top of <script setup> (around line 534), add:

import InvoicePreview from '../components/InvoicePreview.vue'
import InvoiceTemplatePicker from '../components/InvoiceTemplatePicker.vue'
import { getTemplateById, INVOICE_TEMPLATES } from '../utils/invoiceTemplates'
import type { BusinessInfo } from '../utils/invoicePdfRenderer'

Add state for selected template (after showTokenHelp ref around line 582):

const selectedTemplateId = ref('clean-minimal')

Add computed for business info (reads from settings store):

const businessInfo = computed<BusinessInfo>(() => ({
  name: settingsStore.settings.business_name || '',
  address: settingsStore.settings.business_address || '',
  email: settingsStore.settings.business_email || '',
  phone: settingsStore.settings.business_phone || '',
  logo: settingsStore.settings.business_logo || '',
}))

Import useSettingsStore and create store instance:

import { useSettingsStore } from '../stores/settings'
const settingsStore = useSettingsStore()

(Settings store is already fetched on mount by the global app setup, but add settingsStore.fetchSettings() to the onMounted Promise.all if not already there.)

Step 2: Add template picker to Create view

In the template, after the "Line Items" section (after line 313), add the template picker:

<!-- Template Selection -->
<div>
  <label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-2">Invoice Template</label>
  <InvoiceTemplatePicker
    v-model="selectedTemplateId"
    :invoice="previewCreateInvoice"
    :client="selectedClient"
    :items="createPreviewItems"
    :business-info="businessInfo"
  />
</div>

Add computed for create form preview data:

const previewCreateInvoice = computed<Invoice>(() => ({
  client_id: createForm.client_id,
  invoice_number: resolvedInvoiceNumber.value,
  date: createForm.date,
  due_date: createForm.due_date || undefined,
  subtotal: calculateSubtotal(),
  tax_rate: createForm.tax_rate,
  tax_amount: calculateTax(),
  discount: createForm.discount,
  total: calculateTotal(),
  notes: createForm.notes || undefined,
  status: 'pending',
}))

const createPreviewItems = computed<InvoiceItem[]>(() =>
  lineItems.value.map(li => ({
    description: li.description,
    quantity: li.quantity,
    rate: li.unit_price,
    amount: li.quantity * li.unit_price,
  }))
)

(Import InvoiceItem from ../utils/invoicePdf — already imported in the file on line 541 via the store.)

Step 3: Replace hardcoded preview dialog with InvoicePreview component

Replace the entire "Letter-size page" div (lines 417-497) with:

<!-- Template selector in toolbar -->
<div class="flex items-center gap-3 mb-4 shrink-0">
  <select
    v-model="selectedTemplateId"
    class="px-3 py-2 bg-white/10 border border-white/20 text-white text-[0.75rem] rounded-lg focus:outline-none"
  >
    <option v-for="tmpl in INVOICE_TEMPLATES" :key="tmpl.id" :value="tmpl.id" class="text-black">
      {{ tmpl.name }}
    </option>
  </select>
  <button
    @click="exportPDF(selectedInvoice!)"
    class="px-4 py-2 bg-accent text-bg-base text-[0.75rem] font-medium rounded-lg hover:bg-accent-hover transition-colors"
  >
    Export PDF
  </button>
  <button
    @click="showDetailDialog = false"
    class="px-4 py-2 border border-white/20 text-white/80 text-[0.75rem] rounded-lg hover:bg-white/10 transition-colors"
  >
    Close
  </button>
</div>

<!-- Scaled invoice preview -->
<div class="w-[8.5in]">
  <InvoicePreview
    :template="getTemplateById(selectedTemplateId)"
    :invoice="selectedInvoice!"
    :client="previewClient"
    :items="previewItems"
    :business-info="businessInfo"
  />
</div>

Step 4: Update exportPDF to pass template ID

In the exportPDF function (around line 755), change:

const doc = generateInvoicePdf(invoice, client, items)

to:

const doc = generateInvoicePdf(invoice, client, items, selectedTemplateId.value, businessInfo.value)

Step 5: Verify build

Run: npm run build 2>&1 | tail -20

Step 6: Commit

git add src/views/Invoices.vue
git commit -m "feat: integrate template picker into invoice create and preview views"

Task 6: Add business identity settings to Settings.vue

Files:

  • Modify: src/views/Settings.vue

Context: The Billing tab in Settings.vue (line 311-385) currently has hourly rate and rounding settings. Add a "Business Identity" section with fields for business name, address, email, phone, and logo upload. These values are stored as settings keys (business_name, business_address, business_email, business_phone, business_logo).

Step 1: Add business identity state variables

After the roundingMethod ref (around line 559), add:

const businessName = ref('')
const businessAddress = ref('')
const businessEmail = ref('')
const businessPhone = ref('')
const businessLogo = ref('')

Step 2: Add business identity section to the Billing tab template

After the rounding section (around line 384), before the closing </div> of the billing tab, add:

<!-- Divider -->
<div class="border-t border-border-subtle mt-5 pt-5" />

<!-- Business Identity -->
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium mb-4">Business Identity</h3>
<p class="text-[0.6875rem] text-text-tertiary mb-4">This information appears on your invoices.</p>

<div class="space-y-4 max-w-md">
  <div>
    <label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Business Name</label>
    <input
      v-model="businessName"
      type="text"
      class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
      placeholder="Your Company Name"
      @change="saveBusinessSettings"
    />
  </div>
  <div>
    <label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Address</label>
    <textarea
      v-model="businessAddress"
      rows="3"
      class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible resize-none"
      placeholder="123 Business St&#10;City, State ZIP"
      @change="saveBusinessSettings"
    ></textarea>
  </div>
  <div class="grid grid-cols-2 gap-4">
    <div>
      <label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Email</label>
      <input
        v-model="businessEmail"
        type="email"
        class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
        placeholder="billing@company.com"
        @change="saveBusinessSettings"
      />
    </div>
    <div>
      <label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Phone</label>
      <input
        v-model="businessPhone"
        type="tel"
        class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
        placeholder="(555) 123-4567"
        @change="saveBusinessSettings"
      />
    </div>
  </div>
  <div>
    <label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Logo</label>
    <div class="flex items-center gap-4">
      <div
        v-if="businessLogo"
        class="w-20 h-12 border border-border-subtle rounded flex items-center justify-center overflow-hidden bg-white"
      >
        <img :src="businessLogo" class="max-w-full max-h-full object-contain" />
      </div>
      <div class="flex gap-2">
        <button
          @click="uploadLogo"
          class="px-3 py-1.5 text-[0.75rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors"
        >
          {{ businessLogo ? 'Change' : 'Upload' }}
        </button>
        <button
          v-if="businessLogo"
          @click="removeLogo"
          class="px-3 py-1.5 text-[0.75rem] border border-border-subtle text-status-error rounded-lg hover:bg-status-error/10 transition-colors"
        >
          Remove
        </button>
      </div>
    </div>
    <p class="text-[0.625rem] text-text-tertiary mt-1">PNG or JPG, max 200x80px. Appears on invoice header.</p>
  </div>
</div>

Step 3: Add save and logo upload functions

async function saveBusinessSettings() {
  await settingsStore.updateSetting('business_name', businessName.value)
  await settingsStore.updateSetting('business_address', businessAddress.value)
  await settingsStore.updateSetting('business_email', businessEmail.value)
  await settingsStore.updateSetting('business_phone', businessPhone.value)
}

async function uploadLogo() {
  try {
    const { open } = await import('@tauri-apps/plugin-dialog')
    const selected = await open({
      filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg'] }]
    })
    if (!selected) return

    // Read file as base64
    const { readFile } = await import('@tauri-apps/plugin-fs')
    const bytes = await readFile(selected as string)
    const ext = (selected as string).toLowerCase().endsWith('.png') ? 'png' : 'jpeg'
    const base64 = btoa(String.fromCharCode(...bytes))
    const dataUrl = `data:image/${ext};base64,${base64}`
    businessLogo.value = dataUrl
    await settingsStore.updateSetting('business_logo', dataUrl)
  } catch (e) {
    console.error('Failed to upload logo:', e)
    toastStore.error('Failed to upload logo')
  }
}

async function removeLogo() {
  businessLogo.value = ''
  await settingsStore.updateSetting('business_logo', '')
}

Step 4: Load business settings in onMounted

Add to the onMounted callback (around line 805-822):

businessName.value = settingsStore.settings.business_name || ''
businessAddress.value = settingsStore.settings.business_address || ''
businessEmail.value = settingsStore.settings.business_email || ''
businessPhone.value = settingsStore.settings.business_phone || ''
businessLogo.value = settingsStore.settings.business_logo || ''

Step 5: Verify build

Run: npm run build 2>&1 | tail -20

Step 6: Commit

git add src/views/Settings.vue
git commit -m "feat: add business identity settings for invoice branding"

Task 7: Full renderer implementation and testing

Files:

  • Modify: src/utils/invoicePdfRenderer.ts (complete the full implementation)

Context: Task 2 created the skeleton. This task fills in every render function with complete jsPDF drawing code. This is the largest single task since each header style and table style needs precise coordinate math.

Step 1: Implement all render functions

Complete the full renderDecorative, renderHeader, renderTable, renderTotals, renderNotes functions.

Key implementation patterns:

Color interpolation for gradients:

function interpolateColor(hex1: string, hex2: string, t: number): string {
  const r1 = parseInt(hex1.slice(1,3), 16), g1 = parseInt(hex1.slice(3,5), 16), b1 = parseInt(hex1.slice(5,7), 16)
  const r2 = parseInt(hex2.slice(1,3), 16), g2 = parseInt(hex2.slice(3,5), 16), b2 = parseInt(hex2.slice(5,7), 16)
  const r = Math.round(r1 + (r2 - r1) * t)
  const g = Math.round(g1 + (g2 - g1) * t)
  const b = Math.round(b1 + (b2 - b1) * t)
  return `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`
}

Gradient header rendering:

if (config.decorative.useGradientHeader && config.decorative.gradientFrom && config.decorative.gradientTo) {
  const gradientHeight = 45
  const steps = 30
  for (let i = 0; i < steps; i++) {
    const t = i / steps
    doc.setFillColor(interpolateColor(config.decorative.gradientFrom, config.decorative.gradientTo, t))
    doc.rect(0, (gradientHeight / steps) * i, pageWidth, gradientHeight / steps + 0.5, 'F')
  }
}

Split header:

if (config.layout.headerStyle === 'split') {
  doc.setFillColor(config.colors.headerBg)
  doc.rect(0, 0, pageWidth / 2, 50, 'F')
  // Left side: white text with business info
  // Right side: dark text with invoice details
}

Logo rendering:

if (businessInfo.logo) {
  try {
    const logoX = config.layout.logoPosition === 'top-right' ? pageWidth - margin - 40
      : config.layout.logoPosition === 'top-center' ? pageWidth / 2 - 20
      : margin
    doc.addImage(businessInfo.logo, 'PNG', logoX, y, 40, 16)
  } catch { /* silently skip if logo is invalid */ }
}

Table with page break + background redraw:

for (let idx = 0; idx < items.length; idx++) {
  if (y > 260) {
    doc.addPage()
    // Redraw page background on new page
    if (config.colors.background !== '#FFFFFF') {
      doc.setFillColor(config.colors.background)
      doc.rect(0, 0, pageWidth, pageHeight, 'F')
    }
    if (config.decorative.sidebarWidth > 0) {
      doc.setFillColor(config.decorative.sidebarColor)
      doc.rect(0, 0, config.decorative.sidebarWidth, pageHeight, 'F')
    }
    y = margin
  }
  // ... render row
}

Step 2: Test all 15 templates

After building, manually test by:

  1. Run npm run tauri dev
  2. Create a test invoice with 3+ line items
  3. In the preview dialog, select each of the 15 templates from the dropdown
  4. Verify the HTML preview looks correct for each
  5. Export PDF for at least 3 different templates (one from each category)
  6. Open the PDFs and verify they match the previews

Step 3: Verify build

Run: npm run build 2>&1 | tail -20

Step 4: Commit

git add src/utils/invoicePdfRenderer.ts
git commit -m "feat: complete jsPDF renderer with all 7 header styles, 5 table styles, and decorative elements"

Task 8: Polish InvoicePreview.vue — all 7 header styles

Files:

  • Modify: src/components/InvoicePreview.vue

Context: Task 3 created a basic preview with only full-width and minimal header styles. This task adds the remaining 5 header style variants (split, sidebar, gradient, geometric, centered) to the HTML preview so it matches the PDF renderer.

Step 1: Add all header style variants

In the template, replace the simple v-if/v-else header section with all 7 variants:

  • full-width: Full-width colored header band (already done)
  • minimal: Simple title + accent line (already done)
  • split: Left half dark, right half light. Use display: flex with two children at 50%.
  • sidebar: Content offset from the left sidebar. The sidebar is already drawn as a decorative element.
  • gradient: CSS linear-gradient from gradientFrom to gradientTo as header background.
  • geometric: The SVG triangle is already in decorative. Header content offset to avoid the triangle.
  • centered: Title centered, business name centered below, horizontal rule dividers above and below.

Step 2: Add business info rendering to headers

Each header style should show the business name and info if available:

<p v-if="businessInfo?.name" :style="{ fontSize: '10px', fontWeight: 600 }">{{ businessInfo.name }}</p>
<p v-if="businessInfo?.address" :style="{ fontSize: '7px', whiteSpace: 'pre-line' }">{{ businessInfo.address }}</p>

Step 3: Add logo rendering

<img
  v-if="businessInfo?.logo"
  :src="businessInfo.logo"
  :style="{
    maxWidth: '60px', maxHeight: '24px', objectFit: 'contain',
    position: logoPosition === 'top-right' ? 'absolute' : 'static',
    ...logoPositionStyle
  }"
/>

Step 4: Verify build and visual correctness

Run dev server. In the template picker, cycle through all 15 templates and verify each header looks visually correct.

Step 5: Commit

git add src/components/InvoicePreview.vue
git commit -m "feat: add all 7 header styles and business info to invoice preview"

Task 9: Final integration and cleanup

Files:

  • Modify: src/views/Invoices.vue (add settings store fetch)
  • Modify: src/utils/invoicePdf.ts (ensure backward compatibility)

Context: Final integration pass. Make sure:

  1. Settings store is fetched on mount in Invoices.vue so businessInfo is available
  2. The generateInvoicePdf function signature change doesn't break anything
  3. Remove any leftover hardcoded amber references
  4. The InvoiceItem export from invoicePdf.ts is still importable

Step 1: Ensure settings store is loaded in Invoices.vue

In Invoices.vue onMounted (line 774), add settingsStore.fetchSettings() to the Promise.all:

onMounted(async () => {
  await Promise.all([
    invoicesStore.fetchInvoices(),
    clientsStore.fetchClients(),
    projectsStore.fetchProjects(),
    settingsStore.fetchSettings(),
  ])
})

Step 2: Verify the export chain

Make sure InvoiceItem is still exported from src/utils/invoicePdf.ts so that imports in Invoices.vue:541 still work:

// In src/utils/invoicePdf.ts
export type { InvoiceItem }  // or keep the interface export

Step 3: Run full build

Run: npm run build 2>&1 | tail -20 Expected: Build succeeds with no errors.

Step 4: Verify dev server

Run: npm run tauri dev Test:

  • Navigate to Invoices > Create. Template picker should appear below line items.
  • Select different templates. Preview updates live.
  • Create an invoice with line items.
  • View the invoice. Preview dialog should show template-styled invoice.
  • Change template in dropdown. Preview updates.
  • Export PDF. Open the file — should match the preview.

Step 5: Commit

git add src/views/Invoices.vue src/utils/invoicePdf.ts
git commit -m "feat: complete invoice template integration with settings and backward compatibility"

Verification Checklist

  1. All 15 templates render correctly in the HTML preview (InvoicePreview.vue)
  2. All 15 templates render correctly in PDF export (invoicePdfRenderer.ts)
  3. Template picker shows all templates grouped by category with live preview
  4. Each template is visually distinct — different colors, layouts, decorative elements
  5. Business identity (name, address, email, phone) appears on invoices when set in Settings
  6. Logo uploads, displays in preview, and renders in PDF
  7. Currency/locale formatting works across all templates (uses formatCurrency from locale.ts)
  8. Long invoice content (many line items) handles page breaks correctly with background redraw
  9. PDF export works via Tauri save dialog for all templates
  10. Build passes: npm run build