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

1716 lines
60 KiB
Markdown

# 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:
```ts
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**
```bash
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 }`.
```ts
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:
```ts
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:
```ts
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**
```bash
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**
```vue
<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**
```bash
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.
```vue
<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**
```bash
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:
```ts
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):
```ts
const selectedTemplateId = ref('clean-minimal')
```
Add computed for business info (reads from settings store):
```ts
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:
```ts
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:
```html
<!-- 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:
```ts
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:
```html
<!-- 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:
```ts
const doc = generateInvoicePdf(invoice, client, items)
```
to:
```ts
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**
```bash
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:
```ts
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:
```html
<!-- 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**
```ts
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):
```ts
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**
```bash
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:**
```ts
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:**
```ts
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:**
```ts
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:**
```ts
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:**
```ts
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**
```bash
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:
```html
<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**
```html
<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**
```bash
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:
```ts
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:
```ts
// 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**
```bash
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`