Add expense tracking system with receipt management and currency conversion

- Add expense list and detail views with filtering capabilities
- Implement receipt image viewer and PDF export functionality
- Add currency conversion support with automatic rate updates
- Create API endpoints for expense CRUD operations
- Integrate with NocoDB for expense data persistence
- Add expense menu item to dashboard navigation
This commit is contained in:
2025-07-03 21:29:42 +02:00
parent 38a08edbfd
commit 5cee783ef5
17 changed files with 3272 additions and 1 deletions

View File

@@ -0,0 +1,406 @@
<template>
<div v-if="modelValue && expense" class="modal-overlay" @click.self="closeModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Expense Details</h3>
<button @click="closeModal" class="btn-close">
<Icon name="mdi:close" class="w-5 h-5" />
</button>
</div>
<div class="modal-body">
<!-- Establishment & Price -->
<div class="expense-header">
<div class="establishment-name">
{{ expense['Establishment Name'] || 'Unknown Establishment' }}
</div>
<div class="price-amount">
{{ expense.DisplayPrice || expense.Price }}
</div>
<div v-if="expense.ConversionRate && expense.ConversionRate !== 1" class="conversion-info">
<span class="text-sm text-gray-600 dark:text-gray-400">
Rate: {{ expense.ConversionRate }} | USD: {{ expense.DisplayPriceUSD }}
</span>
</div>
</div>
<!-- Meta Information -->
<div class="expense-meta">
<div class="meta-item">
<Icon name="mdi:account" class="w-4 h-4 text-gray-500" />
<span class="meta-label">Paid by:</span>
<span class="meta-value">{{ expense.Payer || 'Unknown' }}</span>
</div>
<div class="meta-item">
<Icon name="mdi:tag" class="w-4 h-4 text-gray-500" />
<span class="meta-label">Category:</span>
<div class="category-badge" :class="`category-${expense.Category?.toLowerCase()}`">
{{ expense.Category }}
</div>
</div>
<div class="meta-item">
<Icon
:name="expense['Payment Method'] === 'Card' ? 'mdi:credit-card' : 'mdi:cash'"
class="w-4 h-4 text-gray-500"
/>
<span class="meta-label">Payment:</span>
<span class="meta-value">{{ expense['Payment Method'] }}</span>
</div>
<div class="meta-item">
<Icon name="mdi:calendar" class="w-4 h-4 text-gray-500" />
<span class="meta-label">Date:</span>
<span class="meta-value">{{ formatDateTime(expense.Time) }}</span>
</div>
<div class="meta-item">
<Icon name="mdi:check-circle" class="w-4 h-4 text-gray-500" />
<span class="meta-label">Status:</span>
<span :class="['meta-value', expense.Paid ? 'text-green-600' : 'text-yellow-600']">
{{ expense.Paid ? 'Paid' : 'Pending' }}
</span>
</div>
</div>
<!-- Description -->
<div v-if="expense.Contents" class="expense-description">
<h4 class="section-title">
<Icon name="mdi:text" class="w-4 h-4" />
Description
</h4>
<div class="description-content">
{{ expense.Contents }}
</div>
</div>
<!-- Receipt Images -->
<div v-if="expense.Receipt && expense.Receipt.length > 0" class="expense-receipts">
<h4 class="section-title">
<Icon name="mdi:receipt" class="w-4 h-4" />
Receipt{{ expense.Receipt.length > 1 ? 's' : '' }} ({{ expense.Receipt.length }})
</h4>
<div class="receipts-grid">
<div
v-for="(receipt, index) in expense.Receipt"
:key="receipt.id || index"
class="receipt-item"
@click="openReceiptModal(receipt, index)"
>
<LazyReceiptImage
:receipt="receipt"
:alt="`Receipt ${index + 1} for ${expense['Establishment Name']}`"
:use-small="true"
class="receipt-thumbnail"
/>
<div class="receipt-info">
<div class="receipt-name">{{ receipt.title || `Receipt ${index + 1}` }}</div>
<div class="receipt-size">{{ formatFileSize(receipt.size) }}</div>
</div>
<button class="receipt-action" title="Open in new tab">
<Icon name="mdi:open-in-new" class="w-4 h-4" />
</button>
</div>
</div>
</div>
<!-- System Information -->
<div class="system-info">
<h4 class="section-title">
<Icon name="mdi:information" class="w-4 h-4" />
System Information
</h4>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Expense ID:</span>
<span class="info-value">{{ expense.Id }}</span>
</div>
<div class="info-item">
<span class="info-label">Created:</span>
<span class="info-value">{{ formatDateTime(expense.CreatedAt) }}</span>
</div>
<div class="info-item">
<span class="info-label">Updated:</span>
<span class="info-value">{{ formatDateTime(expense.UpdatedAt) }}</span>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button @click="closeModal" class="btn btn-ghost">
Close
</button>
<button
v-if="expense.Receipt && expense.Receipt.length > 0"
@click="downloadAllReceipts"
class="btn btn-outline"
>
<Icon name="mdi:download" class="w-4 h-4" />
Download Receipts
</button>
</div>
</div>
<!-- Receipt Viewer Modal -->
<ReceiptViewerModal
v-model="showReceiptViewer"
:receipts="expense?.Receipt || []"
:current-index="currentReceiptIndex"
@update:current-index="currentReceiptIndex = $event"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import type { Expense, ExpenseReceipt } from '@/utils/types';
// Component imports
const LazyReceiptImage = defineAsyncComponent(() => import('@/components/LazyReceiptImage.vue'));
const ReceiptViewerModal = defineAsyncComponent(() => import('@/components/ReceiptViewerModal.vue'));
// Props
interface Props {
modelValue: boolean;
expense: Expense | null;
}
const props = defineProps<Props>();
// Emits
const emit = defineEmits<{
'update:modelValue': [value: boolean];
}>();
// Reactive state
const showReceiptViewer = ref(false);
const currentReceiptIndex = ref(0);
// Methods
const closeModal = () => {
emit('update:modelValue', false);
};
const formatDateTime = (dateString: string) => {
if (!dateString) return 'N/A';
try {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return dateString;
}
};
const formatFileSize = (bytes: number) => {
if (!bytes) return 'Unknown size';
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
};
const openReceiptModal = (receipt: ExpenseReceipt, index: number) => {
currentReceiptIndex.value = index;
showReceiptViewer.value = true;
};
const downloadAllReceipts = async () => {
if (!props.expense?.Receipt?.length) return;
try {
for (const [index, receipt] of props.expense.Receipt.entries()) {
if (receipt.signedUrl) {
// Create download link
const link = document.createElement('a');
link.href = receipt.signedUrl;
link.download = receipt.title || `receipt-${props.expense.Id}-${index + 1}`;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Small delay between downloads
if (index < props.expense.Receipt.length - 1) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
}
} catch (error) {
console.error('[ExpenseDetailsModal] Error downloading receipts:', error);
}
};
</script>
<style scoped>
.modal-overlay {
@apply fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50;
}
.modal-content {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden;
}
.modal-header {
@apply flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700;
}
.modal-title {
@apply text-lg font-semibold text-gray-900 dark:text-white;
}
.btn-close {
@apply p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors;
}
.modal-body {
@apply p-6 space-y-6 overflow-y-auto;
}
.expense-header {
@apply text-center space-y-2 pb-4 border-b border-gray-200 dark:border-gray-700;
}
.establishment-name {
@apply text-xl font-bold text-gray-900 dark:text-white;
}
.price-amount {
@apply text-3xl font-bold text-primary;
}
.expense-meta {
@apply space-y-3;
}
.meta-item {
@apply flex items-center gap-3;
}
.meta-label {
@apply text-sm font-medium text-gray-600 dark:text-gray-400 min-w-20;
}
.meta-value {
@apply text-sm text-gray-900 dark:text-white;
}
.category-badge {
@apply px-2 py-1 rounded-full text-xs font-medium;
}
.category-food\/drinks {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200;
}
.category-shop {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200;
}
.category-online {
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200;
}
.category-other {
@apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200;
}
.section-title {
@apply flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2;
}
.expense-description {
@apply space-y-3;
}
.description-content {
@apply text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700 rounded-lg p-4;
}
.expense-receipts {
@apply space-y-3;
}
.receipts-grid {
@apply grid grid-cols-1 sm:grid-cols-2 gap-4;
}
.receipt-item {
@apply relative bg-gray-50 dark:bg-gray-700 rounded-lg p-3 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors cursor-pointer;
}
.receipt-thumbnail {
@apply w-full h-24 rounded object-cover mb-2;
}
.receipt-info {
@apply space-y-1;
}
.receipt-name {
@apply text-sm font-medium text-gray-900 dark:text-white truncate;
}
.receipt-size {
@apply text-xs text-gray-500 dark:text-gray-400;
}
.receipt-action {
@apply absolute top-2 right-2 p-1 bg-white dark:bg-gray-800 rounded-full shadow-sm opacity-0 group-hover:opacity-100 transition-opacity;
}
.receipt-item:hover .receipt-action {
@apply opacity-100;
}
.system-info {
@apply space-y-3;
}
.info-grid {
@apply grid grid-cols-1 sm:grid-cols-3 gap-4;
}
.info-item {
@apply space-y-1;
}
.info-label {
@apply text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide;
}
.info-value {
@apply text-sm text-gray-900 dark:text-white;
}
.modal-footer {
@apply flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700;
}
.btn {
@apply inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors;
}
.btn-ghost {
@apply text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700;
}
.btn-outline {
@apply border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700;
}
</style>

338
components/ExpenseList.vue Normal file
View File

@@ -0,0 +1,338 @@
<template>
<div class="expense-list">
<!-- Bulk Selection Header -->
<div v-if="expenses.length > 0" class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="isAllSelected"
:indeterminate="isSomeSelected"
@change="toggleSelectAll"
class="checkbox checkbox-primary"
/>
<span class="text-sm font-medium">
{{ selectedExpenses.length > 0 ? `${selectedExpenses.length} selected` : 'Select all' }}
</span>
</label>
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ expenses.length }} expenses {{ totalAmount.toFixed(2) }}
</div>
</div>
<!-- Expense Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="expense in expenses"
:key="expense.Id"
class="expense-card"
:class="{ 'selected': isSelected(expense.Id) }"
@click="selectExpense(expense.Id)"
>
<!-- Selection Checkbox -->
<div class="absolute top-3 left-3 z-10">
<input
type="checkbox"
:checked="isSelected(expense.Id)"
@click.stop="toggleExpense(expense.Id)"
class="checkbox checkbox-primary checkbox-sm"
/>
</div>
<!-- Receipt Image -->
<div class="receipt-container">
<div
v-if="expense.Receipt && expense.Receipt.length > 0"
class="receipt-image-wrapper"
@click.stop="$emit('expense-clicked', expense)"
>
<LazyReceiptImage
:receipt="expense.Receipt[0]"
:alt="`Receipt for ${expense['Establishment Name']}`"
class="receipt-image"
/>
<!-- Multiple receipts indicator -->
<div v-if="expense.Receipt.length > 1" class="receipt-count-badge">
+{{ expense.Receipt.length - 1 }}
</div>
</div>
<!-- No receipt placeholder -->
<div v-else class="no-receipt-placeholder" @click.stop="$emit('expense-clicked', expense)">
<Icon name="mdi:receipt-outline" class="w-12 h-12 text-gray-400" />
<span class="text-xs text-gray-500">No receipt</span>
</div>
</div>
<!-- Expense Details -->
<div class="expense-details" @click.stop="$emit('expense-clicked', expense)">
<div class="establishment-name">
{{ expense['Establishment Name'] || 'Unknown' }}
</div>
<div class="price-amount">
{{ expense.DisplayPrice || expense.Price }}
</div>
<div class="expense-meta">
<div class="category-badge" :class="`category-${expense.Category?.toLowerCase()}`">
{{ expense.Category }}
</div>
<div class="payment-method">
<Icon
:name="expense['Payment Method'] === 'Card' ? 'mdi:credit-card' : 'mdi:cash'"
class="w-4 h-4"
/>
{{ expense['Payment Method'] }}
</div>
</div>
<div class="date-time">
{{ formatDateTime(expense.Time) }}
</div>
<!-- Contents Preview -->
<div v-if="expense.Contents" class="contents-preview">
{{ expense.Contents.length > 50 ? expense.Contents.substring(0, 50) + '...' : expense.Contents }}
</div>
</div>
<!-- Quick Actions -->
<div class="expense-actions">
<button
@click.stop="$emit('expense-clicked', expense)"
class="action-btn"
title="View Details"
>
<Icon name="mdi:eye" class="w-4 h-4" />
</button>
<button
v-if="expense.Receipt && expense.Receipt.length > 0"
@click.stop="openReceipt(expense.Receipt[0])"
class="action-btn"
title="View Receipt"
>
<Icon name="mdi:image" class="w-4 h-4" />
</button>
</div>
</div>
</div>
<!-- Loading More Indicator -->
<div v-if="isLoadingMore" class="flex justify-center py-6">
<span class="loading loading-spinner loading-md"></span>
</div>
<!-- Empty State -->
<div v-if="expenses.length === 0" class="text-center py-12">
<Icon name="mdi:receipt" class="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">
No expenses found
</h3>
<p class="text-gray-600 dark:text-gray-300">
No expenses match the current filters
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { Expense } from '@/utils/types';
// Component imports
const LazyReceiptImage = defineAsyncComponent(() => import('@/components/LazyReceiptImage.vue'));
// Props
interface Props {
expenses: Expense[];
selectedExpenses: number[];
isLoadingMore?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
isLoadingMore: false
});
// Emits
const emit = defineEmits<{
'update:selected': [expenseIds: number[]];
'expense-clicked': [expense: Expense];
}>();
// Computed
const totalAmount = computed(() => {
return props.expenses.reduce((sum, expense) => sum + (expense.PriceNumber || 0), 0);
});
const isAllSelected = computed(() => {
return props.expenses.length > 0 && props.expenses.every(expense =>
props.selectedExpenses.includes(expense.Id)
);
});
const isSomeSelected = computed(() => {
return props.selectedExpenses.length > 0 && !isAllSelected.value;
});
// Methods
const isSelected = (expenseId: number) => {
return props.selectedExpenses.includes(expenseId);
};
const selectExpense = (expenseId: number) => {
// Toggle selection on card click
toggleExpense(expenseId);
};
const toggleExpense = (expenseId: number) => {
const currentSelected = [...props.selectedExpenses];
const index = currentSelected.indexOf(expenseId);
if (index > -1) {
currentSelected.splice(index, 1);
} else {
currentSelected.push(expenseId);
}
emit('update:selected', currentSelected);
};
const toggleSelectAll = () => {
if (isAllSelected.value) {
// Deselect all
const currentSelected = props.selectedExpenses.filter(id =>
!props.expenses.some(expense => expense.Id === id)
);
emit('update:selected', currentSelected);
} else {
// Select all
const currentSelected = [...props.selectedExpenses];
props.expenses.forEach(expense => {
if (!currentSelected.includes(expense.Id)) {
currentSelected.push(expense.Id);
}
});
emit('update:selected', currentSelected);
}
};
const formatDateTime = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return dateString;
}
};
const openReceipt = (receipt: any) => {
if (receipt.signedUrl) {
window.open(receipt.signedUrl, '_blank');
}
};
</script>
<style scoped>
.expense-list {
@apply w-full;
}
.expense-card {
@apply relative bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden cursor-pointer transition-all duration-200 hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600;
}
.expense-card.selected {
@apply border-primary bg-primary/5 dark:bg-primary/10;
}
.receipt-container {
@apply relative h-32 bg-gray-50 dark:bg-gray-700;
}
.receipt-image-wrapper {
@apply relative w-full h-full overflow-hidden;
}
.receipt-image {
@apply w-full h-full object-cover;
}
.receipt-count-badge {
@apply absolute top-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded-full;
}
.no-receipt-placeholder {
@apply w-full h-full flex flex-col items-center justify-center text-gray-400;
}
.expense-details {
@apply p-4 space-y-2;
}
.establishment-name {
@apply font-semibold text-gray-900 dark:text-white truncate;
}
.price-amount {
@apply text-xl font-bold text-primary;
}
.expense-meta {
@apply flex items-center justify-between gap-2;
}
.category-badge {
@apply px-2 py-1 rounded-full text-xs font-medium;
}
.category-food\/drinks {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200;
}
.category-shop {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200;
}
.category-online {
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200;
}
.category-other {
@apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200;
}
.payment-method {
@apply flex items-center gap-1 text-sm text-gray-600 dark:text-gray-300;
}
.date-time {
@apply text-sm text-gray-500 dark:text-gray-400;
}
.contents-preview {
@apply text-sm text-gray-600 dark:text-gray-300 line-clamp-2;
}
.expense-actions {
@apply absolute top-3 right-3 flex gap-1 opacity-0 transition-opacity duration-200;
}
.expense-card:hover .expense-actions {
@apply opacity-100;
}
.action-btn {
@apply p-1.5 bg-white dark:bg-gray-800 rounded-full shadow-sm border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors;
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<div class="lazy-receipt-image" ref="containerRef">
<!-- Loading state -->
<div v-if="!loaded && !error" class="loading-state">
<span class="loading loading-spinner loading-sm"></span>
<span class="text-xs text-gray-500">Loading...</span>
</div>
<!-- Error state -->
<div v-else-if="error" class="error-state" @click="retry">
<Icon name="mdi:image-broken" class="w-8 h-8 text-red-400" />
<span class="text-xs text-red-500">Failed to load</span>
<span class="text-xs text-gray-500">Click to retry</span>
</div>
<!-- Loaded image -->
<img
v-else-if="loaded && imageUrl"
:src="imageUrl"
:alt="alt"
:class="['receipt-img', imageClass]"
@error="handleImageError"
@load="handleImageLoad"
/>
<!-- Fallback if no URL -->
<div v-else class="no-image-state">
<Icon name="mdi:receipt-outline" class="w-8 h-8 text-gray-400" />
<span class="text-xs text-gray-500">No image</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import type { ExpenseReceipt } from '@/utils/types';
// Props
interface Props {
receipt: ExpenseReceipt;
alt?: string;
useCardCover?: boolean;
useSmall?: boolean;
useTiny?: boolean;
imageClass?: string;
}
const props = withDefaults(defineProps<Props>(), {
alt: 'Receipt image',
useCardCover: true,
useSmall: false,
useTiny: false,
imageClass: ''
});
// Reactive state
const containerRef = ref<HTMLElement>();
const loaded = ref(false);
const error = ref(false);
const isVisible = ref(false);
// Computed
const imageUrl = computed(() => {
if (!props.receipt) return null;
// Choose the appropriate image size based on props
if (props.useTiny && props.receipt.thumbnails?.tiny?.signedUrl) {
return props.receipt.thumbnails.tiny.signedUrl;
}
if (props.useSmall && props.receipt.thumbnails?.small?.signedUrl) {
return props.receipt.thumbnails.small.signedUrl;
}
if (props.useCardCover && props.receipt.thumbnails?.card_cover?.signedUrl) {
return props.receipt.thumbnails.card_cover.signedUrl;
}
// Fallback to full-size image
return props.receipt.signedUrl || props.receipt.url;
});
// Intersection Observer for lazy loading
let observer: IntersectionObserver | null = null;
const initIntersectionObserver = () => {
if (!containerRef.value || typeof IntersectionObserver === 'undefined') {
// Fallback for environments without IntersectionObserver
isVisible.value = true;
loadImage();
return;
}
observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting && !isVisible.value) {
isVisible.value = true;
loadImage();
// Stop observing once visible
if (observer && containerRef.value) {
observer.unobserve(containerRef.value);
}
}
},
{
rootMargin: '50px', // Start loading 50px before the image comes into view
threshold: 0.1
}
);
observer.observe(containerRef.value);
};
const loadImage = () => {
if (!imageUrl.value || loaded.value) return;
console.log('[LazyReceiptImage] Loading image:', imageUrl.value);
// Pre-load the image to handle errors properly
const img = new Image();
img.onload = () => {
console.log('[LazyReceiptImage] Image loaded successfully');
loaded.value = true;
error.value = false;
};
img.onerror = () => {
console.error('[LazyReceiptImage] Failed to load image:', imageUrl.value);
error.value = true;
loaded.value = false;
};
img.src = imageUrl.value;
};
const handleImageLoad = () => {
console.log('[LazyReceiptImage] Image displayed successfully');
};
const handleImageError = () => {
console.error('[LazyReceiptImage] Image display error:', imageUrl.value);
error.value = true;
loaded.value = false;
};
const retry = () => {
console.log('[LazyReceiptImage] Retrying image load');
error.value = false;
loaded.value = false;
loadImage();
};
// Lifecycle
onMounted(() => {
initIntersectionObserver();
});
onUnmounted(() => {
if (observer && containerRef.value) {
observer.unobserve(containerRef.value);
observer = null;
}
});
</script>
<style scoped>
.lazy-receipt-image {
@apply relative w-full h-full flex items-center justify-center bg-gray-50 dark:bg-gray-700;
}
.loading-state {
@apply flex flex-col items-center justify-center gap-2 text-gray-500;
}
.error-state {
@apply flex flex-col items-center justify-center gap-1 text-red-500 cursor-pointer hover:bg-red-50 dark:hover:bg-red-950 transition-colors rounded p-2;
}
.no-image-state {
@apply flex flex-col items-center justify-center gap-1 text-gray-400;
}
.receipt-img {
@apply w-full h-full object-cover transition-opacity duration-300;
}
.receipt-img:hover {
@apply opacity-90;
}
</style>

