feat: Add CreateExpenseForm component and integrate into ListDetailPage

- Introduced CreateExpenseForm.vue for creating new expenses with fields for description, total amount, split type, and date.
- Integrated the CreateExpenseForm into ListDetailPage.vue, allowing users to add expenses directly from the list view.
- Enhanced UI with a modal for the expense creation form and added validation for required fields.
- Updated styles for consistency across the application.
- Implemented logic to refresh the expense list upon successful creation of a new expense.
This commit is contained in:
Mohamad.Elsena 2025-05-22 13:05:49 +02:00
parent e7b072c2bd
commit 52fc33b472
9 changed files with 1050 additions and 517 deletions

View File

@ -0,0 +1,346 @@
<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="splitType" class="form-label">Split Type</label>
<select
id="splitType"
v-model="formData.split_type"
class="form-input"
required
>
<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>
<div v-if="formData.split_type !== 'EQUAL'" class="form-group">
<label class="form-label">Split Details</label>
<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">
<input
type="number"
v-model.number="split.owed_amount"
class="form-input"
min="0.01"
step="0.01"
placeholder="Amount"
/>
<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>
<button
type="button"
class="btn btn-secondary btn-sm"
@click="addSplit"
>
Add Split
</button>
</div>
<!-- Add other split type inputs here -->
</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"
>
<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 } from 'vue';
import { apiClient, API_ENDPOINTS } from '@/config/api';
import { useNotificationStore } from '@/stores/notifications';
import type { ExpenseCreate } from '@/types/expense';
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 formModalRef = ref<HTMLElement | null>(null);
const isSubmitting = ref(false);
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: 0, // Will be set from auth store
splits_in: [{ owed_amount: 0 }]
});
const addSplit = () => {
formData.value.splits_in?.push({ owed_amount: 0 });
};
const removeSplit = (index: number) => {
if (formData.value.splits_in && formData.value.splits_in.length > 1) {
formData.value.splits_in.splice(index, 1);
}
};
const closeForm = () => {
emit('close');
};
const handleSubmit = async () => {
if (!formData.value.description || !formData.value.total_amount) {
notificationStore.addNotification({
message: 'Please fill in all required fields',
type: 'warning'
});
return;
}
isSubmitting.value = true;
try {
const response = await apiClient.post(API_ENDPOINTS.EXPENSES.CREATE, 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;
}
};
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;
}
.amount-input-group .form-input {
padding-left: 2rem;
}
.splits-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.split-item {
display: flex;
gap: 0.5rem;
align-items: center;
}
.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;
}
.btn-primary {
background: #111;
color: white;
border: none;
}
.btn-neutral {
background: #f5f5f5;
color: #333;
border: 2px solid #ddd;
}
.btn-danger {
background: #fee2e2;
color: #dc2626;
border: none;
}
.btn-secondary {
background: #e5e7eb;
color: #374151;
border: none;
}
.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>

View File

