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:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user