feat: receipt thumbnails, lightbox, and file picker for expenses
This commit is contained in:
@@ -345,6 +345,7 @@
|
||||
<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>
|
||||
@@ -378,6 +379,18 @@
|
||||
<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"
|
||||
@@ -630,6 +643,25 @@
|
||||
/>
|
||||
</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"
|
||||
@@ -687,13 +719,23 @@
|
||||
/>
|
||||
|
||||
<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 } from 'lucide-vue-next'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
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'
|
||||
@@ -701,6 +743,7 @@ 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'
|
||||
@@ -718,6 +761,8 @@ 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())
|
||||
@@ -816,7 +861,7 @@ function getEditFormData() {
|
||||
}
|
||||
|
||||
function getExpenseFormData() {
|
||||
return { project_id: expenseForm.project_id, category: expenseForm.category, description: expenseForm.description, amount: expenseForm.amount, date: expenseForm.date }
|
||||
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() {
|
||||
@@ -1321,12 +1366,14 @@ const expenseForm = reactive<{
|
||||
description: string
|
||||
amount: number
|
||||
date: string
|
||||
receipt_path: string
|
||||
}>({
|
||||
project_id: 0,
|
||||
category: '',
|
||||
description: '',
|
||||
amount: 0,
|
||||
date: new Date().toISOString().split('T')[0]
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
receipt_path: ''
|
||||
})
|
||||
|
||||
// Filtered expenses
|
||||
@@ -1355,6 +1402,35 @@ const filteredExpenses = computed(() => {
|
||||
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
|
||||
@@ -1363,6 +1439,7 @@ function openAddExpenseDialog() {
|
||||
expenseForm.description = ''
|
||||
expenseForm.amount = 0
|
||||
expenseForm.date = new Date().toISOString().split('T')[0]
|
||||
expenseForm.receipt_path = ''
|
||||
snapshotExpenseForm(getExpenseFormData())
|
||||
showExpenseDialog.value = true
|
||||
}
|
||||
@@ -1375,6 +1452,7 @@ function openEditExpenseDialog(expense: Expense) {
|
||||
expenseForm.description = expense.description || ''
|
||||
expenseForm.amount = expense.amount
|
||||
expenseForm.date = expense.date
|
||||
expenseForm.receipt_path = expense.receipt_path || ''
|
||||
snapshotExpenseForm(getExpenseFormData())
|
||||
showExpenseDialog.value = true
|
||||
}
|
||||
@@ -1403,7 +1481,7 @@ async function handleExpenseSave() {
|
||||
description: expenseForm.description || undefined,
|
||||
amount: expenseForm.amount,
|
||||
date: expenseForm.date,
|
||||
receipt_path: editingExpense.value.receipt_path,
|
||||
receipt_path: expenseForm.receipt_path || undefined,
|
||||
invoiced: editingExpense.value.invoiced,
|
||||
}
|
||||
await expensesStore.updateExpense(updated)
|
||||
@@ -1416,6 +1494,7 @@ async function handleExpenseSave() {
|
||||
description: expenseForm.description || undefined,
|
||||
amount: expenseForm.amount,
|
||||
date: expenseForm.date,
|
||||
receipt_path: expenseForm.receipt_path || undefined,
|
||||
invoiced: 0,
|
||||
}
|
||||
await expensesStore.createExpense(newExpense)
|
||||
|
||||
Reference in New Issue
Block a user