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:
@@ -107,6 +107,11 @@ const interestMenu = [
|
||||
icon: "mdi-account-check",
|
||||
title: "Interest Status",
|
||||
},
|
||||
{
|
||||
to: "/dashboard/expenses",
|
||||
icon: "mdi-receipt",
|
||||
title: "Expenses",
|
||||
},
|
||||
{
|
||||
to: "/dashboard/file-browser",
|
||||
icon: "mdi-folder",
|
||||
|
||||
506
pages/dashboard/expenses.vue
Normal file
506
pages/dashboard/expenses.vue
Normal file
@@ -0,0 +1,506 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<!-- Header with Actions -->
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Expense Tracking
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-300 mt-1">
|
||||
Track and manage expense receipts with smart grouping and export options
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<!-- Export Actions -->
|
||||
<button
|
||||
@click="exportCSV"
|
||||
:disabled="loading || selectedExpenses.length === 0"
|
||||
class="btn btn-outline btn-sm"
|
||||
>
|
||||
<Icon name="mdi:file-excel" class="w-4 h-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="showPDFModal = true"
|
||||
:disabled="loading || selectedExpenses.length === 0"
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
<Icon name="mdi:file-pdf" class="w-4 h-4" />
|
||||
Generate PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Range Filter -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 mb-6">
|
||||
<div class="flex flex-col sm:flex-row gap-4 items-end">
|
||||
<div class="flex-1">
|
||||
<label class="label">
|
||||
<span class="label-text">Start Date</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="filters.startDate"
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
@change="fetchExpenses"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<label class="label">
|
||||
<span class="label-text">End Date</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="filters.endDate"
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
@change="fetchExpenses"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="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>
|
||||
|
||||
<button
|
||||
@click="resetToCurrentMonth"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
Current Month
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex justify-center items-center py-12">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</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>
|
||||
|
||||
<!-- 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
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ currencyStatus?.cached ?
|
||||
`Updated ${currencyStatus.minutesUntilExpiry}min ago • ${currencyStatus.ratesCount} rates` :
|
||||
'Not cached'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- 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' : ''
|
||||
]"
|
||||
>
|
||||
{{ 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>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="p-4">
|
||||
<ExpenseList
|
||||
v-if="activeTabExpenses"
|
||||
:expenses="activeTabExpenses"
|
||||
:selected-expenses="selectedExpenses"
|
||||
@update:selected="updateSelected"
|
||||
@expense-clicked="showExpenseModal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- PDF Options Modal -->
|
||||
<PDFOptionsModal
|
||||
v-model="showPDFModal"
|
||||
:selected-expenses="selectedExpenses"
|
||||
@generate="generatePDF"
|
||||
/>
|
||||
|
||||
<!-- Expense Details Modal -->
|
||||
<ExpenseDetailsModal
|
||||
v-model="showDetailsModal"
|
||||
:expense="selectedExpense"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import type { Expense } from '@/utils/types';
|
||||
import { groupExpensesByPayer } from '@/server/utils/nocodb';
|
||||
|
||||
// Component imports (to be created)
|
||||
const ExpenseList = defineAsyncComponent(() => import('@/components/ExpenseList.vue'));
|
||||
const PDFOptionsModal = defineAsyncComponent(() => import('@/components/PDFOptionsModal.vue'));
|
||||
const ExpenseDetailsModal = defineAsyncComponent(() => import('@/components/ExpenseDetailsModal.vue'));
|
||||
|
||||
// Page meta
|
||||
definePageMeta({
|
||||
middleware: 'authentication',
|
||||
layout: 'dashboard'
|
||||
});
|
||||
|
||||
// Reactive state
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const expenses = ref<Expense[]>([]);
|
||||
const selectedExpenses = ref<number[]>([]);
|
||||
const showPDFModal = ref(false);
|
||||
const showDetailsModal = ref(false);
|
||||
const selectedExpense = ref<Expense | null>(null);
|
||||
const activeTab = ref<string>('');
|
||||
|
||||
// Filters
|
||||
const filters = ref({
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
category: '',
|
||||
payer: ''
|
||||
});
|
||||
|
||||
// Summary data
|
||||
const summary = ref<{
|
||||
total: number;
|
||||
totalUSD?: number;
|
||||
count: number;
|
||||
uniquePayers: number;
|
||||
currencies?: string[];
|
||||
} | null>(null);
|
||||
|
||||
// Currency status
|
||||
const currencyStatus = ref<{
|
||||
cached: boolean;
|
||||
lastUpdated?: string;
|
||||
ratesCount?: number;
|
||||
minutesUntilExpiry?: number;
|
||||
} | null>(null);
|
||||
const refreshingCurrency = ref(false);
|
||||
|
||||
// Computed properties
|
||||
const groupedExpenses = computed(() => {
|
||||
if (!expenses.value.length) return [];
|
||||
|
||||
const groups = expenses.value.reduce((acc, expense) => {
|
||||
const payer = expense.Payer || 'Unknown';
|
||||
if (!acc[payer]) {
|
||||
acc[payer] = {
|
||||
name: payer,
|
||||
expenses: [],
|
||||
count: 0,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
acc[payer].expenses.push(expense);
|
||||
acc[payer].count++;
|
||||
acc[payer].total += expense.PriceNumber || 0;
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, { name: string; expenses: Expense[]; count: number; total: number }>);
|
||||
|
||||
const groupArray = Object.values(groups);
|
||||
|
||||
// Set initial active tab
|
||||
if (groupArray.length > 0 && !activeTab.value) {
|
||||
activeTab.value = groupArray[0].name;
|
||||
}
|
||||
|
||||
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;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
console.log('[expenses] Fetching expenses with filters:', filters.value);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (filters.value.startDate) params.append('startDate', filters.value.startDate);
|
||||
if (filters.value.endDate) params.append('endDate', filters.value.endDate);
|
||||
if (filters.value.category) params.append('category', filters.value.category);
|
||||
if (filters.value.payer) params.append('payer', filters.value.payer);
|
||||
|
||||
const response = await $fetch<{
|
||||
expenses: Expense[];
|
||||
summary: { total: number; count: number; uniquePayers: number };
|
||||
}>(`/api/get-expenses?${params.toString()}`);
|
||||
|
||||
expenses.value = response.expenses || [];
|
||||
summary.value = response.summary;
|
||||
|
||||
console.log('[expenses] Fetched expenses:', expenses.value.length);
|
||||
|
||||
// Reset selections when data changes
|
||||
selectedExpenses.value = [];
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('[expenses] Error fetching expenses:', err);
|
||||
error.value = err.message || 'Failed to fetch expenses';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetToCurrentMonth = () => {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
filters.value.startDate = startOfMonth.toISOString().slice(0, 10);
|
||||
filters.value.endDate = endOfMonth.toISOString().slice(0, 10);
|
||||
filters.value.category = '';
|
||||
filters.value.payer = '';
|
||||
|
||||
fetchExpenses();
|
||||
};
|
||||
|
||||
const updateSelected = (expenseIds: number[]) => {
|
||||
selectedExpenses.value = expenseIds;
|
||||
};
|
||||
|
||||
const showExpenseModal = (expense: Expense) => {
|
||||
selectedExpense.value = expense;
|
||||
showDetailsModal.value = true;
|
||||
};
|
||||
|
||||
const exportCSV = async () => {
|
||||
try {
|
||||
const selectedExpenseData = expenses.value.filter(e =>
|
||||
selectedExpenses.value.includes(e.Id)
|
||||
);
|
||||
|
||||
if (selectedExpenseData.length === 0) {
|
||||
alert('Please select expenses to export');
|
||||
return;
|
||||
}
|
||||
|
||||
// Call CSV export API
|
||||
const response = await $fetch('/api/expenses/export-csv', {
|
||||
method: 'POST',
|
||||
body: { expenseIds: selectedExpenses.value }
|
||||
});
|
||||
|
||||
// Create and download CSV file
|
||||
const blob = new Blob([response], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `expenses-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('[expenses] Error exporting CSV:', err);
|
||||
alert('Failed to export CSV');
|
||||
}
|
||||
};
|
||||
|
||||
const generatePDF = async (options: any) => {
|
||||
try {
|
||||
console.log('[expenses] Generating PDF with options:', options);
|
||||
|
||||
const response = await $fetch('/api/expenses/generate-pdf', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
expenseIds: selectedExpenses.value,
|
||||
options
|
||||
}
|
||||
});
|
||||
|
||||
// Handle PDF download
|
||||
const blob = new Blob([response as any], { type: 'application/pdf' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${options.documentName || 'expenses'}.pdf`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
showPDFModal.value = false;
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('[expenses] Error generating PDF:', err);
|
||||
alert('Failed to generate PDF');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCurrencyStatus = async () => {
|
||||
try {
|
||||
const status = await $fetch<{
|
||||
cached: boolean;
|
||||
lastUpdated?: string;
|
||||
ratesCount?: number;
|
||||
minutesUntilExpiry?: number;
|
||||
}>('/api/currency/status');
|
||||
|
||||
currencyStatus.value = status;
|
||||
} catch (err: any) {
|
||||
console.error('[expenses] Error fetching currency status:', err);
|
||||
currencyStatus.value = { cached: false };
|
||||
}
|
||||
};
|
||||
|
||||
const refreshCurrency = async () => {
|
||||
refreshingCurrency.value = true;
|
||||
|
||||
try {
|
||||
console.log('[expenses] Refreshing currency rates...');
|
||||
|
||||
const response = await $fetch<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
ratesCount?: number;
|
||||
}>('/api/currency/refresh');
|
||||
|
||||
if (response.success) {
|
||||
console.log('[expenses] Currency rates refreshed successfully');
|
||||
|
||||
// Fetch updated status
|
||||
await fetchCurrencyStatus();
|
||||
|
||||
// Refresh expenses to get updated conversions
|
||||
await fetchExpenses();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[expenses] Error refreshing currency rates:', err);
|
||||
alert('Failed to refresh currency rates');
|
||||
} finally {
|
||||
refreshingCurrency.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// Initialize with current month
|
||||
resetToCurrentMonth();
|
||||
|
||||
// Fetch currency status
|
||||
await fetchCurrencyStatus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.btn {
|
||||
@apply inline-flex items-center gap-2;
|
||||
}
|
||||
|
||||
.stat {
|
||||
@apply p-4;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
@apply border-b border-gray-200 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user