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:
parent
ac7176ff17
commit
7ba8c98663
|
|
@ -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>
|
||||
|
|
@ -1,51 +1,59 @@
|
|||
<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"
|
||||
<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"
|
||||
@change="toggleSelectAll"
|
||||
class="checkbox checkbox-primary"
|
||||
@update:model-value="toggleSelectAll"
|
||||
hide-details
|
||||
density="compact"
|
||||
class="mr-2"
|
||||
/>
|
||||
<span class="text-sm font-medium">
|
||||
<span class="text-body-2 font-weight-medium">
|
||||
{{ selectedExpenses.length > 0 ? `${selectedExpenses.length} selected` : 'Select all' }}
|
||||
</span>
|
||||
</label>
|
||||
</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"
|
||||
cols="12"
|
||||
sm="6"
|
||||
lg="4"
|
||||
xl="3"
|
||||
>
|
||||
<v-card
|
||||
:class="{ 'selected-card': isSelected(expense.Id) }"
|
||||
class="expense-card"
|
||||
:class="{ 'selected': isSelected(expense.Id) }"
|
||||
elevation="2"
|
||||
@click="selectExpense(expense.Id)"
|
||||
>
|
||||
<!-- 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 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">
|
||||
<!-- Receipt Image Section -->
|
||||
<div class="receipt-section">
|
||||
<div
|
||||
v-if="expense.Receipt && expense.Receipt.length > 0"
|
||||
class="receipt-image-wrapper"
|
||||
class="receipt-image-container"
|
||||
@click.stop="$emit('expense-clicked', expense)"
|
||||
>
|
||||
<LazyReceiptImage
|
||||
|
|
@ -55,94 +63,125 @@
|
|||
/>
|
||||
|
||||
<!-- Multiple receipts indicator -->
|
||||
<div v-if="expense.Receipt.length > 1" class="receipt-count-badge">
|
||||
<v-chip
|
||||
v-if="expense.Receipt.length > 1"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
class="receipt-count-chip"
|
||||
>
|
||||
+{{ expense.Receipt.length - 1 }}
|
||||
</div>
|
||||
</v-chip>
|
||||
</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
|
||||
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 -->
|
||||
<div class="expense-details" @click.stop="$emit('expense-clicked', expense)">
|
||||
<div class="establishment-name">
|
||||
{{ expense['Establishment Name'] || 'Unknown' }}
|
||||
<v-card-text @click.stop="$emit('expense-clicked', expense)">
|
||||
<!-- Merchant Name -->
|
||||
<div class="merchant-name">
|
||||
{{ expense['Establishment Name'] || 'Unknown Merchant' }}
|
||||
</div>
|
||||
|
||||
<div class="price-amount">
|
||||
{{ expense.DisplayPrice || expense.Price }}
|
||||
<!-- 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>
|
||||
|
||||
<div class="expense-meta">
|
||||
<div class="category-badge" :class="`category-${expense.Category?.toLowerCase()}`">
|
||||
{{ expense.Category }}
|
||||
</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="payment-method">
|
||||
<Icon
|
||||
:name="expense['Payment Method'] === 'Card' ? 'mdi:credit-card' : 'mdi:cash'"
|
||||
class="w-4 h-4"
|
||||
<div class="d-flex align-center">
|
||||
<v-icon
|
||||
:icon="expense['Payment Method'] === 'Card' ? 'mdi-credit-card' : 'mdi-cash'"
|
||||
size="small"
|
||||
class="mr-1"
|
||||
/>
|
||||
{{ expense['Payment Method'] }}
|
||||
<span class="text-caption">{{ expense['Payment Method'] || 'Unknown' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="date-time">
|
||||
<!-- 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">
|
||||
{{ expense.Contents.length > 50 ? expense.Contents.substring(0, 50) + '...' : expense.Contents }}
|
||||
</div>
|
||||
{{ getContentsPreview(expense) }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="expense-actions">
|
||||
<button
|
||||
<div class="card-actions">
|
||||
<v-btn
|
||||
@click.stop="$emit('expense-clicked', expense)"
|
||||
class="action-btn"
|
||||
title="View Details"
|
||||
>
|
||||
<Icon name="mdi:eye" class="w-4 h-4" />
|
||||
</button>
|
||||
icon="mdi-eye"
|
||||
size="small"
|
||||
variant="elevated"
|
||||
color="white"
|
||||
class="action-button"
|
||||
/>
|
||||
|
||||
<button
|
||||
<v-btn
|
||||
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>
|
||||
icon="mdi-image"
|
||||
size="small"
|
||||
variant="elevated"
|
||||
color="white"
|
||||
class="action-button"
|
||||
/>
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,180 +1,154 @@
|
|||
<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>
|
||||
<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>
|
||||
|
||||
<!-- 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"
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="showCreateModal = true"
|
||||
size="large"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
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-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>
|
||||
: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
|
||||
<v-btn
|
||||
@click="resetToCurrentMonth"
|
||||
class="btn btn-ghost btn-sm"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
Current Month
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
</v-btn>
|
||||
</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" />
|
||||
<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-sm font-medium text-gray-900 dark:text-white">
|
||||
Exchange Rates
|
||||
<div class="text-h6">{{ summary.count }}</div>
|
||||
<div class="text-caption text-grey-darken-1">Total Expenses</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
</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.minutesUntilExpiry}min ago • ${currencyStatus.ratesCount} rates` :
|
||||
'Not cached'
|
||||
|
|
@ -183,63 +157,105 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<v-btn
|
||||
@click="refreshCurrency"
|
||||
:disabled="refreshingCurrency"
|
||||
class="btn btn-sm btn-outline"
|
||||
:loading="refreshingCurrency"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
prepend-icon="mdi-refresh"
|
||||
>
|
||||
<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>
|
||||
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 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-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"
|
||||
@click="activeTab = group.name"
|
||||
:class="[
|
||||
'tab tab-lg',
|
||||
activeTab === group.name ? 'tab-active' : ''
|
||||
]"
|
||||
:value="group.name"
|
||||
>
|
||||
{{ group.name }}
|
||||
<div class="badge badge-primary badge-sm ml-2">
|
||||
{{ group.count }}
|
||||
<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 class="badge badge-secondary badge-sm ml-1">
|
||||
€{{ group.total.toFixed(2) }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="p-4">
|
||||
<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
|
||||
v-if="activeTabExpenses"
|
||||
:expenses="activeTabExpenses"
|
||||
:expenses="group.expenses"
|
||||
:selected-expenses="selectedExpenses"
|
||||
@update:selected="updateSelected"
|
||||
@expense-clicked="showExpenseModal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { requireSalesOrAdmin } from '@/server/utils/auth';
|
||||
import { getNocoDbConfiguration } from '@/server/utils/nocodb';
|
||||
import type { Expense } from '@/utils/types';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// Ensure only sales/admin users can create expenses
|
||||
await requireSalesOrAdmin(event);
|
||||
|
||||
const body = await readBody(event);
|
||||
console.log('[create-expense] Creating expense with data:', body);
|
||||
|
||||
// Validate required fields
|
||||
const requiredFields = ['Establishment Name', 'Price', 'Category', 'Payer', 'Time'];
|
||||
for (const field of requiredFields) {
|
||||
if (!body[field]) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `Missing required field: ${field}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get NocoDB configuration
|
||||
const config = getNocoDbConfiguration();
|
||||
const expenseTableId = "mxfcefkk4dqs6uq"; // Expense table ID from nocodb.ts
|
||||
|
||||
// Prepare expense data for NocoDB
|
||||
const expenseData = {
|
||||
"Establishment Name": body["Establishment Name"],
|
||||
Price: body.Price,
|
||||
Category: body.Category,
|
||||
Payer: body.Payer,
|
||||
Time: body.Time,
|
||||
Contents: body.Contents || null,
|
||||
"Payment Method": body["Payment Method"] || "Card",
|
||||
currency: body.currency || "EUR",
|
||||
Paid: body.Paid || false
|
||||
};
|
||||
|
||||
console.log('[create-expense] Sending to NocoDB:', expenseData);
|
||||
|
||||
// Create the expense in NocoDB
|
||||
const response = await $fetch<Expense>(`${config.url}/api/v2/tables/${expenseTableId}/records`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'xc-token': config.token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: expenseData
|
||||
});
|
||||
|
||||
console.log('[create-expense] Expense created successfully:', response.Id);
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[create-expense] Failed to create expense:', error);
|
||||
|
||||
if (error.statusCode === 403) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Access denied. This feature requires sales team or administrator privileges.'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.statusCode === 400) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: error.statusMessage
|
||||
});
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to create expense. Please try again later.'
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue