Files
zeroclock/docs/plans/2026-02-18-invoice-templates-v2-implementation.md
2026-02-18 14:32:38 +02:00

33 KiB
Raw Blame History

Invoice Templates v2 Implementation Plan

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

Goal: Replace the invoice template system with 15 genuinely beautiful, typographically distinct templates and a two-step UX flow (create invoice → full-screen template picker).

Architecture: The invoices view gains a third state 'template-picker' that shows a full-screen split-pane picker after invoice creation. Each of the 15 templates has its own unique HTML layout in InvoicePreview.vue and its own PDF render function in invoicePdfRenderer.ts, following researched typography scales. A new template_id column on the invoices table persists the user's template choice.

Tech Stack: Vue 3 Composition API, Tailwind CSS v4, jsPDF, Tauri v2 (Rust/rusqlite), Pinia


Task 1: Add template_id column to database and Rust backend

Files:

  • Modify: src-tauri/src/database.rs:94-112 (invoices table area)
  • Modify: src-tauri/src/commands.rs:52-65 (Invoice struct)
  • Modify: src-tauri/src/commands.rs:317-367 (create/get/update invoice commands)
  • Modify: src-tauri/src/lib.rs:39-89 (register new command)

Step 1: Add migration in database.rs

After the invoices CREATE TABLE (around line 112), add a safe migration using the same pattern as clients/projects:

// Migrate invoices table — add template_id column (safe to re-run)
let invoice_migrations = [
    "ALTER TABLE invoices ADD COLUMN template_id TEXT DEFAULT 'clean'",
];
for sql in &invoice_migrations {
    match conn.execute(sql, []) {
        Ok(_) => {}
        Err(e) => {
            let msg = e.to_string();
            if !msg.contains("duplicate column") {
                return Err(e);
            }
        }
    }
}

Step 2: Add template_id to Invoice struct in commands.rs

Add to the Invoice struct (after status field at line 64):

pub template_id: Option<String>,

Step 3: Update get_invoices to SELECT template_id

In get_invoices (line 330), change the SELECT to include template_id at position 12:

"SELECT id, client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status, template_id
 FROM invoices ORDER BY date DESC"

And in the row mapping, add after status: row.get(11)?:

template_id: row.get(12)?,

Step 4: Update create_invoice to INSERT template_id

In create_invoice (line 317), change the INSERT:

"INSERT INTO invoices (client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status, template_id)
 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![invoice.client_id, invoice.invoice_number, invoice.date, invoice.due_date,
        invoice.subtotal, invoice.tax_rate, invoice.tax_amount, invoice.discount,
        invoice.total, invoice.notes, invoice.status, invoice.template_id],

Step 5: Update update_invoice to SET template_id

In update_invoice (line 356), change the UPDATE:

"UPDATE invoices SET client_id = ?1, invoice_number = ?2, date = ?3, due_date = ?4,
 subtotal = ?5, tax_rate = ?6, tax_amount = ?7, discount = ?8, total = ?9, notes = ?10, status = ?11, template_id = ?12
 WHERE id = ?13",
params![invoice.client_id, invoice.invoice_number, invoice.date, invoice.due_date,
        invoice.subtotal, invoice.tax_rate, invoice.tax_amount, invoice.discount,
        invoice.total, invoice.notes, invoice.status, invoice.template_id, invoice.id],

Step 6: Add update_invoice_template command

Add a new lightweight command after delete_invoice (after line 374):

#[tauri::command]
pub fn update_invoice_template(state: State<AppState>, id: i64, template_id: String) -> Result<(), String> {
    let conn = state.db.lock().map_err(|e| e.to_string())?;
    conn.execute(
        "UPDATE invoices SET template_id = ?1 WHERE id = ?2",
        params![template_id, id],
    ).map_err(|e| e.to_string())?;
    Ok(())
}

Step 7: Register in lib.rs

Add commands::update_invoice_template, after the commands::delete_invoice, line in the generate_handler! macro.

Step 8: Verify

Run: cd src-tauri && cargo build Expected: Compiles with no errors.

Step 9: Commit

git add src-tauri/src/database.rs src-tauri/src/commands.rs src-tauri/src/lib.rs
git commit -m "feat: add template_id column to invoices table and update_invoice_template command"

