feat: add InvoiceTemplatePicker split-pane component
This commit is contained in:
136
src/components/InvoiceTemplatePicker.vue
Normal file
136
src/components/InvoiceTemplatePicker.vue
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import InvoicePreview from './InvoicePreview.vue'
|
||||||
|
import {
|
||||||
|
TEMPLATE_CATEGORIES,
|
||||||
|
getTemplatesByCategory,
|
||||||
|
getTemplateById,
|
||||||
|
} from '../utils/invoiceTemplates'
|
||||||
|
import type { InvoiceItem } from '../utils/invoicePdf'
|
||||||
|
import type { BusinessInfo } from '../utils/invoicePdfRenderer'
|
||||||
|
import type { Invoice } from '../stores/invoices'
|
||||||
|
import type { Client } from '../stores/clients'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
invoice?: Invoice
|
||||||
|
client?: Client | null
|
||||||
|
items?: InvoiceItem[]
|
||||||
|
businessInfo?: BusinessInfo
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
invoice: undefined,
|
||||||
|
client: undefined,
|
||||||
|
items: undefined,
|
||||||
|
businessInfo: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Default sample data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const defaultInvoice: 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',
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultClient: Client = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Acme Corporation',
|
||||||
|
email: 'billing@acme.com',
|
||||||
|
address: '123 Business Ave\nSuite 100\nNew York, NY 10001',
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultBusinessInfo: BusinessInfo = {
|
||||||
|
name: 'Your Business Name',
|
||||||
|
address: '456 Creative St, Design City',
|
||||||
|
email: 'hello@business.com',
|
||||||
|
phone: '(555) 123-4567',
|
||||||
|
logo: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Computed
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const selectedTemplate = computed(() => getTemplateById(props.modelValue))
|
||||||
|
|
||||||
|
const previewInvoice = computed(() => props.invoice ?? defaultInvoice)
|
||||||
|
|
||||||
|
const previewClient = computed(() =>
|
||||||
|
props.client !== undefined ? props.client : defaultClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
const previewItems = computed(() =>
|
||||||
|
props.items && props.items.length > 0 ? props.items : [],
|
||||||
|
)
|
||||||
|
|
||||||
|
const previewBusinessInfo = computed(() => props.businessInfo ?? defaultBusinessInfo)
|
||||||
|
|
||||||
|
function selectTemplate(id: string) {
|
||||||
|
emit('update:modelValue', id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex border border-border-subtle rounded-lg overflow-hidden"
|
||||||
|
style="height: 480px"
|
||||||
|
>
|
||||||
|
<!-- Left panel: 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">
|
||||||
|
<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 === modelValue
|
||||||
|
? 'bg-accent/10 text-accent-text'
|
||||||
|
: 'text-text-secondary hover:bg-bg-elevated hover:text-text-primary'
|
||||||
|
"
|
||||||
|
@click="selectTemplate(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 panel: Live 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="previewBusinessInfo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user