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>
|
||||
|
||||
<!-- 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
|
||||
id="tab-list"
|
||||
role="tab"
|
||||
:aria-selected="view === 'list'"
|
||||
aria-controls="tabpanel-list"
|
||||
:tabindex="view === 'list' ? 0 : -1"
|
||||
@click="view = 'list'"
|
||||
class="pb-2 text-[0.8125rem] transition-colors duration-150 -mb-px"
|
||||
:class="view === 'list'
|
||||
@@ -14,6 +19,12 @@
|
||||
List
|
||||
</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'"
|
||||
class="pb-2 text-[0.8125rem] transition-colors duration-150 -mb-px"
|
||||
:class="view === 'create'
|
||||
@@ -25,8 +36,24 @@
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-if="view === 'list'">
|
||||
<div v-if="invoicesStore.invoices.length > 0" class="overflow-x-auto">
|
||||
<div v-if="view === 'list'" id="tabpanel-list" role="tabpanel" aria-labelledby="tab-list">
|
||||
<!-- 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">
|
||||
<thead>
|
||||
<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-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Amount</th>
|
||||
<th class="px-4 py-3 text-center text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Status</th>
|
||||
<th class="px-4 py-3 w-24"></th>
|
||||
<th class="px-4 py-3 w-32"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="invoice in invoicesStore.invoices"
|
||||
v-for="invoice in filteredInvoices"
|
||||
:key="invoice.id"
|
||||
class="group border-b border-border-subtle hover:bg-bg-elevated transition-colors duration-150"
|
||||
>
|
||||
@@ -58,24 +85,37 @@
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span
|
||||
class="text-[0.6875rem] font-medium"
|
||||
:class="{
|
||||
'text-status-running': invoice.status === 'paid',
|
||||
'text-status-warning': invoice.status === 'pending',
|
||||
'text-status-error': invoice.status === 'overdue' || invoice.status === 'draft'
|
||||
}"
|
||||
class="inline-block px-2 py-0.5 text-[0.6875rem] font-medium rounded-full capitalize"
|
||||
:class="getStatusClass(invoice.status)"
|
||||
>
|
||||
{{ invoice.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-100">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
v-if="invoice.status === 'draft'"
|
||||
@click="invoicesStore.updateStatus(invoice.id!, 'sent')"
|
||||
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'"
|
||||
>
|
||||
Mark Sent
|
||||
</button>
|
||||
<button
|
||||
v-if="invoice.status === 'sent' || invoice.status === 'overdue'"
|
||||
@click="invoicesStore.updateStatus(invoice.id!, 'paid')"
|
||||
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'"
|
||||
>
|
||||
Mark Paid
|
||||
</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"
|
||||
title="View"
|
||||
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">
|
||||
<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>
|
||||
@@ -83,30 +123,42 @@
|
||||
<button
|
||||
@click="exportPDF(invoice)"
|
||||
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
|
||||
title="Export PDF"
|
||||
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">
|
||||
<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"
|
||||
title="Delete"
|
||||
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">
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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">
|
||||
<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-xs text-text-tertiary mt-2 max-w-xs text-center">Create invoices from your tracked time to bill clients.</p>
|
||||
<button
|
||||
@@ -119,16 +171,17 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<!-- Top: Invoice settings + Client info -->
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<!-- Left: Invoice settings -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Invoice Number</label>
|
||||
<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">
|
||||
<input
|
||||
id="invoice-number"
|
||||
v-model="createForm.invoice_number"
|
||||
type="text"
|
||||
class="flex-1 px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary font-mono focus:outline-none focus:border-border-visible"
|
||||
@@ -139,13 +192,18 @@
|
||||
type="button"
|
||||
@click="showTokenHelp = !showTokenHelp"
|
||||
class="px-2.5 py-2 border border-border-subtle text-text-tertiary rounded-lg hover:bg-bg-elevated hover:text-text-secondary transition-colors text-[0.75rem]"
|
||||
title="Token help"
|
||||
aria-label="Token help"
|
||||
:aria-expanded="showTokenHelp"
|
||||
:aria-describedby="showTokenHelp ? 'token-help-popover' : undefined"
|
||||
>
|
||||
{ }
|
||||
</button>
|
||||
<div
|
||||
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]"
|
||||
@keydown.escape="showTokenHelp = false"
|
||||
>
|
||||
<p class="text-text-secondary mb-2">Available tokens:</p>
|
||||
<div class="space-y-1 font-mono text-text-tertiary">
|
||||
@@ -231,6 +289,7 @@
|
||||
placeholder="Import from project..."
|
||||
:placeholder-value="0"
|
||||
class="w-48"
|
||||
@update:model-value="(v: number) => { if (v) previewImport(v) }"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -240,6 +299,15 @@
|
||||
>
|
||||
Import
|
||||
</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
|
||||
type="button"
|
||||
@click="addLineItem"
|
||||
@@ -250,6 +318,31 @@
|
||||
</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">
|
||||
<thead>
|
||||
<tr class="border-b border-border-subtle bg-bg-inset">
|
||||
@@ -272,6 +365,7 @@
|
||||
type="text"
|
||||
class="w-full px-2 py-1 bg-transparent border-0 text-[0.75rem] text-text-primary focus:outline-none focus:bg-bg-inset rounded"
|
||||
placeholder="Item description"
|
||||
aria-label="Item description"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-1.5">
|
||||
@@ -281,6 +375,7 @@
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="w-full px-2 py-1 bg-transparent border-0 text-[0.75rem] text-text-primary text-right font-mono focus:outline-none focus:bg-bg-inset rounded"
|
||||
aria-label="Quantity"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-1.5">
|
||||
@@ -290,6 +385,7 @@
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="w-full px-2 py-1 bg-transparent border-0 text-[0.75rem] text-text-primary text-right font-mono focus:outline-none focus:bg-bg-inset rounded"
|
||||
aria-label="Unit price"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-1.5 text-right text-[0.75rem] font-mono text-text-primary">
|
||||
@@ -300,8 +396,9 @@
|
||||
type="button"
|
||||
@click="lineItems.splice(i, 1)"
|
||||
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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -316,8 +413,9 @@
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<!-- Left: Notes -->
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Notes</label>
|
||||
<label for="invoice-notes" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Notes</label>
|
||||
<textarea
|
||||
id="invoice-notes"
|
||||
v-model="createForm.notes"
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible resize-none"
|
||||
@@ -371,6 +469,12 @@
|
||||
</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 -->
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
@@ -391,15 +495,16 @@
|
||||
</div>
|
||||
|
||||
<!-- 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 -->
|
||||
<div class="flex items-center justify-between px-6 py-3 border-b border-border-subtle bg-bg-surface shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="handlePickerBack"
|
||||
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
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" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -443,7 +548,7 @@
|
||||
: '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="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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -472,9 +577,9 @@
|
||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
||||
@click.self="showDeleteDialog = false"
|
||||
>
|
||||
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
|
||||
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Delete Invoice</h2>
|
||||
<p class="text-[0.75rem] text-text-secondary mb-6">
|
||||
<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 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 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.
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
@@ -498,8 +603,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { FileText, X, Receipt, Clock } from 'lucide-vue-next'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
import AppNumberInput from '../components/AppNumberInput.vue'
|
||||
@@ -514,7 +619,9 @@ import { useProjectsStore } from '../stores/projects'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import { generateInvoicePdf } from '../utils/invoicePdf'
|
||||
import { useExpensesStore } from '../stores/expenses'
|
||||
import { formatDate, formatCurrency, getCurrencySymbol } from '../utils/locale'
|
||||
import { useFocusTrap } from '../utils/focusTrap'
|
||||
|
||||
interface LineItem {
|
||||
description: string
|
||||
@@ -527,6 +634,10 @@ const clientsStore = useClientsStore()
|
||||
const projectsStore = useProjectsStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const toastStore = useToastStore()
|
||||
const expensesStore = useExpensesStore()
|
||||
|
||||
// Track imported expense IDs so we can mark them invoiced after save
|
||||
const importedExpenseIds = ref<number[]>([])
|
||||
|
||||
// View state
|
||||
const view = ref<'list' | 'create' | 'template-picker'>('list')
|
||||
@@ -537,6 +648,36 @@ const showDeleteDialog = ref(false)
|
||||
const selectedInvoice = 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
|
||||
const createForm = reactive({
|
||||
client_id: 0,
|
||||
@@ -552,8 +693,77 @@ const createForm = reactive({
|
||||
const lineItems = ref<LineItem[]>([])
|
||||
const importProjectId = ref(0)
|
||||
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')
|
||||
|
||||
// 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
|
||||
const previewItems = ref<InvoiceItem[]>([])
|
||||
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
|
||||
})
|
||||
|
||||
const isRoundingEnabled = computed(() => settingsStore.settings.rounding_enabled === 'true')
|
||||
|
||||
const businessInfo = computed<BusinessInfo>(() => ({
|
||||
name: settingsStore.settings.business_name || '',
|
||||
address: settingsStore.settings.business_address || '',
|
||||
@@ -607,7 +819,8 @@ async function importFromProject() {
|
||||
|
||||
try {
|
||||
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 totalHours = projectEntries.reduce((sum, e) => sum + e.duration / 3600, 0)
|
||||
@@ -619,12 +832,45 @@ async function importFromProject() {
|
||||
unit_price: project.hourly_rate
|
||||
})
|
||||
} 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) {
|
||||
console.error('Failed to import entries:', e)
|
||||
}
|
||||
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
|
||||
@@ -693,7 +939,7 @@ async function handleCreate() {
|
||||
discount: createForm.discount,
|
||||
total,
|
||||
notes: createForm.notes || undefined,
|
||||
status: 'pending'
|
||||
status: 'draft'
|
||||
}
|
||||
|
||||
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
|
||||
selectedInvoice.value = { ...invoice, id: invoiceId }
|
||||
pickerInvoiceId.value = invoiceId
|
||||
@@ -733,6 +988,7 @@ async function handleCreate() {
|
||||
createForm.discount = 0
|
||||
createForm.notes = ''
|
||||
lineItems.value = []
|
||||
importedExpenseIds.value = []
|
||||
|
||||
view.value = 'template-picker'
|
||||
}
|
||||
|
||||
@@ -132,12 +132,22 @@
|
||||
</dl>
|
||||
|
||||
<!-- 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 class="text-border-subtle">|</span>
|
||||
<span>Non-billable: <span class="font-mono text-text-tertiary">{{ formatHours(nonBillableSeconds) }}</span></span>
|
||||
</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 -->
|
||||
<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>
|
||||
@@ -486,7 +496,7 @@
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
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 AppDateRangePresets from '../components/AppDateRangePresets.vue'
|
||||
import AppSelect from '../components/AppSelect.vue'
|
||||
@@ -613,6 +623,45 @@ const nonBillableSeconds = computed(() => {
|
||||
.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
|
||||
const filteredByProject = computed(() => {
|
||||
if (billableFilter.value === 'all') return reportData.value.byProject || []
|
||||
|
||||
Reference in New Issue
Block a user