feat: add two-step invoice flow with full-screen template picker
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h1 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary mb-6">Invoices</h1>
|
<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 -->
|
<!-- View tabs (hidden in template picker) -->
|
||||||
<div class="flex gap-6 mb-6 border-b border-border-subtle">
|
<div v-if="view !== 'template-picker'" class="flex gap-6 mb-6 border-b border-border-subtle">
|
||||||
<button
|
<button
|
||||||
@click="view = 'list'"
|
@click="view = 'list'"
|
||||||
class="pb-2 text-[0.8125rem] transition-colors duration-150 -mb-px"
|
class="pb-2 text-[0.8125rem] transition-colors duration-150 -mb-px"
|
||||||
@@ -371,18 +371,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Template Selection -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-2">Invoice Template</label>
|
|
||||||
<InvoiceTemplatePicker
|
|
||||||
v-model="selectedTemplateId"
|
|
||||||
:invoice="previewCreateInvoice"
|
|
||||||
:client="selectedClient"
|
|
||||||
:items="createPreviewItems"
|
|
||||||
:business-info="businessInfo"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
@@ -402,49 +390,80 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Invoice Preview Dialog -->
|
<!-- Template Picker View -->
|
||||||
<Transition name="modal">
|
<div v-else-if="view === 'template-picker'" class="-m-6 -mt-[calc(1.5rem+1.75rem+1.5rem)] h-screen flex flex-col">
|
||||||
<div
|
<!-- Top bar -->
|
||||||
v-if="showDetailDialog"
|
<div class="flex items-center justify-between px-6 py-3 border-b border-border-subtle bg-bg-surface shrink-0">
|
||||||
class="fixed inset-0 bg-black/80 backdrop-blur-[4px] flex flex-col items-center p-6 z-50 overflow-y-auto"
|
<div class="flex items-center gap-3">
|
||||||
@click.self="showDetailDialog = false"
|
|
||||||
>
|
|
||||||
<!-- Toolbar -->
|
|
||||||
<div class="flex items-center gap-3 mb-4 shrink-0">
|
|
||||||
<select
|
|
||||||
v-model="selectedTemplateId"
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<option v-for="tmpl in INVOICE_TEMPLATES" :key="tmpl.id" :value="tmpl.id" class="text-black bg-white">
|
|
||||||
{{ tmpl.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<button
|
<button
|
||||||
@click="exportPDF(selectedInvoice!)"
|
@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"
|
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
|
Export PDF
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="showDetailDialog = false"
|
@click="handlePickerSave"
|
||||||
class="px-4 py-2 border border-white/20 text-white/80 text-[0.75rem] rounded-lg hover:bg-white/10 transition-colors"
|
class="px-4 py-2 border border-border-subtle text-text-secondary text-[0.75rem] rounded-lg hover:bg-bg-elevated transition-colors"
|
||||||
>
|
>
|
||||||
Close
|
Save & Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Template preview -->
|
<!-- Split pane -->
|
||||||
<div class="w-[8.5in] shrink-0">
|
<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
|
<InvoicePreview
|
||||||
|
v-if="selectedInvoice"
|
||||||
:template="getTemplateById(selectedTemplateId)"
|
:template="getTemplateById(selectedTemplateId)"
|
||||||
:invoice="selectedInvoice!"
|
:invoice="selectedInvoice"
|
||||||
:client="previewClient"
|
:client="pickerClient"
|
||||||
:items="previewItems"
|
:items="previewItems"
|
||||||
:business-info="businessInfo"
|
:business-info="businessInfo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Delete Confirmation Dialog -->
|
<!-- Delete Confirmation Dialog -->
|
||||||
<Transition name="modal">
|
<Transition name="modal">
|
||||||
@@ -487,8 +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 InvoiceTemplatePicker from '../components/InvoiceTemplatePicker.vue'
|
import { getTemplateById, getTemplatesByCategory, TEMPLATE_CATEGORIES } from '../utils/invoiceTemplates'
|
||||||
import { getTemplateById, INVOICE_TEMPLATES } 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'
|
||||||
@@ -511,10 +529,10 @@ const settingsStore = useSettingsStore()
|
|||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
|
|
||||||
// View state
|
// View state
|
||||||
const view = ref<'list' | 'create'>('list')
|
const view = ref<'list' | 'create' | 'template-picker'>('list')
|
||||||
|
const pickerInvoiceId = ref<number | null>(null)
|
||||||
|
|
||||||
// Dialog state
|
// Dialog state
|
||||||
const showDetailDialog = ref(false)
|
|
||||||
const showDeleteDialog = ref(false)
|
const showDeleteDialog = ref(false)
|
||||||
const selectedInvoice = ref<Invoice | null>(null)
|
const selectedInvoice = ref<Invoice | null>(null)
|
||||||
const invoiceToDelete = ref<Invoice | null>(null)
|
const invoiceToDelete = ref<Invoice | null>(null)
|
||||||
@@ -534,11 +552,11 @@ const createForm = reactive({
|
|||||||
const lineItems = ref<LineItem[]>([])
|
const lineItems = ref<LineItem[]>([])
|
||||||
const importProjectId = ref(0)
|
const importProjectId = ref(0)
|
||||||
const showTokenHelp = ref(false)
|
const showTokenHelp = ref(false)
|
||||||
const selectedTemplateId = ref('clean-minimal')
|
const selectedTemplateId = ref('clean')
|
||||||
|
|
||||||
// Preview state
|
// Preview / picker state
|
||||||
const previewItems = ref<InvoiceItem[]>([])
|
const previewItems = ref<InvoiceItem[]>([])
|
||||||
const previewClient = computed<Client | null>(() => {
|
const pickerClient = computed<Client | null>(() => {
|
||||||
if (!selectedInvoice.value) return null
|
if (!selectedInvoice.value) return null
|
||||||
return clientsStore.clients.find(c => c.id === selectedInvoice.value!.client_id) || null
|
return clientsStore.clients.find(c => c.id === selectedInvoice.value!.client_id) || null
|
||||||
})
|
})
|
||||||
@@ -557,29 +575,6 @@ const businessInfo = computed<BusinessInfo>(() => ({
|
|||||||
logo: settingsStore.settings.business_logo || '',
|
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
|
// Resolve invoice number tokens
|
||||||
const resolvedInvoiceNumber = computed(() => {
|
const resolvedInvoiceNumber = computed(() => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -647,16 +642,18 @@ function calculateTotal(): number {
|
|||||||
return calculateSubtotal() + calculateTax() - createForm.discount
|
return calculateSubtotal() + calculateTax() - createForm.discount
|
||||||
}
|
}
|
||||||
|
|
||||||
// View invoice details
|
// View invoice → open template picker
|
||||||
async function viewInvoice(invoice: Invoice) {
|
async function viewInvoice(invoice: Invoice) {
|
||||||
selectedInvoice.value = invoice
|
selectedInvoice.value = invoice
|
||||||
|
pickerInvoiceId.value = invoice.id!
|
||||||
|
selectedTemplateId.value = invoice.template_id || 'clean'
|
||||||
try {
|
try {
|
||||||
previewItems.value = invoice.id ? await invoicesStore.getInvoiceItems(invoice.id) : []
|
previewItems.value = invoice.id ? await invoicesStore.getInvoiceItems(invoice.id) : []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load invoice items:', e)
|
console.error('Failed to load invoice items:', e)
|
||||||
previewItems.value = []
|
previewItems.value = []
|
||||||
}
|
}
|
||||||
showDetailDialog.value = true
|
view.value = 'template-picker'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm delete
|
// Confirm delete
|
||||||
@@ -715,7 +712,19 @@ async function handleCreate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset form
|
// 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.client_id = 0
|
||||||
createForm.invoice_number = 'INV-{YYYY}-{###}'
|
createForm.invoice_number = 'INV-{YYYY}-{###}'
|
||||||
createForm.date = new Date().toISOString().split('T')[0]
|
createForm.date = new Date().toISOString().split('T')[0]
|
||||||
@@ -725,6 +734,28 @@ async function handleCreate() {
|
|||||||
createForm.notes = ''
|
createForm.notes = ''
|
||||||
lineItems.value = []
|
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'
|
view.value = 'list'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user