Files
zeroclock/src/views/Entries.vue

1503 lines
56 KiB
Vue

<template>
<div class="p-6">
<h1 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary mb-6">Entries</h1>
<!-- Tab Toggle: Time | Expenses -->
<div data-tour-id="entries-tabs" class="flex items-center gap-2 mb-4" role="tablist" aria-label="Entry type" @keydown="onMainTabKeydown">
<button
id="tab-time"
role="tab"
:aria-selected="activeTab === 'time'"
aria-controls="tabpanel-time"
:tabindex="activeTab === 'time' ? 0 : -1"
@click="activeTab = 'time'"
class="px-4 py-2 text-[0.75rem] font-medium rounded-lg transition-colors"
:class="activeTab === 'time' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:text-text-primary'"
>
Time
</button>
<button
id="tab-expenses"
role="tab"
:aria-selected="activeTab === 'expenses'"
aria-controls="tabpanel-expenses"
:tabindex="activeTab === 'expenses' ? 0 : -1"
@click="switchToExpenses"
class="px-4 py-2 text-[0.75rem] font-medium rounded-lg transition-colors"
:class="activeTab === 'expenses' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:text-text-primary'"
>
<span class="flex items-center gap-1.5">
<Receipt class="w-3.5 h-3.5" :stroke-width="1.5" aria-hidden="true" />
Expenses
</span>
</button>
</div>
<!-- ===== TIME TAB ===== -->
<template v-if="activeTab === 'time'">
<div id="tabpanel-time" role="tabpanel" aria-labelledby="tab-time">
<!-- Filters -->
<div class="bg-bg-surface rounded-lg p-4 mb-6 flex flex-wrap items-end gap-4">
<AppDateRangePresets
:start-date="startDate"
:end-date="endDate"
@select="({ start, end }) => { startDate = start; endDate = end; applyFilters() }"
class="mb-3 w-full"
/>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Start Date</label>
<AppDatePicker
v-model="startDate"
placeholder="Start date"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">End Date</label>
<AppDatePicker
v-model="endDate"
placeholder="End date"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Project</label>
<AppSelect
v-model="filterProject"
:options="projectsStore.projects"
label-key="name"
value-key="id"
placeholder="All Projects"
:placeholder-value="null"
/>
</div>
<button
@click="applyFilters"
class="px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
Apply
</button>
<button
@click="clearFilters"
class="text-text-secondary text-xs hover:text-text-primary transition-colors"
>
Clear
</button>
<div class="ml-auto flex gap-2">
<button
@click="copyPreviousDay"
class="text-text-secondary text-xs hover:text-text-primary transition-colors border border-border-subtle rounded-lg px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Copy Yesterday
</button>
<button
@click="copyPreviousWeek"
class="text-text-secondary text-xs hover:text-text-primary transition-colors border border-border-subtle rounded-lg px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Copy Last Week
</button>
<button
@click="showTemplatePicker = true"
class="text-text-secondary text-xs hover:text-text-primary transition-colors border border-border-subtle rounded-lg px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
From Template
</button>
</div>
</div>
<!-- Entries count -->
<p v-if="entriesStore.total > 0" class="text-[0.6875rem] text-text-tertiary mb-2" aria-live="polite">
Showing {{ entriesStore.entries.length }} of {{ entriesStore.total }} entries
</p>
<!-- Entries Table -->
<div v-if="filteredEntries.length > 0" class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-border-subtle bg-bg-surface">
<th class="px-4 py-3 w-10">
<input type="checkbox" :checked="allSelected" :indeterminate="someSelected" @change="toggleSelectAll" aria-label="Select all entries" class="w-4 h-4 rounded border-border-visible accent-accent" />
</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-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Project</th>
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Task</th>
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Description</th>
<th class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Duration</th>
<th class="px-4 py-3 w-20"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<TransitionGroup name="list" tag="tbody">
<tr
v-for="(entry, index) in filteredEntries"
:key="entry.id"
class="group border-b border-border-subtle hover:bg-bg-elevated transition-colors duration-150"
:style="{ transitionDelay: `${index * 30}ms` }"
>
<td class="px-4 py-3 w-10">
<input type="checkbox" :checked="selectedIds.has(entry.id!)" @change="toggleSelect(entry.id!)" :aria-label="'Select entry ' + (entry.description || 'untitled')" class="w-4 h-4 rounded border-border-visible accent-accent" />
</td>
<td class="px-4 py-3 text-[0.75rem] text-text-primary">
{{ formatDate(entry.start_time) }}
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div
class="w-2 h-2 rounded-full shrink-0"
:style="{ backgroundColor: getProjectColor(entry.project_id) }"
aria-hidden="true"
/>
<span class="text-[0.75rem] text-text-primary">{{ getProjectName(entry.project_id) }}</span>
</div>
</td>
<td class="px-4 py-3 text-[0.75rem] text-text-secondary">
{{ getTaskName(entry.task_id) || '-' }}
</td>
<td class="px-4 py-3 text-[0.75rem] text-text-secondary">
<span v-if="entry.description" v-html="renderMarkdown(entry.description)" class="markdown-inline" />
<span v-else>-</span>
<div v-if="entryTags[entry.id!]?.length" class="flex flex-wrap gap-1 mt-1">
<span
v-for="tagId in entryTags[entry.id!]"
:key="tagId"
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[0.5625rem]"
:style="{ backgroundColor: getTagColor(tagId) + '22', color: getTagColor(tagId) }"
>
{{ getTagName(tagId) }}
</span>
</div>
</td>
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-accent-text">
<div class="flex items-center justify-end gap-1.5">
<span
v-if="entry.billable === 0"
class="text-[0.5625rem] font-sans font-medium text-text-tertiary bg-bg-elevated px-1 py-0.5 rounded"
title="Non-billable"
>NB</span>
{{ formatDuration(entry.duration) }}
</div>
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-1">
<template v-if="isEntryLocked(entry)">
<span class="flex items-center gap-1 text-[0.6875rem] text-amber-500" title="This entry is in a locked week">
<Lock class="h-3.5 w-3.5" :stroke-width="2" aria-hidden="true" />
</span>
</template>
<template v-else>
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-100">
<button
@click="duplicateEntry(entry)"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
aria-label="Duplicate entry"
>
<Copy class="h-3.5 w-3.5" :stroke-width="2" aria-hidden="true" />
</button>
<button
@click="openEditDialog(entry)"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
aria-label="Edit entry"
>
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
@click="confirmDelete(entry)"
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors duration-150"
aria-label="Delete entry"
>
<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>
</template>
</div>
</td>
</tr>
</TransitionGroup>
</table>
<div v-if="entriesStore.hasMore" class="flex items-center justify-center py-4">
<button
@click="loadMore"
:aria-label="'Load more entries, showing ' + entriesStore.entries.length + ' of ' + entriesStore.total"
class="px-4 py-2 text-[0.75rem] font-medium border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Load More
</button>
</div>
<Transition name="slide-up">
<div
v-if="selectedIds.size > 0"
role="toolbar"
:aria-label="selectedIds.size + ' entries selected'"
class="sticky bottom-0 z-10 bg-bg-surface border-t border-border-subtle p-3 flex items-center justify-between"
>
<span class="text-[0.75rem] text-text-secondary font-medium" aria-live="polite">
{{ selectedIds.size }} selected
</span>
<div class="flex items-center gap-2">
<button @click="bulkToggleBillable" class="px-3 py-1.5 text-[0.6875rem] border border-border-subtle text-text-secondary rounded-md hover:bg-bg-elevated transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
Toggle Billable
</button>
<template v-if="showBulkDeleteConfirm">
<span class="text-[0.6875rem] text-status-error font-medium">Delete {{ selectedIds.size }} entries?</span>
<button @click="executeBulkDelete" class="px-3 py-1.5 text-[0.6875rem] bg-status-error text-white rounded-md hover:bg-red-600 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
Confirm
</button>
<button @click="showBulkDeleteConfirm = false" class="px-3 py-1.5 text-[0.6875rem] text-text-tertiary hover:text-text-secondary transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
No
</button>
</template>
<button v-else @click="confirmBulkDelete" class="px-3 py-1.5 text-[0.6875rem] bg-status-error text-white rounded-md hover:bg-red-600 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
Delete
</button>
<button @click="clearSelection" class="px-3 py-1.5 text-[0.6875rem] text-text-tertiary hover:text-text-secondary transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
Deselect
</button>
</div>
</div>
</Transition>
</div>
<div v-else class="flex flex-col items-center justify-center py-16">
<ListIcon class="w-12 h-12 text-text-tertiary animate-float" :stroke-width="1.5" aria-hidden="true" />
<p class="text-sm text-text-secondary mt-4">No entries found</p>
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Time entries will appear here as you track your work. Try adjusting the date range if you have existing entries.</p>
<router-link to="/timer" class="mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors">
Go to Timer
</router-link>
</div>
</div>
</template>
<!-- ===== EXPENSES TAB ===== -->
<template v-if="activeTab === 'expenses'">
<div id="tabpanel-expenses" role="tabpanel" aria-labelledby="tab-expenses">
<!-- Expense Filters -->
<div class="bg-bg-surface rounded-lg p-4 mb-6 flex flex-wrap items-end gap-4">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Start Date</label>
<AppDatePicker
v-model="expStartDate"
placeholder="Start date"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">End Date</label>
<AppDatePicker
v-model="expEndDate"
placeholder="End date"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Project</label>
<AppSelect
v-model="expFilterProject"
:options="projectsStore.projects"
label-key="name"
value-key="id"
placeholder="All Projects"
:placeholder-value="null"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Category</label>
<AppSelect
v-model="expFilterCategory"
:options="categoryFilterOptions"
label-key="label"
value-key="value"
placeholder="All Categories"
:placeholder-value="null"
/>
</div>
<button
@click="applyExpenseFilters"
class="px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
Apply
</button>
<button
@click="clearExpenseFilters"
class="text-text-secondary text-xs hover:text-text-primary transition-colors"
>
Clear
</button>
<div class="ml-auto">
<button
@click="openAddExpenseDialog"
class="flex items-center gap-1.5 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
<Plus class="w-3.5 h-3.5" :stroke-width="2" aria-hidden="true" />
Add Expense
</button>
</div>
</div>
<!-- Expense Table -->
<div v-if="filteredExpenses.length > 0" class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-border-subtle bg-bg-surface">
<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">Project</th>
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Category</th>
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Description</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">Invoiced</th>
<th class="px-4 py-3 w-20"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<TransitionGroup name="list" tag="tbody">
<tr
v-for="(expense, index) in filteredExpenses"
:key="expense.id"
class="group border-b border-border-subtle hover:bg-bg-elevated transition-colors duration-150"
:style="{ transitionDelay: `${index * 30}ms` }"
>
<td class="px-4 py-3 text-[0.75rem] text-text-primary">
{{ formatDate(expense.date) }}
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div
class="w-2 h-2 rounded-full shrink-0"
:style="{ backgroundColor: getProjectColor(expense.project_id) }"
aria-hidden="true"
/>
<span class="text-[0.75rem] text-text-primary">{{ getProjectName(expense.project_id) }}</span>
</div>
</td>
<td class="px-4 py-3 text-[0.75rem] text-text-secondary capitalize">
{{ expense.category }}
</td>
<td class="px-4 py-3 text-[0.75rem] text-text-secondary">
{{ expense.description || '-' }}
</td>
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-accent-text">
{{ formatCurrency(expense.amount) }}
</td>
<td class="px-4 py-3 text-center">
<span
class="text-[0.6875rem] font-medium"
:class="expense.invoiced ? 'text-status-running' : 'text-text-tertiary'"
>
{{ expense.invoiced ? 'Yes' : 'No' }}
</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-100">
<button
@click="openEditExpenseDialog(expense)"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
aria-label="Edit expense"
>
<Pencil class="h-3.5 w-3.5" :stroke-width="2" aria-hidden="true" />
</button>
<button
@click="confirmDeleteExpense(expense)"
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors duration-150"
aria-label="Delete expense"
>
<Trash2 class="h-3.5 w-3.5" :stroke-width="2" aria-hidden="true" />
</button>
</div>
</td>
</tr>
</TransitionGroup>
</table>
</div>
<div v-else class="flex flex-col items-center justify-center py-16">
<Receipt class="w-12 h-12 text-text-tertiary animate-float" :stroke-width="1.5" aria-hidden="true" />
<p class="text-sm text-text-secondary mt-4">No expenses found</p>
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Track project expenses here. Click "Add Expense" to get started.</p>
<button
@click="openAddExpenseDialog"
class="mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors"
>
Add Expense
</button>
</div>
</div>
</template>
<!-- Edit Entry Dialog -->
<Transition name="modal">
<div
v-if="showEditDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="tryCloseEditDialog"
>
<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-xl p-6 max-h-[calc(100vh-2rem)] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="edit-entry-title">
<h2 id="edit-entry-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">{{ editingEntry ? 'Edit Entry' : 'New Entry' }}</h2>
<form @submit.prevent="handleEdit" class="space-y-4">
<!-- Two-column layout: Details | Time -->
<div class="grid grid-cols-2 gap-6">
<!-- Left column: What -->
<div class="space-y-4">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Project *</label>
<AppSelect
v-model="editForm.project_id"
:options="projectsStore.projects"
label-key="name"
value-key="id"
placeholder="Select project"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Description</label>
<input
v-model="editForm.description"
type="text"
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"
placeholder="What did you work on?"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Tags</label>
<AppTagInput v-model="editEntryTags" />
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Billable</label>
<button
type="button"
@click="editForm.billable = editForm.billable === 1 ? 0 : 1"
:title="editForm.billable === 1 ? 'Billable' : 'Non-billable'"
:aria-label="editForm.billable === 1 ? 'Mark as non-billable' : 'Mark as billable'"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-[0.8125rem] transition-colors"
:class="editForm.billable === 1
? 'bg-accent-muted text-accent-text'
: 'bg-bg-elevated text-text-tertiary'"
>
<DollarSign class="w-4 h-4" aria-hidden="true" />
{{ editForm.billable === 1 ? 'Billable' : 'Non-billable' }}
</button>
</div>
</div>
<!-- Right column: When -->
<div class="space-y-4">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Duration (minutes)</label>
<AppNumberInput
v-model="durationMinutes"
:min="1"
:step="1"
suffix="min"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Start Date & Time</label>
<AppDatePicker
v-model="editDate"
:show-time="true"
v-model:hour="editHour"
v-model:minute="editMinute"
placeholder="Date & Time"
/>
</div>
</div>
</div>
<!-- Buttons -->
<div class="flex justify-end gap-3 pt-4">
<button
v-if="editingEntry"
@click="saveAsTemplate"
type="button"
class="mr-auto px-3 py-1.5 text-[0.75rem] text-accent-text border border-border-subtle rounded-lg hover:bg-bg-elevated transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Save as Template
</button>
<button
type="button"
@click="closeEditDialog"
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
Save
</button>
</div>
</form>
</div>
</div>
</Transition>
<!-- Delete Entry Confirmation Dialog -->
<Transition name="modal">
<div
v-if="showDeleteDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="cancelDelete"
>
<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" role="alertdialog" aria-modal="true" aria-labelledby="delete-entry-title" aria-describedby="delete-entry-desc">
<h2 id="delete-entry-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Delete Entry</h2>
<p id="delete-entry-desc" class="text-[0.75rem] text-text-secondary mb-6">
Are you sure you want to delete this time entry? This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<button
@click="cancelDelete"
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
@click="handleDelete"
class="px-4 py-2 border border-status-error text-status-error font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
>
Delete
</button>
</div>
</div>
</div>
</Transition>
<!-- Add/Edit Expense Dialog -->
<Transition name="modal">
<div
v-if="showExpenseDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="tryCloseExpenseDialog"
>
<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-md p-6 max-h-[calc(100vh-2rem)] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="expense-dialog-title">
<h2 id="expense-dialog-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
{{ editingExpense ? 'Edit Expense' : 'Add Expense' }}
</h2>
<form @submit.prevent="handleExpenseSave" class="space-y-4">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Project *</label>
<AppSelect
v-model="expenseForm.project_id"
:options="projectsStore.projects"
label-key="name"
value-key="id"
placeholder="Select project"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Category *</label>
<AppSelect
v-model="expenseForm.category"
:options="EXPENSE_CATEGORIES"
label-key="label"
value-key="value"
placeholder="Select category"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Description</label>
<input
v-model="expenseForm.description"
type="text"
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"
placeholder="What was this expense for?"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Amount *</label>
<AppNumberInput
v-model="expenseForm.amount"
:min="0"
:step="0.01"
:precision="2"
:prefix="getCurrencySymbol()"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Date *</label>
<AppDatePicker
v-model="expenseForm.date"
placeholder="Expense date"
/>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
type="button"
@click="closeExpenseDialog"
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
{{ editingExpense ? 'Save' : 'Add' }}
</button>
</div>
</form>
</div>
</div>
</Transition>
<!-- Delete Expense Confirmation Dialog -->
<Transition name="modal">
<div
v-if="showDeleteExpenseDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="cancelDeleteExpense"
>
<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" role="alertdialog" aria-modal="true" aria-labelledby="delete-expense-title" aria-describedby="delete-expense-desc">
<h2 id="delete-expense-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Delete Expense</h2>
<p id="delete-expense-desc" class="text-[0.75rem] text-text-secondary mb-6">
Are you sure you want to delete this expense? This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<button
@click="cancelDeleteExpense"
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
@click="handleDeleteExpense"
class="px-4 py-2 border border-status-error text-status-error font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
>
Delete
</button>
</div>
</div>
</div>
</Transition>
<EntryTemplatePicker
:show="showTemplatePicker"
@select="handleTemplateSelect"
@cancel="showTemplatePicker = false"
/>
<AppDiscardDialog :show="showDiscardDialog" @cancel="cancelDiscard" @discard="confirmDiscard" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { List as ListIcon, Copy, DollarSign, Receipt, Plus, Pencil, Trash2, Lock } from 'lucide-vue-next'
import { invoke } from '@tauri-apps/api/core'
import AppNumberInput from '../components/AppNumberInput.vue'
import AppSelect from '../components/AppSelect.vue'
import AppDatePicker from '../components/AppDatePicker.vue'
import AppDateRangePresets from '../components/AppDateRangePresets.vue'
import AppDiscardDialog from '../components/AppDiscardDialog.vue'
import AppTagInput from '../components/AppTagInput.vue'
import EntryTemplatePicker from '../components/EntryTemplatePicker.vue'
import { useEntriesStore, type TimeEntry } from '../stores/entries'
import { useExpensesStore, EXPENSE_CATEGORIES, type Expense } from '../stores/expenses'
import { useProjectsStore } from '../stores/projects'
import { useTagsStore } from '../stores/tags'
import { useToastStore } from '../stores/toast'
import { useEntryTemplatesStore } from '../stores/entryTemplates'
import { formatDate, formatCurrency, getCurrencySymbol } from '../utils/locale'
import { useFormGuard } from '../utils/formGuard'
import { renderMarkdown } from '../utils/markdown'
const entriesStore = useEntriesStore()
const expensesStore = useExpensesStore()
const projectsStore = useProjectsStore()
const tagsStore = useTagsStore()
const toast = useToastStore()
const entryTemplatesStore = useEntryTemplatesStore()
const showTemplatePicker = ref(false)
const entryTags = ref<Record<number, number[]>>({})
const editEntryTags = ref<number[]>([])
const lockedWeeks = ref<Set<string>>(new Set())
const taskMap = ref<Map<number, string>>(new Map())
async function loadTaskMap() {
const projectIds = new Set(entriesStore.entries.map(e => e.project_id))
const map = new Map<number, string>()
for (const pid of projectIds) {
const tasks = await projectsStore.fetchTasks(pid)
for (const t of tasks) {
if (t.id) map.set(t.id, t.name)
}
}
taskMap.value = map
}
// =============================================
// BULK SELECTION STATE
// =============================================
const selectedIds = ref<Set<number>>(new Set())
const allSelected = computed(() =>
entriesStore.entries.length > 0 && entriesStore.entries.every(e => e.id && selectedIds.value.has(e.id))
)
const someSelected = computed(() => selectedIds.value.size > 0 && !allSelected.value)
function toggleSelect(id: number) {
if (selectedIds.value.has(id)) {
selectedIds.value.delete(id)
} else {
selectedIds.value.add(id)
}
selectedIds.value = new Set(selectedIds.value)
}
function toggleSelectAll() {
if (allSelected.value) {
selectedIds.value = new Set()
} else {
selectedIds.value = new Set(entriesStore.entries.map(e => e.id!).filter(Boolean))
}
}
function clearSelection() {
selectedIds.value = new Set()
}
async function bulkToggleBillable() {
const ids = Array.from(selectedIds.value)
const selected = entriesStore.entries.filter(e => e.id && selectedIds.value.has(e.id))
const majorityBillable = selected.filter(e => e.billable === 1).length > selected.length / 2
try {
await invoke('bulk_update_entries_billable', { ids, billable: majorityBillable ? 0 : 1 })
clearSelection()
await entriesStore.fetchEntriesPaginated(startDate.value || undefined, endDate.value || undefined)
await loadEntryTags()
await loadTaskMap()
} catch (e) {
console.error('Failed to toggle billable:', e)
}
}
const showBulkDeleteConfirm = ref(false)
function confirmBulkDelete() {
showBulkDeleteConfirm.value = true
}
async function executeBulkDelete() {
showBulkDeleteConfirm.value = false
const ids = Array.from(selectedIds.value)
try {
await invoke('bulk_delete_entries', { ids })
clearSelection()
await entriesStore.fetchEntriesPaginated(startDate.value || undefined, endDate.value || undefined)
await loadEntryTags()
await loadTaskMap()
} catch (e) {
console.error('Failed to bulk delete entries:', e)
}
}
// Main tab state
const activeTab = ref<'time' | 'expenses'>('time')
// Form guard for entry edit dialog
const { showDiscardDialog, snapshot: snapshotForm, tryClose: tryCloseForm, confirmDiscard, cancelDiscard } = useFormGuard()
// Form guard for expense dialog (reuses the same discard pattern)
const { snapshot: snapshotExpenseForm, tryClose: tryCloseExpenseForm } = useFormGuard()
function getEditFormData() {
return { project_id: editForm.project_id, description: editForm.description, duration: editForm.duration, date: editDate.value, hour: editHour.value, minute: editMinute.value, billable: editForm.billable }
}
function getExpenseFormData() {
return { project_id: expenseForm.project_id, category: expenseForm.category, description: expenseForm.description, amount: expenseForm.amount, date: expenseForm.date }
}
function tryCloseEditDialog() {
tryCloseForm(getEditFormData(), closeEditDialog)
}
function tryCloseExpenseDialog() {
tryCloseExpenseForm(getExpenseFormData(), closeExpenseDialog)
}
// Tab keyboard navigation
function onMainTabKeydown(e: KeyboardEvent) {
const tabs: Array<'time' | 'expenses'> = ['time', 'expenses']
const current = tabs.indexOf(activeTab.value)
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
}
activeTab.value = tabs[next]
if (tabs[next] === 'expenses') {
loadExpensesIfNeeded()
}
document.getElementById(`tab-${tabs[next]}`)?.focus()
}
function switchToExpenses() {
activeTab.value = 'expenses'
loadExpensesIfNeeded()
}
const expensesLoaded = ref(false)
async function loadExpensesIfNeeded() {
if (!expensesLoaded.value) {
expensesLoaded.value = true
await expensesStore.fetchExpenses(
expFilterProject.value || undefined,
expStartDate.value || undefined,
expEndDate.value || undefined
)
}
}
// =============================================
// TIME ENTRIES - Filter state
// =============================================
const startDate = ref('')
const endDate = ref('')
const filterProject = ref<number | null>(null)
// Dialog state
const showEditDialog = ref(false)
const showDeleteDialog = ref(false)
const editingEntry = ref<TimeEntry | null>(null)
const entryToDelete = ref<TimeEntry | null>(null)
// Edit form
const editForm = reactive<TimeEntry>({
id: 0,
project_id: 0,
description: '',
start_time: '',
duration: 0,
billable: 1
})
// Split date/time refs for edit dialog
const editDate = ref('')
const editHour = ref(0)
const editMinute = ref(0)
// Duration in minutes (computed for editing)
const durationMinutes = computed({
get: () => Math.round(editForm.duration / 60),
set: (val: number) => { editForm.duration = val * 60 }
})
// Filtered entries based on date range and project
const filteredEntries = computed(() => {
let result = [...entriesStore.entries]
// Filter by project
if (filterProject.value !== null) {
result = result.filter(e => e.project_id === filterProject.value)
}
// Filter by date range
if (startDate.value) {
const start = new Date(startDate.value)
result = result.filter(e => new Date(e.start_time) >= start)
}
if (endDate.value) {
const end = new Date(endDate.value)
end.setHours(23, 59, 59)
result = result.filter(e => new Date(e.start_time) <= end)
}
// Sort by date descending
result.sort((a, b) => new Date(b.start_time).getTime() - new Date(a.start_time).getTime())
return result
})
// Get project name by ID
function getProjectName(projectId: number): string {
const project = projectsStore.projects.find(p => p.id === projectId)
return project?.name || 'Unknown Project'
}
// Get project color by ID
function getProjectColor(projectId: number): string {
const project = projectsStore.projects.find(p => p.id === projectId)
return project?.color || '#6B7280'
}
// Get task name by ID
function getTaskName(taskId?: number): string {
if (!taskId) return ''
return taskMap.value.get(taskId) || ''
}
// Format duration from seconds to readable format
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) {
return `${hours}h ${minutes}m`
}
return `${minutes}m`
}
// Tag helper functions
function getTagName(tagId: number): string {
const tag = tagsStore.tags.find(t => t.id === tagId)
return tag?.name || ''
}
function getTagColor(tagId: number): string {
const tag = tagsStore.tags.find(t => t.id === tagId)
return tag?.color || '#6B7280'
}
async function loadEntryTags() {
const tags: Record<number, number[]> = {}
for (const entry of entriesStore.entries) {
if (entry.id) {
const entryTagList = await tagsStore.getEntryTags(entry.id)
tags[entry.id] = entryTagList.map(t => t.id!).filter(id => id != null)
}
}
entryTags.value = tags
}
// Lock helpers
function getWeekStart(dateStr: string): string {
const d = new Date(dateStr)
const day = d.getDay()
const diff = day === 0 ? -6 : 1 - day
d.setDate(d.getDate() + diff)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${dd}`
}
function isEntryLocked(entry: TimeEntry): boolean {
const ws = getWeekStart(entry.start_time)
return lockedWeeks.value.has(ws)
}
// Duplicate an entry with current timestamp
async function duplicateEntry(entry: TimeEntry) {
const now = new Date()
const newEntry: TimeEntry = {
project_id: entry.project_id,
task_id: entry.task_id,
description: entry.description,
start_time: now.toISOString(),
end_time: new Date(now.getTime() + entry.duration * 1000).toISOString(),
duration: entry.duration,
billable: entry.billable,
}
try {
await entriesStore.createEntry(newEntry)
} catch (error) {
const msg = String(error)
if (msg.includes('locked week')) {
toast.error('Cannot add entries to a locked week')
} else {
toast.error('Failed to duplicate entry')
}
return
}
await entriesStore.fetchEntriesPaginated(startDate.value || undefined, endDate.value || undefined)
await loadEntryTags()
await loadTaskMap()
}
// Copy yesterday's entries to today
async function copyPreviousDay() {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const yStr = yesterday.toISOString().split('T')[0]
const prevEntries = await invoke<TimeEntry[]>('get_time_entries', {
startDate: yStr, endDate: yStr
})
const now = new Date()
const todayStr = now.toISOString().split('T')[0]
for (const e of prevEntries) {
const startHour = new Date(e.start_time).getHours()
const startMin = new Date(e.start_time).getMinutes()
const newStart = new Date(`${todayStr}T${String(startHour).padStart(2,'0')}:${String(startMin).padStart(2,'0')}:00`)
try {
await entriesStore.createEntry({
project_id: e.project_id,
task_id: e.task_id,
description: e.description,
start_time: newStart.toISOString(),
end_time: new Date(newStart.getTime() + e.duration * 1000).toISOString(),
duration: e.duration,
billable: e.billable,
})
} catch (error) {
const msg = String(error)
if (msg.includes('locked week')) {
toast.error('Cannot add entries to a locked week')
} else {
toast.error('Failed to copy entry')
}
return
}
}
await entriesStore.fetchEntriesPaginated(startDate.value || undefined, endDate.value || undefined)
await loadEntryTags()
await loadTaskMap()
}
// Copy last week's entries shifted forward 7 days
async function copyPreviousWeek() {
const now = new Date()
const prevWeekStart = new Date(now)
prevWeekStart.setDate(now.getDate() - now.getDay() + 1 - 7)
const prevWeekEnd = new Date(prevWeekStart)
prevWeekEnd.setDate(prevWeekStart.getDate() + 6)
const prevEntries = await invoke<TimeEntry[]>('get_time_entries', {
startDate: prevWeekStart.toISOString().split('T')[0],
endDate: prevWeekEnd.toISOString().split('T')[0],
})
for (const e of prevEntries) {
const entryDate = new Date(e.start_time)
const newDate = new Date(entryDate)
newDate.setDate(entryDate.getDate() + 7)
try {
await entriesStore.createEntry({
project_id: e.project_id,
task_id: e.task_id,
description: e.description,
start_time: newDate.toISOString(),
end_time: new Date(newDate.getTime() + e.duration * 1000).toISOString(),
duration: e.duration,
billable: e.billable,
})
} catch (error) {
const msg = String(error)
if (msg.includes('locked week')) {
toast.error('Cannot add entries to a locked week')
} else {
toast.error('Failed to copy entry')
}
return
}
}
await entriesStore.fetchEntriesPaginated(startDate.value || undefined, endDate.value || undefined)
await loadEntryTags()
await loadTaskMap()
}
// Apply filters
async function applyFilters() {
await entriesStore.fetchEntriesPaginated(startDate.value || undefined, endDate.value || undefined)
await loadEntryTags()
await loadTaskMap()
}
// Load more paginated entries
async function loadMore() {
await entriesStore.fetchMore(startDate.value || undefined, endDate.value || undefined)
await loadEntryTags()
await loadTaskMap()
}
// Clear filters
async function clearFilters() {
startDate.value = ''
endDate.value = ''
filterProject.value = null
await entriesStore.fetchEntriesPaginated()
await loadEntryTags()
}
// Open edit dialog
async function openEditDialog(entry: TimeEntry) {
editingEntry.value = entry
editForm.id = entry.id || 0
editForm.project_id = entry.project_id
editForm.description = entry.description || ''
editForm.duration = entry.duration
editForm.billable = entry.billable ?? 1
// Split start_time into date and time parts
const dt = new Date(entry.start_time)
const y = dt.getFullYear()
const m = String(dt.getMonth() + 1).padStart(2, '0')
const d = String(dt.getDate()).padStart(2, '0')
editDate.value = `${y}-${m}-${d}`
editHour.value = dt.getHours()
editMinute.value = dt.getMinutes()
// Load tags for this entry
if (entry.id) {
const tags = await tagsStore.getEntryTags(entry.id)
editEntryTags.value = tags.map(t => t.id!).filter(id => id != null)
} else {
editEntryTags.value = []
}
snapshotForm(getEditFormData())
showEditDialog.value = true
}
// Close edit dialog
function closeEditDialog() {
showEditDialog.value = false
editingEntry.value = null
}
// Handle edit submit
async function handleEdit() {
// Reconstruct start time from split date/time fields
const [y, m, d] = editDate.value.split('-').map(Number)
const start = new Date(y, m - 1, d, editHour.value, editMinute.value)
const end = new Date(start.getTime() + editForm.duration * 1000)
if (editingEntry.value) {
const updatedEntry: TimeEntry = {
id: editForm.id,
project_id: editForm.project_id,
description: editForm.description || undefined,
start_time: start.toISOString(),
end_time: end.toISOString(),
duration: editForm.duration,
billable: editForm.billable,
}
try {
await entriesStore.updateEntry(updatedEntry)
} catch (error) {
const msg = String(error)
if (msg.includes('locked week')) {
toast.error('Cannot modify entries in a locked week')
} else {
toast.error('Failed to update entry')
}
return
}
// Save tags for the edited entry
if (editForm.id) {
await tagsStore.setEntryTags(editForm.id, editEntryTags.value)
await loadEntryTags()
await loadTaskMap()
}
closeEditDialog()
} else {
// Creating a new entry from template
const newEntry: TimeEntry = {
project_id: editForm.project_id,
description: editForm.description || undefined,
start_time: start.toISOString(),
end_time: end.toISOString(),
duration: editForm.duration,
billable: editForm.billable,
}
try {
await entriesStore.createEntry(newEntry)
await loadEntryTags()
await loadTaskMap()
toast.success('Entry created from template')
} catch (error) {
toast.error('Failed to create entry')
return
}
closeEditDialog()
}
}
// Handle template selection from picker
function handleTemplateSelect(template: any) {
showTemplatePicker.value = false
editingEntry.value = null
editForm.id = 0
editForm.project_id = template.project_id
editForm.description = template.description || ''
editForm.duration = template.duration || 0
editForm.billable = template.billable ?? 1
const now = new Date()
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
const d = String(now.getDate()).padStart(2, '0')
editDate.value = `${y}-${m}-${d}`
editHour.value = now.getHours()
editMinute.value = now.getMinutes()
editEntryTags.value = []
editForm.start_time = now.toISOString()
snapshotForm(getEditFormData())
showEditDialog.value = true
}
// Save current edit form as a reusable template
async function saveAsTemplate() {
const name = editForm.description || `Template ${new Date().toLocaleDateString()}`
const id = await entryTemplatesStore.createTemplate({
name,
project_id: editForm.project_id,
task_id: undefined,
description: editForm.description,
duration: editForm.duration || 0,
billable: editForm.billable ?? 1,
})
if (id) {
toast.success('Saved as template')
}
}
// Confirm delete
function confirmDelete(entry: TimeEntry) {
entryToDelete.value = entry
showDeleteDialog.value = true
}
// Cancel delete
function cancelDelete() {
showDeleteDialog.value = false
entryToDelete.value = null
}
// Handle delete
async function handleDelete() {
if (entryToDelete.value?.id) {
try {
await entriesStore.deleteEntry(entryToDelete.value.id)
} catch (error) {
const msg = String(error)
if (msg.includes('locked week')) {
toast.error('Cannot delete entries in a locked week')
} else {
toast.error('Failed to delete entry')
}
}
}
cancelDelete()
}
// =============================================
// EXPENSES
// =============================================
const expStartDate = ref('')
const expEndDate = ref('')
const expFilterProject = ref<number | null>(null)
const expFilterCategory = ref<string | null>(null)
const categoryFilterOptions = EXPENSE_CATEGORIES
// Expense dialog state
const showExpenseDialog = ref(false)
const editingExpense = ref<Expense | null>(null)
const showDeleteExpenseDialog = ref(false)
const expenseToDelete = ref<Expense | null>(null)
// Expense form
const expenseForm = reactive<{
project_id: number
category: string
description: string
amount: number
date: string
}>({
project_id: 0,
category: '',
description: '',
amount: 0,
date: new Date().toISOString().split('T')[0]
})
// Filtered expenses
const filteredExpenses = computed(() => {
let result = [...expensesStore.expenses]
if (expFilterProject.value !== null) {
result = result.filter(e => e.project_id === expFilterProject.value)
}
if (expFilterCategory.value !== null) {
result = result.filter(e => e.category === expFilterCategory.value)
}
if (expStartDate.value) {
result = result.filter(e => e.date >= expStartDate.value)
}
if (expEndDate.value) {
result = result.filter(e => e.date <= expEndDate.value)
}
// Sort by date descending
result.sort((a, b) => b.date.localeCompare(a.date))
return result
})
// Open add expense dialog
function openAddExpenseDialog() {
editingExpense.value = null
expenseForm.project_id = 0
expenseForm.category = ''
expenseForm.description = ''
expenseForm.amount = 0
expenseForm.date = new Date().toISOString().split('T')[0]
snapshotExpenseForm(getExpenseFormData())
showExpenseDialog.value = true
}
// Open edit expense dialog
function openEditExpenseDialog(expense: Expense) {
editingExpense.value = expense
expenseForm.project_id = expense.project_id
expenseForm.category = expense.category
expenseForm.description = expense.description || ''
expenseForm.amount = expense.amount
expenseForm.date = expense.date
snapshotExpenseForm(getExpenseFormData())
showExpenseDialog.value = true
}
// Close expense dialog
function closeExpenseDialog() {
showExpenseDialog.value = false
editingExpense.value = null
}
// Save expense (create or update)
async function handleExpenseSave() {
if (!expenseForm.project_id || !expenseForm.category || !expenseForm.date) return
// Find the client_id from the project
const project = projectsStore.projects.find(p => p.id === expenseForm.project_id)
const clientId = project?.client_id
if (editingExpense.value) {
// Update
const updated: Expense = {
id: editingExpense.value.id,
project_id: expenseForm.project_id,
client_id: clientId,
category: expenseForm.category,
description: expenseForm.description || undefined,
amount: expenseForm.amount,
date: expenseForm.date,
receipt_path: editingExpense.value.receipt_path,
invoiced: editingExpense.value.invoiced,
}
await expensesStore.updateExpense(updated)
} else {
// Create
const newExpense: Expense = {
project_id: expenseForm.project_id,
client_id: clientId,
category: expenseForm.category,
description: expenseForm.description || undefined,
amount: expenseForm.amount,
date: expenseForm.date,
invoiced: 0,
}
await expensesStore.createExpense(newExpense)
}
closeExpenseDialog()
}
// Delete expense
function confirmDeleteExpense(expense: Expense) {
expenseToDelete.value = expense
showDeleteExpenseDialog.value = true
}
function cancelDeleteExpense() {
showDeleteExpenseDialog.value = false
expenseToDelete.value = null
}
async function handleDeleteExpense() {
if (expenseToDelete.value?.id) {
await expensesStore.deleteExpense(expenseToDelete.value.id)
}
cancelDeleteExpense()
}
// Apply expense filters
async function applyExpenseFilters() {
expensesLoaded.value = true
await expensesStore.fetchExpenses(
expFilterProject.value || undefined,
expStartDate.value || undefined,
expEndDate.value || undefined
)
}
// Clear expense filters
async function clearExpenseFilters() {
expStartDate.value = ''
expEndDate.value = ''
expFilterProject.value = null
expFilterCategory.value = null
expensesLoaded.value = false
await expensesStore.fetchExpenses()
}
// Timesheet lock type
interface TimesheetLock {
id: number
week_start: string
status: string
locked_at: string
}
// Load data on mount
onMounted(async () => {
await Promise.all([
entriesStore.fetchEntriesPaginated(),
projectsStore.fetchProjects(),
tagsStore.fetchTags()
])
await loadEntryTags()
await loadTaskMap()
// Fetch timesheet locks
try {
const locks = await invoke<TimesheetLock[]>('get_timesheet_locks')
lockedWeeks.value = new Set(locks.map(l => l.week_start))
} catch (error) {
console.error('Failed to fetch locks:', error)
}
// Set default date range to this week
const now = new Date()
const weekStart = new Date(now)
weekStart.setDate(now.getDate() - now.getDay() + 1)
startDate.value = weekStart.toISOString().split('T')[0]
endDate.value = now.toISOString().split('T')[0]
// Set expense default dates too
expStartDate.value = weekStart.toISOString().split('T')[0]
expEndDate.value = now.toISOString().split('T')[0]
})
</script>