feat: Implement expense creation modal and API integration

- Added ExpenseCreateModal component for adding new expenses with form validation.
- Integrated API endpoint for creating expenses, ensuring only authorized users can access it.
- Updated dashboard to include functionality for adding expenses and refreshing the expense list after creation.
- Enhanced UI with Vuetify components for better user experience and responsiveness.
This commit is contained in:
2025-07-09 13:58:38 -04:00
parent ac7176ff17
commit 7ba8c98663
4 changed files with 957 additions and 405 deletions

View File

@@ -0,0 +1,322 @@
<template>
<v-dialog
v-model="dialog"
max-width="600"
persistent
scrollable
>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-receipt-text</v-icon>
<span>Add New Expense</span>
<v-spacer />
<v-btn
@click="closeModal"
icon="mdi-close"
variant="text"
size="small"
/>
</v-card-title>
<v-card-text>
<v-form ref="form" @submit.prevent="saveExpense">
<v-row>
<!-- Merchant/Description -->
<v-col cols="12">
<v-text-field
v-model="expense.merchant"
label="Merchant/Description"
variant="outlined"
:rules="[rules.required]"
required
/>
</v-col>
<!-- Amount and Currency -->
<v-col cols="8">
<v-text-field
v-model="expense.amount"
label="Amount"
type="number"
step="0.01"
variant="outlined"
:rules="[rules.required, rules.positive]"
required
/>
</v-col>
<v-col cols="4">
<v-select
v-model="expense.currency"
:items="currencies"
label="Currency"
variant="outlined"
:rules="[rules.required]"
required
/>
</v-col>
<!-- Category -->
<v-col cols="12" md="6">
<v-select
v-model="expense.category"
:items="categories"
label="Category"
variant="outlined"
:rules="[rules.required]"
required
/>
</v-col>
<!-- Payer -->
<v-col cols="12" md="6">
<v-text-field
v-model="expense.payer"
label="Payer"
variant="outlined"
:rules="[rules.required]"
required
/>
</v-col>
<!-- Date -->
<v-col cols="12" md="6">
<v-text-field
v-model="expense.date"
label="Date"
type="date"
variant="outlined"
:rules="[rules.required]"
required
/>
</v-col>
<!-- Time -->
<v-col cols="12" md="6">
<v-text-field
v-model="expense.time"
label="Time"
type="time"
variant="outlined"
:rules="[rules.required]"
required
/>
</v-col>
<!-- Notes -->
<v-col cols="12">
<v-textarea
v-model="expense.notes"
label="Notes (Optional)"
variant="outlined"
rows="3"
auto-grow
/>
</v-col>
<!-- Receipt Upload -->
<v-col cols="12">
<v-file-input
v-model="expense.receipt"
label="Receipt Image (Optional)"
accept="image/*"
variant="outlined"
prepend-icon="mdi-camera"
show-size
/>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions class="px-6 pb-4">
<v-spacer />
<v-btn
@click="closeModal"
variant="text"
:disabled="saving"
>
Cancel
</v-btn>
<v-btn
@click="saveExpense"
color="primary"
:loading="saving"
:disabled="!isValid"
>
Add Expense
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import type { Expense } from '@/utils/types';
interface Props {
modelValue: boolean;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'created', expense: Expense): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const dialog = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const form = ref();
const saving = ref(false);
// Form data
const expense = ref({
merchant: '',
amount: '',
currency: 'EUR',
category: '',
payer: '',
date: '',
time: '',
notes: '',
receipt: null as File[] | null
});
// Form options
const currencies = ['EUR', 'USD', 'GBP', 'AUD', 'CAD', 'CHF', 'SEK', 'NOK', 'DKK'];
const categories = ['Food/Drinks', 'Shop', 'Online', 'Transportation', 'Accommodation', 'Entertainment', 'Other'];
// Validation rules
const rules = {
required: (value: any) => !!value || 'This field is required',
positive: (value: any) => {
const num = parseFloat(value);
return (!isNaN(num) && num > 0) || 'Amount must be positive';
}
};
// Computed properties
const isValid = computed(() => {
return !!(
expense.value.merchant &&
expense.value.amount &&
expense.value.currency &&
expense.value.category &&
expense.value.payer &&
expense.value.date &&
expense.value.time &&
parseFloat(expense.value.amount) > 0
);
});
// Methods
const resetForm = () => {
const now = new Date();
expense.value = {
merchant: '',
amount: '',
currency: 'EUR',
category: '',
payer: '',
date: now.toISOString().slice(0, 10),
time: now.toTimeString().slice(0, 5),
notes: '',
receipt: null
};
if (form.value) {
form.value.resetValidation();
}
};
const closeModal = () => {
if (!saving.value) {
dialog.value = false;
}
};
const saveExpense = async () => {
if (!form.value) return;
const { valid } = await form.value.validate();
if (!valid) return;
saving.value = true;
try {
// Combine date and time for the API
const dateTime = `${expense.value.date}T${expense.value.time}:00`;
// Prepare the expense data
const expenseData = {
"Establishment Name": expense.value.merchant,
Price: `${expense.value.currency}${expense.value.amount}`,
Category: expense.value.category,
Payer: expense.value.payer,
Time: dateTime,
Contents: expense.value.notes || null,
"Payment Method": "Card", // Default to Card for now
Paid: false,
currency: expense.value.currency
};
console.log('[ExpenseCreateModal] Creating expense:', expenseData);
// Call API to create expense
const response = await $fetch<Expense>('/api/create-expense', {
method: 'POST',
body: expenseData
});
console.log('[ExpenseCreateModal] Expense created successfully:', response);
// Emit the created event
emit('created', response);
// Close the modal
dialog.value = false;
} catch (error: any) {
console.error('[ExpenseCreateModal] Error creating expense:', error);
// Show error message (you might want to use a toast notification here)
alert('Failed to create expense. Please try again.');
} finally {
saving.value = false;
}
};
// Watch for modal open/close
watch(dialog, (newValue) => {
if (newValue) {
// Reset form when modal opens
nextTick(() => {
resetForm();
});
}
});
// Initialize form with current date/time
onMounted(() => {
resetForm();
});
</script>
<style scoped>
.v-dialog > .v-card {
overflow: visible;
}
.v-form {
width: 100%;
}
.v-card-actions {
border-top: 1px solid rgba(0, 0, 0, 0.12);
}
</style>

