mitlist/fe/src/components/CreateExpenseForm.vue
mohamad a0d67f6c66 feat: Add comprehensive notes and tasks for project stabilization and enhancements
- Introduced a new `notes.md` file to document critical tasks and progress for stabilizing the core functionality of the MitList application.
- Documented the status and findings for key tasks, including backend financial logic fixes, frontend expense split settlement implementation, and core authentication flow reviews.
- Outlined remaining work for production deployment, including secret management, CI/CD pipeline setup, and performance optimizations.
- Updated the logging configuration to change the log level to WARNING for production readiness.
- Enhanced the database connection settings to disable SQL query logging in production.
- Added a new endpoint to list all chores for improved user experience and optimized database queries.
- Implemented various CRUD operations for chore assignments, including creation, retrieval, updating, and deletion.
- Updated frontend components and services to support new chore assignment features and improved error handling.
- Enhanced the expense management system with new fields and improved API interactions for better user experience.
2025-05-24 21:36:57 +02:00

678 lines
20 KiB
Vue

<template>
<div class="modal-backdrop open" @click.self="closeForm">
<div class="modal-container" ref="formModalRef" style="min-width: 550px;">
<div class="modal-header">
<h3>Create New Expense</h3>
<button class="close-button" @click="closeForm" aria-label="Close">
<svg class="icon">
<use xlink:href="#icon-close" />
</svg>
</button>
</div>
<div class="modal-body">
<form @submit.prevent="handleSubmit" class="expense-form">
<div class="form-group">
<label for="description" class="form-label">Description</label>
<input type="text" id="description" v-model="formData.description" class="form-input" required
placeholder="What was this expense for?" />
</div>
<div class="form-group">
<label for="totalAmount" class="form-label">Total Amount</label>
<div class="amount-input-group">
<span class="currency-symbol">$</span>
<input type="number" id="totalAmount" v-model.number="formData.total_amount" class="form-input" required
min="0.01" step="0.01" placeholder="0.00" />
</div>
</div>
<div class="form-group">
<label for="paidBy" class="form-label">Paid By</label>
<select id="paidBy" v-model="formData.paid_by_user_id" class="form-input" required>
<option value="" disabled>Select who paid</option>
<option v-for="user in availableUsers" :key="user.id" :value="user.id">
{{ user.name || user.email }}
</option>
</select>
</div>
<div class="form-group">
<label for="splitType" class="form-label">Split Type</label>
<select id="splitType" v-model="formData.split_type" class="form-input" required
@change="onSplitTypeChange">
<option value="EQUAL">Equal Split</option>
<option value="EXACT_AMOUNTS">Exact Amounts</option>
<option value="PERCENTAGE">Percentage</option>
<option value="SHARES">Shares</option>
<option value="ITEM_BASED">Item Based</option>
</select>
</div>
<div class="form-group">
<label for="expenseDate" class="form-label">Date</label>
<input type="date" id="expenseDate" v-model="formData.expense_date" class="form-input" :max="today" />
</div>
<!-- Split Details Section -->
<div v-if="showSplitConfiguration" class="form-group">
<label class="form-label">{{ splitConfigurationLabel }}</label>
<!-- EXACT_AMOUNTS -->
<div v-if="formData.split_type === 'EXACT_AMOUNTS'" class="splits-container">
<div v-for="(split, index) in formData.splits_in" :key="index" class="split-item">
<select v-model="split.user_id" class="form-input split-user-select" required>
<option value="" disabled>Select user</option>
<option v-for="user in availableUsers" :key="user.id" :value="user.id">
{{ user.name || user.email }}
</option>
</select>
<input type="number" v-model.number="split.owed_amount" class="form-input" min="0.01" step="0.01"
placeholder="Amount" required />
<button type="button" class="btn btn-danger btn-sm" @click="removeSplit(index)"
:disabled="formData.splits_in!.length <= 1">
<svg class="icon icon-sm">
<use xlink:href="#icon-trash" />
</svg>
</button>
</div>
<div class="split-summary">
<span>Total: ${{ exactAmountTotal.toFixed(2) }}</span>
<span v-if="exactAmountTotal !== formData.total_amount" class="validation-error">
(Should equal ${{ formData.total_amount.toFixed(2) }})
</span>
</div>
<button type="button" class="btn btn-secondary btn-sm" @click="addSplit">
Add Split
</button>
</div>
<!-- PERCENTAGE -->
<div v-else-if="formData.split_type === 'PERCENTAGE'" class="splits-container">
<div v-for="(split, index) in formData.splits_in" :key="index" class="split-item">
<select v-model="split.user_id" class="form-input split-user-select" required>
<option value="" disabled>Select user</option>
<option v-for="user in availableUsers" :key="user.id" :value="user.id">
{{ user.name || user.email }}
</option>
</select>
<div class="percentage-input-group">
<input type="number" v-model.number="split.share_percentage" class="form-input" min="0.01" max="100"
step="0.01" placeholder="Percentage" required />
<span class="percentage-symbol">%</span>
</div>
<span class="split-amount-preview">
${{ calculatePercentageAmount(split.share_percentage).toFixed(2) }}
</span>
<button type="button" class="btn btn-danger btn-sm" @click="removeSplit(index)"
:disabled="formData.splits_in!.length <= 1">
<svg class="icon icon-sm">
<use xlink:href="#icon-trash" />
</svg>
</button>
</div>
<div class="split-summary">
<span>Total: {{ percentageTotal.toFixed(2) }}%</span>
<span v-if="Math.abs(percentageTotal - 100) > 0.01" class="validation-error">
(Should equal 100%)
</span>
</div>
<button type="button" class="btn btn-secondary btn-sm" @click="addSplit">
Add Split
</button>
</div>
<!-- SHARES -->
<div v-else-if="formData.split_type === 'SHARES'" class="splits-container">
<div v-for="(split, index) in formData.splits_in" :key="index" class="split-item">
<select v-model="split.user_id" class="form-input split-user-select" required>
<option value="" disabled>Select user</option>
<option v-for="user in availableUsers" :key="user.id" :value="user.id">
{{ user.name || user.email }}
</option>
</select>
<input type="number" v-model.number="split.share_units" class="form-input" min="1" step="1"
placeholder="Shares" required />
<span class="split-amount-preview">
${{ calculateShareAmount(split.share_units).toFixed(2) }}
</span>
<button type="button" class="btn btn-danger btn-sm" @click="removeSplit(index)"
:disabled="formData.splits_in!.length <= 1">
<svg class="icon icon-sm">
<use xlink:href="#icon-trash" />
</svg>
</button>
</div>
<div class="split-summary">
<span>Total shares: {{ sharesTotal }}</span>
</div>
<button type="button" class="btn btn-secondary btn-sm" @click="addSplit">
Add Split
</button>
</div>
<!-- ITEM_BASED -->
<div v-else-if="formData.split_type === 'ITEM_BASED'" class="item-based-info">
<div class="info-box">
<svg class="icon">
<use xlink:href="#icon-info" />
</svg>
<div>
<p><strong>Item-Based Split</strong></p>
<p>The expense will be automatically split based on item prices and who added each item to the list.
</p>
<p v-if="props.itemId">Split will be based on the selected item only.</p>
<p v-else>Split will be based on all priced items in the list.</p>
</div>
</div>
</div>
</div>
<!-- Validation Messages -->
<div v-if="validationErrors.length > 0" class="validation-messages">
<div v-for="error in validationErrors" :key="error" class="validation-error">
{{ error }}
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-neutral" @click="closeForm">Cancel</button>
<button type="submit" class="btn btn-primary ml-2" :disabled="isSubmitting || !isFormValid">
<span v-if="isSubmitting" class="spinner-dots-sm"><span /><span /><span /></span>
<span v-else>Create Expense</span>
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { apiClient, API_ENDPOINTS } from '@/config/api';
import { useNotificationStore } from '@/stores/notifications';
import { useAuthStore } from '@/stores/auth';
import type { ExpenseCreate, ExpenseSplitCreate } from '@/types/expense';
import type { UserPublic } from '@/types/user';
import { onClickOutside } from '@vueuse/core';
const props = defineProps<{
listId?: number;
groupId?: number;
itemId?: number;
}>();
const emit = defineEmits<{
(e: 'close'): void;
(e: 'created', expense: any): void;
}>();
const notificationStore = useNotificationStore();
const authStore = useAuthStore();
const formModalRef = ref<HTMLElement | null>(null);
const isSubmitting = ref(false);
const availableUsers = ref<UserPublic[]>([]);
const today = computed(() => {
const date = new Date();
return date.toISOString().split('T')[0];
});
const formData = ref<ExpenseCreate>({
description: '',
total_amount: 0,
currency: 'USD',
expense_date: today.value,
split_type: 'EQUAL',
list_id: props.listId,
group_id: props.groupId,
item_id: props.itemId,
paid_by_user_id: authStore.user?.id || 0,
splits_in: []
});
// Computed properties for split configuration
const showSplitConfiguration = computed(() => {
return ['EXACT_AMOUNTS', 'PERCENTAGE', 'SHARES', 'ITEM_BASED'].includes(formData.value.split_type);
});
const splitConfigurationLabel = computed(() => {
switch (formData.value.split_type) {
case 'EXACT_AMOUNTS': return 'Specify exact amounts for each person';
case 'PERCENTAGE': return 'Specify percentage for each person';
case 'SHARES': return 'Specify number of shares for each person';
case 'ITEM_BASED': return 'Item-based split configuration';
default: return 'Split configuration';
}
});
// Validation computed properties
const exactAmountTotal = computed(() => {
if (formData.value.split_type !== 'EXACT_AMOUNTS' || !formData.value.splits_in) return 0;
return formData.value.splits_in.reduce((sum: number, split: ExpenseSplitCreate) => sum + (split.owed_amount || 0), 0);
});
const percentageTotal = computed(() => {
if (formData.value.split_type !== 'PERCENTAGE' || !formData.value.splits_in) return 0;
return formData.value.splits_in.reduce((sum: number, split: ExpenseSplitCreate) => sum + (split.share_percentage || 0), 0);
});
const sharesTotal = computed(() => {
if (formData.value.split_type !== 'SHARES' || !formData.value.splits_in) return 0;
return formData.value.splits_in.reduce((sum: number, split: ExpenseSplitCreate) => sum + (split.share_units || 0), 0);
});
const validationErrors = computed(() => {
const errors: string[] = [];
if (!formData.value.description) errors.push('Description is required');
if (!formData.value.total_amount || formData.value.total_amount <= 0) errors.push('Total amount must be greater than 0');
if (!formData.value.paid_by_user_id) errors.push('Please select who paid for this expense');
if (formData.value.split_type === 'EXACT_AMOUNTS' && formData.value.splits_in) {
if (Math.abs(exactAmountTotal.value - formData.value.total_amount) > 0.01) {
errors.push('Split amounts must equal the total expense amount');
}
if (formData.value.splits_in.some((s: ExpenseSplitCreate) => !s.user_id)) {
errors.push('Please select a user for each split');
}
}
if (formData.value.split_type === 'PERCENTAGE' && formData.value.splits_in) {
if (Math.abs(percentageTotal.value - 100) > 0.01) {
errors.push('Percentages must total 100%');
}
if (formData.value.splits_in.some((s: ExpenseSplitCreate) => !s.user_id)) {
errors.push('Please select a user for each split');
}
}
if (formData.value.split_type === 'SHARES' && formData.value.splits_in) {
if (sharesTotal.value === 0) {
errors.push('Total shares must be greater than 0');
}
if (formData.value.splits_in.some((s: ExpenseSplitCreate) => !s.user_id)) {
errors.push('Please select a user for each split');
}
}
return errors;
});
const isFormValid = computed(() => {
return validationErrors.value.length === 0;
});
// Helper functions
const calculatePercentageAmount = (percentage: number | undefined) => {
if (!percentage) return 0;
return (formData.value.total_amount * percentage) / 100;
};
const calculateShareAmount = (shares: number | undefined) => {
if (!shares || sharesTotal.value === 0) return 0;
return (formData.value.total_amount * shares) / sharesTotal.value;
};
const initializeSplits = () => {
if (formData.value.split_type === 'EQUAL' || formData.value.split_type === 'ITEM_BASED') {
formData.value.splits_in = undefined;
} else {
formData.value.splits_in = [createEmptySplit()];
}
};
const createEmptySplit = (): ExpenseSplitCreate => {
const base = { user_id: 0 };
switch (formData.value.split_type) {
case 'EXACT_AMOUNTS':
return { ...base, owed_amount: 0 };
case 'PERCENTAGE':
return { ...base, share_percentage: 0 };
case 'SHARES':
return { ...base, share_units: 0 };
default:
return { ...base, owed_amount: 0 };
}
};
const addSplit = () => {
if (!formData.value.splits_in) {
formData.value.splits_in = [];
}
formData.value.splits_in.push(createEmptySplit());
};
const removeSplit = (index: number) => {
if (formData.value.splits_in && formData.value.splits_in.length > 1) {
formData.value.splits_in.splice(index, 1);
}
};
const onSplitTypeChange = () => {
initializeSplits();
};
const fetchAvailableUsers = async () => {
try {
let endpoint = '';
if (props.listId) {
// Get users from list's group or just current user for personal lists
const listResponse = await apiClient.get(`${API_ENDPOINTS.LISTS.BASE}/${props.listId}`);
if (listResponse.data.group_id) {
endpoint = `${API_ENDPOINTS.GROUPS.BASE}/${listResponse.data.group_id}/members`;
} else {
// Personal list - only current user
availableUsers.value = authStore.user ? [authStore.user] : [];
return;
}
} else if (props.groupId) {
endpoint = `${API_ENDPOINTS.GROUPS.BASE}/${props.groupId}/members`;
} else {
// Fallback to current user only
availableUsers.value = authStore.user ? [authStore.user] : [];
return;
}
const response = await apiClient.get(endpoint);
availableUsers.value = response.data;
} catch (error) {
console.error('Failed to fetch available users:', error);
availableUsers.value = authStore.user ? [authStore.user] : [];
}
};
const closeForm = () => {
emit('close');
};
const handleSubmit = async () => {
if (!isFormValid.value) {
notificationStore.addNotification({
message: 'Please fix the validation errors before submitting',
type: 'warning'
});
return;
}
isSubmitting.value = true;
try {
const response = await apiClient.post(API_ENDPOINTS.FINANCIALS.EXPENSES, formData.value);
emit('created', response.data);
closeForm();
notificationStore.addNotification({
message: 'Expense created successfully',
type: 'success'
});
} catch (err) {
notificationStore.addNotification({
message: (err instanceof Error ? err.message : String(err)) || 'Failed to create expense',
type: 'error'
});
} finally {
isSubmitting.value = false;
}
};
// Initialize
onMounted(() => {
fetchAvailableUsers();
initializeSplits();
});
// Watch for changes to ensure current user is selected as payer by default
watch(() => authStore.user, (user) => {
if (user && !formData.value.paid_by_user_id) {
formData.value.paid_by_user_id = user.id;
}
}, { immediate: true });
onClickOutside(formModalRef, closeForm);
</script>
<style scoped>
.expense-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-weight: 600;
color: #333;
}
.form-input {
padding: 0.75rem;
border: 2px solid #111;
border-radius: 8px;
font-size: 1rem;
width: 100%;
}
.amount-input-group {
position: relative;
display: flex;
align-items: center;
}
.currency-symbol {
position: absolute;
left: 1rem;
color: #666;
z-index: 1;
}
.amount-input-group .form-input {
padding-left: 2rem;
}
.percentage-input-group {
position: relative;
display: flex;
align-items: center;
}
.percentage-symbol {
position: absolute;
right: 1rem;
color: #666;
z-index: 1;
}
.percentage-input-group .form-input {
padding-right: 2rem;
}
.splits-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.split-item {
display: flex;
gap: 0.5rem;
align-items: center;
}
.split-user-select {
flex: 2;
}
.split-amount-preview {
min-width: 80px;
font-size: 0.875rem;
color: #666;
text-align: right;
}
.split-summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: #f9f9f9;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
}
.validation-error {
color: #dc2626;
font-size: 0.875rem;
}
.validation-messages {
background: #fee2e2;
border: 1px solid #f87171;
border-radius: 8px;
padding: 0.75rem;
}
.item-based-info {
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
padding: 1rem;
}
.info-box {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.info-box .icon {
color: #3b82f6;
width: 20px;
height: 20px;
flex-shrink: 0;
margin-top: 0.125rem;
}
.info-box p {
margin: 0 0 0.5rem 0;
}
.info-box p:last-child {
margin-bottom: 0;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.2s;
}
.btn-primary {
background: #111;
color: white;
border: none;
}
.btn-primary:hover:not(:disabled) {
background: #333;
}
.btn-neutral {
background: #f5f5f5;
color: #333;
border: 2px solid #ddd;
}
.btn-neutral:hover {
background: #e5e5e5;
}
.btn-danger {
background: #fee2e2;
color: #dc2626;
border: none;
}
.btn-danger:hover {
background: #fecaca;
}
.btn-secondary {
background: #e5e7eb;
color: #374151;
border: none;
}
.btn-secondary:hover {
background: #d1d5db;
}
.btn-sm {
padding: 0.5rem;
font-size: 0.875rem;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ml-2 {
margin-left: 0.5rem;
}
.spinner-dots-sm {
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
.spinner-dots-sm span {
width: 4px;
height: 4px;
background-color: white;
border-radius: 50%;
animation: dot-pulse 1.4s infinite ease-in-out both;
}
.spinner-dots-sm span:nth-child(1) {
animation-delay: -0.32s;
}
.spinner-dots-sm span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes dot-pulse {
0%,
80%,
100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
</style>