Files
zeroclock/src/views/Invoices.vue
Your Name edccc12c34 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.
2026-02-18 15:17:54 +02:00

801 lines
33 KiB
Vue

<template>
<div class="p-6">
<h1 v-if="view !== 'template-picker'" class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary mb-6">Invoices</h1>
<!-- View tabs (hidden in template picker) -->
<div v-if="view !== 'template-picker'" class="flex gap-6 mb-6 border-b border-border-subtle">
<button
@click="view = 'list'"
class="pb-2 text-[0.8125rem] transition-colors duration-150 -mb-px"
:class="view === 'list'
? 'text-text-primary border-b-2 border-accent'
: 'text-text-tertiary hover:text-text-secondary'"
>
List
</button>
<button
@click="view = 'create'"
class="pb-2 text-[0.8125rem] transition-colors duration-150 -mb-px"
:class="view === 'create'
? 'text-text-primary border-b-2 border-accent'
: 'text-text-tertiary hover:text-text-secondary'"
>
Create
</button>
</div>
<!-- List View -->
<div v-if="view === 'list'">
<div v-if="invoicesStore.invoices.length > 0" class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-border-subtle bg-bg-surface">
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Invoice #</th>
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Client</th>
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Date</th>
<th class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Amount</th>
<th class="px-4 py-3 text-center text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Status</th>
<th class="px-4 py-3 w-24"></th>
</tr>
</thead>
<tbody>
<tr
v-for="invoice in invoicesStore.invoices"
:key="invoice.id"
class="group border-b border-border-subtle hover:bg-bg-elevated transition-colors duration-150"
>
<td class="px-4 py-3 text-[0.75rem] font-medium text-text-primary">
{{ invoice.invoice_number }}
</td>
<td class="px-4 py-3 text-[0.75rem] text-text-primary">
{{ getClientName(invoice.client_id) }}
</td>
<td class="px-4 py-3 text-[0.75rem] text-text-secondary">
{{ formatDate(invoice.date) }}
</td>
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-accent-text">
{{ formatCurrency(invoice.total) }}
</td>
<td class="px-4 py-3 text-center">
<span
class="text-[0.6875rem] font-medium"
:class="{
'text-status-running': invoice.status === 'paid',
'text-status-warning': invoice.status === 'pending',
'text-status-error': invoice.status === 'overdue' || invoice.status === 'draft'
}"
>
{{ invoice.status }}
</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-100">
<button
@click="viewInvoice(invoice)"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
title="View"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
<button
@click="exportPDF(invoice)"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
title="Export PDF"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</button>
<button
@click="confirmDelete(invoice)"
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors duration-150"
title="Delete"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="flex flex-col items-center justify-center py-16">
<FileText class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" />
<p class="text-sm text-text-secondary mt-4">No invoices yet</p>
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Create invoices from your tracked time to bill clients.</p>
<button
@click="view = 'create'"
class="mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors"
>
Create Invoice
</button>
</div>
</div>
<!-- Create View -->
<div v-else-if="view === 'create'" class="max-w-4xl mx-auto">
<form @submit.prevent="handleCreate" class="space-y-5">
<!-- Top: Invoice settings + Client info -->
<div class="grid grid-cols-2 gap-6">
<!-- Left: Invoice settings -->
<div class="space-y-4">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Invoice Number</label>
<div class="flex gap-2">
<input
v-model="createForm.invoice_number"
type="text"
class="flex-1 px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary font-mono focus:outline-none focus:border-border-visible"
placeholder="INV-001"
/>
<div class="relative group">
<button
type="button"
@click="showTokenHelp = !showTokenHelp"
class="px-2.5 py-2 border border-border-subtle text-text-tertiary rounded-lg hover:bg-bg-elevated hover:text-text-secondary transition-colors text-[0.75rem]"
title="Token help"
>
{ }
</button>
<div
v-if="showTokenHelp"
class="absolute right-0 top-full mt-1 w-56 bg-bg-elevated border border-border-subtle rounded-lg shadow-lg p-3 z-20 text-[0.6875rem]"
>
<p class="text-text-secondary mb-2">Available tokens:</p>
<div class="space-y-1 font-mono text-text-tertiary">
<p><span class="text-accent-text">{YYYY}</span> {{ new Date().getFullYear() }}</p>
<p><span class="text-accent-text">{YY}</span> {{ String(new Date().getFullYear()).slice(-2) }}</p>
<p><span class="text-accent-text">{MM}</span> {{ String(new Date().getMonth() + 1).padStart(2, '0') }}</p>
<p><span class="text-accent-text">{DD}</span> {{ String(new Date().getDate()).padStart(2, '0') }}</p>
<p><span class="text-accent-text">{###}</span> next number ({{ String(invoicesStore.invoices.length + 1).padStart(3, '0') }})</p>
</div>
<p class="text-text-tertiary mt-2">e.g. INV-{YYYY}-{###}</p>
</div>
</div>
</div>
<p v-if="resolvedInvoiceNumber !== createForm.invoice_number" class="text-[0.625rem] text-text-tertiary mt-1 font-mono">
Preview: {{ resolvedInvoiceNumber }}
</p>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Client *</label>
<AppSelect
v-model="createForm.client_id"
:options="clientsStore.clients"
label-key="name"
value-key="id"
placeholder="Select a client"
:placeholder-value="0"
/>
</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">Invoice Date *</label>
<AppDatePicker
v-model="createForm.date"
placeholder="Invoice date"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Due Date</label>
<AppDatePicker
v-model="createForm.due_date"
placeholder="Due date"
/>
</div>
</div>
</div>
<!-- Right: Client info card -->
<div>
<div v-if="selectedClient" class="bg-bg-inset border border-border-subtle rounded-lg p-4 space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-[0.8125rem] font-semibold text-text-primary">{{ selectedClient.name }}</h3>
<span class="text-[0.625rem] text-text-tertiary uppercase tracking-wider">Bill To</span>
</div>
<div class="space-y-1.5 text-[0.75rem] text-text-secondary">
<p v-if="selectedClient.company">{{ selectedClient.company }}</p>
<p v-if="selectedClient.email">{{ selectedClient.email }}</p>
<p v-if="selectedClient.phone">{{ selectedClient.phone }}</p>
<p v-if="selectedClient.address" class="whitespace-pre-line">{{ selectedClient.address }}</p>
<p v-if="selectedClient.tax_id" class="text-text-tertiary">Tax ID: {{ selectedClient.tax_id }}</p>
</div>
<p v-if="selectedClient.payment_terms" class="text-[0.6875rem] text-text-tertiary pt-2 border-t border-border-subtle">
Terms: {{ selectedClient.payment_terms }}
</p>
</div>
<div v-else class="bg-bg-inset border border-border-subtle rounded-lg p-4 flex items-center justify-center h-full min-h-[8rem]">
<p class="text-[0.75rem] text-text-tertiary">Select a client to see their billing info</p>
</div>
</div>
</div>
<!-- Line Items -->
<div class="bg-bg-surface border border-border-subtle rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-border-subtle">
<h3 class="text-[0.8125rem] font-medium text-text-primary">Line Items</h3>
<div class="flex items-center gap-2">
<AppSelect
v-model="importProjectId"
:options="projectsStore.projects"
label-key="name"
value-key="id"
placeholder="Import from project..."
:placeholder-value="0"
class="w-48"
/>
<button
type="button"
@click="importFromProject"
:disabled="!importProjectId"
class="px-2.5 py-1.5 text-[0.75rem] font-medium bg-accent text-bg-base rounded-lg hover:bg-accent-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Import
</button>
<button
type="button"
@click="addLineItem"
class="px-2.5 py-1.5 text-[0.75rem] font-medium border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors"
>
+ Add Row
</button>
</div>
</div>
<table v-if="lineItems.length > 0" class="w-full">
<thead>
<tr class="border-b border-border-subtle bg-bg-inset">
<th class="px-4 py-2 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Description</th>
<th class="px-4 py-2 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-20">Qty</th>
<th class="px-4 py-2 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-28">Rate</th>
<th class="px-4 py-2 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-28">Amount</th>
<th class="px-4 py-2 w-10"></th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, i) in lineItems"
:key="i"
class="border-b border-border-subtle last:border-b-0"
>
<td class="px-4 py-1.5">
<input
v-model="item.description"
type="text"
class="w-full px-2 py-1 bg-transparent border-0 text-[0.75rem] text-text-primary focus:outline-none focus:bg-bg-inset rounded"
placeholder="Item description"
/>
</td>
<td class="px-4 py-1.5">
<input
v-model.number="item.quantity"
type="number"
min="0"
step="0.01"
class="w-full px-2 py-1 bg-transparent border-0 text-[0.75rem] text-text-primary text-right font-mono focus:outline-none focus:bg-bg-inset rounded"
/>
</td>
<td class="px-4 py-1.5">
<input
v-model.number="item.unit_price"
type="number"
min="0"
step="0.01"
class="w-full px-2 py-1 bg-transparent border-0 text-[0.75rem] text-text-primary text-right font-mono focus:outline-none focus:bg-bg-inset rounded"
/>
</td>
<td class="px-4 py-1.5 text-right text-[0.75rem] font-mono text-text-primary">
{{ formatCurrency(item.quantity * item.unit_price) }}
</td>
<td class="px-4 py-1.5">
<button
type="button"
@click="lineItems.splice(i, 1)"
class="p-1 text-text-tertiary hover:text-status-error transition-colors"
>
<X class="w-3.5 h-3.5" :stroke-width="1.5" />
</button>
</td>
</tr>
</tbody>
</table>
<div v-else class="px-4 py-8 text-center text-[0.75rem] text-text-tertiary">
No line items. Import from a project or add rows manually.
</div>
</div>
<!-- Bottom: Totals + Notes side by side -->
<div class="grid grid-cols-2 gap-6">
<!-- Left: Notes -->
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Notes</label>
<textarea
v-model="createForm.notes"
rows="4"
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="Payment instructions, thank you message, etc."
></textarea>
</div>
<!-- Right: Totals -->
<div>
<div class="grid grid-cols-2 gap-4 mb-3">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Tax Rate (%)</label>
<AppNumberInput
v-model="createForm.tax_rate"
:min="0"
:max="100"
:step="0.5"
:precision="2"
suffix="%"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Discount ({{ getCurrencySymbol() }})</label>
<AppNumberInput
v-model="createForm.discount"
:min="0"
:step="1"
:precision="2"
:prefix="getCurrencySymbol()"
/>
</div>
</div>
<div class="bg-bg-inset rounded-lg p-4">
<div class="flex justify-between text-[0.75rem] text-text-secondary mb-2">
<span>Subtotal:</span>
<span class="font-mono">{{ formatCurrency(calculateSubtotal()) }}</span>
</div>
<div class="flex justify-between text-[0.75rem] text-text-secondary mb-2">
<span>Tax ({{ createForm.tax_rate }}%):</span>
<span class="font-mono">{{ formatCurrency(calculateTax()) }}</span>
</div>
<div v-if="createForm.discount > 0" class="flex justify-between text-[0.75rem] text-text-secondary mb-2">
<span>Discount:</span>
<span class="font-mono">-{{ formatCurrency(createForm.discount) }}</span>
</div>
<div class="flex justify-between text-text-primary font-medium text-[0.8125rem] pt-2 border-t border-border-subtle">
<span>Total:</span>
<span class="font-mono text-accent-text">{{ formatCurrency(calculateTotal()) }}</span>
</div>
</div>
</div>
</div>
<!-- Buttons -->
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
@click="view = 'list'"
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
Create Invoice
</button>
</div>
</form>
</div>
<!-- Template Picker View -->
<div v-else-if="view === 'template-picker'" class="fixed inset-0 z-40 bg-bg-base flex flex-col">
<!-- Top bar -->
<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"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</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 &amp; Close
</button>
</div>
</div>
<!-- Split pane -->
<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
v-if="selectedInvoice"
:template="getTemplateById(selectedTemplateId)"
:invoice="selectedInvoice"
:client="pickerClient"
:items="previewItems"
:business-info="businessInfo"
/>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<Transition name="modal">
<div
v-if="showDeleteDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="showDeleteDialog = false"
>
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Delete Invoice</h2>
<p class="text-[0.75rem] text-text-secondary mb-6">
Are you sure you want to delete invoice "{{ invoiceToDelete?.invoice_number }}"? This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<button
@click="showDeleteDialog = false"
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
@click="handleDelete"
class="px-4 py-2 border border-status-error text-status-error font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
>
Delete
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { invoke } from '@tauri-apps/api/core'
import { save } from '@tauri-apps/plugin-dialog'
import AppNumberInput from '../components/AppNumberInput.vue'
import AppSelect from '../components/AppSelect.vue'
import AppDatePicker from '../components/AppDatePicker.vue'
import InvoicePreview from '../components/InvoicePreview.vue'
import { getTemplateById, getTemplatesByCategory, loadTemplates, TEMPLATE_CATEGORIES } from '../utils/invoiceTemplates'
import type { BusinessInfo } from '../utils/invoicePdfRenderer'
import { useInvoicesStore, type Invoice, type InvoiceItem } from '../stores/invoices'
import { useClientsStore, type Client } from '../stores/clients'
import { useProjectsStore } from '../stores/projects'
import { useSettingsStore } from '../stores/settings'
import { useToastStore } from '../stores/toast'
import { generateInvoicePdf } from '../utils/invoicePdf'
import { formatDate, formatCurrency, getCurrencySymbol } from '../utils/locale'
interface LineItem {
description: string
quantity: number
unit_price: number
}
const invoicesStore = useInvoicesStore()
const clientsStore = useClientsStore()
const projectsStore = useProjectsStore()
const settingsStore = useSettingsStore()
const toastStore = useToastStore()
// View state
const view = ref<'list' | 'create' | 'template-picker'>('list')
const pickerInvoiceId = ref<number | null>(null)
// Dialog state
const showDeleteDialog = ref(false)
const selectedInvoice = ref<Invoice | null>(null)
const invoiceToDelete = ref<Invoice | null>(null)
// Create form
const createForm = reactive({
client_id: 0,
invoice_number: 'INV-{YYYY}-{###}',
date: new Date().toISOString().split('T')[0],
due_date: '',
tax_rate: 0,
discount: 0,
notes: ''
})
// Line items
const lineItems = ref<LineItem[]>([])
const importProjectId = ref(0)
const showTokenHelp = ref(false)
const selectedTemplateId = ref('clean')
// Preview / picker state
const previewItems = ref<InvoiceItem[]>([])
const pickerClient = computed<Client | null>(() => {
if (!selectedInvoice.value) return null
return clientsStore.clients.find(c => c.id === selectedInvoice.value!.client_id) || null
})
// Selected client info
const selectedClient = computed<Client | null>(() => {
if (!createForm.client_id) return null
return clientsStore.clients.find(c => c.id === createForm.client_id) || null
})
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 || '',
}))
// Resolve invoice number tokens
const resolvedInvoiceNumber = computed(() => {
const now = new Date()
const nextNum = invoicesStore.invoices.length + 1
return createForm.invoice_number
.replace(/\{YYYY\}/g, String(now.getFullYear()))
.replace(/\{YY\}/g, String(now.getFullYear()).slice(-2))
.replace(/\{MM\}/g, String(now.getMonth() + 1).padStart(2, '0'))
.replace(/\{DD\}/g, String(now.getDate()).padStart(2, '0'))
.replace(/\{###\}/g, String(nextNum).padStart(3, '0'))
.replace(/\{##\}/g, String(nextNum).padStart(2, '0'))
})
// Get client name by ID
function getClientName(clientId: number): string {
const client = clientsStore.clients.find(c => c.id === clientId)
return client?.name || 'Unknown Client'
}
// Add empty line item
function addLineItem() {
lineItems.value.push({ description: '', quantity: 1, unit_price: 0 })
}
// Import line items from a project's time entries
async function importFromProject() {
if (!importProjectId.value) return
const project = projectsStore.projects.find(p => p.id === importProjectId.value)
if (!project) return
try {
const entries = await invoke<{ duration: number; description?: string }[]>('get_time_entries', {
startDate: null, endDate: null
})
const projectEntries = entries.filter((e: any) => e.project_id === importProjectId.value)
const totalHours = projectEntries.reduce((sum, e) => sum + e.duration / 3600, 0)
if (totalHours > 0) {
lineItems.value.push({
description: `${project.name}${totalHours.toFixed(1)}h tracked`,
quantity: parseFloat(totalHours.toFixed(2)),
unit_price: project.hourly_rate
})
} else {
toastStore.info('No time entries found for this project')
}
} catch (e) {
console.error('Failed to import entries:', e)
}
importProjectId.value = 0
}
// Calculate subtotal from line items
function calculateSubtotal(): number {
return lineItems.value.reduce((sum, item) => sum + (item.quantity * item.unit_price), 0)
}
// Calculate tax
function calculateTax(): number {
return calculateSubtotal() * (createForm.tax_rate / 100)
}
// Calculate total
function calculateTotal(): number {
return calculateSubtotal() + calculateTax() - createForm.discount
}
// View invoice → open template picker
async function viewInvoice(invoice: Invoice) {
selectedInvoice.value = invoice
pickerInvoiceId.value = invoice.id!
selectedTemplateId.value = invoice.template_id || 'clean'
try {
previewItems.value = invoice.id ? await invoicesStore.getInvoiceItems(invoice.id) : []
} catch (e) {
console.error('Failed to load invoice items:', e)
previewItems.value = []
}
view.value = 'template-picker'
}
// Confirm delete
function confirmDelete(invoice: Invoice) {
invoiceToDelete.value = invoice
showDeleteDialog.value = true
}
// Handle delete
async function handleDelete() {
if (invoiceToDelete.value?.id) {
await invoicesStore.deleteInvoice(invoiceToDelete.value.id)
}
showDeleteDialog.value = false
invoiceToDelete.value = null
}
// Handle create
async function handleCreate() {
if (!createForm.client_id) {
toastStore.info('Please select a client')
return
}
const subtotal = calculateSubtotal()
const taxAmount = subtotal * (createForm.tax_rate / 100)
const total = subtotal + taxAmount - createForm.discount
const invoice: Invoice = {
client_id: createForm.client_id,
invoice_number: resolvedInvoiceNumber.value,
date: createForm.date,
due_date: createForm.due_date || undefined,
subtotal,
tax_rate: createForm.tax_rate,
tax_amount: taxAmount,
discount: createForm.discount,
total,
notes: createForm.notes || undefined,
status: 'pending'
}
const invoiceId = await invoicesStore.createInvoice(invoice)
if (!invoiceId) {
toastStore.error('Failed to create invoice')
return
}
// Save line items
if (lineItems.value.length > 0) {
try {
await invoicesStore.saveInvoiceItems(invoiceId, lineItems.value)
} catch (e) {
console.error('Failed to save line items:', e)
toastStore.error('Invoice created but line items failed to save')
}
}
// Navigate to template picker with the new invoice
selectedInvoice.value = { ...invoice, id: invoiceId }
pickerInvoiceId.value = invoiceId
selectedTemplateId.value = 'clean'
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 for next use
createForm.client_id = 0
createForm.invoice_number = 'INV-{YYYY}-{###}'
createForm.date = new Date().toISOString().split('T')[0]
createForm.due_date = ''
createForm.tax_rate = 0
createForm.discount = 0
createForm.notes = ''
lineItems.value = []
view.value = 'template-picker'
}
// Template picker actions
async function handlePickerSave() {
if (pickerInvoiceId.value) {
await invoicesStore.updateInvoiceTemplate(pickerInvoiceId.value, selectedTemplateId.value)
}
pickerInvoiceId.value = null
selectedInvoice.value = null
view.value = 'list'
}
async function handlePickerExport() {
if (selectedInvoice.value) {
await exportPDF(selectedInvoice.value)
}
}
function handlePickerBack() {
pickerInvoiceId.value = null
selectedInvoice.value = null
view.value = 'list'
}
// Export PDF using Tauri file save dialog
async function exportPDF(invoice: Invoice) {
const client = clientsStore.clients.find(c => c.id === invoice.client_id)
if (!client) {
toastStore.error('Client not found for invoice')
return
}
try {
const items = invoice.id ? await invoicesStore.getInvoiceItems(invoice.id) : []
const doc = generateInvoicePdf(invoice, client, items, selectedTemplateId.value, businessInfo.value)
// Use Tauri's save dialog instead of jsPDF's browser-based save
const filePath = await save({
defaultPath: `${invoice.invoice_number}.pdf`,
filters: [{ name: 'PDF', extensions: ['pdf'] }]
})
if (!filePath) return // User cancelled
const pdfBytes = doc.output('arraybuffer')
await invoke('save_binary_file', { path: filePath, data: Array.from(new Uint8Array(pdfBytes)) })
toastStore.success('PDF exported successfully')
} catch (e) {
console.error('Failed to export PDF:', e)
toastStore.error('Failed to export PDF')
}
}
// Load data on mount
onMounted(async () => {
await Promise.all([
invoicesStore.fetchInvoices(),
clientsStore.fetchClients(),
projectsStore.fetchProjects(),
settingsStore.fetchSettings(),
loadTemplates(),
])
})
</script>