1582 lines
59 KiB
Vue
1582 lines
59 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">Receipt</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">
|
|
<button
|
|
v-if="expense.receipt_path"
|
|
@click="openReceiptLightbox(expense)"
|
|
class="inline-flex items-center gap-1 px-2 py-1 text-[0.6875rem] text-accent-text bg-accent-muted rounded-md hover:bg-accent/20 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
|
:aria-label="'View receipt for ' + (expense.description || expense.category)"
|
|
>
|
|
<Image class="w-3.5 h-3.5" :stroke-width="2" aria-hidden="true" />
|
|
View
|
|
</button>
|
|
<span v-else class="text-[0.6875rem] text-text-tertiary">-</span>
|
|
</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>
|
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Receipt</label>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
@click="chooseReceipt"
|
|
role="button"
|
|
aria-label="Choose receipt file"
|
|
class="flex items-center gap-2 px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-secondary hover:bg-bg-elevated transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
|
>
|
|
<Upload class="w-4 h-4" aria-hidden="true" />
|
|
{{ expenseForm.receipt_path ? 'Change' : 'Upload' }}
|
|
</button>
|
|
<span v-if="expenseForm.receipt_path" class="text-[0.6875rem] text-text-tertiary truncate max-w-[200px]">
|
|
{{ expenseForm.receipt_path.split(/[\\/]/).pop() }}
|
|
</span>
|
|
</div>
|
|
</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" />
|
|
|
|
<ReceiptLightbox
|
|
:show="showReceiptLightbox"
|
|
:image-url="receiptLightboxData.imageUrl"
|
|
:description="receiptLightboxData.description"
|
|
:category="receiptLightboxData.category"
|
|
:date="receiptLightboxData.date"
|
|
:amount="receiptLightboxData.amount"
|
|
@close="showReceiptLightbox = false"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
|
import { List as ListIcon, Copy, DollarSign, Receipt, Plus, Pencil, Trash2, Lock, Image, Upload } from 'lucide-vue-next'
|
|
import { invoke, convertFileSrc } 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 ReceiptLightbox from '../components/ReceiptLightbox.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 showReceiptLightbox = ref(false)
|
|
const receiptLightboxData = ref({ imageUrl: '', description: '', category: '', date: '', amount: '' })
|
|
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, receipt_path: expenseForm.receipt_path }
|
|
}
|
|
|
|
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
|
|
receipt_path: string
|
|
}>({
|
|
project_id: 0,
|
|
category: '',
|
|
description: '',
|
|
amount: 0,
|
|
date: new Date().toISOString().split('T')[0],
|
|
receipt_path: ''
|
|
})
|
|
|
|
// 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 receipt lightbox
|
|
function openReceiptLightbox(expense: Expense) {
|
|
if (!expense.receipt_path) return
|
|
receiptLightboxData.value = {
|
|
imageUrl: convertFileSrc(expense.receipt_path),
|
|
description: expense.description || '',
|
|
category: expense.category || '',
|
|
date: expense.date || '',
|
|
amount: formatCurrency(expense.amount)
|
|
}
|
|
showReceiptLightbox.value = true
|
|
}
|
|
|
|
// Choose receipt file
|
|
async function chooseReceipt() {
|
|
try {
|
|
const { open } = await import('@tauri-apps/plugin-dialog')
|
|
const selected = await open({
|
|
title: 'Choose receipt image',
|
|
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf'] }]
|
|
})
|
|
if (selected && typeof selected === 'string') {
|
|
expenseForm.receipt_path = selected
|
|
}
|
|
} catch {
|
|
// User cancelled
|
|
}
|
|
}
|
|
|
|
// 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]
|
|
expenseForm.receipt_path = ''
|
|
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
|
|
expenseForm.receipt_path = expense.receipt_path || ''
|
|
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: expenseForm.receipt_path || undefined,
|
|
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,
|
|
receipt_path: expenseForm.receipt_path || undefined,
|
|
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>
|