feat: load invoice templates from JSON files via backend

Templates are now loaded dynamically from data/templates/*.json
via the get_invoice_templates Tauri command instead of being
hardcoded in TypeScript. Preview and PDF renderer switch on
template.layout instead of template.id, allowing custom templates
to reuse built-in layouts with different colors.
This commit is contained in:
Your Name
2026-02-18 15:17:54 +02:00
parent 8b8d451806
commit 8c8de6a2a7
4 changed files with 51 additions and 327 deletions

View File

@@ -39,7 +39,7 @@ void clientAddress
<!-- TEMPLATE 1: CLEAN --> <!-- TEMPLATE 1: CLEAN -->
<!-- ================================================================ --> <!-- ================================================================ -->
<div <div
v-if="template.id === 'clean'" v-if="template.layout === 'clean'"
:style="{ :style="{
backgroundColor: c.background, backgroundColor: c.background,
color: c.bodyText, color: c.bodyText,
@@ -148,7 +148,7 @@ void clientAddress
<!-- TEMPLATE 2: PROFESSIONAL --> <!-- TEMPLATE 2: PROFESSIONAL -->
<!-- ================================================================ --> <!-- ================================================================ -->
<div <div
v-else-if="template.id === 'professional'" v-else-if="template.layout === 'professional'"
:style="{ :style="{
backgroundColor: c.background, backgroundColor: c.background,
color: c.bodyText, color: c.bodyText,
@@ -246,7 +246,7 @@ void clientAddress
<!-- TEMPLATE 3: BOLD --> <!-- TEMPLATE 3: BOLD -->
<!-- ================================================================ --> <!-- ================================================================ -->
<div <div
v-else-if="template.id === 'bold'" v-else-if="template.layout === 'bold'"
:style="{ :style="{
backgroundColor: c.background, backgroundColor: c.background,
color: c.bodyText, color: c.bodyText,
@@ -348,7 +348,7 @@ void clientAddress
<!-- TEMPLATE 4: MINIMAL --> <!-- TEMPLATE 4: MINIMAL -->
<!-- ================================================================ --> <!-- ================================================================ -->
<div <div
v-else-if="template.id === 'minimal'" v-else-if="template.layout === 'minimal'"
:style="{ :style="{
backgroundColor: c.background, backgroundColor: c.background,
color: c.bodyText, color: c.bodyText,
@@ -444,7 +444,7 @@ void clientAddress
<!-- TEMPLATE 5: CLASSIC --> <!-- TEMPLATE 5: CLASSIC -->
<!-- ================================================================ --> <!-- ================================================================ -->
<div <div
v-else-if="template.id === 'classic'" v-else-if="template.layout === 'classic'"
:style="{ :style="{
backgroundColor: c.background, backgroundColor: c.background,
color: c.bodyText, color: c.bodyText,
@@ -536,7 +536,7 @@ void clientAddress
<!-- TEMPLATE 6: MODERN --> <!-- TEMPLATE 6: MODERN -->
<!-- ================================================================ --> <!-- ================================================================ -->
<div <div
v-else-if="template.id === 'modern'" v-else-if="template.layout === 'modern'"
:style="{ :style="{
backgroundColor: c.background, backgroundColor: c.background,
color: c.bodyText, color: c.bodyText,
@@ -636,7 +636,7 @@ void clientAddress
<!-- TEMPLATE 7: ELEGANT --> <!-- TEMPLATE 7: ELEGANT -->
<!-- ================================================================ --> <!-- ================================================================ -->
<div <div
v-else-if="template.id === 'elegant'" v-else-if="template.layout === 'elegant'"
:style="{ :style="{
backgroundColor: c.background, backgroundColor: c.background,
color: c.bodyText, color: c.bodyText,
@@ -756,7 +756,7 @@ void clientAddress
<!-- TEMPLATE 8: CREATIVE --> <!-- TEMPLATE 8: CREATIVE -->
<!-- ================================================================ --> <!-- ================================================================ -->
<div <div
v-else-if="template.id === 'creative'" v-else-if="template.layout === 'creative'"
:style="{ :style="{
backgroundColor: c.background, backgroundColor: c.background,
color: c.bodyText, color: c.bodyText,
@@ -859,7 +859,7 @@ void clientAddress
<!-- TEMPLATE 9: COMPACT --> <!-- TEMPLATE 9: COMPACT -->
<!-- ================================================================ --> <!-- ================================================================ -->
<div <div
v-else-if="template.id === 'compact'" v-else-if="template.layout === 'compact'"
:style="{ :style="{
backgroundColor: c.background, backgroundColor: c.background,
color: c.bodyText, color: c.bodyText,
@@ -954,7 +954,7 @@ void clientAddress
<!-- TEMPLATE 10: DARK --> <!-- TEMPLATE 10: DARK -->
<!-- ================================================================ --> <!-- ================================================================ -->
<div <div
v-else-if="template.id === 'dark'" v-else-if="template.layout === 'dark'"
:style="{ :style="{
backgroundColor: c.background, backgroundColor: c.background,
color: c.bodyText, color: c.bodyText,
@@ -1053,7 +1053,7 @@ void clientAddress
<!-- TEMPLATE 11: VIBRANT --> <!-- TEMPLATE 11: VIBRANT -->
<!-- ================================================================ --> <!-- ================================================================ -->
<div <div
v-else-if="template.id === 'vibrant'" v-else-if="template.layout === 'vibrant'"
:style="{ :style="{
backgroundColor: c.background, backgroundColor: c.background,
color: c.bodyText, color: c.bodyText,
@@ -1151,7 +1151,7 @@ void clientAddress
<!-- TEMPLATE 12: CORPORATE --> <!-- TEMPLATE 12: CORPORATE -->
<!-- ================================================================ --> <!-- ================================================================ -->
<div <div
v-else-if="template.id === 'corporate'" v-else-if="template.layout === 'corporate'"
:style="{ :style="{
backgroundColor: c.background, backgroundColor: c.background,
color: c.bodyText, color: c.bodyText,
@@ -1249,7 +1249,7 @@ void clientAddress
<!-- TEMPLATE 13: FRESH --> <!-- TEMPLATE 13: FRESH -->
<!-- ================================================================ --> <!-- ================================================================ -->
<div <div
v-else-if="template.id === 'fresh'" v-else-if="template.layout === 'fresh'"
:style="{ :style="{
backgroundColor: c.background, backgroundColor: c.background,
color: c.bodyText, color: c.bodyText,
@@ -1352,7 +1352,7 @@ void clientAddress
<!-- TEMPLATE 14: NATURAL --> <!-- TEMPLATE 14: NATURAL -->
<!-- ================================================================ --> <!-- ================================================================ -->
<div <div
v-else-if="template.id === 'natural'" v-else-if="template.layout === 'natural'"
:style="{ :style="{
backgroundColor: c.background, backgroundColor: c.background,
color: c.bodyText, color: c.bodyText,
@@ -1452,7 +1452,7 @@ void clientAddress
<!-- TEMPLATE 15: STATEMENT --> <!-- TEMPLATE 15: STATEMENT -->
<!-- ================================================================ --> <!-- ================================================================ -->
<div <div
v-else-if="template.id === 'statement'" v-else-if="template.layout === 'statement'"
:style="{ :style="{
backgroundColor: c.background, backgroundColor: c.background,
color: c.bodyText, color: c.bodyText,

View File

@@ -1825,7 +1825,7 @@ export function renderInvoicePdf(
items: InvoiceItem[], items: InvoiceItem[],
businessInfo: BusinessInfo, businessInfo: BusinessInfo,
): jsPDF { ): jsPDF {
switch (config.id) { switch (config.layout) {
case 'clean': return renderClean(config, invoice, client, items, businessInfo) case 'clean': return renderClean(config, invoice, client, items, businessInfo)
case 'professional': return renderProfessional(config, invoice, client, items, businessInfo) case 'professional': return renderProfessional(config, invoice, client, items, businessInfo)
case 'bold': return renderBold(config, invoice, client, items, businessInfo) case 'bold': return renderBold(config, invoice, client, items, businessInfo)

View File

@@ -1,3 +1,6 @@
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
export interface InvoiceTemplateColors { export interface InvoiceTemplateColors {
primary: string primary: string
secondary: string secondary: string
@@ -15,318 +18,12 @@ export interface InvoiceTemplateColors {
export interface InvoiceTemplateConfig { export interface InvoiceTemplateConfig {
id: string id: string
name: string name: string
category: 'essential' | 'creative' | 'warm' | 'premium' layout: string
category: string
description: string description: string
colors: InvoiceTemplateColors 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 = [ export const TEMPLATE_CATEGORIES = [
{ id: 'essential', label: 'Professional Essentials' }, { id: 'essential', label: 'Professional Essentials' },
{ id: 'creative', label: 'Creative & Modern' }, { id: 'creative', label: 'Creative & Modern' },
@@ -334,10 +31,36 @@ export const TEMPLATE_CATEGORIES = [
{ id: 'premium', label: 'Premium & Specialized' }, { id: 'premium', label: 'Premium & Specialized' },
] as const ] as const
const templates = ref<InvoiceTemplateConfig[]>([])
let loaded = false
export async function loadTemplates(): Promise<InvoiceTemplateConfig[]> {
if (loaded && templates.value.length > 0) return templates.value
try {
templates.value = await invoke<InvoiceTemplateConfig[]>('get_invoice_templates')
loaded = true
} catch (e) {
console.error('Failed to load invoice templates:', e)
}
return templates.value
}
export function getLoadedTemplates(): InvoiceTemplateConfig[] {
return templates.value
}
export function getTemplateById(id: string): InvoiceTemplateConfig { export function getTemplateById(id: string): InvoiceTemplateConfig {
return INVOICE_TEMPLATES.find(t => t.id === id) || INVOICE_TEMPLATES[0] return templates.value.find(t => t.id === id) || templates.value[0] || {
id: 'clean', name: 'Clean', layout: 'clean', category: 'essential',
description: 'Default', colors: {
primary: '#1e293b', secondary: '#3b82f6', background: '#ffffff',
headerBg: '#ffffff', headerText: '#1e293b', bodyText: '#374151',
tableHeaderBg: '#f8fafc', tableHeaderText: '#374151',
tableRowAlt: '#f8fafc', tableBorder: '#e5e7eb', totalHighlight: '#3b82f6',
},
}
} }
export function getTemplatesByCategory(category: string): InvoiceTemplateConfig[] { export function getTemplatesByCategory(category: string): InvoiceTemplateConfig[] {
return INVOICE_TEMPLATES.filter(t => t.category === category) return templates.value.filter(t => t.category === category)
} }

View File

@@ -506,7 +506,7 @@ import AppNumberInput from '../components/AppNumberInput.vue'
import AppSelect from '../components/AppSelect.vue' import AppSelect from '../components/AppSelect.vue'
import AppDatePicker from '../components/AppDatePicker.vue' import AppDatePicker from '../components/AppDatePicker.vue'
import InvoicePreview from '../components/InvoicePreview.vue' import InvoicePreview from '../components/InvoicePreview.vue'
import { getTemplateById, getTemplatesByCategory, TEMPLATE_CATEGORIES } from '../utils/invoiceTemplates' import { getTemplateById, getTemplatesByCategory, loadTemplates, TEMPLATE_CATEGORIES } from '../utils/invoiceTemplates'
import type { BusinessInfo } from '../utils/invoicePdfRenderer' import type { BusinessInfo } from '../utils/invoicePdfRenderer'
import { useInvoicesStore, type Invoice, type InvoiceItem } from '../stores/invoices' import { useInvoicesStore, type Invoice, type InvoiceItem } from '../stores/invoices'
import { useClientsStore, type Client } from '../stores/clients' import { useClientsStore, type Client } from '../stores/clients'
@@ -794,6 +794,7 @@ onMounted(async () => {
clientsStore.fetchClients(), clientsStore.fetchClients(),
projectsStore.fetchProjects(), projectsStore.fetchProjects(),
settingsStore.fetchSettings(), settingsStore.fetchSettings(),
loadTemplates(),
]) ])
}) })
</script> </script>