feat: rounding visibility in invoices and reports
This commit is contained in:
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 || []
|
||||||
|
|||||||
Reference in New Issue
Block a user