feat: rounding visibility in invoices and reports

This commit is contained in:
Your Name
2026-02-20 15:37:20 +02:00
parent 773ba1d338
commit fa7b70aa61
2 changed files with 358 additions and 53 deletions

View File

@@ -3,8 +3,13 @@
<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> <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) --> <!-- View tabs (hidden in template picker) -->
<div v-if="view !== 'template-picker'" 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" role="tablist" aria-label="Invoice views" @keydown="onTabKeydown">
<button <button
id="tab-list"
role="tab"
:aria-selected="view === 'list'"
aria-controls="tabpanel-list"
:tabindex="view === 'list' ? 0 : -1"
@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"
:class="view === 'list' :class="view === 'list'
@@ -14,6 +19,12 @@
List List
</button> </button>
<button <button
id="tab-create"
data-tour-id="new-invoice"
role="tab"
:aria-selected="view === 'create'"
aria-controls="tabpanel-create"
:tabindex="view === 'create' ? 0 : -1"
@click="view = 'create'" @click="view = 'create'"
class="pb-2 text-[0.8125rem] transition-colors duration-150 -mb-px" class="pb-2 text-[0.8125rem] transition-colors duration-150 -mb-px"
:class="view === 'create' :class="view === 'create'
@@ -25,8 +36,24 @@
</div> </div>
<!-- List View --> <!-- List View -->
<div v-if="view === 'list'"> <div v-if="view === 'list'" id="tabpanel-list" role="tabpanel" aria-labelledby="tab-list">
<div v-if="invoicesStore.invoices.length > 0" class="overflow-x-auto"> <!-- Status filter -->
<div v-if="invoicesStore.invoices.length > 0" class="flex flex-wrap gap-2 mb-4" role="group" aria-label="Filter by status">
<button
v-for="opt in statusOptions"
:key="opt.value"
@click="statusFilter = opt.value"
:aria-pressed="statusFilter === opt.value"
class="px-3 py-1 text-[0.6875rem] font-medium rounded-full border transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
:class="statusFilter === opt.value
? 'bg-accent text-bg-base border-accent'
: 'bg-bg-surface text-text-secondary border-border-subtle hover:border-border-visible'"
>
{{ opt.label }} ({{ getStatusCount(opt.value) }})
</button>
</div>
<div v-if="filteredInvoices.length > 0" class="overflow-x-auto">
<table class="w-full"> <table class="w-full">
<thead> <thead>
<tr class="border-b border-border-subtle bg-bg-surface"> <tr class="border-b border-border-subtle bg-bg-surface">
@@ -35,12 +62,12 @@
<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-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-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 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> <th class="px-4 py-3 w-32"><span class="sr-only">Actions</span></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="invoice in invoicesStore.invoices" v-for="invoice in filteredInvoices"
:key="invoice.id" :key="invoice.id"
class="group border-b border-border-subtle hover:bg-bg-elevated transition-colors duration-150" class="group border-b border-border-subtle hover:bg-bg-elevated transition-colors duration-150"
> >
@@ -58,46 +85,60 @@
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-4 py-3 text-center">
<span <span
class="text-[0.6875rem] font-medium" class="inline-block px-2 py-0.5 text-[0.6875rem] font-medium rounded-full capitalize"
:class="{ :class="getStatusClass(invoice.status)"
'text-status-running': invoice.status === 'paid',
'text-status-warning': invoice.status === 'pending',
'text-status-error': invoice.status === 'overdue' || invoice.status === 'draft'
}"
> >
{{ invoice.status }} {{ invoice.status }}
</span> </span>
</td> </td>
<td class="px-4 py-3"> <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"> <div class="flex items-center justify-end gap-1">
<button <button
@click="viewInvoice(invoice)" v-if="invoice.status === 'draft'"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150" @click="invoicesStore.updateStatus(invoice.id!, 'sent')"
title="View" class="px-2 py-1 text-[0.625rem] font-medium text-blue-400 border border-blue-400/30 rounded hover:bg-blue-400/10 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
:aria-label="'Mark invoice ' + invoice.invoice_number + ' as sent'"
> >
<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"> Mark Sent
<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>
<button <button
@click="exportPDF(invoice)" v-if="invoice.status === 'sent' || invoice.status === 'overdue'"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150" @click="invoicesStore.updateStatus(invoice.id!, 'paid')"
title="Export PDF" class="px-2 py-1 text-[0.625rem] font-medium text-green-400 border border-green-400/30 rounded hover:bg-green-400/10 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
:aria-label="'Mark invoice ' + invoice.invoice_number + ' as paid'"
> >
<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"> Mark Paid
<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> </button>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 group-focus-within: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"
aria-label="View invoice"
>
<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" aria-hidden="true">
<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"
aria-label="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" aria-hidden="true">
<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"
aria-label="Delete invoice"
>
<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" aria-hidden="true">
<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>
</div> </div>
</td> </td>
</tr> </tr>
@@ -105,8 +146,19 @@
</table> </table>
</div> </div>
<div v-else-if="invoicesStore.invoices.length > 0 && filteredInvoices.length === 0" class="flex flex-col items-center justify-center py-16">
<FileText class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" aria-hidden="true" />
<p class="text-sm text-text-secondary mt-4">No {{ statusFilter }} invoices</p>
<button
@click="statusFilter = 'all'"
class="mt-4 px-4 py-2 border border-border-subtle text-text-secondary text-xs font-medium rounded-lg hover:bg-bg-elevated transition-colors"
>
Show All
</button>
</div>
<div v-else class="flex flex-col items-center justify-center py-16"> <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" /> <FileText class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" aria-hidden="true" />
<p class="text-sm text-text-secondary mt-4">No invoices yet</p> <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> <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 <button
@@ -119,16 +171,17 @@
</div> </div>
<!-- Create View --> <!-- Create View -->
<div v-else-if="view === 'create'" class="max-w-4xl mx-auto"> <div v-else-if="view === 'create'" id="tabpanel-create" role="tabpanel" aria-labelledby="tab-create" class="max-w-4xl mx-auto">
<form @submit.prevent="handleCreate" class="space-y-5"> <form @submit.prevent="handleCreate" class="space-y-5">
<!-- Top: Invoice settings + Client info --> <!-- Top: Invoice settings + Client info -->
<div class="grid grid-cols-2 gap-6"> <div class="grid grid-cols-2 gap-6">
<!-- Left: Invoice settings --> <!-- Left: Invoice settings -->
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Invoice Number</label> <label for="invoice-number" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Invoice Number</label>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
id="invoice-number"
v-model="createForm.invoice_number" v-model="createForm.invoice_number"
type="text" 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" 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"
@@ -139,13 +192,18 @@
type="button" type="button"
@click="showTokenHelp = !showTokenHelp" @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]" 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" aria-label="Token help"
:aria-expanded="showTokenHelp"
:aria-describedby="showTokenHelp ? 'token-help-popover' : undefined"
> >
{ } { }
</button> </button>
<div <div
v-if="showTokenHelp" v-if="showTokenHelp"
id="token-help-popover"
role="tooltip"
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]" 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]"
@keydown.escape="showTokenHelp = false"
> >
<p class="text-text-secondary mb-2">Available tokens:</p> <p class="text-text-secondary mb-2">Available tokens:</p>
<div class="space-y-1 font-mono text-text-tertiary"> <div class="space-y-1 font-mono text-text-tertiary">
@@ -231,6 +289,7 @@
placeholder="Import from project..." placeholder="Import from project..."
:placeholder-value="0" :placeholder-value="0"
class="w-48" class="w-48"
@update:model-value="(v: number) => { if (v) previewImport(v) }"
/> />
<button <button
type="button" type="button"
@@ -240,6 +299,15 @@
> >
Import Import
</button> </button>
<button
type="button"
@click="importExpenses"
:disabled="!createForm.client_id"
class="flex items-center gap-1 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 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Receipt class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
Import Expenses
</button>
<button <button
type="button" type="button"
@click="addLineItem" @click="addLineItem"
@@ -250,6 +318,31 @@
</div> </div>
</div> </div>
<!-- Import date range filter -->
<div v-if="importProjectId" class="px-4 py-3 border-b border-border-subtle bg-bg-inset">
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2">
<label class="text-[0.6875rem] text-text-tertiary whitespace-nowrap">From</label>
<AppDatePicker
:model-value="importStartDate"
@update:model-value="(v: string) => { importStartDate = v; previewImport(importProjectId) }"
placeholder="Start date"
/>
</div>
<div class="flex items-center gap-2">
<label class="text-[0.6875rem] text-text-tertiary whitespace-nowrap">To</label>
<AppDatePicker
:model-value="importEndDate"
@update:model-value="(v: string) => { importEndDate = v; previewImport(importProjectId) }"
placeholder="End date"
/>
</div>
<div v-if="importPreview" aria-live="polite" class="text-[0.6875rem] text-text-secondary ml-auto">
{{ importPreview.count }} entries, {{ importPreview.hours.toFixed(1) }} hours, {{ formatCurrency(importPreview.amount) }}
</div>
</div>
</div>
<table v-if="lineItems.length > 0" class="w-full"> <table v-if="lineItems.length > 0" class="w-full">
<thead> <thead>
<tr class="border-b border-border-subtle bg-bg-inset"> <tr class="border-b border-border-subtle bg-bg-inset">
@@ -272,6 +365,7 @@
type="text" 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" 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" placeholder="Item description"
aria-label="Item description"
/> />
</td> </td>
<td class="px-4 py-1.5"> <td class="px-4 py-1.5">
@@ -281,6 +375,7 @@
min="0" min="0"
step="0.01" 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" 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"
aria-label="Quantity"
/> />
</td> </td>
<td class="px-4 py-1.5"> <td class="px-4 py-1.5">
@@ -290,6 +385,7 @@
min="0" min="0"
step="0.01" 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" 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"
aria-label="Unit price"
/> />
</td> </td>
<td class="px-4 py-1.5 text-right text-[0.75rem] font-mono text-text-primary"> <td class="px-4 py-1.5 text-right text-[0.75rem] font-mono text-text-primary">
@@ -300,8 +396,9 @@
type="button" type="button"
@click="lineItems.splice(i, 1)" @click="lineItems.splice(i, 1)"
class="p-1 text-text-tertiary hover:text-status-error transition-colors" class="p-1 text-text-tertiary hover:text-status-error transition-colors"
:aria-label="'Remove line item ' + (i + 1)"
> >
<X class="w-3.5 h-3.5" :stroke-width="1.5" /> <X class="w-3.5 h-3.5" :stroke-width="1.5" aria-hidden="true" />
</button> </button>
</td> </td>
</tr> </tr>
@@ -316,8 +413,9 @@
<div class="grid grid-cols-2 gap-6"> <div class="grid grid-cols-2 gap-6">
<!-- Left: Notes --> <!-- Left: Notes -->
<div> <div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Notes</label> <label for="invoice-notes" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Notes</label>
<textarea <textarea
id="invoice-notes"
v-model="createForm.notes" v-model="createForm.notes"
rows="4" 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" 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"
@@ -371,6 +469,12 @@
</div> </div>
</div> </div>
<!-- Rounding note -->
<div v-if="isRoundingEnabled" class="text-[0.625rem] text-text-tertiary flex items-center gap-1">
<Clock class="w-3 h-3 shrink-0" aria-hidden="true" :stroke-width="1.5" />
Durations may include rounding adjustments
</div>
<!-- Buttons --> <!-- Buttons -->
<div class="flex justify-end gap-3 pt-2"> <div class="flex justify-end gap-3 pt-2">
<button <button
@@ -391,15 +495,16 @@
</div> </div>
<!-- Template Picker View --> <!-- Template Picker View -->
<div v-else-if="view === 'template-picker'" class="fixed inset-0 z-40 bg-bg-base flex flex-col"> <div v-else-if="view === 'template-picker'" class="fixed inset-0 z-40 bg-bg-base flex flex-col" role="dialog" aria-modal="true" aria-label="Template picker">
<!-- Top bar --> <!-- 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 justify-between px-6 py-3 border-b border-border-subtle bg-bg-surface shrink-0">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <button
@click="handlePickerBack" @click="handlePickerBack"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors" class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors"
aria-label="Back to invoice list"
> >
<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"> <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" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" /> <path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
</button> </button>
@@ -443,7 +548,7 @@
: 'text-text-secondary hover:bg-bg-elevated hover:text-text-primary'" : 'text-text-secondary hover:bg-bg-elevated hover:text-text-primary'"
@click="selectedTemplateId = tmpl.id" @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="w-2.5 h-2.5 rounded-full shrink-0 border border-black/10" :style="{ backgroundColor: tmpl.colors.primary }" aria-hidden="true" />
<span class="truncate">{{ tmpl.name }}</span> <span class="truncate">{{ tmpl.name }}</span>
</button> </button>
</div> </div>
@@ -472,9 +577,9 @@
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/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="showDeleteDialog = false" @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"> <div ref="deleteDialogRef" 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" role="alertdialog" aria-modal="true" aria-labelledby="delete-invoice-title" aria-describedby="delete-invoice-desc">
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Delete Invoice</h2> <h2 id="delete-invoice-title" 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"> <p id="delete-invoice-desc" 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. Are you sure you want to delete invoice "{{ invoiceToDelete?.invoice_number }}"? This action cannot be undone.
</p> </p>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
@@ -498,8 +603,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next' import { FileText, X, Receipt, Clock } from 'lucide-vue-next'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { save } from '@tauri-apps/plugin-dialog' import { save } from '@tauri-apps/plugin-dialog'
import AppNumberInput from '../components/AppNumberInput.vue' import AppNumberInput from '../components/AppNumberInput.vue'
@@ -514,7 +619,9 @@ import { useProjectsStore } from '../stores/projects'
import { useSettingsStore } from '../stores/settings' 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 { useExpensesStore } from '../stores/expenses'
import { formatDate, formatCurrency, getCurrencySymbol } from '../utils/locale' import { formatDate, formatCurrency, getCurrencySymbol } from '../utils/locale'
import { useFocusTrap } from '../utils/focusTrap'
interface LineItem { interface LineItem {
description: string description: string
@@ -527,6 +634,10 @@ const clientsStore = useClientsStore()
const projectsStore = useProjectsStore() const projectsStore = useProjectsStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const toastStore = useToastStore() const toastStore = useToastStore()
const expensesStore = useExpensesStore()
// Track imported expense IDs so we can mark them invoiced after save
const importedExpenseIds = ref<number[]>([])
// View state // View state
const view = ref<'list' | 'create' | 'template-picker'>('list') const view = ref<'list' | 'create' | 'template-picker'>('list')
@@ -537,6 +648,36 @@ 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)
// Status filter
const statusFilter = ref('all')
const statusOptions = [
{ label: 'All', value: 'all' },
{ label: 'Draft', value: 'draft' },
{ label: 'Sent', value: 'sent' },
{ label: 'Paid', value: 'paid' },
{ label: 'Overdue', value: 'overdue' },
]
const filteredInvoices = computed(() => {
if (statusFilter.value === 'all') return invoicesStore.invoices
return invoicesStore.invoices.filter(i => i.status === statusFilter.value)
})
function getStatusClass(status: string): string {
switch (status) {
case 'draft': return 'bg-bg-elevated text-text-tertiary'
case 'sent': return 'bg-blue-500/10 text-blue-400'
case 'paid': return 'bg-green-500/10 text-green-400'
case 'overdue': return 'bg-red-500/10 text-red-400'
default: return 'bg-bg-elevated text-text-tertiary'
}
}
function getStatusCount(status: string): number {
if (status === 'all') return invoicesStore.invoices.length
return invoicesStore.invoices.filter(i => i.status === status).length
}
// Create form // Create form
const createForm = reactive({ const createForm = reactive({
client_id: 0, client_id: 0,
@@ -552,8 +693,77 @@ 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)
// Import date filtering
const importStartDate = ref(new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0])
const importEndDate = ref(new Date().toISOString().split('T')[0])
const importPreview = ref<{ count: number; hours: number; amount: number } | null>(null)
async function previewImport(projectId: number) {
try {
const entries = await invoke<any[]>('get_time_entries', {
startDate: importStartDate.value,
endDate: importEndDate.value,
})
const filtered = entries.filter((e: any) => e.project_id === projectId)
const totalSeconds = filtered.reduce((sum: number, e: any) => sum + e.duration, 0)
const hours = totalSeconds / 3600
const project = projectsStore.projects.find(p => p.id === projectId)
const rate = project?.hourly_rate || 0
importPreview.value = { count: filtered.length, hours, amount: hours * rate }
} catch (e) {
console.error('Failed to preview import:', e)
importPreview.value = null
}
}
const selectedTemplateId = ref('clean') const selectedTemplateId = ref('clean')
// Delete dialog focus trap
const deleteDialogRef = ref<HTMLElement | null>(null)
const { activate: activateDeleteTrap, deactivate: deactivateDeleteTrap } = useFocusTrap()
watch(showDeleteDialog, (val) => {
if (val) {
setTimeout(() => {
if (deleteDialogRef.value) activateDeleteTrap(deleteDialogRef.value, { onDeactivate: () => { showDeleteDialog.value = false } })
}, 50)
} else {
deactivateDeleteTrap()
}
})
// Clear imported expense IDs when leaving create view
watch(view, (newView, oldView) => {
if (oldView === 'create' && newView !== 'create') {
importedExpenseIds.value = []
}
})
// Arrow key navigation for tabs
function onTabKeydown(e: KeyboardEvent) {
const tabs: Array<'list' | 'create'> = ['list', 'create']
const current = tabs.indexOf(view.value as 'list' | 'create')
if (current === -1) return
let next = current
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault()
next = (current + 1) % tabs.length
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault()
next = (current - 1 + tabs.length) % tabs.length
} else if (e.key === 'Home') {
e.preventDefault()
next = 0
} else if (e.key === 'End') {
e.preventDefault()
next = tabs.length - 1
} else {
return
}
view.value = tabs[next]
document.getElementById(`tab-${tabs[next]}`)?.focus()
}
// Preview / picker state // Preview / picker state
const previewItems = ref<InvoiceItem[]>([]) const previewItems = ref<InvoiceItem[]>([])
const pickerClient = computed<Client | null>(() => { const pickerClient = computed<Client | null>(() => {
@@ -567,6 +777,8 @@ const selectedClient = computed<Client | null>(() => {
return clientsStore.clients.find(c => c.id === createForm.client_id) || null return clientsStore.clients.find(c => c.id === createForm.client_id) || null
}) })
const isRoundingEnabled = computed(() => settingsStore.settings.rounding_enabled === 'true')
const businessInfo = computed<BusinessInfo>(() => ({ const businessInfo = computed<BusinessInfo>(() => ({
name: settingsStore.settings.business_name || '', name: settingsStore.settings.business_name || '',
address: settingsStore.settings.business_address || '', address: settingsStore.settings.business_address || '',
@@ -607,7 +819,8 @@ async function importFromProject() {
try { try {
const entries = await invoke<{ duration: number; description?: string }[]>('get_time_entries', { const entries = await invoke<{ duration: number; description?: string }[]>('get_time_entries', {
startDate: null, endDate: null startDate: importStartDate.value,
endDate: importEndDate.value,
}) })
const projectEntries = entries.filter((e: any) => e.project_id === importProjectId.value) const projectEntries = entries.filter((e: any) => e.project_id === importProjectId.value)
const totalHours = projectEntries.reduce((sum, e) => sum + e.duration / 3600, 0) const totalHours = projectEntries.reduce((sum, e) => sum + e.duration / 3600, 0)
@@ -619,12 +832,45 @@ async function importFromProject() {
unit_price: project.hourly_rate unit_price: project.hourly_rate
}) })
} else { } else {
toastStore.info('No time entries found for this project') toastStore.info('No time entries found for this project in the selected date range')
} }
} catch (e) { } catch (e) {
console.error('Failed to import entries:', e) console.error('Failed to import entries:', e)
} }
importProjectId.value = 0 importProjectId.value = 0
importPreview.value = null
}
// Import uninvoiced expenses for the selected client
async function importExpenses() {
if (!createForm.client_id) return
try {
const expenses = await expensesStore.fetchUninvoiced(undefined, createForm.client_id)
if (expenses.length === 0) {
toastStore.info('No uninvoiced expenses found for this client')
return
}
for (const exp of expenses) {
if (exp.id && importedExpenseIds.value.includes(exp.id)) continue
const desc = exp.description
? `[${exp.category}] ${exp.description}`
: `[${exp.category}]`
lineItems.value.push({
description: desc,
quantity: 1,
unit_price: exp.amount
})
if (exp.id) {
importedExpenseIds.value.push(exp.id)
}
}
toastStore.success(`Imported ${expenses.length} expense(s)`)
} catch (e) {
console.error('Failed to import expenses:', e)
toastStore.error('Failed to import expenses')
}
} }
// Calculate subtotal from line items // Calculate subtotal from line items
@@ -693,7 +939,7 @@ async function handleCreate() {
discount: createForm.discount, discount: createForm.discount,
total, total,
notes: createForm.notes || undefined, notes: createForm.notes || undefined,
status: 'pending' status: 'draft'
} }
const invoiceId = await invoicesStore.createInvoice(invoice) const invoiceId = await invoicesStore.createInvoice(invoice)
@@ -712,6 +958,15 @@ async function handleCreate() {
} }
} }
// Mark imported expenses as invoiced
if (importedExpenseIds.value.length > 0) {
try {
await expensesStore.markInvoiced(importedExpenseIds.value)
} catch (e) {
console.error('Failed to mark expenses as invoiced:', e)
}
}
// Navigate to template picker with the new invoice // Navigate to template picker with the new invoice
selectedInvoice.value = { ...invoice, id: invoiceId } selectedInvoice.value = { ...invoice, id: invoiceId }
pickerInvoiceId.value = invoiceId pickerInvoiceId.value = invoiceId
@@ -733,6 +988,7 @@ async function handleCreate() {
createForm.discount = 0 createForm.discount = 0
createForm.notes = '' createForm.notes = ''
lineItems.value = [] lineItems.value = []
importedExpenseIds.value = []
view.value = 'template-picker' view.value = 'template-picker'
} }

