672 lines
19 KiB
Vue
672 lines
19 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="2">
|
|
<v-text-field
|
|
v-model="filters.startDate"
|
|
type="date"
|
|
label="Start Date"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
hide-details
|
|
class="date-input-fix"
|
|
/>
|
|
</v-col>
|
|
|
|
<v-col cols="12" sm="6" md="2">
|
|
<v-text-field
|
|
v-model="filters.endDate"
|
|
type="date"
|
|
label="End Date"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
hide-details
|
|
class="date-input-fix"
|
|
/>
|
|
</v-col>
|
|
|
|
<v-col cols="12" sm="6" md="2">
|
|
<v-select
|
|
v-model="filters.category"
|
|
:items="['', 'Food/Drinks', 'Shop', 'Online', 'Other']"
|
|
label="Category"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
hide-details
|
|
clearable
|
|
/>
|
|
</v-col>
|
|
|
|
<v-col cols="12" sm="6" md="2">
|
|
<v-btn
|
|
@click="fetchExpenses"
|
|
color="primary"
|
|
variant="flat"
|
|
size="large"
|
|
class="w-100"
|
|
prepend-icon="mdi-magnify"
|
|
>
|
|
Apply
|
|
</v-btn>
|
|
</v-col>
|
|
|
|
<v-col cols="12" sm="6" md="2">
|
|
<v-btn
|
|
@click="resetToCurrentMonth"
|
|
variant="outlined"
|
|
size="default"
|
|
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 ga-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"
|
|
/>
|
|
|
|
<!-- PDF Generation Loading Overlay -->
|
|
<v-overlay
|
|
:model-value="generatingPDF"
|
|
persistent
|
|
class="align-center justify-center"
|
|
>
|
|
<v-card
|
|
color="surface"
|
|
class="pa-8"
|
|
width="400"
|
|
>
|
|
<div class="text-center">
|
|
<v-progress-circular
|
|
:size="70"
|
|
:width="7"
|
|
color="primary"
|
|
indeterminate
|
|
/>
|
|
|
|
<h3 class="text-h6 mt-4 mb-2">Generating PDF...</h3>
|
|
<p class="text-body-2 text-grey-darken-1">
|
|
Your expense report is being generated with receipt images
|
|
</p>
|
|
<p class="text-caption text-grey-darken-1 mt-2">
|
|
This may take a moment for large reports
|
|
</p>
|
|
</div>
|
|
</v-card>
|
|
</v-overlay>
|
|
</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', 'authorization'],
|
|
layout: 'dashboard-unified',
|
|
roles: ['sales', 'admin']
|
|
});
|
|
|
|
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>('');
|
|
const generatingPDF = ref(false);
|
|
|
|
// 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);
|
|
|
|
// Better error messages based on status codes
|
|
if (err.statusCode === 401) {
|
|
error.value = 'Authentication required. Please refresh the page and log in again.';
|
|
} else if (err.statusCode === 403) {
|
|
error.value = 'Access denied. You need proper permissions to view expenses.';
|
|
} else if (err.statusCode === 503) {
|
|
error.value = 'Service temporarily unavailable. Please try again in a few moments.';
|
|
} else {
|
|
error.value = err.data?.message || err.message || 'Failed to fetch expenses. Please check your connection and try again.';
|
|
}
|
|
} 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) => {
|
|
generatingPDF.value = true;
|
|
showPDFModal.value = false; // Close the modal immediately
|
|
|
|
try {
|
|
console.log('[expenses] Generating PDF with options:', options);
|
|
|
|
const response = await $fetch<{
|
|
success: boolean;
|
|
data: {
|
|
filename: string;
|
|
content: string;
|
|
mimeType: string;
|
|
size: number;
|
|
};
|
|
}>('/api/expenses/generate-pdf', {
|
|
method: 'POST',
|
|
body: {
|
|
expenseIds: selectedExpenses.value,
|
|
options
|
|
}
|
|
});
|
|
|
|
if (response.success && response.data) {
|
|
// Decode base64 PDF content
|
|
const pdfContent = atob(response.data.content);
|
|
|
|
// Convert to byte array
|
|
const byteNumbers = new Array(pdfContent.length);
|
|
for (let i = 0; i < pdfContent.length; i++) {
|
|
byteNumbers[i] = pdfContent.charCodeAt(i);
|
|
}
|
|
const byteArray = new Uint8Array(byteNumbers);
|
|
|
|
// Create PDF blob and download
|
|
const blob = new Blob([byteArray], { type: 'application/pdf' });
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = response.data.filename;
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
console.log('[expenses] PDF downloaded successfully:', response.data.filename);
|
|
}
|
|
|
|
} catch (err: any) {
|
|
console.error('[expenses] Error generating PDF:', err);
|
|
error.value = err.message || 'Failed to generate PDF';
|
|
} finally {
|
|
generatingPDF.value = false;
|
|
}
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
/* Fix for date input calendar button positioning */
|
|
.date-input-fix :deep(.v-field__append-inner) {
|
|
padding-inline-start: 8px;
|
|
margin-inline-end: 4px;
|
|
}
|
|
</style>
|