Refactor expense form and add PDF generation functionality

- Update expense form fields (merchant->establishmentName, amount->price)
- Add PDF generation with Puppeteer integration
- Create PDFOptionsModal component for export options
- Update expense form validation and UI layout
- Add server API endpoint for PDF generation
This commit is contained in:
2025-07-09 22:23:50 -04:00
parent b6d71faf5f
commit 893927d4b1
6 changed files with 901 additions and 189 deletions

View File

@@ -7,7 +7,7 @@
>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-receipt-text</v-icon>
<v-icon class="mr-2">mdi-plus</v-icon>
<span>Add New Expense</span>
<v-spacer />
<v-btn
@@ -19,44 +19,35 @@
</v-card-title>
<v-card-text>
<v-form ref="form" @submit.prevent="saveExpense">
<v-form ref="form" @submit.prevent="handleSubmit">
<v-row>
<!-- Merchant/Description -->
<!-- Establishment Name -->
<v-col cols="12">
<v-text-field
v-model="expense.merchant"
label="Merchant/Description"
v-model="expense.establishmentName"
label="Establishment Name"
variant="outlined"
:rules="[rules.required]"
required
placeholder="e.g., Shell, American Airlines, etc."
/>
</v-col>
<!-- Amount and Currency -->
<v-col cols="8">
<!-- Price -->
<v-col cols="12" sm="6">
<v-text-field
v-model="expense.amount"
label="Amount"
type="number"
step="0.01"
v-model="expense.price"
label="Price"
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]"
:rules="[rules.required, rules.price]"
required
placeholder="e.g., 59.95"
prepend-inner-icon="mdi-currency-eur"
/>
</v-col>
<!-- Category -->
<v-col cols="12" md="6">
<v-col cols="12" sm="6">
<v-select
v-model="expense.category"
:items="categories"
@@ -68,60 +59,49 @@
</v-col>
<!-- Payer -->
<v-col cols="12" md="6">
<v-col cols="12" sm="6">
<v-text-field
v-model="expense.payer"
label="Payer"
variant="outlined"
:rules="[rules.required]"
required
placeholder="e.g., John, Mary, etc."
/>
</v-col>
<!-- Payment Method -->
<v-col cols="12" sm="6">
<v-select
v-model="expense.paymentMethod"
:items="paymentMethods"
label="Payment Method"
variant="outlined"
:rules="[rules.required]"
required
/>
</v-col>
<!-- Date -->
<v-col cols="12" md="6">
<v-col cols="12">
<v-text-field
v-model="expense.date"
label="Date"
type="date"
label="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 -->
<!-- Contents/Description -->
<v-col cols="12">
<v-textarea
v-model="expense.notes"
label="Notes (Optional)"
v-model="expense.contents"
label="Description (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
placeholder="Additional details about the expense..."
/>
</v-col>
</v-row>
@@ -133,17 +113,19 @@
<v-btn
@click="closeModal"
variant="text"
:disabled="saving"
:disabled="creating"
>
Cancel
</v-btn>
<v-btn
@click="saveExpense"
@click="handleSubmit"
:disabled="creating"
color="primary"
:loading="saving"
:disabled="!isValid"
:loading="creating"
>
Add Expense
<v-icon v-if="!creating" class="mr-1">mdi-plus</v-icon>
Create Expense
</v-btn>
</v-card-actions>
</v-card>
@@ -151,82 +133,81 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import type { Expense } from '@/utils/types';
import { ref, computed, watch } from 'vue';
// Props
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>();
// Emits
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'created': [expense: any];
}>();
// Computed dialog model
const dialog = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
// Reactive state
const form = ref();
const saving = ref(false);
const creating = ref(false);
// Form data
const expense = ref({
merchant: '',
amount: '',
currency: 'EUR',
establishmentName: '',
price: '',
category: '',
payer: '',
paymentMethod: '',
date: '',
time: '',
notes: '',
receipt: null as File[] | null
contents: ''
});
// Form options
const currencies = ['EUR', 'USD', 'GBP', 'AUD', 'CAD', 'CHF', 'SEK', 'NOK', 'DKK'];
const categories = ['Food/Drinks', 'Shop', 'Online', 'Transportation', 'Accommodation', 'Entertainment', 'Other'];
const categories = [
'Food/Drinks',
'Shop',
'Online',
'Other'
];
const paymentMethods = [
'Card',
'Cash',
'N/A'
];
// Validation rules
const rules = {
required: (value: any) => !!value || 'This field is required',
positive: (value: any) => {
required: (value: string) => !!value || 'This field is required',
price: (value: string) => {
if (!value) return 'Price is required';
const num = parseFloat(value);
return (!isNaN(num) && num > 0) || 'Amount must be positive';
if (isNaN(num) || num <= 0) return 'Please enter a valid price';
return true;
}
};
// 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 closeModal = () => {
dialog.value = false;
resetForm();
};
const resetForm = () => {
const now = new Date();
expense.value = {
merchant: '',
amount: '',
currency: 'EUR',
establishmentName: '',
price: '',
category: '',
payer: '',
date: now.toISOString().slice(0, 10),
time: now.toTimeString().slice(0, 5),
notes: '',
receipt: null
paymentMethod: '',
date: '',
contents: ''
};
if (form.value) {
@@ -234,89 +215,56 @@ const resetForm = () => {
}
};
const closeModal = () => {
if (!saving.value) {
dialog.value = false;
}
};
const saveExpense = async () => {
const handleSubmit = async () => {
if (!form.value) return;
const { valid } = await form.value.validate();
if (!valid) return;
saving.value = true;
creating.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', {
// Create expense via API
const response = await $fetch<{
success: boolean;
data?: any;
message?: string;
}>('/api/create-expense', {
method: 'POST',
body: expenseData
body: {
'Establishment Name': expense.value.establishmentName,
'Price': expense.value.price,
'Category': expense.value.category,
'Payer': expense.value.payer,
'Payment Method': expense.value.paymentMethod,
'Time': expense.value.date,
'Contents': expense.value.contents
}
});
console.log('[ExpenseCreateModal] Expense created successfully:', response);
// Emit the created event
emit('created', response);
// Close the modal
dialog.value = false;
if (response.success) {
emit('created', response.data);
closeModal();
}
} 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.');
// Handle error display here if needed
} finally {
saving.value = false;
creating.value = false;
}
};
// Watch for modal open/close
watch(dialog, (newValue) => {
if (newValue) {
// Reset form when modal opens
nextTick(() => {
resetForm();
});
// Watch for modal open to set default date
watch(dialog, (isOpen) => {
if (isOpen && !expense.value.date) {
expense.value.date = new Date().toISOString().slice(0, 10);
}
});
// 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);
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
</style>

View File

@@ -103,6 +103,19 @@
</template>
</v-checkbox>
<v-checkbox
v-model="options.includeReceiptContents"
color="primary"
hide-details
>
<template #label>
<div>
<div class="font-weight-medium">Include Receipt Contents</div>
<div class="text-caption text-grey-darken-1">Show receipt description/contents in detail table</div>
</div>
</template>
</v-checkbox>
<v-checkbox
v-model="options.includeProcessingFee"
color="primary"
@@ -204,6 +217,7 @@ interface PDFOptions {
subheader: string;
groupBy: 'none' | 'payer' | 'category' | 'date';
includeReceipts: boolean;
includeReceiptContents: boolean;
includeSummary: boolean;
includeDetails: boolean;
includeProcessingFee: boolean;
@@ -225,6 +239,7 @@ const options = ref<PDFOptions>({
subheader: '',
groupBy: 'payer',
includeReceipts: true,
includeReceiptContents: true,
includeSummary: true,
includeDetails: true,
includeProcessingFee: true,