Task 2: Update frontend Invoice interface and store

Files:

  • Modify: src/stores/invoices.ts:5-18 (Invoice interface)
  • Modify: src/stores/invoices.ts (add updateInvoiceTemplate action)

Step 1: Add template_id to Invoice interface

In src/stores/invoices.ts, add template_id to the Invoice interface (after status: string at line 17):

template_id?: string

Step 2: Add updateInvoiceTemplate store action

Add after the deleteInvoice function (after line 79):

async function updateInvoiceTemplate(id: number, templateId: string): Promise<boolean> {
  try {
    await invoke('update_invoice_template', { id, templateId })
    const index = invoices.value.findIndex(i => i.id === id)
    if (index !== -1) {
      invoices.value[index].template_id = templateId
    }
    return true
  } catch (error) {
    console.error('Failed to update invoice template:', error)
    return false
  }
}

Step 3: Export the new action

Add updateInvoiceTemplate to the return object (around line 111-121).

Step 4: Commit

git add src/stores/invoices.ts
git commit -m "feat: add template_id to Invoice interface and updateInvoiceTemplate action"

Task 3: Rewrite invoiceTemplates.ts with new IDs and design doc colors

Files:

  • Rewrite: src/utils/invoiceTemplates.ts

Step 1: Rewrite the entire file

Replace the entire file with new template configs matching the design doc's 15 templates. The new IDs are: clean, professional, bold, minimal, classic, modern, elegant, creative, compact, dark, vibrant, corporate, fresh, natural, statement.

The interface stays simple — id, name, category, description, colors — but the color object maps to the exact hex values from the design doc for each template.

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 InvoiceTemplateConfig {
  id: string
  name: string
  category: 'essential' | 'creative' | 'warm' | 'premium'
  description: string
  colors: InvoiceTemplateColors
}

const clean: InvoiceTemplateConfig = {
  id: 'clean',
  name: 'Clean',
  category: 'essential',
  description: 'Swiss minimalism with a single blue accent',
  colors: {
    primary: '#1e293b',
    secondary: '#3b82f6',
    background: '#ffffff',
    headerBg: '#ffffff',
    headerText: '#1e293b',
    bodyText: '#374151',
    tableHeaderBg: '#f8fafc',
    tableHeaderText: '#374151',
    tableRowAlt: '#f8fafc',
    tableBorder: '#e5e7eb',
    totalHighlight: '#3b82f6',
  },
}

const professional: InvoiceTemplateConfig = {
  id: 'professional',
  name: 'Professional',
  category: 'essential',
  description: 'Navy header band with corporate polish',
  colors: {
    primary: '#1e3a5f',
    secondary: '#2563eb',
    background: '#ffffff',
    headerBg: '#1e3a5f',
    headerText: '#ffffff',
    bodyText: '#374151',
    tableHeaderBg: '#1e3a5f',
    tableHeaderText: '#ffffff',
    tableRowAlt: '#f3f4f6',
    tableBorder: '#d1d5db',
    totalHighlight: '#1e3a5f',
  },
}

const bold: InvoiceTemplateConfig = {
  id: 'bold',
  name: 'Bold',
  category: 'essential',
  description: 'Large indigo block with oversized typography',
  colors: {
    primary: '#4f46e5',
    secondary: '#a5b4fc',
    background: '#ffffff',
    headerBg: '#4f46e5',
    headerText: '#ffffff',
    bodyText: '#1f2937',
    tableHeaderBg: '#4f46e5',
    tableHeaderText: '#ffffff',
    tableRowAlt: '#f5f3ff',
    tableBorder: '#e0e7ff',
    totalHighlight: '#4f46e5',
  },
}

const minimal: InvoiceTemplateConfig = {
  id: 'minimal',
  name: 'Minimal',
  category: 'essential',
  description: 'Pure monochrome centered layout',
  colors: {
    primary: '#18181b',
    secondary: '#18181b',
    background: '#ffffff',
    headerBg: '#ffffff',
    headerText: '#18181b',
    bodyText: '#3f3f46',
    tableHeaderBg: '#ffffff',
    tableHeaderText: '#18181b',
    tableRowAlt: '#ffffff',
    tableBorder: '#e4e4e7',
    totalHighlight: '#18181b',
  },
}

