9-task plan covering template config types, jsPDF renderer, HTML preview component, template picker UI, Invoices.vue integration, business identity settings, and polish passes.
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 bordersstriped: Alternating row backgrounds, no vertical bordersborderless: No borders at all, just spacingminimal-lines: Horizontal lines only between rowscolored-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.logois set, render viadoc.addImage()at the position specified byconfig.layout.logoPosition. - Page breaks: Check
yPosition > 270before each row, add new page if needed.
Key technical notes:
- jsPDF gradient: Simulate by drawing 20+ thin horizontal
rect()strips with interpolated colors fromgradientFromtogradientTo. - 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. Usehelveticafor all. Formonospace-feelnumbers, usecourierfor amount columns only. - Color parsing: The config uses hex strings. jsPDF
setFillColor/setTextColoraccept 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: drawdoc.rect(0, 0, config.decorative.sidebarWidth, pageHeight, 'F')withsidebarColor. - If
config.decorative.cornerShape === 'colored-block': drawdoc.rect(0, 0, 60, 60, 'F')withconfig.colors.primary. - If
config.decorative.cornerShape === 'triangle': drawdoc.triangle(pageWidth, 0, pageWidth, 80, pageWidth - 80, 0, 'F')withconfig.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 fromgradientFromtogradientTo. 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 bysidebarWidth + 6. - After header style-specific rendering, always draw:
- Invoice title "INVOICE" in
config.colors.primaryatconfig.typography.titleSize - Invoice number, date, due date, status
- "Bill To:" section with client info
- Logo if available
- Invoice title "INVOICE" in
- 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, textconfig.colors.tableHeaderText - For each item row: apply table style
'bordered': draw rect border around each cell'striped': draw alternatingconfig.colors.tableRowAltbackgrounds'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', usecourierfont 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.totalHighlightat 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:
- Add template picker to the Create view (between the top form section and line items)
- Add template selector dropdown to the Preview dialog toolbar
- Pass selected template ID to
generateInvoicePdf()and toInvoicePreviewin the preview dialog - Replace the hardcoded amber preview in the dialog with
InvoicePreviewcomponent
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 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:
- Run
npm run tauri dev - Create a test invoice with 3+ line items
- In the preview dialog, select each of the 15 templates from the dropdown
- Verify the HTML preview looks correct for each
- Export PDF for at least 3 different templates (one from each category)
- 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. Usedisplay: flexwith two children at 50%.sidebar: Content offset from the left sidebar. The sidebar is already drawn as a decorative element.gradient: CSSlinear-gradientfromgradientFromtogradientToas 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:
- Settings store is fetched on mount in Invoices.vue so
businessInfois available - The
generateInvoicePdffunction signature change doesn't break anything - Remove any leftover hardcoded amber references
- The
InvoiceItemexport frominvoicePdf.tsis 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
- All 15 templates render correctly in the HTML preview (InvoicePreview.vue)
- All 15 templates render correctly in PDF export (invoicePdfRenderer.ts)
- Template picker shows all templates grouped by category with live preview
- Each template is visually distinct — different colors, layouts, decorative elements
- Business identity (name, address, email, phone) appears on invoices when set in Settings
- Logo uploads, displays in preview, and renders in PDF
- Currency/locale formatting works across all templates (uses
formatCurrencyfrom locale.ts) - Long invoice content (many line items) handles page breaks correctly with background redraw
- PDF export works via Tauri save dialog for all templates
- Build passes:
npm run build