port-nimara-client-portal/pages/dashboard/expenses.vue

587 lines
16 KiB
Vue

<template>
<div class="pa-4">
<!-- Duplicate notification banner for sales/admin -->
<ExpenseDuplicateNotificationBanner />
<!-- Header Section -->
<div class="d-flex align-center mb-6">
<div>
<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 -->
<v-card class="mb-6">
<v-card-text class="pa-6">
<v-row align="center" class="mb-0">
<v-col cols="12" sm="6" md="3">
<v-text-field
v-model="filters.startDate"
type="date"
label="Start Date"
variant="outlined"
density="comfortable"
hide-details
@change="fetchExpenses"
/>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-text-field
v-model="filters.endDate"
type="date"
label="End Date"
variant="outlined"
density="comfortable"
hide-details
@change="fetchExpenses"
/>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-select
v-model="filters.category"
:items="['', 'Food/Drinks', 'Shop', 'Online', 'Other']"
label="Category"
variant="outlined"
density="comfortable"
hide-details
clearable
@update:model-value="fetchExpenses"
/>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-btn
@click="resetToCurrentMonth"
variant="outlined"
size="large"
class="w-100"
>
Current Month
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Loading State -->
<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 -->
<v-alert
v-else-if="error"
type="error"
variant="tonal"
class="mb-6"
:text="error"
closable
@click:close="error = null"
/>
<!-- Summary Stats -->
<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>
</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.minutesSinceUpdate}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>
</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 align-center">
<span class="text-subtitle-1 font-weight-medium mr-6">Export Options:</span>
<div class="d-flex gap-4">
<v-btn
@click="exportCSV"
:disabled="selectedExpenses.length === 0"
variant="outlined"
size="default"
prepend-icon="mdi-file-excel"
class="px-6"
>
Export CSV ({{ selectedExpenses.length }})
</v-btn>
<v-btn
@click="showPDFModal = true"
:disabled="selectedExpenses.length === 0"
variant="outlined"
size="default"
prepend-icon="mdi-file-pdf"
class="px-6"
>
Generate PDF ({{ selectedExpenses.length }})
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
<!-- Person Tabs -->
<v-card v-if="groupedExpenses.length > 0">
<v-card-text>
<v-tabs
v-model="activeTab"
bg-color="transparent"
color="primary"
grow
>
<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>
<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 -->
<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
v-model="showPDFModal"
:selected-expenses="selectedExpenses"
:expenses="expenses"
@generate="generatePDF"
/>
<!-- Expense Details Modal -->
<ExpenseDetailsModal
v-model="showDetailsModal"
:expense="selectedExpense"
/>
<!-- Create Expense Modal -->
<ExpenseCreateModal
v-model="showCreateModal"
@created="handleExpenseCreated"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import type { Expense } from '@/utils/types';
// Component imports
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'
});
useHead({
title: 'Expense Tracking'
});
// 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 showCreateModal = 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;
minutesSinceUpdate?: 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;
});
// 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; totalUSD: number; count: number; uniquePayers: number; currencies: string[] };
}>(`/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 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 =>
selectedExpenses.value.includes(e.Id)
);
if (selectedExpenseData.length === 0) {
error.value = '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);
error.value = '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);
error.value = '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);
error.value = '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>
.v-card {
transition: all 0.3s ease;
}
.v-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12);
}
.v-tabs--grow .v-tab {
min-width: 120px;
}
.v-tab {
text-transform: none !important;
}
</style>