View File

@@ -0,0 +1,294 @@
<template>
<div v-if="modelValue" class="modal-overlay" @click.self="closeModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Configure PDF Export</h3>
<button @click="closeModal" class="btn-close">
<Icon name="mdi:close" class="w-5 h-5" />
</button>
</div>
<form @submit.prevent="handleGenerate" class="modal-body">
<!-- Document Name -->
<div class="form-group">
<label class="label">
<span class="label-text">Document Name <span class="text-red-500">*</span></span>
</label>
<input
v-model="options.documentName"
type="text"
placeholder="e.g., May 2025 Expenses"
class="input input-bordered w-full"
required
/>
</div>
<!-- Subheader -->
<div class="form-group">
<label class="label">
<span class="label-text">Subheader (optional)</span>
</label>
<input
v-model="options.subheader"
type="text"
placeholder="e.g., Port Nimara Business Trip"
class="input input-bordered w-full"
/>
</div>
<!-- Grouping Options -->
<div class="form-group">
<label class="label">
<span class="label-text">Group Expenses By</span>
</label>
<select v-model="options.groupBy" class="select select-bordered w-full">
<option value="none">No Grouping</option>
<option value="payer">Group by Person</option>
<option value="category">Group by Category</option>
<option value="date">Group by Date</option>
</select>
</div>
<!-- Include Options -->
<div class="form-group">
<label class="label">
<span class="label-text">Include in PDF</span>
</label>
<div class="space-y-3">
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="options.includeReceipts"
type="checkbox"
class="checkbox checkbox-primary"
/>
<div>
<div class="font-medium">Include Receipt Images</div>
<div class="text-sm text-gray-500">Attach receipt photos to the PDF document</div>
</div>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="options.includeSummary"
type="checkbox"
class="checkbox checkbox-primary"
/>
<div>
<div class="font-medium">Include Summary</div>
<div class="text-sm text-gray-500">Add totals and breakdown at the end</div>
</div>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="options.includeDetails"
type="checkbox"
class="checkbox checkbox-primary"
/>
<div>
<div class="font-medium">Include Expense Details</div>
<div class="text-sm text-gray-500">Show establishment name, date, description</div>
</div>
</label>
</div>
</div>
<!-- Page Format -->
<div class="form-group">
<label class="label">
<span class="label-text">Page Format</span>
</label>
<select v-model="options.pageFormat" class="select select-bordered w-full">
<option value="A4">A4 (210 × 297 mm)</option>
<option value="Letter">Letter (8.5 × 11 in)</option>
<option value="Legal">Legal (8.5 × 14 in)</option>
</select>
</div>
<!-- Preview Info -->
<div class="bg-blue-50 dark:bg-blue-950 rounded-lg p-4">
<div class="flex items-start gap-3">
<Icon name="mdi:information" class="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
<div class="text-sm">
<div class="font-medium text-blue-900 dark:text-blue-100 mb-1">
PDF Preview
</div>
<div class="text-blue-800 dark:text-blue-200">
<div>Selected expenses: <strong>{{ selectedExpenses.length }}</strong></div>
<div>Total amount: <strong>{{ totalAmount.toFixed(2) }}</strong></div>
<div v-if="options.groupBy !== 'none'">
Grouped by: <strong>{{ groupByLabel }}</strong>
</div>
</div>
</div>
</div>
</div>
</form>
<div class="modal-footer">
<button
type="button"
@click="closeModal"
class="btn btn-ghost"
>
Cancel
</button>
<button
@click="handleGenerate"
:disabled="!options.documentName || generating"
class="btn btn-primary"
>
<span v-if="generating" class="loading loading-spinner loading-sm"></span>
<Icon v-else name="mdi:file-pdf" class="w-4 h-4" />
{{ generating ? 'Generating...' : 'Generate PDF' }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
// Props
interface Props {
modelValue: boolean;
selectedExpenses: number[];
}
const props = defineProps<Props>();
// Emits
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'generate': [options: PDFOptions];
}>();
// PDF Options interface
interface PDFOptions {
documentName: string;
subheader: string;
groupBy: 'none' | 'payer' | 'category' | 'date';
includeReceipts: boolean;
includeSummary: boolean;
includeDetails: boolean;
pageFormat: 'A4' | 'Letter' | 'Legal';
}
// Reactive state
const generating = ref(false);
const options = ref<PDFOptions>({
documentName: '',
subheader: '',
groupBy: 'payer',
includeReceipts: true,
includeSummary: true,
includeDetails: true,
pageFormat: 'A4'
});
// Computed
const totalAmount = computed(() => {
// This would ideally come from the parent component
// For now, we'll use a placeholder
return props.selectedExpenses.length * 25; // Rough estimate
});
const groupByLabel = computed(() => {
switch (options.value.groupBy) {
case 'payer': return 'Person';
case 'category': return 'Category';
case 'date': return 'Date';
default: return 'None';
}
});
// Methods
const closeModal = () => {
emit('update:modelValue', false);
};
const handleGenerate = async () => {
if (!options.value.documentName) return;
generating.value = true;
try {
emit('generate', { ...options.value });
} catch (error) {
console.error('[PDFOptionsModal] Error generating PDF:', error);
} finally {
generating.value = false;
}
};
// Watch for modal open to set default document name
watch(() => props.modelValue, (isOpen) => {
if (isOpen && !options.value.documentName) {
const now = new Date();
const monthName = now.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
options.value.documentName = `${monthName} Expenses`;
}
});
</script>
<style scoped>
.modal-overlay {
@apply fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50;
}
.modal-content {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-hidden;
}
.modal-header {
@apply flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700;
}
.modal-title {
@apply text-lg font-semibold text-gray-900 dark:text-white;
}
.btn-close {
@apply p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors;
}
.modal-body {
@apply p-6 space-y-4 overflow-y-auto;
}
.form-group {
@apply space-y-2;
}
.label-text {
@apply text-sm font-medium text-gray-700 dark:text-gray-300;
}
.modal-footer {
@apply flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700;
}
.btn {
@apply inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors;
}
.btn-ghost {
@apply text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700;
}
.btn-primary {
@apply bg-primary text-white hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed;
}
.input, .select {
@apply block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent;
}
.checkbox {
@apply rounded border-gray-300 dark:border-gray-600 text-primary focus:ring-primary;
}
</style>