const classic: InvoiceTemplateConfig = {
  id: 'classic',
  name: 'Classic',
  category: 'essential',
  description: 'Traditional layout with burgundy accents',
  colors: {
    primary: '#7f1d1d',
    secondary: '#991b1b',
    background: '#ffffff',
    headerBg: '#7f1d1d',
    headerText: '#ffffff',
    bodyText: '#374151',
    tableHeaderBg: '#7f1d1d',
    tableHeaderText: '#ffffff',
    tableRowAlt: '#f5f5f4',
    tableBorder: '#d6d3d1',
    totalHighlight: '#7f1d1d',
  },
}

const modern: InvoiceTemplateConfig = {
  id: 'modern',
  name: 'Modern',
  category: 'creative',
  description: 'Asymmetric header with teal accents',
  colors: {
    primary: '#0d9488',
    secondary: '#14b8a6',
    background: '#ffffff',
    headerBg: '#ffffff',
    headerText: '#0f172a',
    bodyText: '#334155',
    tableHeaderBg: '#ffffff',
    tableHeaderText: '#0d9488',
    tableRowAlt: '#f0fdfa',
    tableBorder: '#99f6e4',
    totalHighlight: '#0d9488',
  },
}

const elegant: InvoiceTemplateConfig = {
  id: 'elegant',
  name: 'Elegant',
  category: 'creative',
  description: 'Gold double-rule accents on centered layout',
  colors: {
    primary: '#a16207',
    secondary: '#ca8a04',
    background: '#ffffff',
    headerBg: '#ffffff',
    headerText: '#422006',
    bodyText: '#57534e',
    tableHeaderBg: '#ffffff',
    tableHeaderText: '#422006',
    tableRowAlt: '#fefce8',
    tableBorder: '#a16207',
    totalHighlight: '#a16207',
  },
}

const creative: InvoiceTemplateConfig = {
  id: 'creative',
  name: 'Creative',
  category: 'creative',
  description: 'Purple sidebar with card-style rows',
  colors: {
    primary: '#7c3aed',
    secondary: '#a78bfa',
    background: '#ffffff',
    headerBg: '#ffffff',
    headerText: '#1f2937',
    bodyText: '#374151',
    tableHeaderBg: '#faf5ff',
    tableHeaderText: '#7c3aed',
    tableRowAlt: '#faf5ff',
    tableBorder: '#e9d5ff',
    totalHighlight: '#7c3aed',
  },
}

const compact: InvoiceTemplateConfig = {
  id: 'compact',
  name: 'Compact',
  category: 'creative',
  description: 'Data-dense layout with tight spacing',
  colors: {
    primary: '#475569',
    secondary: '#64748b',
    background: '#ffffff',
    headerBg: '#ffffff',
    headerText: '#0f172a',
    bodyText: '#334155',
    tableHeaderBg: '#f1f5f9',
    tableHeaderText: '#334155',
    tableRowAlt: '#f8fafc',
    tableBorder: '#e2e8f0',
    totalHighlight: '#475569',
  },
}

const dark: InvoiceTemplateConfig = {
  id: 'dark',
  name: 'Dark',
  category: 'warm',
  description: 'Near-black background with cyan highlights',
  colors: {
    primary: '#06b6d4',
    secondary: '#22d3ee',
    background: '#0f172a',
    headerBg: '#020617',
    headerText: '#e2e8f0',
    bodyText: '#cbd5e1',
    tableHeaderBg: '#020617',
    tableHeaderText: '#06b6d4',
    tableRowAlt: '#1e293b',
    tableBorder: '#334155',
    totalHighlight: '#06b6d4',
  },
}

const vibrant: InvoiceTemplateConfig = {
  id: 'vibrant',
  name: 'Vibrant',
  category: 'warm',
  description: 'Coral-to-orange gradient header band',
  colors: {
    primary: '#ea580c',
    secondary: '#f97316',
    background: '#ffffff',
    headerBg: '#ea580c',
    headerText: '#ffffff',
    bodyText: '#1f2937',
    tableHeaderBg: '#fff7ed',
    tableHeaderText: '#9a3412',
    tableRowAlt: '#fff7ed',
    tableBorder: '#fed7aa',
    totalHighlight: '#ea580c',
  },
}

