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

@@ -1,245 +1,261 @@
<template>
<div class="container mx-auto px-4 py-6">
<!-- Header with Actions -->
<div class="flex flex-col gap-4 mb-6">
<div class="pa-4">
<!-- Header Section -->
<div class="d-flex align-center mb-6">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
Expense Tracking
</h1>
<p class="text-sm sm:text-base text-gray-600 dark:text-gray-300 mt-1">
Track and manage expense receipts with smart grouping and export options
</p>
</div>
<!-- Mobile-optimized export buttons -->
<div class="flex flex-col sm:flex-row gap-2 sm:gap-3 w-full sm:w-auto">
<button
@click="exportCSV"
:disabled="loading || selectedExpenses.length === 0"
class="btn btn-outline btn-sm sm:btn-md w-full sm:w-auto touch-manipulation"
>
<Icon name="mdi:file-excel" class="w-4 h-4" />
<span class="hidden xs:inline">Export </span>CSV
</button>
<button
@click="showPDFModal = true"
:disabled="loading || selectedExpenses.length === 0"
class="btn btn-primary btn-sm sm:btn-md w-full sm:w-auto touch-manipulation"
>
<Icon name="mdi:file-pdf" class="w-4 h-4" />
<span class="hidden xs:inline">Generate </span>PDF
</button>
<h1 class="text-h4 mb-1">Expense Tracking</h1>
<p class="text-subtitle-1 text-grey-darken-1">Track and manage expense receipts with smart grouping and export options</p>
</div>
<v-spacer />
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="showCreateModal = true"
size="large"
>
Add Expense
</v-btn>
</div>
<!-- Date Range Filter -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-3 sm:p-4 mb-6">
<div class="flex flex-col gap-3 sm:gap-4">
<!-- Mobile: Stack date inputs in first row -->
<div class="grid grid-cols-2 sm:flex sm:flex-row gap-3 sm:gap-4">
<div class="flex-1">
<label class="label">
<span class="label-text text-xs sm:text-sm">Start Date</span>
</label>
<input
<v-card class="mb-6">
<v-card-text>
<div class="d-flex flex-wrap gap-4 align-center">
<div class="d-flex gap-3">
<v-text-field
v-model="filters.startDate"
type="date"
class="input input-bordered input-sm sm:input-md w-full text-sm"
label="Start Date"
variant="outlined"
density="compact"
hide-details
@change="fetchExpenses"
/>
</div>
<div class="flex-1">
<label class="label">
<span class="label-text text-xs sm:text-sm">End Date</span>
</label>
<input
<v-text-field
v-model="filters.endDate"
type="date"
class="input input-bordered input-sm sm:input-md w-full text-sm"
label="End Date"
variant="outlined"
density="compact"
hide-details
@change="fetchExpenses"
/>
</div>
<!-- Category and button on larger screens -->
<div class="hidden sm:block flex-1">
<label class="label">
<span class="label-text">Category</span>
</label>
<select
v-model="filters.category"
class="select select-bordered w-full"
@change="fetchExpenses"
>
<option value="">All Categories</option>
<option value="Food/Drinks">Food/Drinks</option>
<option value="Shop">Shop</option>
<option value="Online">Online</option>
<option value="Other">Other</option>
</select>
</div>
<v-select
v-model="filters.category"
:items="['', 'Food/Drinks', 'Shop', 'Online', 'Other']"
label="Category"
variant="outlined"
density="compact"
hide-details
clearable
@update:model-value="fetchExpenses"
/>
<div class="hidden sm:flex items-end">
<button
@click="resetToCurrentMonth"
class="btn btn-ghost btn-sm"
>
Current Month
</button>
</div>
<v-btn
@click="resetToCurrentMonth"
variant="outlined"
size="small"
>
Current Month
</v-btn>
</div>
<!-- Mobile: Category and button in second row -->
<div class="flex gap-3 sm:hidden">
<div class="flex-1">
<label class="label">
<span class="label-text text-xs">Category</span>
</label>
<select
v-model="filters.category"
class="select select-bordered select-sm w-full text-sm"
@change="fetchExpenses"
>
<option value="">All Categories</option>
<option value="Food/Drinks">Food/Drinks</option>
<option value="Shop">Shop</option>
<option value="Online">Online</option>
<option value="Other">Other</option>
</select>
</div>
<div class="flex items-end">
<button
@click="resetToCurrentMonth"
class="btn btn-ghost btn-sm text-xs px-2"
>
Current Month
</button>
</div>
</div>
</div>
</div>
</v-card-text>
</v-card>
<!-- Loading State -->
<div v-if="loading" class="flex justify-center items-center py-12">
<span class="loading loading-spinner loading-lg"></span>
<div v-if="loading" class="text-center py-12">
<v-progress-circular indeterminate color="primary" size="64" />
<p class="text-h6 mt-4">Loading expenses...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="alert alert-error mb-6">
<Icon name="mdi:alert-circle" class="w-5 h-5" />
<span>{{ error }}</span>
<button @click="fetchExpenses" class="btn btn-sm btn-ghost">
Retry
</button>
</div>
<v-alert
v-else-if="error"
type="error"
variant="tonal"
class="mb-6"
:text="error"
closable
@click:close="error = null"
/>
<!-- Summary Stats -->
<div v-else-if="summary" class="grid grid-cols-1 md:grid-cols-5 gap-4 mb-6">
<div class="stat bg-white dark:bg-gray-800 rounded-lg shadow">
<div class="stat-title">Total Expenses</div>
<div class="stat-value text-primary">{{ summary.count }}</div>
</div>
<div class="stat bg-white dark:bg-gray-800 rounded-lg shadow">
<div class="stat-title">Original Total</div>
<div class="stat-value text-secondary">Mixed</div>
<div class="stat-desc">{{ summary.currencies?.join(', ') || 'Various' }}</div>
</div>
<div class="stat bg-white dark:bg-gray-800 rounded-lg shadow">
<div class="stat-title">USD Total</div>
<div class="stat-value text-green-600">${{ (summary.totalUSD || 0).toFixed(2) }}</div>
<div class="stat-desc">Converted Amount</div>
</div>
<div class="stat bg-white dark:bg-gray-800 rounded-lg shadow">
<div class="stat-title">People</div>
<div class="stat-value">{{ summary.uniquePayers }}</div>
</div>
<div class="stat bg-white dark:bg-gray-800 rounded-lg shadow">
<div class="stat-title">Selected</div>
<div class="stat-value text-accent">{{ selectedExpenses.length }}</div>
</div>
</div>
<!-- Currency Status & Refresh -->
<div v-if="summary" class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Icon name="mdi:currency-usd" class="w-5 h-5 text-gray-500" />
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">
Exchange Rates
<v-row v-else-if="summary" class="mb-6">
<v-col cols="12" md="6" lg="3">
<v-card>
<v-card-text>
<div class="d-flex align-center">
<v-icon color="primary" size="large" class="mr-3">mdi-receipt</v-icon>
<div>
<div class="text-h6">{{ summary.count }}</div>
<div class="text-caption text-grey-darken-1">Total Expenses</div>
</div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ currencyStatus?.cached ?
`Updated ${currencyStatus.minutesUntilExpiry}min ago • ${currencyStatus.ratesCount} rates` :
'Not cached'
}}
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6" lg="3">
<v-card>
<v-card-text>
<div class="d-flex align-center">
<v-icon color="orange" size="large" class="mr-3">mdi-currency-usd</v-icon>
<div>
<div class="text-h6">${{ (summary.totalUSD || 0).toFixed(2) }}</div>
<div class="text-caption text-grey-darken-1">USD Total</div>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6" lg="3">
<v-card>
<v-card-text>
<div class="d-flex align-center">
<v-icon color="green" size="large" class="mr-3">mdi-account-multiple</v-icon>
<div>
<div class="text-h6">{{ summary.uniquePayers }}</div>
<div class="text-caption text-grey-darken-1">People</div>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6" lg="3">
<v-card>
<v-card-text>
<div class="d-flex align-center">
<v-icon color="blue" size="large" class="mr-3">mdi-check-circle</v-icon>
<div>
<div class="text-h6">{{ selectedExpenses.length }}</div>
<div class="text-caption text-grey-darken-1">Selected</div>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Currency Status -->
<v-card v-if="summary" class="mb-6">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<v-icon color="primary" class="mr-3">mdi-currency-usd</v-icon>
<div>
<div class="text-subtitle-1 font-weight-medium">Exchange Rates</div>
<div class="text-body-2 text-grey-darken-1">
{{ currencyStatus?.cached ?
`Updated ${currencyStatus.minutesUntilExpiry}min ago • ${currencyStatus.ratesCount} rates` :
'Not cached'
}}
</div>
</div>
</div>
<v-btn
@click="refreshCurrency"
:loading="refreshingCurrency"
variant="outlined"
size="small"
prepend-icon="mdi-refresh"
>
Refresh Rates
</v-btn>
</div>
<button
@click="refreshCurrency"
:disabled="refreshingCurrency"
class="btn btn-sm btn-outline"
>
<span v-if="refreshingCurrency" class="loading loading-spinner loading-xs"></span>
<Icon v-else name="mdi:refresh" class="w-4 h-4" />
{{ refreshingCurrency ? 'Refreshing...' : 'Refresh Rates' }}
</button>
</div>
</div>
</v-card-text>
</v-card>
<!-- Export Actions -->
<v-card v-if="summary && summary.count > 0" class="mb-6">
<v-card-text>
<div class="d-flex flex-wrap gap-3 align-center">
<span class="text-subtitle-1 font-weight-medium">Export Options:</span>
<v-btn
@click="exportCSV"
:disabled="selectedExpenses.length === 0"
variant="outlined"
size="small"
prepend-icon="mdi-file-excel"
>
Export CSV ({{ selectedExpenses.length }})
</v-btn>
<v-btn
@click="showPDFModal = true"
:disabled="selectedExpenses.length === 0"
variant="outlined"
size="small"
prepend-icon="mdi-file-pdf"
>
Generate PDF ({{ selectedExpenses.length }})
</v-btn>
</div>
</v-card-text>
</v-card>
<!-- Person Tabs -->
<div v-if="groupedExpenses.length > 0" class="bg-white dark:bg-gray-800 rounded-lg shadow">
<!-- Tab Headers -->
<div class="tabs tabs-bordered px-4 pt-4">
<button
v-for="group in groupedExpenses"
:key="group.name"
@click="activeTab = group.name"
:class="[
'tab tab-lg',
activeTab === group.name ? 'tab-active' : ''
]"
<v-card v-if="groupedExpenses.length > 0">
<v-card-text>
<v-tabs
v-model="activeTab"
bg-color="transparent"
color="primary"
grow
>
{{ group.name }}
<div class="badge badge-primary badge-sm ml-2">
{{ group.count }}
</div>
<div class="badge badge-secondary badge-sm ml-1">
{{ group.total.toFixed(2) }}
</div>
</button>
</div>
<v-tab
v-for="group in groupedExpenses"
:key="group.name"
:value="group.name"
>
<div class="text-center">
<div class="font-weight-medium">{{ group.name }}</div>
<div class="text-caption text-grey-darken-1">
{{ group.count }} {{ group.total.toFixed(2) }}
</div>
</div>
</v-tab>
</v-tabs>
<!-- Tab Content -->
<div class="p-4">
<ExpenseList
v-if="activeTabExpenses"
:expenses="activeTabExpenses"
:selected-expenses="selectedExpenses"
@update:selected="updateSelected"
@expense-clicked="showExpenseModal"
/>
</div>
</div>
<v-tabs-window v-model="activeTab" class="mt-4">
<v-tabs-window-item
v-for="group in groupedExpenses"
:key="group.name"
:value="group.name"
>
<ExpenseList
:expenses="group.expenses"
:selected-expenses="selectedExpenses"
@update:selected="updateSelected"
@expense-clicked="showExpenseModal"
/>
</v-tabs-window-item>
</v-tabs-window>
</v-card-text>
</v-card>
<!-- Empty State -->
<div v-else-if="!loading" 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">
Try adjusting your date range or filters
</p>
</div>
<v-card v-else-if="!loading && !error">
<v-card-text class="text-center py-12">
<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 mb-4">
Try adjusting your date range or filters
</p>
<v-btn
@click="showCreateModal = true"
color="primary"
prepend-icon="mdi-plus"
>
Add Your First Expense
</v-btn>
</v-card-text>
</v-card>
<!-- PDF Options Modal -->
<PDFOptionsModal
@@ -252,6 +268,13 @@
<ExpenseDetailsModal
v-model="showDetailsModal"
:expense="selectedExpense"
@save="handleSaveExpense"
/>
<!-- Create Expense Modal -->
<ExpenseCreateModal
v-model="showCreateModal"
@created="handleExpenseCreated"
/>
</div>
</template>
@@ -264,12 +287,16 @@ import type { Expense } from '@/utils/types';
const ExpenseList = defineAsyncComponent(() => import('@/components/ExpenseList.vue'));
const PDFOptionsModal = defineAsyncComponent(() => import('@/components/PDFOptionsModal.vue'));
const ExpenseDetailsModal = defineAsyncComponent(() => import('@/components/ExpenseDetailsModal.vue'));
const ExpenseCreateModal = defineAsyncComponent(() => import('@/components/ExpenseCreateModal.vue'));
// Page meta
definePageMeta({
middleware: 'authentication',
layout: 'dashboard',
roles: ['sales', 'admin']
middleware: ['authentication'],
layout: 'dashboard'
});
useHead({
title: 'Expense Tracking'
});
// Reactive state
@@ -279,6 +306,7 @@ const expenses = ref<Expense[]>([]);
const selectedExpenses = ref<number[]>([]);
const showPDFModal = ref(false);
const showDetailsModal = ref(false);
const showCreateModal = ref(false);
const selectedExpense = ref<Expense | null>(null);
const activeTab = ref<string>('');
@@ -340,11 +368,6 @@ const groupedExpenses = computed(() => {
return groupArray;
});
const activeTabExpenses = computed(() => {
const group = groupedExpenses.value.find(g => g.name === activeTab.value);
return group?.expenses || [];
});
// Methods
const fetchExpenses = async () => {
loading.value = true;
@@ -361,7 +384,7 @@ const fetchExpenses = async () => {
const response = await $fetch<{
expenses: Expense[];
summary: { total: number; count: number; uniquePayers: number };
summary: { total: number; totalUSD: number; count: number; uniquePayers: number; currencies: string[] };
}>(`/api/get-expenses?${params.toString()}`);
expenses.value = response.expenses || [];
@@ -402,6 +425,16 @@ const showExpenseModal = (expense: Expense) => {
showDetailsModal.value = true;
};
const handleSaveExpense = async (expense: Expense) => {
// Refresh the expenses data to reflect the updates
await fetchExpenses();
};
const handleExpenseCreated = async (expense: Expense) => {
// Refresh the expenses data to include the new expense
await fetchExpenses();
};
const exportCSV = async () => {
try {
const selectedExpenseData = expenses.value.filter(e =>
@@ -409,7 +442,7 @@ const exportCSV = async () => {
);
if (selectedExpenseData.length === 0) {
alert('Please select expenses to export');
error.value = 'Please select expenses to export';
return;
}
@@ -430,7 +463,7 @@ const exportCSV = async () => {
} catch (err: any) {
console.error('[expenses] Error exporting CSV:', err);
alert('Failed to export CSV');
error.value = 'Failed to export CSV';
}
};
@@ -459,7 +492,7 @@ const generatePDF = async (options: any) => {
} catch (err: any) {
console.error('[expenses] Error generating PDF:', err);
alert('Failed to generate PDF');
error.value = 'Failed to generate PDF';
}
};
@@ -502,7 +535,7 @@ const refreshCurrency = async () => {
}
} catch (err: any) {
console.error('[expenses] Error refreshing currency rates:', err);
alert('Failed to refresh currency rates');
error.value = 'Failed to refresh currency rates';
} finally {
refreshingCurrency.value = false;
}
@@ -519,23 +552,19 @@ onMounted(async () => {
</script>
<style scoped>
.btn {
@apply inline-flex items-center gap-2;
.v-card {
transition: all 0.3s ease;
}
.stat {
@apply p-4;
.v-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12);
}
.tabs {
@apply border-b border-gray-200 dark:border-gray-700;
.v-tabs--grow .v-tab {
min-width: 120px;
}
.tab {
@apply border-b-2 border-transparent px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300;
}
.tab-active {
@apply border-primary text-primary dark:text-primary;
.v-tab {
text-transform: none !important;
}
</style>