View File

@@ -0,0 +1,360 @@
<template>
<div v-if="modelValue" class="modal-overlay" @click.self="closeModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">
Receipt {{ currentIndex + 1 }} of {{ receipts.length }}
</h3>
<button @click="closeModal" class="btn-close">
<Icon name="mdi:close" class="w-5 h-5" />
</button>
</div>
<div class="modal-body">
<div v-if="currentReceipt" class="receipt-viewer">
<!-- Receipt Image -->
<div class="receipt-image-container">
<img
:src="currentReceipt.signedUrl || currentReceipt.url"
:alt="currentReceipt.title || `Receipt ${currentIndex + 1}`"
class="receipt-image"
@error="handleImageError"
@load="handleImageLoad"
/>
<!-- Loading overlay -->
<div v-if="imageLoading" class="loading-overlay">
<span class="loading loading-spinner loading-lg"></span>
</div>
<!-- Error overlay -->
<div v-if="imageError" class="error-overlay">
<Icon name="mdi:image-broken" class="w-12 h-12 text-red-400 mb-2" />
<p class="text-red-500 text-sm">Failed to load image</p>
<button @click="retryLoad" class="btn btn-sm btn-outline mt-2">
Retry
</button>
</div>
</div>
<!-- Receipt Info -->
<div class="receipt-info">
<div class="info-item">
<span class="info-label">File Name:</span>
<span class="info-value">{{ currentReceipt.title || 'Unknown' }}</span>
</div>
<div class="info-item">
<span class="info-label">File Size:</span>
<span class="info-value">{{ formatFileSize(currentReceipt.size) }}</span>
</div>
<div class="info-item">
<span class="info-label">Type:</span>
<span class="info-value">{{ currentReceipt.mimetype || 'Unknown' }}</span>
</div>
<div v-if="currentReceipt.width && currentReceipt.height" class="info-item">
<span class="info-label">Dimensions:</span>
<span class="info-value">{{ currentReceipt.width }} × {{ currentReceipt.height }}px</span>
</div>
</div>
</div>
<!-- Navigation Controls -->
<div v-if="receipts.length > 1" class="navigation-controls">
<button
@click="previousReceipt"
:disabled="currentIndex === 0"
class="nav-btn"
title="Previous receipt"
>
<Icon name="mdi:chevron-left" class="w-5 h-5" />
</button>
<div class="receipt-dots">
<button
v-for="(receipt, index) in receipts"
:key="receipt.id || index"
@click="goToReceipt(index)"
:class="['dot', { active: index === currentIndex }]"
:title="`Go to receipt ${index + 1}`"
/>
</div>
<button
@click="nextReceipt"
:disabled="currentIndex === receipts.length - 1"
class="nav-btn"
title="Next receipt"
>
<Icon name="mdi:chevron-right" class="w-5 h-5" />
</button>
</div>
</div>
<div class="modal-footer">
<button @click="closeModal" class="btn btn-ghost">
Close
</button>
<button
v-if="currentReceipt"
@click="downloadReceipt"
class="btn btn-outline"
>
<Icon name="mdi:download" class="w-4 h-4" />
Download
</button>
<button
v-if="currentReceipt"
@click="openInNewTab"
class="btn btn-primary"
>
<Icon name="mdi:open-in-new" class="w-4 h-4" />
Open in New Tab
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import type { ExpenseReceipt } from '@/utils/types';
// Props
interface Props {
modelValue: boolean;
receipts: ExpenseReceipt[];
currentIndex?: number;
}
const props = withDefaults(defineProps<Props>(), {
currentIndex: 0
});
// Emits
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'update:currentIndex': [index: number];
}>();
// Reactive state
const imageLoading = ref(true);
const imageError = ref(false);
// Computed
const currentReceipt = computed(() => {
return props.receipts[props.currentIndex] || null;
});
// Methods
const closeModal = () => {
emit('update:modelValue', false);
};
const handleImageLoad = () => {
imageLoading.value = false;
imageError.value = false;
};
const handleImageError = () => {
imageLoading.value = false;
imageError.value = true;
};
const retryLoad = () => {
imageLoading.value = true;
imageError.value = false;
// Force reload by updating the image src
const img = document.querySelector('.receipt-image') as HTMLImageElement;
if (img && currentReceipt.value) {
const url = currentReceipt.value.signedUrl || currentReceipt.value.url;
img.src = url + '?t=' + Date.now(); // Add timestamp to force reload
}
};
const previousReceipt = () => {
if (props.currentIndex > 0) {
emit('update:currentIndex', props.currentIndex - 1);
}
};
const nextReceipt = () => {
if (props.currentIndex < props.receipts.length - 1) {
emit('update:currentIndex', props.currentIndex + 1);
}
};
const goToReceipt = (index: number) => {
emit('update:currentIndex', index);
};
const formatFileSize = (bytes: number) => {
if (!bytes) return 'Unknown size';
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
};
const downloadReceipt = () => {
if (!currentReceipt.value?.signedUrl) return;
const link = document.createElement('a');
link.href = currentReceipt.value.signedUrl;
link.download = currentReceipt.value.title || `receipt-${props.currentIndex + 1}`;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const openInNewTab = () => {
if (!currentReceipt.value?.signedUrl) return;
window.open(currentReceipt.value.signedUrl, '_blank');
};
// Watch for receipt changes
watch(() => props.currentIndex, () => {
imageLoading.value = true;
imageError.value = false;
}, { immediate: true });
// Keyboard navigation
const handleKeydown = (event: KeyboardEvent) => {
if (!props.modelValue) return;
switch (event.key) {
case 'Escape':
closeModal();
break;
case 'ArrowLeft':
previousReceipt();
break;
case 'ArrowRight':
nextReceipt();
break;
}
};
// Add keyboard event listener when modal is open
watch(() => props.modelValue, (isOpen) => {
if (isOpen) {
document.addEventListener('keydown', handleKeydown);
} else {
document.removeEventListener('keydown', handleKeydown);
}
});
</script>
<style scoped>
.modal-overlay {
@apply fixed inset-0 bg-black/80 flex items-center justify-center p-4 z-50;
}
.modal-content {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden;
}
.modal-header {
@apply flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700;
}
.modal-title {
@apply text-lg font-semibold text-gray-900 dark:text-white;
}
.btn-close {
@apply p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors;
}
.modal-body {
@apply p-4 overflow-y-auto;
}
.receipt-viewer {
@apply space-y-4;
}
.receipt-image-container {
@apply relative bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden;
min-height: 400px;
}
.receipt-image {
@apply w-full h-auto max-h-[60vh] object-contain;
}
.loading-overlay {
@apply absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-700;
}
.error-overlay {
@apply absolute inset-0 flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-700 text-center p-8;
}
.receipt-info {
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg;
}
.info-item {
@apply space-y-1;
}
.info-label {
@apply text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide;
}
.info-value {
@apply text-sm text-gray-900 dark:text-white;
}
.navigation-controls {
@apply flex items-center justify-center gap-4 p-4;
}
.nav-btn {
@apply p-2 rounded-full bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors;
}
.receipt-dots {
@apply flex items-center gap-2;
}
.dot {
@apply w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors;
}
.dot.active {
@apply bg-primary;
}
.modal-footer {
@apply flex items-center justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700;
}
.btn {
@apply inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors;
}
.btn-ghost {
@apply text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700;
}
.btn-outline {
@apply border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700;
}
.btn-primary {
@apply bg-primary text-white hover:bg-primary/90;
}
.btn-sm {
@apply px-3 py-1 text-sm;
}
</style>