const corporate: InvoiceTemplateConfig = {
  id: 'corporate',
  name: 'Corporate',
  category: 'warm',
  description: 'Deep blue header with info bar below',
  colors: {
    primary: '#1e40af',
    secondary: '#3b82f6',
    background: '#ffffff',
    headerBg: '#1e40af',
    headerText: '#ffffff',
    bodyText: '#1f2937',
    tableHeaderBg: '#1e40af',
    tableHeaderText: '#ffffff',
    tableRowAlt: '#eff6ff',
    tableBorder: '#bfdbfe',
    totalHighlight: '#1e40af',
  },
}

const fresh: InvoiceTemplateConfig = {
  id: 'fresh',
  name: 'Fresh',
  category: 'premium',
  description: 'Oversized watermark invoice number',
  colors: {
    primary: '#0284c7',
    secondary: '#38bdf8',
    background: '#ffffff',
    headerBg: '#ffffff',
    headerText: '#0c4a6e',
    bodyText: '#334155',
    tableHeaderBg: '#0284c7',
    tableHeaderText: '#ffffff',
    tableRowAlt: '#f0f9ff',
    tableBorder: '#bae6fd',
    totalHighlight: '#0284c7',
  },
}

const natural: InvoiceTemplateConfig = {
  id: 'natural',
  name: 'Natural',
  category: 'premium',
  description: 'Warm beige background with terracotta accents',
  colors: {
    primary: '#c2703e',
    secondary: '#d97706',
    background: '#fdf6ec',
    headerBg: '#fdf6ec',
    headerText: '#78350f',
    bodyText: '#57534e',
    tableHeaderBg: '#c2703e',
    tableHeaderText: '#ffffff',
    tableRowAlt: '#fef3c7',
    tableBorder: '#d6d3d1',
    totalHighlight: '#c2703e',
  },
}

const statement: InvoiceTemplateConfig = {
  id: 'statement',
  name: 'Statement',
  category: 'premium',
  description: 'Total-forward design with hero amount',
  colors: {
    primary: '#18181b',
    secondary: '#be123c',
    background: '#ffffff',
    headerBg: '#ffffff',
    headerText: '#18181b',
    bodyText: '#3f3f46',
    tableHeaderBg: '#ffffff',
    tableHeaderText: '#18181b',
    tableRowAlt: '#ffffff',
    tableBorder: '#e4e4e7',
    totalHighlight: '#be123c',
  },
}

export const INVOICE_TEMPLATES: InvoiceTemplateConfig[] = [
  clean, professional, bold, minimal, classic,
  modern, elegant, creative, compact,
  dark, vibrant, corporate,
  fresh, natural, statement,
]

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: Commit

git add src/utils/invoiceTemplates.ts
git commit -m "feat: rewrite invoice template configs with design-doc IDs and colors"

Task 4: Rewrite InvoicePreview.vue with 15 unique HTML layouts

Files:

  • Rewrite: src/components/InvoicePreview.vue

This is the largest single task. Each of the 15 templates needs a genuinely unique HTML layout following the design doc specifications. The component receives template, invoice, client, items, businessInfo props and renders a scaled A4 preview (~300px wide).

Typography scale for HTML preview (from design doc):

  • "INVOICE" title: 18-24px bold
  • Company name: 11-14px semi-bold
  • Section headers: 8-9px uppercase, letter-spacing 0.05em
  • Body text: 7.5-8px regular
  • Column headers: 7-7.5px medium, uppercase
  • Total amount: 12-16px bold
  • Fine print: 6.5-7px regular