View File

@@ -132,12 +132,22 @@
</dl> </dl>
<!-- Billable split --> <!-- Billable split -->
<div class="flex items-center gap-4 text-[0.75rem] text-text-secondary mb-8"> <div class="flex items-center gap-4 text-[0.75rem] text-text-secondary mb-4">
<span>Billable: <span class="font-mono text-accent-text">{{ formatHours(billableSeconds) }}</span></span> <span>Billable: <span class="font-mono text-accent-text">{{ formatHours(billableSeconds) }}</span></span>
<span class="text-border-subtle">|</span> <span class="text-border-subtle">|</span>
<span>Non-billable: <span class="font-mono text-text-tertiary">{{ formatHours(nonBillableSeconds) }}</span></span> <span>Non-billable: <span class="font-mono text-text-tertiary">{{ formatHours(nonBillableSeconds) }}</span></span>
</div> </div>
<!-- Rounding impact -->
<div v-if="roundingEnabled && roundingImpact !== 0" class="flex items-center gap-2 text-[0.75rem] text-text-secondary mb-8">
<Clock class="w-3.5 h-3.5 text-text-tertiary shrink-0" :stroke-width="1.5" aria-hidden="true" />
<span>Rounding {{ roundingImpact > 0 ? 'added' : 'subtracted' }}:
<span class="font-mono text-text-primary">{{ formatRoundingHours(Math.abs(roundingImpact)) }}</span>
across {{ roundedEntryCount }} {{ roundedEntryCount === 1 ? 'entry' : 'entries' }}
</span>
</div>
<div v-else class="mb-4" />
<!-- Chart --> <!-- Chart -->
<div class="mb-8"> <div class="mb-8">
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Hours by Project</h2> <h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Hours by Project</h2>
@@ -486,7 +496,7 @@
import { ref, computed, onMounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { Bar } from 'vue-chartjs' import { Bar } from 'vue-chartjs'
import { BarChart3, DollarSign, Receipt, Grid3x3 } from 'lucide-vue-next' import { BarChart3, DollarSign, Receipt, Grid3x3, Clock } from 'lucide-vue-next'
import AppDatePicker from '../components/AppDatePicker.vue' import AppDatePicker from '../components/AppDatePicker.vue'
import AppDateRangePresets from '../components/AppDateRangePresets.vue' import AppDateRangePresets from '../components/AppDateRangePresets.vue'
import AppSelect from '../components/AppSelect.vue' import AppSelect from '../components/AppSelect.vue'
@@ -613,6 +623,45 @@ const nonBillableSeconds = computed(() => {
.reduce((sum, e) => sum + e.duration, 0) .reduce((sum, e) => sum + e.duration, 0)
}) })
// Rounding impact helpers
function roundDuration(seconds: number, increment: number, method: string): number {
const incrementSeconds = increment * 60
if (incrementSeconds <= 0) return seconds
if (method === 'up') return Math.ceil(seconds / incrementSeconds) * incrementSeconds
if (method === 'down') return Math.floor(seconds / incrementSeconds) * incrementSeconds
return Math.round(seconds / incrementSeconds) * incrementSeconds
}
const roundingEnabled = computed(() => settingsStore.settings.rounding_enabled === 'true')
const roundedEntryCount = ref(0)
const roundingImpact = computed(() => {
if (!roundingEnabled.value) return 0
const increment = parseInt(settingsStore.settings.rounding_increment) || 0
const method = settingsStore.settings.rounding_method || 'nearest'
if (increment <= 0) return 0
let totalDiff = 0
let count = 0
for (const entry of entriesStore.entries) {
const rounded = roundDuration(entry.duration, increment, method)
if (rounded !== entry.duration) {
totalDiff += rounded - entry.duration
count++
}
}
roundedEntryCount.value = count
return totalDiff
})
function formatRoundingHours(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0 && m > 0) return `${h}h ${m}m`
if (h > 0) return `${h}h`
return `${m}m`
}
// Filtered report data based on billable filter // Filtered report data based on billable filter
const filteredByProject = computed(() => { const filteredByProject = computed(() => {
if (billableFilter.value === 'all') return reportData.value.byProject || [] if (billableFilter.value === 'all') return reportData.value.byProject || []