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">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-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-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 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>
|
<th class="px-4 py-3 w-20"><span class="sr-only">Actions</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -378,6 +379,18 @@
|
|||||||
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-accent-text">
|
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-accent-text">
|
||||||
{{ formatCurrency(expense.amount) }}
|
{{ formatCurrency(expense.amount) }}
|
||||||
</td>
|
</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">
|
<td class="px-4 py-3 text-center">
|
||||||
<span
|
<span
|
||||||
class="text-[0.6875rem] font-medium"
|
class="text-[0.6875rem] font-medium"
|
||||||
@@ -630,6 +643,25 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -687,13 +719,23 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AppDiscardDialog :show="showDiscardDialog" @cancel="cancelDiscard" @discard="confirmDiscard" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { List as ListIcon, Copy, DollarSign, Receipt, Plus, Pencil, Trash2, Lock } from 'lucide-vue-next'
|
import { List as ListIcon, Copy, DollarSign, Receipt, Plus, Pencil, Trash2, Lock, Image, Upload } from 'lucide-vue-next'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke, convertFileSrc } from '@tauri-apps/api/core'
|
||||||
import AppNumberInput from '../components/AppNumberInput.vue'
|
import AppNumberInput from '../components/AppNumberInput.vue'
|
||||||
import AppSelect from '../components/AppSelect.vue'
|
import AppSelect from '../components/AppSelect.vue'
|
||||||
import AppDatePicker from '../components/AppDatePicker.vue'
|
import AppDatePicker from '../components/AppDatePicker.vue'
|
||||||
@@ -701,6 +743,7 @@ import AppDateRangePresets from '../components/AppDateRangePresets.vue'
|
|||||||
import AppDiscardDialog from '../components/AppDiscardDialog.vue'
|
import AppDiscardDialog from '../components/AppDiscardDialog.vue'
|
||||||
import AppTagInput from '../components/AppTagInput.vue'
|
import AppTagInput from '../components/AppTagInput.vue'
|
||||||
import EntryTemplatePicker from '../components/EntryTemplatePicker.vue'
|
import EntryTemplatePicker from '../components/EntryTemplatePicker.vue'
|
||||||
|
import ReceiptLightbox from '../components/ReceiptLightbox.vue'
|
||||||
import { useEntriesStore, type TimeEntry } from '../stores/entries'
|
import { useEntriesStore, type TimeEntry } from '../stores/entries'
|
||||||
import { useExpensesStore, EXPENSE_CATEGORIES, type Expense } from '../stores/expenses'
|
import { useExpensesStore, EXPENSE_CATEGORIES, type Expense } from '../stores/expenses'
|
||||||
import { useProjectsStore } from '../stores/projects'
|
import { useProjectsStore } from '../stores/projects'
|
||||||
@@ -718,6 +761,8 @@ const tagsStore = useTagsStore()
|
|||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
const entryTemplatesStore = useEntryTemplatesStore()
|
const entryTemplatesStore = useEntryTemplatesStore()
|
||||||
const showTemplatePicker = ref(false)
|
const showTemplatePicker = ref(false)
|
||||||
|
const showReceiptLightbox = ref(false)
|
||||||
|
const receiptLightboxData = ref({ imageUrl: '', description: '', category: '', date: '', amount: '' })
|
||||||
const entryTags = ref<Record<number, number[]>>({})
|
const entryTags = ref<Record<number, number[]>>({})
|
||||||
const editEntryTags = ref<number[]>([])
|
const editEntryTags = ref<number[]>([])
|
||||||
const lockedWeeks = ref<Set<string>>(new Set())
|
const lockedWeeks = ref<Set<string>>(new Set())
|
||||||
@@ -816,7 +861,7 @@ function getEditFormData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getExpenseFormData() {
|
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() {
|
function tryCloseEditDialog() {
|
||||||
@@ -1321,12 +1366,14 @@ const expenseForm = reactive<{
|
|||||||
description: string
|
description: string
|
||||||
amount: number
|
amount: number
|
||||||
date: string
|
date: string
|
||||||
|
receipt_path: string
|
||||||
}>({
|
}>({
|
||||||
project_id: 0,
|
project_id: 0,
|
||||||
category: '',
|
category: '',
|
||||||
description: '',
|
description: '',
|
||||||
amount: 0,
|
amount: 0,
|
||||||
date: new Date().toISOString().split('T')[0]
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
receipt_path: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// Filtered expenses
|
// Filtered expenses
|
||||||
@@ -1355,6 +1402,35 @@ const filteredExpenses = computed(() => {
|
|||||||
return result
|
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
|
// Open add expense dialog
|
||||||
function openAddExpenseDialog() {
|
function openAddExpenseDialog() {
|
||||||
editingExpense.value = null
|
editingExpense.value = null
|
||||||
@@ -1363,6 +1439,7 @@ function openAddExpenseDialog() {
|
|||||||
expenseForm.description = ''
|
expenseForm.description = ''
|
||||||
expenseForm.amount = 0
|
expenseForm.amount = 0
|
||||||
expenseForm.date = new Date().toISOString().split('T')[0]
|
expenseForm.date = new Date().toISOString().split('T')[0]
|
||||||
|
expenseForm.receipt_path = ''
|
||||||
snapshotExpenseForm(getExpenseFormData())
|
snapshotExpenseForm(getExpenseFormData())
|
||||||
showExpenseDialog.value = true
|
showExpenseDialog.value = true
|
||||||
}
|
}
|
||||||
@@ -1375,6 +1452,7 @@ function openEditExpenseDialog(expense: Expense) {
|
|||||||
expenseForm.description = expense.description || ''
|
expenseForm.description = expense.description || ''
|
||||||
expenseForm.amount = expense.amount
|
expenseForm.amount = expense.amount
|
||||||
expenseForm.date = expense.date
|
expenseForm.date = expense.date
|
||||||
|
expenseForm.receipt_path = expense.receipt_path || ''
|
||||||
snapshotExpenseForm(getExpenseFormData())
|
snapshotExpenseForm(getExpenseFormData())
|
||||||
showExpenseDialog.value = true
|
showExpenseDialog.value = true
|
||||||
}
|
}
|
||||||
@@ -1403,7 +1481,7 @@ async function handleExpenseSave() {
|
|||||||
description: expenseForm.description || undefined,
|
description: expenseForm.description || undefined,
|
||||||
amount: expenseForm.amount,
|
amount: expenseForm.amount,
|
||||||
date: expenseForm.date,
|
date: expenseForm.date,
|
||||||
receipt_path: editingExpense.value.receipt_path,
|
receipt_path: expenseForm.receipt_path || undefined,
|
||||||
invoiced: editingExpense.value.invoiced,
|
invoiced: editingExpense.value.invoiced,
|
||||||
}
|
}
|
||||||
await expensesStore.updateExpense(updated)
|
await expensesStore.updateExpense(updated)
|
||||||
@@ -1416,6 +1494,7 @@ async function handleExpenseSave() {
|
|||||||
description: expenseForm.description || undefined,
|
description: expenseForm.description || undefined,
|
||||||
amount: expenseForm.amount,
|
amount: expenseForm.amount,
|
||||||
date: expenseForm.date,
|
date: expenseForm.date,
|
||||||
|
receipt_path: expenseForm.receipt_path || undefined,
|
||||||
invoiced: 0,
|
invoiced: 0,
|
||||||
}
|
}
|
||||||
await expensesStore.createExpense(newExpense)
|
await expensesStore.createExpense(newExpense)
|
||||||
|
|||||||
Reference in New Issue
Block a user