Key design rules:

  • Total Due is the most prominent number in every template
  • Borderless tables (thin horizontal rules, no vertical borders, no spreadsheet grids)
  • Right-align monetary values, left-align descriptions
  • Tables use thin bottom borders per row (border-bottom: 0.5px solid color)
  • Zebra striping only where specified (alternate barely-gray #f8fafc)
  • Generous whitespace between sections

Per-template layout descriptions (from design doc):

  1. Clean: Logo top-left, biz name below, "INVOICE" in slate with thin accent line (30% width). Two-column From/To. Borderless table with thin gray row borders, light gray header bg. Blue accent only on total.

  2. Professional: Full-width navy band across top ~17% height. White "INVOICE" large inside band. Biz name white right side. Navy header table row, light gray zebra stripes.

  3. Bold: Large indigo rectangle top-left ~55% width × 17% height. "INVOICE" massive white inside. Biz info outside block to right. Indigo header row, no borders at all, generous row height.

  4. Minimal: Everything centered. Logo centered. Thin rule. "INVOICE" centered in charcoal. No backgrounds anywhere. Whitespace-only row separation. Pure monochrome #18181b.

  5. Classic: Traditional two-column header. Biz info left, "INVOICE" + meta right. Thin burgundy rule below. Burgundy header bg with white text. Light bordered grid. Warm gray alternating rows.

  6. Modern: Asymmetric — "INVOICE" large top-left. Biz info pushed right. Thin teal line separator. Teal header text (no bg fill), thin teal bottom borders. Teal bg strip behind total row.

  7. Elegant: Centered. Gold double-rule at top (two thin lines). "INVOICE" centered below. Another double-rule. Gold rules between rows. No colored header bg.

  8. Creative: Narrow purple sidebar on left (~3% width), full height. Content offset past sidebar. Card-style rows (faint bg + padding). Purple-tinted header text.

  9. Compact: Single-line header — biz name left, "INVOICE #XXX" right, same line. Tight zebra stripes (small row height). Efficient space usage. Slate accent.

  10. Dark: Near-black #0f172a background. Light text #e2e8f0. "INVOICE" in cyan #06b6d4. Very dark header with cyan column names. Alternating dark rows.

  11. Vibrant: Full-width gradient band (coral→orange) ~15% height. White text inside. Light warm-tinted table. Coral total.

  12. Corporate: Deep blue band top ~15% with white text. Thin lighter blue info bar below. Light bordered table (thin gray on all cells). Blue header row.

  13. Fresh: Logo left. Large invoice number right side (oversized, light sky blue as watermark). Sky blue header bg. Light blue zebra stripes.

  14. Natural: Warm beige #fdf6ec background for entire page. "INVOICE" in terracotta #c2703e. Terracotta header. Warm cream alternating rows.

  15. Statement: "INVOICE" normal top-left. TOTAL AMOUNT displayed massively top-right (~24px+). Whitespace-only table separation. No borders, no stripes. Rose accent on big total.

Implementation approach:

The template uses a v-if/v-else-if chain to switch between 15 completely different layout blocks. Each block contains its own unique HTML structure. Shared computed values (displayItems, clientName, etc.) are reused across all templates.

The script section stays lean — props, computed helpers for items/client/biz, formatCurrency/formatDate imports. All visual differentiation is in the template.

Step 1: Write the complete component

Write InvoicePreview.vue with the script setup section and all 15 template blocks. Each template block must follow its specific design doc layout. Use inline styles for template-specific colors (from c computed). Use Tailwind-like patterns via inline style objects.

All templates share:

  • Outer <div> with aspect-ratio: 210/297, width: 100%, overflow: hidden, font-family: -apple-system, ...sans-serif
  • Padding of roughly 6% to simulate page margins
  • The same data bindings (invoice number, dates, client info, business info, line items, totals)

Step 2: Verify

Run: npm run build (from project root) Expected: Compiles with no errors. May have chunk size warning — that's OK.

Step 3: Commit

git add src/components/InvoicePreview.vue
git commit -m "feat: rewrite InvoicePreview with 15 unique typographic layouts"

Task 5: Rewrite invoicePdfRenderer.ts with 15 unique PDF render functions

Files:

  • Rewrite: src/utils/invoicePdfRenderer.ts

Each template gets its own render function that produces a unique PDF layout matching the HTML preview. Uses jsPDF directly.

Typography scale for PDF (from design doc, in points):

  • "INVOICE" title: 24-32pt bold
  • Company name: 14-18pt semi-bold
  • Section headers: 11-12pt semi-bold, uppercase, letter-spacing +0.05em
  • Body text: 10-11pt regular
  • Column headers: 9-10pt medium, uppercase
  • Total amount: 16-20pt bold
  • Due date: 12-14pt semi-bold
  • Footer/notes: 8-9pt regular

PDF whitespace (in mm):

  • Page margins: 20mm all sides
  • Between major sections: 8-12mm
  • Table cell padding: 3mm vertical, 4mm horizontal
  • Row height: 8-10mm
  • Logo max height: 16mm

Table design rules:

  • Borderless with thin horizontal rules (0.3pt, light gray)
  • No vertical borders
  • Column headers: weight + bottom border (1pt)
  • Right-align Qty, Rate, Amount columns
  • Zebra striping where specified: white / barely-gray (#f8fafc)

Implementation approach:

The file exports one main function renderInvoicePdf(config, invoice, client, items, businessInfo) that switches on config.id to call the appropriate per-template function. Shared helpers (hexToRgb, drawLogo, truncateText) are defined at the top.

Each per-template function creates a new jsPDF({ unit: 'mm', format: 'a4' }) and draws its unique layout using jsPDF methods: setFont, setFontSize, setTextColor, text, setFillColor, rect, setDrawColor, line, setLineWidth.

The function returns the jsPDF doc instance.

IMPORTANT jsPDF notes:

  • jsPDF uses 'helvetica' font with styles 'normal', 'bold'
  • Colors must be set as RGB via setTextColor(r, g, b), setFillColor(r, g, b), setDrawColor(r, g, b) — use hexToRgb helper
  • Text alignment: doc.text(str, x, y, { align: 'right' }) for right-aligned text
  • A4 size: 210mm × 297mm
  • doc.setFont('helvetica', 'normal') for regular, doc.setFont('helvetica', 'bold') for bold
  • For uppercase tracked text: transform the string to uppercase, use doc.setCharSpace(0.3) then reset with doc.setCharSpace(0)
  • For letter-spacing simulation: jsPDF has setCharSpace(mm) — use 0.3mm for tracked labels

Step 1: Write the complete renderer

Write invoicePdfRenderer.ts with:

  1. BusinessInfo export interface (name, address, email, phone, logo)
  2. hexToRgb helper
  3. setFill, setText, setDraw color helpers that accept hex
  4. drawLogo helper (if businessInfo.logo is a data URL, use doc.addImage)
  5. truncateDesc helper (truncates long descriptions for table cells)
  6. 15 render functions: renderClean, renderProfessional, renderBold, renderMinimal, renderClassic, renderModern, renderElegant, renderCreative, renderCompact, renderDark, renderVibrant, renderCorporate, renderFresh, renderNatural, renderStatement
  7. Main renderInvoicePdf switch function

Each render function must match its corresponding HTML preview layout. The typography scale must follow the design doc exactly.

Step 2: Verify

Run: npm run build Expected: Compiles with no errors.

Step 3: Commit

git add src/utils/invoicePdfRenderer.ts
git commit -m "feat: rewrite PDF renderer with 15 unique typographic layouts"

Task 6: Update invoicePdf.ts wrapper

Files:

  • Modify: src/utils/invoicePdf.ts

Step 1: Update default template ID

Change the default templateId parameter from 'clean-minimal' to 'clean':

export function generateInvoicePdf(
  invoice: Invoice,
  client: Client,
  items: InvoiceItem[],
  templateId: string = 'clean',
  businessInfo?: BusinessInfo,
): jsPDF {

Step 2: Commit

git add src/utils/invoicePdf.ts
git commit -m "feat: update invoicePdf wrapper with new default template ID"

Task 7: Add template-picker view to Invoices.vue

This is the UX flow change — the core of Part 1 of the design doc.

Files:

  • Modify: src/views/Invoices.vue

Changes needed:

  1. View type: Change ref<'list' | 'create'>('list') to ref<'list' | 'create' | 'template-picker'>('list')

  2. New state: Add pickerInvoiceId = ref<number | null>(null) to track which invoice is being styled

  3. Remove template picker from create form: Delete the template selection section (lines 374-384 in current file)

  4. Change handleCreate: After saving invoice + items, instead of resetting and going to list, navigate to template picker:

    pickerInvoiceId.value = invoiceId
    selectedTemplateId.value = 'clean'
    view.value = 'template-picker'
    
  5. Change viewInvoice: Instead of opening a modal dialog, navigate to the template picker:

    async function viewInvoice(invoice: Invoice) {
      pickerInvoiceId.value = invoice.id!
      selectedTemplateId.value = invoice.template_id || 'clean'
      // Load items for the picker
      try {
        previewItems.value = invoice.id ? await invoicesStore.getInvoiceItems(invoice.id) : []
      } catch (e) {
        console.error('Failed to load invoice items:', e)
        previewItems.value = []
      }
      selectedInvoice.value = invoice
      view.value = 'template-picker'
    }
    
  6. Remove the old preview dialog: Delete the "Invoice Preview Dialog" section (lines 405-447) — it's replaced by the template picker view.

  7. Add template-picker view in template: Add a new v-else-if="view === 'template-picker'" block after the create view. This is a full-screen layout:

    <!-- Template Picker View -->
    <div v-else-if="view === 'template-picker'" class="-m-6 h-[calc(100vh-2rem)] flex flex-col">
      <!-- Top bar with invoice info -->
      <div class="flex items-center justify-between px-6 py-3 border-b border-border-subtle bg-bg-surface shrink-0">
        <div class="flex items-center gap-3">
          <button
            @click="handlePickerBack"
            class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors"
          >
            <!-- back arrow SVG -->
          </button>
          <span class="text-[0.8125rem] font-medium text-text-primary">
            {{ selectedInvoice?.invoice_number }}
          </span>
          <span class="text-[0.75rem] text-text-tertiary">
            {{ selectedInvoice ? getClientName(selectedInvoice.client_id) : '' }}
          </span>
        </div>
        <div class="flex items-center gap-3">
          <button @click="handlePickerExport" 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="handlePickerSave" class="px-4 py-2 border border-border-subtle text-text-secondary text-[0.75rem] rounded-lg hover:bg-bg-elevated transition-colors">
            Save & Close
          </button>
        </div>
      </div>
    
      <!-- Split pane: template list + preview -->
      <div class="flex-1 flex overflow-hidden">
        <!-- Left: Template list -->
        <div class="w-56 border-r border-border-subtle overflow-y-auto bg-bg-surface shrink-0">
          <div v-for="cat in TEMPLATE_CATEGORIES" :key="cat.id">
            <div class="text-[0.5625rem] text-text-tertiary uppercase tracking-[0.1em] font-medium px-3 pt-3 pb-1">
              {{ cat.label }}
            </div>
            <button
              v-for="tmpl in getTemplatesByCategory(cat.id)"
              :key="tmpl.id"
              class="w-full flex items-center gap-2 px-3 py-1.5 text-[0.75rem] transition-colors"
              :class="tmpl.id === selectedTemplateId
                ? 'bg-accent/10 text-accent-text'
                : 'text-text-secondary hover:bg-bg-elevated hover:text-text-primary'"
              @click="selectedTemplateId = tmpl.id"
            >
              <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="flex-1 bg-bg-inset p-8 overflow-y-auto flex justify-center">
          <div class="w-full max-w-lg">
            <InvoicePreview
              :template="getTemplateById(selectedTemplateId)"
              :invoice="selectedInvoice!"
              :client="pickerClient"
              :items="previewItems"
              :business-info="businessInfo"
            />
          </div>
        </div>
      </div>
    </div>
    
  8. Add picker helper functions:

    const pickerClient = computed<Client | null>(() => {
      if (!selectedInvoice.value) return null
      return clientsStore.clients.find(c => c.id === selectedInvoice.value!.client_id) || null
    })
    
    async function handlePickerSave() {
      if (pickerInvoiceId.value) {
        await invoicesStore.updateInvoiceTemplate(pickerInvoiceId.value, selectedTemplateId.value)
      }
      pickerInvoiceId.value = null
      view.value = 'list'
    }
    
    async function handlePickerExport() {
      if (selectedInvoice.value) {
        await exportPDF(selectedInvoice.value)
      }
    }
    
    function handlePickerBack() {
      pickerInvoiceId.value = null
      view.value = 'list'
    }
    
  9. Update handleCreate to navigate to picker: After saving invoice and items:

    // Navigate to template picker instead of list
    selectedInvoice.value = { ...invoice, id: invoiceId }
    pickerInvoiceId.value = invoiceId
    selectedTemplateId.value = 'clean'
    // Load items for preview
    previewItems.value = lineItems.value.map(li => ({
      invoice_id: invoiceId,
      description: li.description,
      quantity: li.quantity,
      rate: li.unit_price,
      amount: li.quantity * li.unit_price,
    }))
    // Reset form
    createForm.client_id = 0
    // ... rest of form reset
    lineItems.value = []
    view.value = 'template-picker'
    

    IMPORTANT: Must save the selectedClient BEFORE resetting createForm.client_id, because pickerClient depends on selectedInvoice.

  10. Update selectedTemplateId default: Change from 'clean-minimal' to 'clean'.

  11. Hide tabs when in template-picker view: Wrap the tab buttons in v-if="view !== 'template-picker'".

Step 1: Implement all changes above

Step 2: Verify

Run: npm run build Expected: Compiles. No errors.

Step 3: Commit

git add src/views/Invoices.vue
git commit -m "feat: add two-step invoice flow with full-screen template picker"

Task 8: Update InvoiceTemplatePicker.vue for new template IDs

Files:

  • Modify: src/components/InvoiceTemplatePicker.vue

The InvoiceTemplatePicker is still used in the create form (now removed) but may be referenced elsewhere. Update:

Step 1: Update default template ID in sample data

The component's default data and behavior should reference 'clean' not 'clean-minimal'. Since the old create form no longer uses this component (template picker is now in the Invoices view directly), this component may become unused. However, keep it functional in case it's used for other purposes.

No changes needed if all references already go through getTemplateById. Just verify it works with the new template IDs.

Step 2: Verify no broken imports

Check that InvoiceTemplatePicker.vue still imports from the updated invoiceTemplates.ts correctly. The import paths haven't changed, just the template IDs and config structure — which is the same interface name.

Step 3: Commit (if any changes were needed)

git add src/components/InvoiceTemplatePicker.vue
git commit -m "chore: update InvoiceTemplatePicker for new template IDs"

Task 9: Full build and integration test

Files: None new — verification only.

Step 1: Build the Rust backend

Run: cd src-tauri && cargo build Expected: Compiles with no errors.

Step 2: Build the frontend

Run: npm run build Expected: Compiles with no errors (chunk size warning is acceptable).

Step 3: Manual verification checklist

Run: npm run tauri dev and verify:

  1. Create an invoice with line items and a client → clicking "Create Invoice" navigates to the template picker (not back to list)
  2. Template picker shows all 15 templates in the left sidebar, grouped by category
  3. Clicking different templates shows genuinely different layouts in the preview
  4. Each template is immediately visually distinguishable from the others
  5. Total amount is the most prominent element in every template
  6. "Export PDF" generates a PDF that matches the preview
  7. "Save & Close" saves the template choice and returns to list
  8. Viewing an existing invoice from the list opens the template picker with its saved template pre-selected
  9. Tables use borderless/subtle-rule design (no spreadsheet grids)
  10. Typography is readable — body text not too small, headers properly sized

Step 4: Commit any fixes

git add -A
git commit -m "fix: integration test fixes for invoice templates v2"

Summary of Template ID Migration

Old ID New ID Template Name
clean-minimal clean Clean
corporate-classic professional Professional
modern-bold bold Bold
elegant-serif minimal Minimal
simple-two-tone classic Classic
gradient-header modern Modern
sidebar-accent elegant Elegant
geometric-modern creative Creative
dark-mode compact Compact
warm-natural dark Dark
playful-color-block vibrant Vibrant
retro-professional corporate Corporate
tech-minimal fresh Fresh
executive-premium natural Natural
data-driven-clean statement Statement

Note: Existing invoices in the DB will have old template IDs. The getTemplateById function falls back to the first template (Clean) when an ID is not found, so old invoices will gracefully default to Clean. No data migration is needed.