@ -1,53 +1,47 @@
<template> <template>
<div v-if="show" class="modal-backdrop-settle" @click.self="onCancel"> <div v-if="show" class="modal-backdrop open" @click.self="$emit('cancel')">
<div class="modal-container-settle"> <div class="modal-container" ref="modalRef" style="min-width: 550px;">
<div class="modal-header-settle"> <div class="modal-header">
<h3>Settle Your Share</h3> <h3>Settle Share</h3>
<button class="close-button-settle" @click="onCancel" aria-label="Close">&times;</button> <button class="close-button" @click="$emit('cancel')" aria-label="Close">
<svg class="icon">
<use xlink:href="#icon-close" />
</svg>
</button>
</div> </div>
<div class="modal-body-settle" v-if="split"> <div class="modal-body">
<p>You are about to settle your share for this expense.</p> <div v-if="isLoading" class="text-center">
<div class="info-item"> <div class="spinner-dots"><span /><span /><span /></div>
<span>Owed by:</span> <p>Processing settlement...</p>
<strong>{{ split.user?.name || split.user?.email || `User ID ${split.user_id}` }}</strong>
</div> </div>
<div class="info-item"> <div v-else>
<span>Original Share:</span> <p>Settle amount for {{ split?.user?.name || split?.user?.email || `User ID: ${split?.user_id}` }}:</p>
<strong>{{ formatCurrency(split.owed_amount) }}</strong> <div class="form-group">
<label for="settleAmount" class="form-label">Amount</label>
<input
type="number"
v-model="amount"
class="form-input"
id="settleAmount"
required
:disabled="isLoading"
step="0.01"
min="0"
/>
<p v-if="error" class="form-error-text">{{ error }}</p>
</div>
</div> </div>
<div class="info-item">
<span>Already Paid:</span>
<strong>{{ formatCurrency(paidAmount) }}</strong>
</div>
<hr class="my-3-settle" />
<div class="info-item">
<span>Amount to Settle Now:</span>
<strong class="amount-to-settle">{{ formatCurrency(remainingAmount) }}</strong>
</div>
<!-- For MVP, amount is fixed to remaining. Input field removed. -->
<!--
<div class="form-group-settle">
<label for="amountToSettle" class="form-label-settle">Amount to Settle:</label>
<input
type="number"
id="amountToSettle"
class="form-input-settle"
v-model="amountToSettleInput"
step="0.01"
:readonly="true" // For MVP, fixed to remaining amount
/>
</div>
-->
</div> </div>
<div class="modal-footer-settle"> <div class="modal-footer">
<button type="button" class="btn-neutral-settle" @click="onCancel" :disabled="isLoading">Cancel</button> <button type="button" class="btn btn-neutral" @click="$emit('cancel')" :disabled="isLoading">Cancel</button>
<button <button
type="button" type="button"
class="btn-primary-settle ml-2-settle" class="btn btn-primary ml-2"
@click="onConfirm" @click="handleConfirm"
:disabled="isLoading || remainingAmount <= 0"> :disabled="isLoading || !isValid"
<span v-if="isLoading" class="spinner-dots-sm-settle"><span /><span /><span /></span> >
<span v-else>Confirm Payment</span> <span v-if="isLoading" class="spinner-dots-sm"><span /><span /><span /></span>
<span v-else>Confirm</span>
</button> </button>
</div> </div>
</div> </div>
@ -55,90 +49,66 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, PropType } from 'vue'; import { ref, computed, watch } from 'vue'
import { Decimal } from 'decimal.js'; // For precise arithmetic import { onClickOutside } from '@vueuse/core'
import type { ExpenseSplit } from '@/types/expense'
import { Decimal } from 'decimal.js'
// Define interfaces for props inline for clarity in this component const props = defineProps<{
interface UserInfo { show: boolean
id: number; split: ExpenseSplit | null
name?: string | null; paidAmount: number
email: string; isLoading: boolean
}>()
const emit = defineEmits<{
(e: 'confirm', amount: number): void
(e: 'cancel'): void
}>()
const modalRef = ref<HTMLElement | null>(null)
const amount = ref<string>('')
const error = ref<string | null>(null)
// Close modal when clicking outside
onClickOutside(modalRef, () => {
emit('cancel')
})
// Reset form when modal opens
watch(() => props.show, (newVal) => {
if (newVal && props.split) {
const alreadyPaid = new Decimal(props.paidAmount)
const owed = new Decimal(props.split.owed_amount)
const remaining = owed.minus(alreadyPaid)
amount.value = remaining.toFixed(2)
error.value = null
}
})
const isValid = computed(() => {
if (!amount.value.trim()) return false
const numAmount = new Decimal(amount.value)
if (numAmount.isNaN() || numAmount.isNegative() || numAmount.isZero()) return false
if (props.split) {
const alreadyPaid = new Decimal(props.paidAmount)
const owed = new Decimal(props.split.owed_amount)
const remaining = owed.minus(alreadyPaid)
return numAmount.lessThanOrEqualTo(remaining.plus(new Decimal('0.001'))) // Epsilon for float issues
}
return false
})
const handleConfirm = () => {
if (!isValid.value) return
const numAmount = parseFloat(amount.value)
emit('confirm', numAmount)
} }
export interface ExpenseSplitInfo { // Exporting to be potentially used by parent if needed
id: number;
user_id: number;
owed_amount: string; // Expect string from backend for Decimal types
user?: UserInfo | null;
// Add other necessary fields from your actual ExpenseSplit type
// e.g. status, settlement_activities if they affect logic here (not for MVP)
}
const props = defineProps({
show: {
type: Boolean,
required: true,
},
split: {
type: Object as PropType<ExpenseSplitInfo | null>,
required: true,
},
paidAmount: { // Amount already paid towards this split
type: Number,
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['confirm', 'cancel']);
const remainingAmount = computed(() => {
if (!props.split) return 0;
try {
const owed = new Decimal(props.split.owed_amount);
const paid = new Decimal(props.paidAmount);
const remaining = owed.minus(paid);
return remaining.greaterThan(0) ? remaining.toNumber() : 0;
} catch (e) {
console.error("Error calculating remaining amount:", e);
return 0; // Fallback in case of invalid decimal string
}
});
// For MVP, amountToSettle is always the full remainingAmount
const amountToSettle = computed(() => remainingAmount.value);
const onConfirm = () => {
if (remainingAmount.value > 0) {
emit('confirm', amountToSettle.value); // Emit the number value
}
};
const onCancel = () => {
emit('cancel');
};
// Helper to format currency (can be moved to a utility file)
const formatCurrency = (value: string | number | undefined | null): string => {
if (value === undefined || value === null) return '$0.00';
let numValue: number;
if (typeof value === 'string') {
if (!value.trim()) return '$0.00';
numValue = parseFloat(value);
} else {
numValue = value;
}
return isNaN(numValue) ? '$0.00' : `$${numValue.toFixed(2)}`;
};
</script> </script>
<style scoped> <style scoped>
.modal-backdrop-settle { .modal-backdrop {
background-color: rgba(0, 0, 0, 0.6); /* Darker overlay */ background-color: rgba(0, 0, 0, 0.5);
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
@ -147,144 +117,149 @@ const formatCurrency = (value: string | number | undefined | null): string => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1050; /* Ensure it's above other elements */ z-index: 1000;
} }
.modal-container-settle { .modal-container {
background: white; background: white;
border-radius: 12px; /* Softer radius */ border-radius: 18px;
border: 2px solid #333; /* Slightly softer border */ border: 3px solid #111;
box-shadow: 0 8px 20px rgba(0,0,0,0.25); /* Softer shadow */ box-shadow: 6px 6px 0 #111;
width: 90%; width: 90%;
max-width: 450px; /* Optimal width for a simple modal */ max-width: 500px;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
display: flex; /* For footer alignment */ padding: 0;
flex-direction: column;
} }
.modal-header-settle { .modal-header {
padding: 1rem 1.5rem; padding: 1.5rem;
border-bottom: 1px solid #e0e0e0; /* Lighter border */ border-bottom: 1px solid #eee;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.modal-header-settle h3 { .modal-body {
margin: 0; padding: 1.5rem;
font-size: 1.25rem;
font-weight: 700;
color: #111;
} }
.close-button-settle { .modal-footer {
padding: 1.5rem;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.close-button {
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
font-size: 1.5rem; color: #666;
line-height: 1;
color: #555;
padding: 0.5rem; /* Easier to click */
}
.close-button-settle:hover {
color: #111;
} }
.modal-body-settle { .form-group {
padding: 1.5rem; margin-bottom: 1rem;
line-height: 1.6;
} }
.modal-body-settle p { .form-label {
margin-bottom: 0.75rem; display: block;
}
.info-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-size: 0.95rem; font-weight: 500;
}
.info-item span {
color: #555;
}
.info-item strong {
color: #111;
font-weight: 600;
}
.amount-to-settle {
font-size: 1.1rem;
color: var(--primary-color, #3498db) !important; /* Use theme color */
} }
.my-3-settle { .form-input {
border: 0; width: 100%;
border-top: 1px solid #e0e0e0; padding: 0.75rem;
margin: 1rem 0; border: 2px solid #111;
}
.modal-footer-settle {
padding: 1rem 1.5rem;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
gap: 0.75rem; /* Consistent gap */
background-color: #f9f9f9; /* Slight distinction for footer */
border-bottom-left-radius: 12px; /* Match container radius */
border-bottom-right-radius: 12px;
}
/* Generic button styles - assuming similar to existing .btn but scoped with -settle */
.btn-neutral-settle, .btn-primary-settle {
padding: 0.6rem 1.2rem;
border-radius: 8px; border-radius: 8px;
font-weight: 700; font-size: 1rem;
}
.form-error-text {
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.5rem;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer; cursor: pointer;
border: 2px solid #111; /* Neo-brutalist touch */ border: none;
box-shadow: 2px 2px 0 #111; /* Neo-brutalist touch */
transition: transform 0.1s ease, box-shadow 0.1s ease;
} }
.btn-neutral-settle:hover, .btn-primary-settle:hover {
transform: translate(-1px, -1px); .btn:disabled {
box-shadow: 3px 3px 0 #111; opacity: 0.5;
}
.btn-neutral-settle:disabled, .btn-primary-settle:disabled {
opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
.btn-neutral-settle { .btn-neutral {
background-color: #f0f0f0; background: #f3f4f6;
color: #111; color: #111;
} }
.btn-primary-settle { .btn-primary {
background-color: var(--primary-color, #3498db); background: #111;
color: white; color: white;
border-color: #111; /* Ensure border matches */
} }
.ml-2-settle {
.ml-2 {
margin-left: 0.5rem; margin-left: 0.5rem;
} }
.spinner-dots-sm-settle { /* For loading button */ .text-center {
text-align: center;
}
.spinner-dots {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
margin: 0 auto;
}
.spinner-dots span {
width: 8px;
height: 8px;
background-color: #555;
border-radius: 50%;
animation: dot-pulse 1.4s infinite ease-in-out both;
}
.spinner-dots-sm {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.2rem; gap: 0.2rem;
} }
.spinner-dots-sm-settle span {
.spinner-dots-sm span {
width: 4px; width: 4px;
height: 4px; height: 4px;
background-color: white; /* Assuming primary button has light text */ background-color: white;
border-radius: 50%; border-radius: 50%;
animation: dot-pulse-settle 1.4s infinite ease-in-out both; animation: dot-pulse 1.4s infinite ease-in-out both;
} }
.spinner-dots-sm-settle span:nth-child(1) { animation-delay: -0.32s; }
.spinner-dots-sm-settle span:nth-child(2) { animation-delay: -0.16s; }
@keyframes dot-pulse-settle { .spinner-dots span:nth-child(1),
0%, 80%, 100% { transform: scale(0); } .spinner-dots-sm span:nth-child(1) {
40% { transform: scale(1); } animation-delay: -0.32s;
}
.spinner-dots span:nth-child(2),
.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> </style>

View File

@ -1,119 +1,119 @@
// API Version // API Version
export const API_VERSION = 'v1'; export const API_VERSION = 'v1'
// API Base URL // API Base URL
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
// API Endpoints // API Endpoints
export const API_ENDPOINTS = { export const API_ENDPOINTS = {
// Auth // Auth
AUTH: { AUTH: {
LOGIN: '/auth/jwt/login', LOGIN: '/auth/jwt/login',
SIGNUP: '/auth/register', SIGNUP: '/auth/register',
LOGOUT: '/auth/jwt/logout', LOGOUT: '/auth/jwt/logout',
VERIFY_EMAIL: '/auth/verify', VERIFY_EMAIL: '/auth/verify',
RESET_PASSWORD: '/auth/forgot-password', RESET_PASSWORD: '/auth/forgot-password',
FORGOT_PASSWORD: '/auth/forgot-password', FORGOT_PASSWORD: '/auth/forgot-password',
}, },
// Users // Users
USERS: { USERS: {
PROFILE: '/users/me', PROFILE: '/users/me',
UPDATE_PROFILE: '/api/v1/users/me', UPDATE_PROFILE: '/users/me',
PASSWORD: '/api/v1/users/password', PASSWORD: '/api/v1/users/password',
AVATAR: '/api/v1/users/avatar', AVATAR: '/api/v1/users/avatar',
SETTINGS: '/api/v1/users/settings', SETTINGS: '/api/v1/users/settings',
NOTIFICATIONS: '/api/v1/users/notifications', NOTIFICATIONS: '/api/v1/users/notifications',
PREFERENCES: '/api/v1/users/preferences', PREFERENCES: '/api/v1/users/preferences',
}, },
// Lists // Lists
LISTS: { LISTS: {
BASE: '/lists', BASE: '/lists',
BY_ID: (id: string) => `/lists/${id}`, BY_ID: (id: string) => `/lists/${id}`,
ITEMS: (listId: string) => `/lists/${listId}/items`, ITEMS: (listId: string) => `/lists/${listId}/items`,
ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`, ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`,
SHARE: (listId: string) => `/lists/${listId}/share`, SHARE: (listId: string) => `/lists/${listId}/share`,
UNSHARE: (listId: string) => `/lists/${listId}/unshare`, UNSHARE: (listId: string) => `/lists/${listId}/unshare`,
COMPLETE: (listId: string) => `/lists/${listId}/complete`, COMPLETE: (listId: string) => `/lists/${listId}/complete`,
REOPEN: (listId: string) => `/lists/${listId}/reopen`, REOPEN: (listId: string) => `/lists/${listId}/reopen`,
ARCHIVE: (listId: string) => `/lists/${listId}/archive`, ARCHIVE: (listId: string) => `/lists/${listId}/archive`,
RESTORE: (listId: string) => `/lists/${listId}/restore`, RESTORE: (listId: string) => `/lists/${listId}/restore`,
DUPLICATE: (listId: string) => `/lists/${listId}/duplicate`, DUPLICATE: (listId: string) => `/lists/${listId}/duplicate`,
EXPORT: (listId: string) => `/lists/${listId}/export`, EXPORT: (listId: string) => `/lists/${listId}/export`,
IMPORT: '/lists/import', IMPORT: '/lists/import',
}, },
// Groups // Groups
GROUPS: { GROUPS: {
BASE: '/groups', BASE: '/groups',
BY_ID: (id: string) => `/groups/${id}`, BY_ID: (id: string) => `/groups/${id}`,
LISTS: (groupId: string) => `/groups/${groupId}/lists`, LISTS: (groupId: string) => `/groups/${groupId}/lists`,
MEMBERS: (groupId: string) => `/groups/${groupId}/members`, MEMBERS: (groupId: string) => `/groups/${groupId}/members`,
MEMBER: (groupId: string, userId: string) => `/groups/${groupId}/members/${userId}`, MEMBER: (groupId: string, userId: string) => `/groups/${groupId}/members/${userId}`,
CREATE_INVITE: (groupId: string) => `/groups/${groupId}/invites`, CREATE_INVITE: (groupId: string) => `/groups/${groupId}/invites`,
GET_ACTIVE_INVITE: (groupId: string) => `/groups/${groupId}/invites`, GET_ACTIVE_INVITE: (groupId: string) => `/groups/${groupId}/invites`,
LEAVE: (groupId: string) => `/groups/${groupId}/leave`, LEAVE: (groupId: string) => `/groups/${groupId}/leave`,
DELETE: (groupId: string) => `/groups/${groupId}`, DELETE: (groupId: string) => `/groups/${groupId}`,
SETTINGS: (groupId: string) => `/groups/${groupId}/settings`, SETTINGS: (groupId: string) => `/groups/${groupId}/settings`,
ROLES: (groupId: string) => `/groups/${groupId}/roles`, ROLES: (groupId: string) => `/groups/${groupId}/roles`,
ROLE: (groupId: string, roleId: string) => `/groups/${groupId}/roles/${roleId}`, ROLE: (groupId: string, roleId: string) => `/groups/${groupId}/roles/${roleId}`,
}, },
// Invites // Invites
INVITES: { INVITES: {
BASE: '/invites', BASE: '/invites',
BY_ID: (id: string) => `/invites/${id}`, BY_ID: (id: string) => `/invites/${id}`,
ACCEPT: (id: string) => `/invites/accept/${id}`, ACCEPT: (id: string) => `/invites/accept/${id}`,
DECLINE: (id: string) => `/invites/decline/${id}`, DECLINE: (id: string) => `/invites/decline/${id}`,
REVOKE: (id: string) => `/invites/revoke/${id}`, REVOKE: (id: string) => `/invites/revoke/${id}`,
LIST: '/invites', LIST: '/invites',
PENDING: '/invites/pending', PENDING: '/invites/pending',
SENT: '/invites/sent', SENT: '/invites/sent',
}, },
// Items (for direct operations like update, get by ID) // Items (for direct operations like update, get by ID)
ITEMS: { ITEMS: {
BY_ID: (itemId: string) => `/items/${itemId}`, BY_ID: (itemId: string) => `/items/${itemId}`,
}, },
// OCR // OCR
OCR: { OCR: {
PROCESS: '/ocr/extract-items', PROCESS: '/ocr/extract-items',
STATUS: (jobId: string) => `/ocr/status/${jobId}`, STATUS: (jobId: string) => `/ocr/status/${jobId}`,
RESULT: (jobId: string) => `/ocr/result/${jobId}`, RESULT: (jobId: string) => `/ocr/result/${jobId}`,
BATCH: '/ocr/batch', BATCH: '/ocr/batch',
CANCEL: (jobId: string) => `/ocr/cancel/${jobId}`, CANCEL: (jobId: string) => `/ocr/cancel/${jobId}`,
HISTORY: '/ocr/history', HISTORY: '/ocr/history',
}, },
// Costs // Costs
COSTS: { COSTS: {
BASE: '/costs', BASE: '/costs',
LIST_SUMMARY: (listId: string | number) => `/costs/lists/${listId}/cost-summary`, LIST_SUMMARY: (listId: string | number) => `/costs/lists/${listId}/cost-summary`,
GROUP_BALANCE_SUMMARY: (groupId: string | number) => `/costs/groups/${groupId}/balance-summary`, GROUP_BALANCE_SUMMARY: (groupId: string | number) => `/costs/groups/${groupId}/balance-summary`,
}, },
// Financials // Financials
FINANCIALS: { FINANCIALS: {
EXPENSES: '/financials/expenses', EXPENSES: '/financials/expenses',
EXPENSE: (id: string) => `/financials/expenses/${id}`, EXPENSE: (id: string) => `/financials/expenses/${id}`,
SETTLEMENTS: '/financials/settlements', SETTLEMENTS: '/financials/settlements',
SETTLEMENT: (id: string) => `/financials/settlements/${id}`, SETTLEMENT: (id: string) => `/financials/settlements/${id}`,
BALANCES: '/financials/balances', BALANCES: '/financials/balances',
BALANCE: (userId: string) => `/financials/balances/${userId}`, BALANCE: (userId: string) => `/financials/balances/${userId}`,
REPORTS: '/financials/reports', REPORTS: '/financials/reports',
REPORT: (id: string) => `/financials/reports/${id}`, REPORT: (id: string) => `/financials/reports/${id}`,
CATEGORIES: '/financials/categories', CATEGORIES: '/financials/categories',
CATEGORY: (id: string) => `/financials/categories/${id}`, CATEGORY: (id: string) => `/financials/categories/${id}`,
}, },
// Health // Health
HEALTH: { HEALTH: {
CHECK: '/health', CHECK: '/health',
VERSION: '/health/version', VERSION: '/health/version',
STATUS: '/health/status', STATUS: '/health/status',
METRICS: '/health/metrics', METRICS: '/health/metrics',
LOGS: '/health/logs', LOGS: '/health/logs',
}, },
}; }

View File

@ -99,7 +99,15 @@
<!-- Expenses Section --> <!-- Expenses Section -->
<section v-if="list" class="neo-expenses-section"> <section v-if="list" class="neo-expenses-section">
<h2 class="neo-expenses-title">Expenses</h2> <div class="neo-expenses-header">
<h2 class="neo-expenses-title">Expenses</h2>
<button class="neo-action-button" @click="showCreateExpenseForm = true">
<svg class="icon">
<use xlink:href="#icon-plus" />
</svg>
Add Expense
</button>
</div>
<div v-if="listDetailStore.isLoading && expenses.length === 0" class="neo-loading-state"> <div v-if="listDetailStore.isLoading && expenses.length === 0" class="neo-loading-state">
<div class="spinner-dots" role="status"><span /><span /><span /></div> <div class="spinner-dots" role="status"><span /><span /><span /></div>
<p>Loading expenses...</p> <p>Loading expenses...</p>
@ -136,6 +144,14 @@
Paid: {{ getPaidAmountForSplitDisplay(split) }} Paid: {{ getPaidAmountForSplitDisplay(split) }}
<span v-if="split.paid_at"> on {{ new Date(split.paid_at).toLocaleDateString() }}</span> <span v-if="split.paid_at"> on {{ new Date(split.paid_at).toLocaleDateString() }}</span>
</div> </div>
<button
v-if="split.user_id === authStore.user?.id && split.status !== ExpenseSplitStatusEnum.PAID"
class="neo-button neo-button-primary"
@click="openSettleShareModal(expense, split)"
:disabled="isSettlementLoading"
>
Settle My Share
</button>
<ul v-if="split.settlement_activities && split.settlement_activities.length > 0" class="neo-settlement-activities"> <ul v-if="split.settlement_activities && split.settlement_activities.length > 0" class="neo-settlement-activities">
<li v-for="activity in split.settlement_activities" :key="activity.id"> <li v-for="activity in split.settlement_activities" :key="activity.id">
Activity: {{ formatCurrency(activity.amount_paid) }} by {{ activity.payer?.name || `User ${activity.paid_by_user_id}`}} on {{ new Date(activity.paid_at).toLocaleDateString() }} Activity: {{ formatCurrency(activity.amount_paid) }} by {{ activity.payer?.name || `User ${activity.paid_by_user_id}`}} on {{ new Date(activity.paid_at).toLocaleDateString() }}
@ -147,6 +163,15 @@
</div> </div>
</section> </section>
<!-- Create Expense Form -->
<CreateExpenseForm
v-if="showCreateExpenseForm"
:list-id="list?.id"
:group-id="list?.group_id"
@close="showCreateExpenseForm = false"
@created="handleExpenseCreated"
/>
<!-- OCR Dialog --> <!-- OCR Dialog -->
<div v-if="showOcrDialogState" class="modal-backdrop open" @click.self="closeOcrDialog"> <div v-if="showOcrDialogState" class="modal-backdrop open" @click.self="closeOcrDialog">
<div class="modal-container" ref="ocrModalRef" style="min-width: 400px;"> <div class="modal-container" ref="ocrModalRef" style="min-width: 400px;">
@ -272,6 +297,35 @@
</div> </div>
</div> </div>
<!-- Settle Share Modal -->
<div v-if="showSettleModal" class="modal-backdrop open" @click.self="closeSettleShareModal">
<div class="modal-container" ref="settleModalRef" style="min-width: 550px;">
<div class="modal-header">
<h3>Settle Share</h3>
<button class="close-button" @click="closeSettleShareModal" aria-label="Close"><svg class="icon">
<use xlink:href="#icon-close" />
</svg></button>
</div>
<div class="modal-body">
<div v-if="isSettlementLoading" class="text-center">
<div class="spinner-dots"><span /><span /><span /></div>
<p>Processing settlement...</p>
</div>
<div v-else-if="settleAmountError" class="alert alert-error">{{ settleAmountError }}</div>
<div v-else>
<p>Settle amount for {{ selectedSplitForSettlement?.user?.name || selectedSplitForSettlement?.user?.email || `User ID: ${selectedSplitForSettlement?.user_id}` }}:</p>
<div class="form-group">
<label for="settleAmount" class="form-label">Amount</label>
<input type="number" v-model="settleAmount" class="form-input" id="settleAmount" required />
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" @click="handleConfirmSettle">Confirm</button>
</div>
</div>
</div>
</main> </main>
</template> </template>
@ -283,8 +337,14 @@ import { onClickOutside, useEventListener, useFileDialog, useNetwork } from '@vu
import { useNotificationStore } from '@/stores/notifications'; import { useNotificationStore } from '@/stores/notifications';
import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline'; import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline';
import { useListDetailStore } from '@/stores/listDetailStore'; import { useListDetailStore } from '@/stores/listDetailStore';
import type { Expense, ExpenseSplit } from '@/types/expense'; // Ensure correct path import type { ListWithExpenses } from '@/types/list';
import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense'; // Ensure correct path import type { Expense, ExpenseSplit } from '@/types/expense';
import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense';
import { useAuthStore } from '@/stores/auth';
import { Decimal } from 'decimal.js';
import type { SettlementActivityCreate } from '@/types/expense';
import SettleShareModal from '@/components/SettleShareModal.vue';
import CreateExpenseForm from '@/components/CreateExpenseForm.vue';
interface Item { interface Item {
@ -325,7 +385,7 @@ const route = useRoute();
const { isOnline } = useNetwork(); const { isOnline } = useNetwork();
const notificationStore = useNotificationStore(); const notificationStore = useNotificationStore();
const offlineStore = useOfflineStore(); const offlineStore = useOfflineStore();
const list = ref<List | null>(null); // This is for items const list = ref<ListWithExpenses | null>(null);
const loading = ref(true); // For initial list (items) loading const loading = ref(true); // For initial list (items) loading
const error = ref<string | null>(null); // For initial list (items) loading const error = ref<string | null>(null); // For initial list (items) loading
const addingItem = ref(false); const addingItem = ref(false);
@ -363,10 +423,24 @@ const listCostSummary = ref<ListCostSummaryData | null>(null);
const costSummaryLoading = ref(false); const costSummaryLoading = ref(false);
const costSummaryError = ref<string | null>(null); const costSummaryError = ref<string | null>(null);
// Settle Share
const authStore = useAuthStore();
const showSettleModal = ref(false);
const settleModalRef = ref<HTMLElement | null>(null);
const selectedSplitForSettlement = ref<ExpenseSplit | null>(null);
const parentExpenseOfSelectedSplit = ref<Expense | null>(null);
const settleAmount = ref<string>('');
const settleAmountError = ref<string | null>(null);
const isSettlementLoading = computed(() => listDetailStore.isSettlingSplit);
// Create Expense
const showCreateExpenseForm = ref(false);
onClickOutside(ocrModalRef, () => { showOcrDialogState.value = false; }); onClickOutside(ocrModalRef, () => { showOcrDialogState.value = false; });
onClickOutside(costSummaryModalRef, () => { showCostSummaryDialog.value = false; }); onClickOutside(costSummaryModalRef, () => { showCostSummaryDialog.value = false; });
onClickOutside(confirmModalRef, () => { showConfirmDialogState.value = false; pendingAction.value = null; }); onClickOutside(confirmModalRef, () => { showConfirmDialogState.value = false; pendingAction.value = null; });
onClickOutside(settleModalRef, () => { showSettleModal.value = false; });
const formatCurrency = (value: string | number | undefined | null): string => { const formatCurrency = (value: string | number | undefined | null): string => {
@ -377,10 +451,12 @@ const formatCurrency = (value: string | number | undefined | null): string => {
return isNaN(numValue) ? '$0.00' : `$${numValue.toFixed(2)}`; return isNaN(numValue) ? '$0.00' : `$${numValue.toFixed(2)}`;
}; };
const processListItems = (items: Item[]): Item[] => { const processListItems = (items: Item[]) => {
return items.map(item => ({ return items.map((i: Item) => ({
...item, ...i,
priceInput: item.price !== null && item.price !== undefined ? item.price : '' updating: false,
deleting: false,
priceInput: i.price || '',
})); }));
}; };
@ -389,7 +465,7 @@ const fetchListDetails = async () => { // This is for items primarily
error.value = null; error.value = null;
try { try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(route.params.id))); const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(route.params.id)));
const rawList = response.data as List; const rawList = response.data as ListWithExpenses;
rawList.items = processListItems(rawList.items); rawList.items = processListItems(rawList.items);
list.value = rawList; // Sets item-related list data list.value = rawList; // Sets item-related list data
@ -413,7 +489,7 @@ const checkForUpdates = async () => {
if (!list.value) return; if (!list.value) return;
try { try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(list.value.id))); const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(list.value.id)));
const { updated_at: newListUpdatedAt, items: newItems } = response.data as List; const { updated_at: newListUpdatedAt, items: newItems } = response.data as ListWithExpenses;
const newLastItemUpdate = newItems.reduce((latest: string, item: Item) => item.updated_at > latest ? item.updated_at : latest, ''); const newLastItemUpdate = newItems.reduce((latest: string, item: Item) => item.updated_at > latest ? item.updated_at : latest, '');
if ((lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) || if ((lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) ||
@ -584,14 +660,14 @@ const deleteItem = async (item: Item) => {
itemId: String(item.id) itemId: String(item.id)
} }
}); });
list.value.items = list.value.items.filter(i => i.id !== item.id); list.value.items = list.value.items.filter((i: Item) => i.id !== item.id);
item.deleting = false; item.deleting = false;
return; return;
} }
try { try {
await apiClient.delete(API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id))); await apiClient.delete(API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)));
list.value.items = list.value.items.filter(i => i.id !== item.id); list.value.items = list.value.items.filter((i: Item) => i.id !== item.id);
} catch (err) { } catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' }); notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' });
} finally { } finally {
@ -722,8 +798,6 @@ watch(showCostSummaryDialog, (newVal) => {
// --- Expense and Settlement Status Logic --- // --- Expense and Settlement Status Logic ---
const listDetailStore = useListDetailStore(); const listDetailStore = useListDetailStore();
// listWithExpenses is not directly used in template, expenses getter is used instead
// const listWithExpenses = computed(() => listDetailStore.getList);
const expenses = computed(() => listDetailStore.getExpenses); const expenses = computed(() => listDetailStore.getExpenses);
const getPaidAmountForSplitDisplay = (split: ExpenseSplit): string => { const getPaidAmountForSplitDisplay = (split: ExpenseSplit): string => {
@ -816,6 +890,91 @@ const editItem = (item: Item) => {
}); });
}; };
const openSettleShareModal = (expense: Expense, split: ExpenseSplit) => {
if (split.user_id !== authStore.user?.id) {
notificationStore.addNotification({ message: "You can only settle your own shares.", type: 'warning' });
return;
}
selectedSplitForSettlement.value = split;
parentExpenseOfSelectedSplit.value = expense;
const alreadyPaid = new Decimal(listDetailStore.getPaidAmountForSplit(split.id));
const owed = new Decimal(split.owed_amount);
const remaining = owed.minus(alreadyPaid);
settleAmount.value = remaining.toFixed(2);
settleAmountError.value = null;
showSettleModal.value = true;
};
const closeSettleShareModal = () => {
showSettleModal.value = false;
selectedSplitForSettlement.value = null;
parentExpenseOfSelectedSplit.value = null;
settleAmount.value = '';
settleAmountError.value = null;
};
const validateSettleAmount = (): boolean => {
settleAmountError.value = null;
if (!settleAmount.value.trim()) {
settleAmountError.value = 'Please enter an amount.';
return false;
}
const amount = new Decimal(settleAmount.value);
if (amount.isNaN() || amount.isNegative() || amount.isZero()) {
settleAmountError.value = 'Please enter a positive amount.';
return false;
}
if (selectedSplitForSettlement.value) {
const alreadyPaid = new Decimal(listDetailStore.getPaidAmountForSplit(selectedSplitForSettlement.value.id));
const owed = new Decimal(selectedSplitForSettlement.value.owed_amount);
const remaining = owed.minus(alreadyPaid);
if (amount.greaterThan(remaining.plus(new Decimal('0.001')))) { // Epsilon for float issues
settleAmountError.value = `Amount cannot exceed remaining: ${formatCurrency(remaining.toFixed(2))}.`;
return false;
}
} else {
settleAmountError.value = 'Error: No split selected.'; // Should not happen
return false;
}
return true;
};
const currentListIdForRefetch = computed(() => listDetailStore.currentList?.id || null);
const handleConfirmSettle = async () => {
if (!selectedSplitForSettlement.value || !authStore.user?.id || !currentListIdForRefetch.value) {
notificationStore.addNotification({ message: 'Cannot process settlement: missing data.', type: 'error' });
return;
}
// Use settleAmount.value which is the confirmed amount (remaining amount for MVP)
const activityData: SettlementActivityCreate = {
expense_split_id: selectedSplitForSettlement.value.id,
paid_by_user_id: Number(authStore.user.id), // Convert to number
amount_paid: new Decimal(settleAmount.value).toString(),
paid_at: new Date().toISOString(),
};
const success = await listDetailStore.settleExpenseSplit({
list_id_for_refetch: String(currentListIdForRefetch.value),
expense_split_id: selectedSplitForSettlement.value.id,
activity_data: activityData,
});
if (success) {
notificationStore.addNotification({ message: 'Share settled successfully!', type: 'success' });
closeSettleShareModal();
} else {
notificationStore.addNotification({ message: listDetailStore.error || 'Failed to settle share.', type: 'error' });
}
};
const handleExpenseCreated = (expense: any) => {
// Refresh the expenses list
if (list.value?.id) {
listDetailStore.fetchListWithExpenses(String(list.value.id));
}
};
</script> </script>
<style scoped> <style scoped>
@ -830,6 +989,13 @@ const editItem = (item: Item) => {
box-shadow: 6px 6px 0 #111; box-shadow: 6px 6px 0 #111;
} }
.neo-expenses-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.neo-expenses-title { .neo-expenses-title {
font-size: 2rem; font-size: 2rem;
font-weight: 900; font-weight: 900;

View File

@ -1,8 +1,8 @@
import axios from 'axios'; import axios from 'axios'
import { API_BASE_URL } from '@/config/api-config'; // api-config.ts can be moved to src/config/ import { API_BASE_URL } from '@/config/api-config' // api-config.ts can be moved to src/config/
import router from '@/router'; // Import the router instance import router from '@/router' // Import the router instance
import { useAuthStore } from '@/stores/auth'; // Import the auth store import { useAuthStore } from '@/stores/auth' // Import the auth store
import type { SettlementActivityCreate } from '@/types/expense'; // Import the type for the payload import type { SettlementActivityCreate } from '@/types/expense' // Import the type for the payload
// Create axios instance // Create axios instance
const api = axios.create({ const api = axios.create({
@ -11,76 +11,80 @@ const api = axios.create({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
withCredentials: true, // Enable sending cookies and authentication headers withCredentials: true, // Enable sending cookies and authentication headers
}); })
// Request interceptor // Request interceptor
api.interceptors.request.use( api.interceptors.request.use(
(config) => { (config) => {
const token = localStorage.getItem('token'); // Or use useStorage from VueUse const token = localStorage.getItem('token') // Or use useStorage from VueUse
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`
} }
return config; return config
}, },
(error) => { (error) => {
return Promise.reject(error); // Simpler error handling return Promise.reject(error) // Simpler error handling
} },
); )
// Response interceptor // Response interceptor
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
async (error) => { async (error) => {
const originalRequest = error.config; const originalRequest = error.config
const authStore = useAuthStore(); // Get auth store instance const authStore = useAuthStore() // Get auth store instance
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; originalRequest._retry = true
try { try {
const refreshTokenValue = authStore.refreshToken; // Get from store for consistency const refreshTokenValue = authStore.refreshToken // Get from store for consistency
if (!refreshTokenValue) { if (!refreshTokenValue) {
console.error('No refresh token, redirecting to login'); console.error('No refresh token, redirecting to login')
authStore.clearTokens(); // Clear tokens in store and localStorage authStore.clearTokens() // Clear tokens in store and localStorage
await router.push('/auth/login'); await router.push('/auth/login')
return Promise.reject(error); return Promise.reject(error)
} }
const response = await api.post('/auth/jwt/refresh', { // Use base 'api' instance for refresh const response = await api.post('/auth/jwt/refresh', {
// Use base 'api' instance for refresh
refresh_token: refreshTokenValue, refresh_token: refreshTokenValue,
}); })
const { access_token: newAccessToken, refresh_token: newRefreshToken } = response.data; const { access_token: newAccessToken, refresh_token: newRefreshToken } = response.data
authStore.setTokens({ access_token: newAccessToken, refresh_token: newRefreshToken }); authStore.setTokens({ access_token: newAccessToken, refresh_token: newRefreshToken })
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
return api(originalRequest); return api(originalRequest)
} catch (refreshError) { } catch (refreshError) {
console.error('Refresh token failed:', refreshError); console.error('Refresh token failed:', refreshError)
authStore.clearTokens(); // Clear tokens in store and localStorage authStore.clearTokens() // Clear tokens in store and localStorage
await router.push('/auth/login'); await router.push('/auth/login')
return Promise.reject(refreshError); return Promise.reject(refreshError)
} }
} }
return Promise.reject(error); return Promise.reject(error)
} },
); )
// Export the original axios too if some parts of your app used it directly // Export the original axios too if some parts of your app used it directly
const globalAxios = axios; const globalAxios = axios
export { api, globalAxios }; export { api, globalAxios }
import { API_VERSION, API_ENDPOINTS } from '@/config/api-config'; import { API_VERSION, API_ENDPOINTS } from '@/config/api-config'
export const getApiUrl = (endpoint: string): string => { export const getApiUrl = (endpoint: string): string => {
// Don't add /api/v1 prefix for auth endpoints
if (endpoint.startsWith('/auth/')) {
return `${API_BASE_URL}${endpoint}`
}
// Check if the endpoint already starts with /api/vX (like from API_ENDPOINTS) // Check if the endpoint already starts with /api/vX (like from API_ENDPOINTS)
if (endpoint.startsWith('/api/')) { if (endpoint.startsWith('/api/')) {
return `${API_BASE_URL}${endpoint}`; return `${API_BASE_URL}${endpoint}`
} }
// Otherwise, prefix with /api/API_VERSION // Otherwise, prefix with /api/API_VERSION
return `${API_BASE_URL}/api/${API_VERSION}${endpoint.startsWith('/') ? '' : '/'}${endpoint}`; return `${API_BASE_URL}/api/${API_VERSION}${endpoint.startsWith('/') ? '' : '/'}${endpoint}`
}; }
export const apiClient = { export const apiClient = {
get: (endpoint: string, config = {}) => api.get(getApiUrl(endpoint), config), get: (endpoint: string, config = {}) => api.get(getApiUrl(endpoint), config),
@ -90,11 +94,15 @@ export const apiClient = {
delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config), delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config),
// Specific method for settling an expense split // Specific method for settling an expense split
settleExpenseSplit: (expenseSplitId: number, activityData: SettlementActivityCreate, config = {}) => { settleExpenseSplit: (
expenseSplitId: number,
activityData: SettlementActivityCreate,
config = {},
) => {
// Construct the endpoint URL correctly, assuming API_VERSION is part of the base path or needs to be here // Construct the endpoint URL correctly, assuming API_VERSION is part of the base path or needs to be here
const endpoint = `/expense_splits/${expenseSplitId}/settle`; // Path relative to /api/API_VERSION const endpoint = `/expense_splits/${expenseSplitId}/settle` // Path relative to /api/API_VERSION
return api.post(getApiUrl(endpoint), activityData, config); return api.post(getApiUrl(endpoint), activityData, config)
} },
}; }
export { API_ENDPOINTS }; // Also re-export for convenience export { API_ENDPOINTS } // Also re-export for convenience

View File

@ -1,94 +1,94 @@
import { API_ENDPOINTS } from '@/config/api-config'; import { API_ENDPOINTS } from '@/config/api-config'
import { apiClient } from '@/services/api'; import { apiClient } from '@/services/api'
import { defineStore } from 'pinia'; import { defineStore } from 'pinia'
import { ref, computed } from 'vue'; import { ref, computed } from 'vue'
import router from '@/router'; import router from '@/router'
interface AuthState { interface AuthState {
accessToken: string | null; accessToken: string | null
refreshToken: string | null; refreshToken: string | null
user: { user: {
email: string; email: string
name: string; name: string
id?: string | number; id?: string | number
} | null; } | null
} }
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
// State // State
const accessToken = ref<string | null>(localStorage.getItem('token')); const accessToken = ref<string | null>(localStorage.getItem('token'))
const refreshToken = ref<string | null>(localStorage.getItem('refreshToken')); const refreshToken = ref<string | null>(localStorage.getItem('refreshToken'))
const user = ref<AuthState['user']>(null); const user = ref<AuthState['user']>(null)
// Getters // Getters
const isAuthenticated = computed(() => !!accessToken.value); const isAuthenticated = computed(() => !!accessToken.value)
const getUser = computed(() => user.value); const getUser = computed(() => user.value)
// Actions // Actions
const setTokens = (tokens: { access_token: string; refresh_token?: string }) => { const setTokens = (tokens: { access_token: string; refresh_token?: string }) => {
accessToken.value = tokens.access_token; accessToken.value = tokens.access_token
localStorage.setItem('token', tokens.access_token); localStorage.setItem('token', tokens.access_token)
if (tokens.refresh_token) { if (tokens.refresh_token) {
refreshToken.value = tokens.refresh_token; refreshToken.value = tokens.refresh_token
localStorage.setItem('refreshToken', tokens.refresh_token); localStorage.setItem('refreshToken', tokens.refresh_token)
} }
}; }
const clearTokens = () => { const clearTokens = () => {
accessToken.value = null; accessToken.value = null
refreshToken.value = null; refreshToken.value = null
user.value = null; user.value = null
localStorage.removeItem('token'); localStorage.removeItem('token')
localStorage.removeItem('refreshToken'); localStorage.removeItem('refreshToken')
}; }
const setUser = (userData: AuthState['user']) => { const setUser = (userData: AuthState['user']) => {
user.value = userData; user.value = userData
}; }
const fetchCurrentUser = async () => { const fetchCurrentUser = async () => {
if (!accessToken.value) { if (!accessToken.value) {
clearTokens(); clearTokens()
return null; return null
} }
try { try {
const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE); const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE)
setUser(response.data); setUser(response.data)
return response.data; return response.data
} catch (error: any) { } catch (error: any) {
console.error('AuthStore: Failed to fetch current user:', error); console.error('AuthStore: Failed to fetch current user:', error)
clearTokens(); clearTokens()
return null; return null
} }
}; }
const login = async (email: string, password: string) => { const login = async (email: string, password: string) => {
const formData = new FormData(); const formData = new FormData()
formData.append('username', email); formData.append('username', email)
formData.append('password', password); formData.append('password', password)
const response = await apiClient.post(API_ENDPOINTS.AUTH.LOGIN, formData, { const response = await apiClient.post(API_ENDPOINTS.AUTH.LOGIN, formData, {
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
}, },
}); })
const { access_token, refresh_token } = response.data; const { access_token, refresh_token } = response.data
setTokens({ access_token, refresh_token }); setTokens({ access_token, refresh_token })
await fetchCurrentUser(); // Skip fetching profile data
return response.data; // await fetchCurrentUser();
}; return response.data
}
const signup = async (userData: { name: string; email: string; password: string }) => { const signup = async (userData: { name: string; email: string; password: string }) => {
const response = await apiClient.post(API_ENDPOINTS.AUTH.SIGNUP, userData); const response = await apiClient.post(API_ENDPOINTS.AUTH.SIGNUP, userData)
return response.data; return response.data
}; }
const logout = async () => { const logout = async () => {
clearTokens(); clearTokens()
await router.push('/auth/login'); await router.push('/auth/login')
}; }
return { return {
accessToken, accessToken,
@ -103,5 +103,5 @@ export const useAuthStore = defineStore('auth', () => {
login, login,
signup, signup,
logout, logout,
}; }
}); })

View File

@ -1,18 +1,20 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia'
import { apiClient, API_ENDPOINTS } from '@/services/api'; import { apiClient, API_ENDPOINTS } from '@/services/api'
import type { Expense, ExpenseSplit, SettlementActivity } from '@/types/expense'; import type { Expense, ExpenseSplit, SettlementActivity } from '@/types/expense'
import type { SettlementActivityCreate } from '@/types/expense'; import type { SettlementActivityCreate } from '@/types/expense'
import type { List } from '@/types/list'; import type { List } from '@/types/list'
import type { AxiosResponse } from 'axios'
export interface ListWithExpenses extends List { export interface ListWithExpenses extends List {
expenses: Expense[]; id: number
expenses: Expense[]
} }
interface ListDetailState { interface ListDetailState {
currentList: ListWithExpenses | null; currentList: ListWithExpenses | null
isLoading: boolean; isLoading: boolean
error: string | null; error: string | null
isSettlingSplit: boolean; isSettlingSplit: boolean
} }
export const useListDetailStore = defineStore('listDetail', { export const useListDetailStore = defineStore('listDetail', {
@ -25,101 +27,108 @@ export const useListDetailStore = defineStore('listDetail', {
actions: { actions: {
async fetchListWithExpenses(listId: string) { async fetchListWithExpenses(listId: string) {
this.isLoading = true; this.isLoading = true
this.error = null; this.error = null
try { try {
// This assumes API_ENDPOINTS.LISTS.BY_ID(listId) generates a path like "/lists/{id}" const endpoint = API_ENDPOINTS.LISTS.BY_ID(listId)
// and getApiUrl (from services/api.ts) correctly prefixes it with API_BASE_URL and /api/API_VERSION if necessary. const response = await apiClient.get(endpoint)
const endpoint = API_ENDPOINTS.LISTS.BY_ID(listId); this.currentList = response.data as ListWithExpenses
const response = await apiClient.get(endpoint);
this.currentList = response as ListWithExpenses;
} catch (err: any) { } catch (err: any) {
this.error = err.response?.data?.detail || err.message || 'Failed to fetch list details'; this.error = err.response?.data?.detail || err.message || 'Failed to fetch list details'
this.currentList = null; this.currentList = null
console.error('Error fetching list details:', err); console.error('Error fetching list details:', err)
} finally { } finally {
this.isLoading = false; this.isLoading = false
} }
}, },
async settleExpenseSplit(payload: { async settleExpenseSplit(payload: {
list_id_for_refetch: string, // ID of the list to refetch after settlement list_id_for_refetch: string // ID of the list to refetch after settlement
expense_split_id: number, expense_split_id: number
activity_data: SettlementActivityCreate activity_data: SettlementActivityCreate
}): Promise<boolean> { }): Promise<boolean> {
this.isSettlingSplit = true; this.isSettlingSplit = true
this.error = null; this.error = null
try { try {
// TODO: Uncomment and use when apiClient.settleExpenseSplit is available and correctly implemented in api.ts // TODO: Uncomment and use when apiClient.settleExpenseSplit is available and correctly implemented in api.ts
// For now, simulating the API call as it was not successfully added in the previous step. // For now, simulating the API call as it was not successfully added in the previous step.
console.warn(`Simulating settlement for split ID: ${payload.expense_split_id} with data:`, payload.activity_data); console.warn(
`Simulating settlement for split ID: ${payload.expense_split_id} with data:`,
payload.activity_data,
)
// const createdActivity = await apiClient.settleExpenseSplit(payload.expense_split_id, payload.activity_data); // const createdActivity = await apiClient.settleExpenseSplit(payload.expense_split_id, payload.activity_data);
// console.log('Settlement activity created (simulated):', createdActivity); // console.log('Settlement activity created (simulated):', createdActivity);
await new Promise(resolve => setTimeout(resolve, 500)); // Simulate network delay await new Promise((resolve) => setTimeout(resolve, 500)) // Simulate network delay
// End of placeholder for API call // End of placeholder for API call
// Refresh list data to show updated statuses. // Refresh list data to show updated statuses.
// Ensure currentList is not null and its ID matches before refetching, // Ensure currentList is not null and its ID matches before refetching,
// or always refetch if list_id_for_refetch is the source of truth. // or always refetch if list_id_for_refetch is the source of truth.
if (payload.list_id_for_refetch) { if (payload.list_id_for_refetch) {
await this.fetchListWithExpenses(payload.list_id_for_refetch); await this.fetchListWithExpenses(payload.list_id_for_refetch)
} else if (this.currentList?.id) { } else if (this.currentList?.id) {
// Fallback if list_id_for_refetch is not provided but currentList exists // Fallback if list_id_for_refetch is not provided but currentList exists
await this.fetchListWithExpenses(String(this.currentList.id)); await this.fetchListWithExpenses(String(this.currentList.id))
} else { } else {
console.warn("Could not refetch list details: list_id_for_refetch not provided and no currentList available."); console.warn(
'Could not refetch list details: list_id_for_refetch not provided and no currentList available.',
)
} }
this.isSettlingSplit = false; this.isSettlingSplit = false
return true; // Indicate success return true // Indicate success
} catch (err: any) { } catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to settle expense split.'; const errorMessage =
this.error = errorMessage; err.response?.data?.detail || err.message || 'Failed to settle expense split.'
console.error('Error settling expense split:', err); this.error = errorMessage
this.isSettlingSplit = false; console.error('Error settling expense split:', err)
return false; // Indicate failure this.isSettlingSplit = false
return false // Indicate failure
} }
}, },
setError(errorMessage: string) { setError(errorMessage: string) {
this.error = errorMessage; this.error = errorMessage
this.isLoading = false; this.isLoading = false
} },
}, },
getters: { getters: {
getList(state: ListDetailState): ListWithExpenses | null { getList(state: ListDetailState): ListWithExpenses | null {
return state.currentList; return state.currentList
}, },
getExpenses(state: ListDetailState): Expense[] { getExpenses(state: ListDetailState): Expense[] {
return state.currentList?.expenses || []; return state.currentList?.expenses || []
}, },
getPaidAmountForSplit: (state: ListDetailState) => (splitId: number): number => { getPaidAmountForSplit:
let totalPaid = 0; (state: ListDetailState) =>
if (state.currentList && state.currentList.expenses) { (splitId: number): number => {
for (const expense of state.currentList.expenses) { let totalPaid = 0
const split = expense.splits.find(s => s.id === splitId); if (state.currentList && state.currentList.expenses) {
if (split && split.settlement_activities) { for (const expense of state.currentList.expenses) {
totalPaid = split.settlement_activities.reduce((sum, activity) => { const split = expense.splits.find((s) => s.id === splitId)
return sum + parseFloat(activity.amount_paid); if (split && split.settlement_activities) {
}, 0); totalPaid = split.settlement_activities.reduce((sum, activity) => {
break; return sum + parseFloat(activity.amount_paid)
}, 0)
break
}
} }
} }
} return totalPaid
return totalPaid; },
}, getExpenseSplitById:
getExpenseSplitById: (state: ListDetailState) => (splitId: number): ExpenseSplit | undefined => { (state: ListDetailState) =>
if (!state.currentList || !state.currentList.expenses) return undefined; (splitId: number): ExpenseSplit | undefined => {
if (!state.currentList || !state.currentList.expenses) return undefined
for (const expense of state.currentList.expenses) { for (const expense of state.currentList.expenses) {
const split = expense.splits.find(s => s.id === splitId); const split = expense.splits.find((s) => s.id === splitId)
if (split) return split; if (split) return split
} }
return undefined; return undefined
} },
}, },
}); })
// Assuming List interface might be defined in fe/src/types/list.ts // Assuming List interface might be defined in fe/src/types/list.ts
// If not, it should be defined like this: // If not, it should be defined like this:

11
fe/src/types/item.ts Normal file
View File

@ -0,0 +1,11 @@
export interface Item {
id: number
name: string
quantity?: number | null
is_complete: boolean
price?: string | null // String representation of Decimal
list_id: number
created_at: string
updated_at: string
version: number
}

18
fe/src/types/list.ts Normal file
View File

@ -0,0 +1,18 @@
import type { Expense } from './expense'
import type { Item } from './item'
export interface List {
id: number
name: string
description?: string | null
is_complete: boolean
group_id?: number | null
items: Item[]
version: number
updated_at: string
expenses?: Expense[]
}
export interface ListWithExpenses extends List {
expenses: Expense[]
}