feat: rounding visibility in invoices and reports

This commit is contained in:
Your Name
2026-02-20 15:37:20 +02:00
parent d22d6e844f
commit e97bc0f640
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>
<!-- 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,46 +85,60 @@
</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
@click="viewInvoice(invoice)"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
title="View"
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'"
>
<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="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>
Mark Sent
</button>
<button
@click="exportPDF(invoice)"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
title="Export PDF"
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'"
>
<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="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>
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"
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>
</td>
</tr>
@@ -105,8 +146,19 @@
</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'
}

View File

@@ -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 || []