feat: integrate template picker into invoice create and preview views
This commit is contained in:
@@ -119,99 +119,272 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create View -->
|
<!-- Create View -->
|
||||||
<div v-else-if="view === 'create'" class="max-w-lg">
|
<div v-else-if="view === 'create'" class="max-w-4xl mx-auto">
|
||||||
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Create Invoice</h2>
|
<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>
|
||||||
|
|
||||||
<form @submit.prevent="handleCreate" class="space-y-4">
|
<div>
|
||||||
<!-- Client -->
|
<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>
|
||||||
|
|
||||||
|
<!-- Template Selection -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Client *</label>
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-2">Invoice Template</label>
|
||||||
<AppSelect
|
<InvoiceTemplatePicker
|
||||||
v-model="createForm.client_id"
|
v-model="selectedTemplateId"
|
||||||
:options="clientsStore.clients"
|
:invoice="previewCreateInvoice"
|
||||||
label-key="name"
|
:client="selectedClient"
|
||||||
value-key="id"
|
:items="createPreviewItems"
|
||||||
placeholder="Select a client"
|
:business-info="businessInfo"
|
||||||
:placeholder-value="0"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Date -->
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Tax Rate -->
|
|
||||||
<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">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>
|
|
||||||
|
|
||||||
<!-- 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="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"
|
|
||||||
placeholder="Additional notes"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Calculated Total -->
|
|
||||||
<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 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>
|
|
||||||
|
|
||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<div class="flex justify-end gap-3 pt-4">
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="view = 'list'"
|
@click="view = 'list'"
|
||||||
@@ -229,74 +402,46 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Invoice Detail Dialog -->
|
<!-- Invoice Preview Dialog -->
|
||||||
<Transition name="modal">
|
<Transition name="modal">
|
||||||
<div
|
<div
|
||||||
v-if="showDetailDialog"
|
v-if="showDetailDialog"
|
||||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
class="fixed inset-0 bg-black/80 backdrop-blur-[4px] flex flex-col items-center p-6 z-50 overflow-y-auto"
|
||||||
@click.self="showDetailDialog = false"
|
@click.self="showDetailDialog = 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-2xl p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
|
<!-- Toolbar -->
|
||||||
<div class="flex items-start justify-between mb-6">
|
<div class="flex items-center gap-3 mb-4 shrink-0">
|
||||||
<div>
|
<select
|
||||||
<h2 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary">{{ selectedInvoice?.invoice_number }}</h2>
|
v-model="selectedTemplateId"
|
||||||
<p class="text-[0.75rem] text-text-secondary">{{ getClientName(selectedInvoice?.client_id || 0) }}</p>
|
class="px-3 py-2 bg-white/10 border border-white/20 text-white text-[0.75rem] rounded-lg focus:outline-none appearance-none cursor-pointer"
|
||||||
</div>
|
>
|
||||||
<button
|
<option v-for="tmpl in INVOICE_TEMPLATES" :key="tmpl.id" :value="tmpl.id" class="text-black bg-white">
|
||||||
@click="showDetailDialog = false"
|
{{ tmpl.name }}
|
||||||
class="p-2 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
|
</option>
|
||||||
>
|
</select>
|
||||||
<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">
|
<button
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
@click="exportPDF(selectedInvoice!)"
|
||||||
</svg>
|
class="px-4 py-2 bg-accent text-bg-base text-[0.75rem] font-medium rounded-lg hover:bg-accent-hover transition-colors"
|
||||||
</button>
|
>
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<!-- Template preview -->
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="w-[8.5in] shrink-0">
|
||||||
<div>
|
<InvoicePreview
|
||||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Invoice Date</p>
|
:template="getTemplateById(selectedTemplateId)"
|
||||||
<p class="text-[0.8125rem] text-text-primary">{{ formatDate(selectedInvoice?.date || '') }}</p>
|
:invoice="selectedInvoice!"
|
||||||
</div>
|
:client="previewClient"
|
||||||
<div>
|
:items="previewItems"
|
||||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Due Date</p>
|
:business-info="businessInfo"
|
||||||
<p class="text-[0.8125rem] text-text-primary">{{ selectedInvoice?.due_date ? formatDate(selectedInvoice.due_date) : '-' }}</p>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-t border-border-subtle pt-4">
|
|
||||||
<div class="flex justify-between text-[0.75rem] text-text-secondary mb-2">
|
|
||||||
<span>Subtotal:</span>
|
|
||||||
<span class="font-mono">{{ formatCurrency(selectedInvoice?.subtotal || 0) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-[0.75rem] text-text-secondary mb-2">
|
|
||||||
<span>Tax ({{ selectedInvoice?.tax_rate || 0 }}%):</span>
|
|
||||||
<span class="font-mono">{{ formatCurrency(selectedInvoice?.tax_amount || 0) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-[0.75rem] text-text-secondary mb-2">
|
|
||||||
<span>Discount:</span>
|
|
||||||
<span class="font-mono">-{{ formatCurrency(selectedInvoice?.discount || 0) }}</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(selectedInvoice?.total || 0) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="selectedInvoice?.notes" class="border-t border-border-subtle pt-4">
|
|
||||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Notes</p>
|
|
||||||
<p class="text-[0.8125rem] text-text-primary">{{ selectedInvoice.notes }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 mt-6">
|
|
||||||
<button
|
|
||||||
@click="exportPDF(selectedInvoice!)"
|
|
||||||
class="px-4 py-2 bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
|
|
||||||
>
|
|
||||||
Export PDF
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -334,19 +479,35 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { FileText } from 'lucide-vue-next'
|
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 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 { useInvoicesStore, type Invoice } from '../stores/invoices'
|
import InvoicePreview from '../components/InvoicePreview.vue'
|
||||||
import { useClientsStore } from '../stores/clients'
|
import InvoiceTemplatePicker from '../components/InvoiceTemplatePicker.vue'
|
||||||
|
import { getTemplateById, INVOICE_TEMPLATES } 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 { useToastStore } from '../stores/toast'
|
||||||
import { generateInvoicePdf } from '../utils/invoicePdf'
|
import { generateInvoicePdf } from '../utils/invoicePdf'
|
||||||
import { formatDate, formatCurrency, getCurrencySymbol } from '../utils/locale'
|
import { formatDate, formatCurrency, getCurrencySymbol } from '../utils/locale'
|
||||||
|
|
||||||
|
interface LineItem {
|
||||||
|
description: string
|
||||||
|
quantity: number
|
||||||
|
unit_price: number
|
||||||
|
}
|
||||||
|
|
||||||
const invoicesStore = useInvoicesStore()
|
const invoicesStore = useInvoicesStore()
|
||||||
const clientsStore = useClientsStore()
|
const clientsStore = useClientsStore()
|
||||||
|
const projectsStore = useProjectsStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
|
|
||||||
// View state
|
// View state
|
||||||
@@ -361,6 +522,7 @@ const invoiceToDelete = ref<Invoice | null>(null)
|
|||||||
// Create form
|
// Create form
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
client_id: 0,
|
client_id: 0,
|
||||||
|
invoice_number: 'INV-{YYYY}-{###}',
|
||||||
date: new Date().toISOString().split('T')[0],
|
date: new Date().toISOString().split('T')[0],
|
||||||
due_date: '',
|
due_date: '',
|
||||||
tax_rate: 0,
|
tax_rate: 0,
|
||||||
@@ -368,15 +530,111 @@ const createForm = reactive({
|
|||||||
notes: ''
|
notes: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Line items
|
||||||
|
const lineItems = ref<LineItem[]>([])
|
||||||
|
const importProjectId = ref(0)
|
||||||
|
const showTokenHelp = ref(false)
|
||||||
|
const selectedTemplateId = ref('clean-minimal')
|
||||||
|
|
||||||
|
// Preview state
|
||||||
|
const previewItems = ref<InvoiceItem[]>([])
|
||||||
|
const previewClient = 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 || '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
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(() =>
|
||||||
|
lineItems.value.map(li => ({
|
||||||
|
description: li.description,
|
||||||
|
quantity: li.quantity,
|
||||||
|
rate: li.unit_price,
|
||||||
|
amount: li.quantity * li.unit_price,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
// Get client name by ID
|
||||||
function getClientName(clientId: number): string {
|
function getClientName(clientId: number): string {
|
||||||
const client = clientsStore.clients.find(c => c.id === clientId)
|
const client = clientsStore.clients.find(c => c.id === clientId)
|
||||||
return client?.name || 'Unknown Client'
|
return client?.name || 'Unknown Client'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate subtotal (simplified - would need line items in a real app)
|
// 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 {
|
function calculateSubtotal(): number {
|
||||||
return 0
|
return lineItems.value.reduce((sum, item) => sum + (item.quantity * item.unit_price), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate tax
|
// Calculate tax
|
||||||
@@ -390,8 +648,14 @@ function calculateTotal(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// View invoice details
|
// View invoice details
|
||||||
function viewInvoice(invoice: Invoice) {
|
async function viewInvoice(invoice: Invoice) {
|
||||||
selectedInvoice.value = invoice
|
selectedInvoice.value = invoice
|
||||||
|
try {
|
||||||
|
previewItems.value = invoice.id ? await invoicesStore.getInvoiceItems(invoice.id) : []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load invoice items:', e)
|
||||||
|
previewItems.value = []
|
||||||
|
}
|
||||||
showDetailDialog.value = true
|
showDetailDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,7 +687,7 @@ async function handleCreate() {
|
|||||||
|
|
||||||
const invoice: Invoice = {
|
const invoice: Invoice = {
|
||||||
client_id: createForm.client_id,
|
client_id: createForm.client_id,
|
||||||
invoice_number: `INV-${Date.now()}`,
|
invoice_number: resolvedInvoiceNumber.value,
|
||||||
date: createForm.date,
|
date: createForm.date,
|
||||||
due_date: createForm.due_date || undefined,
|
due_date: createForm.due_date || undefined,
|
||||||
subtotal,
|
subtotal,
|
||||||
@@ -435,27 +699,70 @@ async function handleCreate() {
|
|||||||
status: 'pending'
|
status: 'pending'
|
||||||
}
|
}
|
||||||
|
|
||||||
await invoicesStore.createInvoice(invoice)
|
const invoiceId = await invoicesStore.createInvoice(invoice)
|
||||||
view.value = 'list'
|
if (!invoiceId) {
|
||||||
}
|
toastStore.error('Failed to create invoice')
|
||||||
|
|
||||||
// Export PDF using client-side jsPDF
|
|
||||||
function exportPDF(invoice: Invoice) {
|
|
||||||
const client = clientsStore.clients.find(c => c.id === invoice.client_id)
|
|
||||||
if (!client) {
|
|
||||||
console.error('Client not found for invoice')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = generateInvoicePdf(invoice, client, [])
|
// Save line items
|
||||||
doc.save(`${invoice.invoice_number}.pdf`)
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
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 = '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
|
// Load data on mount
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
invoicesStore.fetchInvoices(),
|
invoicesStore.fetchInvoices(),
|
||||||
clientsStore.fetchClients()
|
clientsStore.fetchClients(),
|
||||||
|
projectsStore.fetchProjects(),
|
||||||
|
settingsStore.fetchSettings(),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user