View File

@@ -1,148 +1,187 @@
<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 v-if="expenses.length > 0" class="d-flex align-center justify-space-between mb-4">
<div class="d-flex align-center">
<v-checkbox
:model-value="isAllSelected"
:indeterminate="isSomeSelected"
@update:model-value="toggleSelectAll"
hide-details
density="compact"
class="mr-2"
/>
<span class="text-body-2 font-weight-medium">
{{ selectedExpenses.length > 0 ? `${selectedExpenses.length} selected` : 'Select all' }}
</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
<div class="text-body-2 text-grey-darken-1">
{{ expenses.length }} expenses {{ totalAmount.toFixed(2) }}
</div>
</div>
<!-- Expense Grid - Mobile optimized -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
<div
<!-- Expense Grid -->
<v-row class="expense-grid">
<v-col
v-for="expense in expenses"
:key="expense.Id"
class="expense-card"
:class="{ 'selected': isSelected(expense.Id) }"
@click="selectExpense(expense.Id)"
cols="12"
sm="6"
lg="4"
xl="3"
>
<!-- 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>
<v-card
:class="{ 'selected-card': isSelected(expense.Id) }"
class="expense-card"
elevation="2"
@click="selectExpense(expense.Id)"
>
<!-- Selection Checkbox -->
<div class="card-checkbox">
<v-checkbox
:model-value="isSelected(expense.Id)"
@update:model-value="toggleExpense(expense.Id)"
@click.stop
hide-details
density="compact"
color="primary"
/>
</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"
<!-- Receipt Image Section -->
<div class="receipt-section">
<div
v-if="expense.Receipt && expense.Receipt.length > 0"
class="receipt-image-container"
@click.stop="$emit('expense-clicked', expense)"
>
<LazyReceiptImage
:receipt="expense.Receipt[0]"
:alt="`Receipt for ${expense['Establishment Name']}`"
class="receipt-image"
/>
<!-- Multiple receipts indicator -->
<v-chip
v-if="expense.Receipt.length > 1"
size="x-small"
color="primary"
class="receipt-count-chip"
>
+{{ expense.Receipt.length - 1 }}
</v-chip>
</div>
<!-- No receipt placeholder -->
<div
v-else
class="no-receipt-placeholder"
@click.stop="$emit('expense-clicked', expense)"
>
<v-icon size="48" color="grey-lighten-1">mdi-receipt-text-outline</v-icon>
<span class="text-caption text-grey">No receipt</span>
</div>
</div>
<!-- Expense Details -->
<v-card-text @click.stop="$emit('expense-clicked', expense)">
<!-- Merchant Name -->
<div class="merchant-name">
{{ expense['Establishment Name'] || 'Unknown Merchant' }}
</div>
<!-- Price -->
<div class="price-display">
{{ expense.Price }}
<span v-if="expense.PriceUSD && expense.PriceUSD !== expense.PriceNumber" class="converted-price">
( ${{ expense.PriceUSD.toFixed(2) }})
</span>
</div>
<!-- Category and Payment Method -->
<div class="d-flex align-center justify-space-between my-2">
<v-chip
:color="getCategoryColor(expense.Category)"
size="small"
variant="tonal"
>
{{ expense.Category || 'Other' }}
</v-chip>
<div class="d-flex align-center">
<v-icon
:icon="expense['Payment Method'] === 'Card' ? 'mdi-credit-card' : 'mdi-cash'"
size="small"
class="mr-1"
/>
<span class="text-caption">{{ expense['Payment Method'] || 'Unknown' }}</span>
</div>
</div>
<!-- Date and Time -->
<div class="date-display">
<v-icon size="small" class="mr-1">mdi-calendar-clock</v-icon>
{{ formatDateTime(expense.Time) }}
</div>
<!-- Payer -->
<div v-if="expense.Payer" class="payer-display">
<v-icon size="small" class="mr-1">mdi-account</v-icon>
{{ expense.Payer }}
</div>
<!-- Contents Preview -->
<div v-if="expense.Contents" class="contents-preview">
{{ getContentsPreview(expense) }}
</div>
</v-card-text>
<!-- Quick Actions -->
<div class="card-actions">
<v-btn
@click.stop="$emit('expense-clicked', expense)"
icon="mdi-eye"
size="small"
variant="elevated"
color="white"
class="action-button"
/>
<!-- Multiple receipts indicator -->
<div v-if="expense.Receipt.length > 1" class="receipt-count-badge">
+{{ expense.Receipt.length - 1 }}
</div>
<v-btn
v-if="expense.Receipt && expense.Receipt.length > 0"
@click.stop="openReceipt(expense.Receipt[0])"
icon="mdi-image"
size="small"
variant="elevated"
color="white"
class="action-button"
/>
</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>
</v-card>
</v-col>
</v-row>
<!-- Loading More Indicator -->
<div v-if="isLoadingMore" class="flex justify-center py-6">
<span class="loading loading-spinner loading-md"></span>
<div v-if="isLoadingMore" class="text-center py-6">
<v-progress-circular indeterminate color="primary" />
</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>
<v-card v-if="expenses.length === 0" class="text-center py-12">
<v-card-text>
<v-icon size="64" color="grey-lighten-2" class="mb-4">mdi-receipt</v-icon>
<h3 class="text-h6 mb-2">No expenses found</h3>
<p class="text-body-2 text-grey-darken-1">
No expenses match the current filters
</p>
</v-card-text>
</v-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed } from 'vue';
import type { Expense } from '@/utils/types';
// Component imports
@@ -236,6 +275,25 @@ const formatDateTime = (dateString: string) => {
}
};
const getCategoryColor = (category: string) => {
const categoryColors: Record<string, string> = {
'Food/Drinks': 'green',
'Shop': 'blue',
'Online': 'purple',
'Transportation': 'orange',
'Accommodation': 'teal',
'Entertainment': 'pink',
'Other': 'grey'
};
return categoryColors[category] || 'grey';
};
const getContentsPreview = (expense: Expense) => {
const content = expense.Contents || '';
return content.length > 80 ? content.substring(0, 80) + '...' : content;
};
const openReceipt = (receipt: any) => {
if (receipt.signedUrl) {
window.open(receipt.signedUrl, '_blank');
@@ -245,94 +303,158 @@ const openReceipt = (receipt: any) => {
<style scoped>
.expense-list {
@apply w-full;
width: 100%;
}
.expense-grid {
margin: 0;
}
.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;
position: relative;
cursor: pointer;
transition: all 0.3s ease;
height: 100%;
}
.expense-card.selected {
@apply border-primary bg-primary/5 dark:bg-primary/10;
.expense-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
.receipt-container {
@apply relative h-32 bg-gray-50 dark:bg-gray-700;
.selected-card {
border: 2px solid rgb(var(--v-theme-primary)) !important;
background-color: rgba(var(--v-theme-primary), 0.05);
}
.receipt-image-wrapper {
@apply relative w-full h-full overflow-hidden;
.card-checkbox {
position: absolute;
top: 8px;
left: 8px;
z-index: 2;
background: rgba(255, 255, 255, 0.9);
border-radius: 4px;
padding: 2px;
}
.receipt-section {
position: relative;
height: 140px;
background: #f5f5f5;
overflow: hidden;
}
.receipt-image-container {
width: 100%;
height: 100%;
position: relative;
}
.receipt-image {
@apply w-full h-full object-cover;
width: 100%;
height: 100%;
object-fit: cover;
}
.receipt-count-badge {
@apply absolute top-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded-full;
.receipt-count-chip {
position: absolute;
top: 8px;
right: 8px;
}
.no-receipt-placeholder {
@apply w-full h-full flex flex-col items-center justify-center text-gray-400;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #9e9e9e;
}
.expense-details {
@apply p-4 space-y-2;
.merchant-name {
font-weight: 600;
font-size: 1rem;
line-height: 1.2;
margin-bottom: 8px;
color: rgb(var(--v-theme-on-surface));
}
.establishment-name {
@apply font-semibold text-gray-900 dark:text-white truncate;
.price-display {
font-size: 1.25rem;
font-weight: 700;
color: rgb(var(--v-theme-primary));
margin-bottom: 8px;
}
.price-amount {
@apply text-xl font-bold text-primary;
.converted-price {
font-size: 0.875rem;
font-weight: 400;
color: rgb(var(--v-theme-on-surface-variant));
margin-left: 4px;
}
.expense-meta {
@apply flex items-center justify-between gap-2;
.date-display {
display: flex;
align-items: center;
font-size: 0.875rem;
color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 4px;
}
.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;
.payer-display {
display: flex;
align-items: center;
font-size: 0.875rem;
color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 8px;
}
.contents-preview {
@apply text-sm text-gray-600 dark:text-gray-300 line-clamp-2;
font-size: 0.875rem;
color: rgb(var(--v-theme-on-surface-variant));
line-height: 1.3;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.expense-actions {
@apply absolute top-3 right-3 flex gap-1 opacity-0 transition-opacity duration-200;
.card-actions {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.3s ease;
}
.expense-card:hover .expense-actions {
@apply opacity-100;
.expense-card:hover .card-actions {
opacity: 1;
}
.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;
.action-button {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Responsive adjustments */
@media (max-width: 600px) {
.expense-grid :deep(.v-col) {
padding: 6px;
}
.receipt-section {
height: 120px;
}
.merchant-name {
font-size: 0.9rem;
}
.price-display {
font-size: 1.1rem;
}
}
</style>