diff --git a/fe/src/components/CreateExpenseForm.vue b/fe/src/components/CreateExpenseForm.vue new file mode 100644 index 0000000..8bd66a0 --- /dev/null +++ b/fe/src/components/CreateExpenseForm.vue @@ -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> diff --git a/fe/src/components/SettleShareModal.vue b/fe/src/components/SettleShareModal.vue index 2d517d7..c7c02d8 100644 --- a/fe/src/components/SettleShareModal.vue +++ b/fe/src/components/SettleShareModal.vue @@ -1,53 +1,47 @@ <template> - <div v-if="show" class="modal-backdrop-settle" @click.self="onCancel"> - <div class="modal-container-settle"> - <div class="modal-header-settle"> - <h3>Settle Your Share</h3> - <button class="close-button-settle" @click="onCancel" aria-label="Close">×</button> + <div v-if="show" class="modal-backdrop open" @click.self="$emit('cancel')"> + <div class="modal-container" ref="modalRef" style="min-width: 550px;"> + <div class="modal-header"> + <h3>Settle Share</h3> + <button class="close-button" @click="$emit('cancel')" aria-label="Close"> + <svg class="icon"> + <use xlink:href="#icon-close" /> + </svg> + </button> </div> - <div class="modal-body-settle" v-if="split"> - <p>You are about to settle your share for this expense.</p> - <div class="info-item"> - <span>Owed by:</span> - <strong>{{ split.user?.name || split.user?.email || `User ID ${split.user_id}` }}</strong> + <div class="modal-body"> + <div v-if="isLoading" class="text-center"> + <div class="spinner-dots"><span /><span /><span /></div> + <p>Processing settlement...</p> </div> - <div class="info-item"> - <span>Original Share:</span> - <strong>{{ formatCurrency(split.owed_amount) }}</strong> + <div v-else> + <p>Settle amount for {{ split?.user?.name || split?.user?.email || `User ID: ${split?.user_id}` }}:</p> + <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 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 class="modal-footer-settle"> - <button type="button" class="btn-neutral-settle" @click="onCancel" :disabled="isLoading">Cancel</button> - <button - type="button" - class="btn-primary-settle ml-2-settle" - @click="onConfirm" - :disabled="isLoading || remainingAmount <= 0"> - <span v-if="isLoading" class="spinner-dots-sm-settle"><span /><span /><span /></span> - <span v-else>Confirm Payment</span> + <div class="modal-footer"> + <button type="button" class="btn btn-neutral" @click="$emit('cancel')" :disabled="isLoading">Cancel</button> + <button + type="button" + class="btn btn-primary ml-2" + @click="handleConfirm" + :disabled="isLoading || !isValid" + > + <span v-if="isLoading" class="spinner-dots-sm"><span /><span /><span /></span> + <span v-else>Confirm</span> </button> </div> </div> @@ -55,90 +49,66 @@ </template> <script setup lang="ts"> -import { computed, ref, watch, PropType } from 'vue'; -import { Decimal } from 'decimal.js'; // For precise arithmetic +import { ref, computed, watch } from 'vue' +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 -interface UserInfo { - id: number; - name?: string | null; - email: string; +const props = defineProps<{ + show: boolean + split: ExpenseSplit | null + paidAmount: number + 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> <style scoped> -.modal-backdrop-settle { - background-color: rgba(0, 0, 0, 0.6); /* Darker overlay */ +.modal-backdrop { + background-color: rgba(0, 0, 0, 0.5); position: fixed; top: 0; left: 0; @@ -147,144 +117,149 @@ const formatCurrency = (value: string | number | undefined | null): string => { display: flex; align-items: center; justify-content: center; - z-index: 1050; /* Ensure it's above other elements */ + z-index: 1000; } -.modal-container-settle { +.modal-container { background: white; - border-radius: 12px; /* Softer radius */ - border: 2px solid #333; /* Slightly softer border */ - box-shadow: 0 8px 20px rgba(0,0,0,0.25); /* Softer shadow */ + border-radius: 18px; + border: 3px solid #111; + box-shadow: 6px 6px 0 #111; width: 90%; - max-width: 450px; /* Optimal width for a simple modal */ + max-width: 500px; max-height: 90vh; overflow-y: auto; - display: flex; /* For footer alignment */ - flex-direction: column; + padding: 0; } -.modal-header-settle { - padding: 1rem 1.5rem; - border-bottom: 1px solid #e0e0e0; /* Lighter border */ +.modal-header { + padding: 1.5rem; + border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; } -.modal-header-settle h3 { - margin: 0; - font-size: 1.25rem; - font-weight: 700; - color: #111; +.modal-body { + padding: 1.5rem; } -.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; border: none; cursor: pointer; - font-size: 1.5rem; - line-height: 1; - color: #555; - padding: 0.5rem; /* Easier to click */ -} -.close-button-settle:hover { - color: #111; + color: #666; } -.modal-body-settle { - padding: 1.5rem; - line-height: 1.6; +.form-group { + margin-bottom: 1rem; } -.modal-body-settle p { - margin-bottom: 0.75rem; -} - -.info-item { - display: flex; - justify-content: space-between; +.form-label { + display: block; margin-bottom: 0.5rem; - font-size: 0.95rem; -} -.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 */ + font-weight: 500; } -.my-3-settle { - border: 0; - border-top: 1px solid #e0e0e0; - margin: 1rem 0; -} - -.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; +.form-input { + width: 100%; + padding: 0.75rem; + border: 2px solid #111; 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; - border: 2px solid #111; /* Neo-brutalist touch */ - box-shadow: 2px 2px 0 #111; /* Neo-brutalist touch */ - transition: transform 0.1s ease, box-shadow 0.1s ease; + border: none; } -.btn-neutral-settle:hover, .btn-primary-settle:hover { - transform: translate(-1px, -1px); - box-shadow: 3px 3px 0 #111; -} -.btn-neutral-settle:disabled, .btn-primary-settle:disabled { - opacity: 0.6; + +.btn:disabled { + opacity: 0.5; cursor: not-allowed; } -.btn-neutral-settle { - background-color: #f0f0f0; +.btn-neutral { + background: #f3f4f6; color: #111; } -.btn-primary-settle { - background-color: var(--primary-color, #3498db); +.btn-primary { + background: #111; color: white; - border-color: #111; /* Ensure border matches */ } -.ml-2-settle { + +.ml-2 { 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; align-items: center; gap: 0.2rem; } -.spinner-dots-sm-settle span { + +.spinner-dots-sm span { width: 4px; height: 4px; - background-color: white; /* Assuming primary button has light text */ + background-color: white; 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 { - 0%, 80%, 100% { transform: scale(0); } - 40% { transform: scale(1); } +.spinner-dots span:nth-child(1), +.spinner-dots-sm span:nth-child(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> diff --git a/fe/src/config/api-config.ts b/fe/src/config/api-config.ts index f6bd1ae..95f0ec7 100644 --- a/fe/src/config/api-config.ts +++ b/fe/src/config/api-config.ts @@ -1,119 +1,119 @@ // API Version -export const API_VERSION = 'v1'; +export const API_VERSION = 'v1' // 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 export const API_ENDPOINTS = { - // Auth - AUTH: { - LOGIN: '/auth/jwt/login', - SIGNUP: '/auth/register', - LOGOUT: '/auth/jwt/logout', - VERIFY_EMAIL: '/auth/verify', - RESET_PASSWORD: '/auth/forgot-password', - FORGOT_PASSWORD: '/auth/forgot-password', - }, + // Auth + AUTH: { + LOGIN: '/auth/jwt/login', + SIGNUP: '/auth/register', + LOGOUT: '/auth/jwt/logout', + VERIFY_EMAIL: '/auth/verify', + RESET_PASSWORD: '/auth/forgot-password', + FORGOT_PASSWORD: '/auth/forgot-password', + }, - // Users - USERS: { - PROFILE: '/users/me', - UPDATE_PROFILE: '/api/v1/users/me', - PASSWORD: '/api/v1/users/password', - AVATAR: '/api/v1/users/avatar', - SETTINGS: '/api/v1/users/settings', - NOTIFICATIONS: '/api/v1/users/notifications', - PREFERENCES: '/api/v1/users/preferences', - }, + // Users + USERS: { + PROFILE: '/users/me', + UPDATE_PROFILE: '/users/me', + PASSWORD: '/api/v1/users/password', + AVATAR: '/api/v1/users/avatar', + SETTINGS: '/api/v1/users/settings', + NOTIFICATIONS: '/api/v1/users/notifications', + PREFERENCES: '/api/v1/users/preferences', + }, - // Lists - LISTS: { - BASE: '/lists', - BY_ID: (id: string) => `/lists/${id}`, - ITEMS: (listId: string) => `/lists/${listId}/items`, - ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`, - SHARE: (listId: string) => `/lists/${listId}/share`, - UNSHARE: (listId: string) => `/lists/${listId}/unshare`, - COMPLETE: (listId: string) => `/lists/${listId}/complete`, - REOPEN: (listId: string) => `/lists/${listId}/reopen`, - ARCHIVE: (listId: string) => `/lists/${listId}/archive`, - RESTORE: (listId: string) => `/lists/${listId}/restore`, - DUPLICATE: (listId: string) => `/lists/${listId}/duplicate`, - EXPORT: (listId: string) => `/lists/${listId}/export`, - IMPORT: '/lists/import', - }, + // Lists + LISTS: { + BASE: '/lists', + BY_ID: (id: string) => `/lists/${id}`, + ITEMS: (listId: string) => `/lists/${listId}/items`, + ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`, + SHARE: (listId: string) => `/lists/${listId}/share`, + UNSHARE: (listId: string) => `/lists/${listId}/unshare`, + COMPLETE: (listId: string) => `/lists/${listId}/complete`, + REOPEN: (listId: string) => `/lists/${listId}/reopen`, + ARCHIVE: (listId: string) => `/lists/${listId}/archive`, + RESTORE: (listId: string) => `/lists/${listId}/restore`, + DUPLICATE: (listId: string) => `/lists/${listId}/duplicate`, + EXPORT: (listId: string) => `/lists/${listId}/export`, + IMPORT: '/lists/import', + }, - // Groups - GROUPS: { - BASE: '/groups', - BY_ID: (id: string) => `/groups/${id}`, - LISTS: (groupId: string) => `/groups/${groupId}/lists`, - MEMBERS: (groupId: string) => `/groups/${groupId}/members`, - MEMBER: (groupId: string, userId: string) => `/groups/${groupId}/members/${userId}`, - CREATE_INVITE: (groupId: string) => `/groups/${groupId}/invites`, - GET_ACTIVE_INVITE: (groupId: string) => `/groups/${groupId}/invites`, - LEAVE: (groupId: string) => `/groups/${groupId}/leave`, - DELETE: (groupId: string) => `/groups/${groupId}`, - SETTINGS: (groupId: string) => `/groups/${groupId}/settings`, - ROLES: (groupId: string) => `/groups/${groupId}/roles`, - ROLE: (groupId: string, roleId: string) => `/groups/${groupId}/roles/${roleId}`, - }, + // Groups + GROUPS: { + BASE: '/groups', + BY_ID: (id: string) => `/groups/${id}`, + LISTS: (groupId: string) => `/groups/${groupId}/lists`, + MEMBERS: (groupId: string) => `/groups/${groupId}/members`, + MEMBER: (groupId: string, userId: string) => `/groups/${groupId}/members/${userId}`, + CREATE_INVITE: (groupId: string) => `/groups/${groupId}/invites`, + GET_ACTIVE_INVITE: (groupId: string) => `/groups/${groupId}/invites`, + LEAVE: (groupId: string) => `/groups/${groupId}/leave`, + DELETE: (groupId: string) => `/groups/${groupId}`, + SETTINGS: (groupId: string) => `/groups/${groupId}/settings`, + ROLES: (groupId: string) => `/groups/${groupId}/roles`, + ROLE: (groupId: string, roleId: string) => `/groups/${groupId}/roles/${roleId}`, + }, - // Invites - INVITES: { - BASE: '/invites', - BY_ID: (id: string) => `/invites/${id}`, - ACCEPT: (id: string) => `/invites/accept/${id}`, - DECLINE: (id: string) => `/invites/decline/${id}`, - REVOKE: (id: string) => `/invites/revoke/${id}`, - LIST: '/invites', - PENDING: '/invites/pending', - SENT: '/invites/sent', - }, + // Invites + INVITES: { + BASE: '/invites', + BY_ID: (id: string) => `/invites/${id}`, + ACCEPT: (id: string) => `/invites/accept/${id}`, + DECLINE: (id: string) => `/invites/decline/${id}`, + REVOKE: (id: string) => `/invites/revoke/${id}`, + LIST: '/invites', + PENDING: '/invites/pending', + SENT: '/invites/sent', + }, - // Items (for direct operations like update, get by ID) - ITEMS: { - BY_ID: (itemId: string) => `/items/${itemId}`, - }, + // Items (for direct operations like update, get by ID) + ITEMS: { + BY_ID: (itemId: string) => `/items/${itemId}`, + }, - // OCR - OCR: { - PROCESS: '/ocr/extract-items', - STATUS: (jobId: string) => `/ocr/status/${jobId}`, - RESULT: (jobId: string) => `/ocr/result/${jobId}`, - BATCH: '/ocr/batch', - CANCEL: (jobId: string) => `/ocr/cancel/${jobId}`, - HISTORY: '/ocr/history', - }, + // OCR + OCR: { + PROCESS: '/ocr/extract-items', + STATUS: (jobId: string) => `/ocr/status/${jobId}`, + RESULT: (jobId: string) => `/ocr/result/${jobId}`, + BATCH: '/ocr/batch', + CANCEL: (jobId: string) => `/ocr/cancel/${jobId}`, + HISTORY: '/ocr/history', + }, - // Costs - COSTS: { - BASE: '/costs', - LIST_SUMMARY: (listId: string | number) => `/costs/lists/${listId}/cost-summary`, - GROUP_BALANCE_SUMMARY: (groupId: string | number) => `/costs/groups/${groupId}/balance-summary`, - }, + // Costs + COSTS: { + BASE: '/costs', + LIST_SUMMARY: (listId: string | number) => `/costs/lists/${listId}/cost-summary`, + GROUP_BALANCE_SUMMARY: (groupId: string | number) => `/costs/groups/${groupId}/balance-summary`, + }, - // Financials - FINANCIALS: { - EXPENSES: '/financials/expenses', - EXPENSE: (id: string) => `/financials/expenses/${id}`, - SETTLEMENTS: '/financials/settlements', - SETTLEMENT: (id: string) => `/financials/settlements/${id}`, - BALANCES: '/financials/balances', - BALANCE: (userId: string) => `/financials/balances/${userId}`, - REPORTS: '/financials/reports', - REPORT: (id: string) => `/financials/reports/${id}`, - CATEGORIES: '/financials/categories', - CATEGORY: (id: string) => `/financials/categories/${id}`, - }, + // Financials + FINANCIALS: { + EXPENSES: '/financials/expenses', + EXPENSE: (id: string) => `/financials/expenses/${id}`, + SETTLEMENTS: '/financials/settlements', + SETTLEMENT: (id: string) => `/financials/settlements/${id}`, + BALANCES: '/financials/balances', + BALANCE: (userId: string) => `/financials/balances/${userId}`, + REPORTS: '/financials/reports', + REPORT: (id: string) => `/financials/reports/${id}`, + CATEGORIES: '/financials/categories', + CATEGORY: (id: string) => `/financials/categories/${id}`, + }, - // Health - HEALTH: { - CHECK: '/health', - VERSION: '/health/version', - STATUS: '/health/status', - METRICS: '/health/metrics', - LOGS: '/health/logs', - }, -}; \ No newline at end of file + // Health + HEALTH: { + CHECK: '/health', + VERSION: '/health/version', + STATUS: '/health/status', + METRICS: '/health/metrics', + LOGS: '/health/logs', + }, +} diff --git a/fe/src/pages/ListDetailPage.vue b/fe/src/pages/ListDetailPage.vue index 80c9107..24d538b 100644 --- a/fe/src/pages/ListDetailPage.vue +++ b/fe/src/pages/ListDetailPage.vue @@ -99,7 +99,15 @@ <!-- 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 class="spinner-dots" role="status"><span /><span /><span /></div> <p>Loading expenses...</p> @@ -123,7 +131,7 @@ Paid by: <strong>{{ expense.paid_by_user?.name || expense.paid_by_user?.email || `User ID: ${expense.paid_by_user_id}` }}</strong> on {{ new Date(expense.expense_date).toLocaleDateString() }} </div> - + <div class="neo-splits-list"> <div v-for="split in expense.splits" :key="split.id" class="neo-split-item"> <div class="neo-split-details"> @@ -136,6 +144,14 @@ Paid: {{ getPaidAmountForSplitDisplay(split) }} <span v-if="split.paid_at"> on {{ new Date(split.paid_at).toLocaleDateString() }}</span> </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"> <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() }} @@ -147,6 +163,15 @@ </div> </section> + <!-- Create Expense Form --> + <CreateExpenseForm + v-if="showCreateExpenseForm" + :list-id="list?.id" + :group-id="list?.group_id" + @close="showCreateExpenseForm = false" + @created="handleExpenseCreated" + /> + <!-- OCR Dialog --> <div v-if="showOcrDialogState" class="modal-backdrop open" @click.self="closeOcrDialog"> <div class="modal-container" ref="ocrModalRef" style="min-width: 400px;"> @@ -272,6 +297,35 @@ </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> </template> @@ -283,8 +337,14 @@ import { onClickOutside, useEventListener, useFileDialog, useNetwork } from '@vu import { useNotificationStore } from '@/stores/notifications'; import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline'; import { useListDetailStore } from '@/stores/listDetailStore'; -import type { Expense, ExpenseSplit } from '@/types/expense'; // Ensure correct path -import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense'; // Ensure correct path +import type { ListWithExpenses } from '@/types/list'; +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 { @@ -325,7 +385,7 @@ const route = useRoute(); const { isOnline } = useNetwork(); const notificationStore = useNotificationStore(); 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 error = ref<string | null>(null); // For initial list (items) loading const addingItem = ref(false); @@ -363,10 +423,24 @@ const listCostSummary = ref<ListCostSummaryData | null>(null); const costSummaryLoading = ref(false); 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(costSummaryModalRef, () => { showCostSummaryDialog.value = false; }); onClickOutside(confirmModalRef, () => { showConfirmDialogState.value = false; pendingAction.value = null; }); +onClickOutside(settleModalRef, () => { showSettleModal.value = false; }); 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)}`; }; -const processListItems = (items: Item[]): Item[] => { - return items.map(item => ({ - ...item, - priceInput: item.price !== null && item.price !== undefined ? item.price : '' +const processListItems = (items: Item[]) => { + return items.map((i: Item) => ({ + ...i, + updating: false, + deleting: false, + priceInput: i.price || '', })); }; @@ -389,7 +465,7 @@ const fetchListDetails = async () => { // This is for items primarily error.value = null; try { 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); list.value = rawList; // Sets item-related list data @@ -398,7 +474,7 @@ const fetchListDetails = async () => { // This is for items primarily return item.updated_at > latest ? item.updated_at : latest; }, ''); - if (showCostSummaryDialog.value) { + if (showCostSummaryDialog.value) { await fetchListCostSummary(); } @@ -413,7 +489,7 @@ const checkForUpdates = async () => { if (!list.value) return; try { 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, ''); if ((lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) || @@ -463,7 +539,7 @@ const onAddItem = async () => { } }); const optimisticItem: Item = { - id: Date.now(), + id: Date.now(), name: newItem.value.name, quantity: newItem.value.quantity, is_complete: false, @@ -497,7 +573,7 @@ const updateItem = async (item: Item, newCompleteStatus: boolean) => { if (!list.value) return; item.updating = true; const originalCompleteStatus = item.is_complete; - item.is_complete = newCompleteStatus; + item.is_complete = newCompleteStatus; if (!isOnline.value) { offlineStore.addAction({ @@ -522,7 +598,7 @@ const updateItem = async (item: Item, newCompleteStatus: boolean) => { ); item.version++; } catch (err) { - item.is_complete = originalCompleteStatus; + item.is_complete = originalCompleteStatus; notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item.', type: 'error' }); } finally { item.updating = false; @@ -584,14 +660,14 @@ const deleteItem = async (item: Item) => { 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; return; } try { 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) { notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' }); } finally { @@ -625,11 +701,11 @@ const cancelConfirmation = () => { const openOcrDialog = () => { ocrItems.value = []; ocrError.value = null; - resetOcrFileDialog(); + resetOcrFileDialog(); showOcrDialogState.value = true; nextTick(() => { if (ocrFileInputRef.value) { - ocrFileInputRef.value.value = ''; + ocrFileInputRef.value.value = ''; } }); }; @@ -672,7 +748,7 @@ const handleOcrUpload = async (file: File) => { ocrError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to process image.'; } finally { ocrLoading.value = false; - if (ocrFileInputRef.value) ocrFileInputRef.value.value = ''; + if (ocrFileInputRef.value) ocrFileInputRef.value.value = ''; } }; @@ -685,7 +761,7 @@ const addOcrItems = async () => { if (!item.name.trim()) continue; const response = await apiClient.post( API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)), - { name: item.name, quantity: "1" } + { name: item.name, quantity: "1" } ); const addedItem = response.data as Item; list.value.items.push(processListItems([addedItem])[0]); @@ -722,8 +798,6 @@ watch(showCostSummaryDialog, (newVal) => { // --- Expense and Settlement Status Logic --- 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 getPaidAmountForSplitDisplay = (split: ExpenseSplit): string => { @@ -772,7 +846,7 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => { }); let touchStartX = 0; -const SWIPE_THRESHOLD = 50; +const SWIPE_THRESHOLD = 50; const handleTouchStart = (event: TouchEvent) => { touchStartX = event.changedTouches[0].clientX; @@ -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> <style scoped> @@ -830,6 +989,13 @@ const editItem = (item: Item) => { box-shadow: 6px 6px 0 #111; } +.neo-expenses-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + .neo-expenses-title { font-size: 2rem; font-weight: 900; @@ -1410,4 +1576,4 @@ const editItem = (item: Item) => { transform: scale(1); } } -</style> \ No newline at end of file +</style> diff --git a/fe/src/services/api.ts b/fe/src/services/api.ts index c105117..e32f834 100644 --- a/fe/src/services/api.ts +++ b/fe/src/services/api.ts @@ -1,8 +1,8 @@ -import axios from 'axios'; -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 { useAuthStore } from '@/stores/auth'; // Import the auth store -import type { SettlementActivityCreate } from '@/types/expense'; // Import the type for the payload +import axios from 'axios' +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 { useAuthStore } from '@/stores/auth' // Import the auth store +import type { SettlementActivityCreate } from '@/types/expense' // Import the type for the payload // Create axios instance const api = axios.create({ @@ -11,76 +11,80 @@ const api = axios.create({ 'Content-Type': 'application/json', }, withCredentials: true, // Enable sending cookies and authentication headers -}); +}) // Request interceptor api.interceptors.request.use( (config) => { - const token = localStorage.getItem('token'); // Or use useStorage from VueUse + const token = localStorage.getItem('token') // Or use useStorage from VueUse if (token) { - config.headers.Authorization = `Bearer ${token}`; + config.headers.Authorization = `Bearer ${token}` } - return config; + return config }, (error) => { - return Promise.reject(error); // Simpler error handling - } -); + return Promise.reject(error) // Simpler error handling + }, +) // Response interceptor api.interceptors.response.use( (response) => response, async (error) => { - const originalRequest = error.config; - const authStore = useAuthStore(); // Get auth store instance + const originalRequest = error.config + const authStore = useAuthStore() // Get auth store instance if (error.response?.status === 401 && !originalRequest._retry) { - originalRequest._retry = true; + originalRequest._retry = true try { - const refreshTokenValue = authStore.refreshToken; // Get from store for consistency + const refreshTokenValue = authStore.refreshToken // Get from store for consistency if (!refreshTokenValue) { - console.error('No refresh token, redirecting to login'); - authStore.clearTokens(); // Clear tokens in store and localStorage - await router.push('/auth/login'); - return Promise.reject(error); + console.error('No refresh token, redirecting to login') + authStore.clearTokens() // Clear tokens in store and localStorage + await router.push('/auth/login') + 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, - }); + }) - const { access_token: newAccessToken, refresh_token: newRefreshToken } = response.data; - authStore.setTokens({ access_token: newAccessToken, refresh_token: newRefreshToken }); + const { access_token: newAccessToken, refresh_token: newRefreshToken } = response.data + authStore.setTokens({ access_token: newAccessToken, refresh_token: newRefreshToken }) - originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; - return api(originalRequest); + originalRequest.headers.Authorization = `Bearer ${newAccessToken}` + return api(originalRequest) } catch (refreshError) { - console.error('Refresh token failed:', refreshError); - authStore.clearTokens(); // Clear tokens in store and localStorage - await router.push('/auth/login'); - return Promise.reject(refreshError); + console.error('Refresh token failed:', refreshError) + authStore.clearTokens() // Clear tokens in store and localStorage + await router.push('/auth/login') + 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 -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 => { + // 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) if (endpoint.startsWith('/api/')) { - return `${API_BASE_URL}${endpoint}`; + return `${API_BASE_URL}${endpoint}` } // 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 = { get: (endpoint: string, config = {}) => api.get(getApiUrl(endpoint), config), @@ -88,13 +92,17 @@ export const apiClient = { put: (endpoint: string, data = {}, config = {}) => api.put(getApiUrl(endpoint), data, config), patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config), delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config), - - // Specific method for settling an expense split - 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 - const endpoint = `/expense_splits/${expenseSplitId}/settle`; // Path relative to /api/API_VERSION - return api.post(getApiUrl(endpoint), activityData, config); - } -}; -export { API_ENDPOINTS }; // Also re-export for convenience \ No newline at end of file + // Specific method for settling an expense split + 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 + const endpoint = `/expense_splits/${expenseSplitId}/settle` // Path relative to /api/API_VERSION + return api.post(getApiUrl(endpoint), activityData, config) + }, +} + +export { API_ENDPOINTS } // Also re-export for convenience diff --git a/fe/src/stores/auth.ts b/fe/src/stores/auth.ts index 0a9276c..3580bb5 100644 --- a/fe/src/stores/auth.ts +++ b/fe/src/stores/auth.ts @@ -1,94 +1,94 @@ -import { API_ENDPOINTS } from '@/config/api-config'; -import { apiClient } from '@/services/api'; -import { defineStore } from 'pinia'; -import { ref, computed } from 'vue'; -import router from '@/router'; - +import { API_ENDPOINTS } from '@/config/api-config' +import { apiClient } from '@/services/api' +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import router from '@/router' interface AuthState { - accessToken: string | null; - refreshToken: string | null; + accessToken: string | null + refreshToken: string | null user: { - email: string; - name: string; - id?: string | number; - } | null; + email: string + name: string + id?: string | number + } | null } export const useAuthStore = defineStore('auth', () => { // State - const accessToken = ref<string | null>(localStorage.getItem('token')); - const refreshToken = ref<string | null>(localStorage.getItem('refreshToken')); - const user = ref<AuthState['user']>(null); + const accessToken = ref<string | null>(localStorage.getItem('token')) + const refreshToken = ref<string | null>(localStorage.getItem('refreshToken')) + const user = ref<AuthState['user']>(null) // Getters - const isAuthenticated = computed(() => !!accessToken.value); - const getUser = computed(() => user.value); + const isAuthenticated = computed(() => !!accessToken.value) + const getUser = computed(() => user.value) // Actions const setTokens = (tokens: { access_token: string; refresh_token?: string }) => { - accessToken.value = tokens.access_token; - localStorage.setItem('token', tokens.access_token); + accessToken.value = tokens.access_token + localStorage.setItem('token', tokens.access_token) if (tokens.refresh_token) { - refreshToken.value = tokens.refresh_token; - localStorage.setItem('refreshToken', tokens.refresh_token); + refreshToken.value = tokens.refresh_token + localStorage.setItem('refreshToken', tokens.refresh_token) } - }; + } const clearTokens = () => { - accessToken.value = null; - refreshToken.value = null; - user.value = null; - localStorage.removeItem('token'); - localStorage.removeItem('refreshToken'); - }; + accessToken.value = null + refreshToken.value = null + user.value = null + localStorage.removeItem('token') + localStorage.removeItem('refreshToken') + } const setUser = (userData: AuthState['user']) => { - user.value = userData; - }; + user.value = userData + } const fetchCurrentUser = async () => { if (!accessToken.value) { - clearTokens(); - return null; + clearTokens() + return null } try { - const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE); - setUser(response.data); - return response.data; + const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE) + setUser(response.data) + return response.data } catch (error: any) { - console.error('AuthStore: Failed to fetch current user:', error); - clearTokens(); - return null; + console.error('AuthStore: Failed to fetch current user:', error) + clearTokens() + return null } - }; + } const login = async (email: string, password: string) => { - const formData = new FormData(); - formData.append('username', email); - formData.append('password', password); + const formData = new FormData() + formData.append('username', email) + formData.append('password', password) const response = await apiClient.post(API_ENDPOINTS.AUTH.LOGIN, formData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, - }); + }) - const { access_token, refresh_token } = response.data; - setTokens({ access_token, refresh_token }); - await fetchCurrentUser(); - return response.data; - }; + const { access_token, refresh_token } = response.data + setTokens({ access_token, refresh_token }) + // Skip fetching profile data + // await fetchCurrentUser(); + return response.data + } const signup = async (userData: { name: string; email: string; password: string }) => { - const response = await apiClient.post(API_ENDPOINTS.AUTH.SIGNUP, userData); - return response.data; - }; + const response = await apiClient.post(API_ENDPOINTS.AUTH.SIGNUP, userData) + return response.data + } const logout = async () => { - clearTokens(); - await router.push('/auth/login'); - }; + clearTokens() + await router.push('/auth/login') + } return { accessToken, @@ -103,5 +103,5 @@ export const useAuthStore = defineStore('auth', () => { login, signup, logout, - }; -}); + } +}) diff --git a/fe/src/stores/listDetailStore.ts b/fe/src/stores/listDetailStore.ts index 0f1e880..0401ba7 100644 --- a/fe/src/stores/listDetailStore.ts +++ b/fe/src/stores/listDetailStore.ts @@ -1,18 +1,20 @@ -import { defineStore } from 'pinia'; -import { apiClient, API_ENDPOINTS } from '@/services/api'; -import type { Expense, ExpenseSplit, SettlementActivity } from '@/types/expense'; -import type { SettlementActivityCreate } from '@/types/expense'; -import type { List } from '@/types/list'; +import { defineStore } from 'pinia' +import { apiClient, API_ENDPOINTS } from '@/services/api' +import type { Expense, ExpenseSplit, SettlementActivity } from '@/types/expense' +import type { SettlementActivityCreate } from '@/types/expense' +import type { List } from '@/types/list' +import type { AxiosResponse } from 'axios' export interface ListWithExpenses extends List { - expenses: Expense[]; + id: number + expenses: Expense[] } interface ListDetailState { - currentList: ListWithExpenses | null; - isLoading: boolean; - error: string | null; - isSettlingSplit: boolean; + currentList: ListWithExpenses | null + isLoading: boolean + error: string | null + isSettlingSplit: boolean } export const useListDetailStore = defineStore('listDetail', { @@ -25,101 +27,108 @@ export const useListDetailStore = defineStore('listDetail', { actions: { async fetchListWithExpenses(listId: string) { - this.isLoading = true; - this.error = null; + this.isLoading = true + this.error = null try { - // This assumes API_ENDPOINTS.LISTS.BY_ID(listId) generates a path like "/lists/{id}" - // and getApiUrl (from services/api.ts) correctly prefixes it with API_BASE_URL and /api/API_VERSION if necessary. - const endpoint = API_ENDPOINTS.LISTS.BY_ID(listId); - - const response = await apiClient.get(endpoint); - this.currentList = response as ListWithExpenses; + const endpoint = API_ENDPOINTS.LISTS.BY_ID(listId) + const response = await apiClient.get(endpoint) + this.currentList = response.data as ListWithExpenses } catch (err: any) { - this.error = err.response?.data?.detail || err.message || 'Failed to fetch list details'; - this.currentList = null; - console.error('Error fetching list details:', err); + this.error = err.response?.data?.detail || err.message || 'Failed to fetch list details' + this.currentList = null + console.error('Error fetching list details:', err) } finally { - this.isLoading = false; + this.isLoading = false } }, - async settleExpenseSplit(payload: { - list_id_for_refetch: string, // ID of the list to refetch after settlement - expense_split_id: number, - activity_data: SettlementActivityCreate + async settleExpenseSplit(payload: { + list_id_for_refetch: string // ID of the list to refetch after settlement + expense_split_id: number + activity_data: SettlementActivityCreate }): Promise<boolean> { - this.isSettlingSplit = true; - this.error = null; + this.isSettlingSplit = true + this.error = null try { // 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. - 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); - // console.log('Settlement activity created (simulated):', createdActivity); - await new Promise(resolve => setTimeout(resolve, 500)); // Simulate network delay + // console.log('Settlement activity created (simulated):', createdActivity); + await new Promise((resolve) => setTimeout(resolve, 500)) // Simulate network delay // End of placeholder for API call // Refresh list data to show updated statuses. // Ensure currentList is not null and its ID matches before refetching, // or always refetch if list_id_for_refetch is the source of truth. 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) { - // Fallback if list_id_for_refetch is not provided but currentList exists - await this.fetchListWithExpenses(String(this.currentList.id)); + // Fallback if list_id_for_refetch is not provided but currentList exists + await this.fetchListWithExpenses(String(this.currentList.id)) } 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; - return true; // Indicate success + + this.isSettlingSplit = false + return true // Indicate success } catch (err: any) { - const errorMessage = err.response?.data?.detail || err.message || 'Failed to settle expense split.'; - this.error = errorMessage; - console.error('Error settling expense split:', err); - this.isSettlingSplit = false; - return false; // Indicate failure + const errorMessage = + err.response?.data?.detail || err.message || 'Failed to settle expense split.' + this.error = errorMessage + console.error('Error settling expense split:', err) + this.isSettlingSplit = false + return false // Indicate failure } }, setError(errorMessage: string) { - this.error = errorMessage; - this.isLoading = false; - } + this.error = errorMessage + this.isLoading = false + }, }, getters: { getList(state: ListDetailState): ListWithExpenses | null { - return state.currentList; + return state.currentList }, getExpenses(state: ListDetailState): Expense[] { - return state.currentList?.expenses || []; + return state.currentList?.expenses || [] }, - getPaidAmountForSplit: (state: ListDetailState) => (splitId: number): number => { - let totalPaid = 0; - if (state.currentList && state.currentList.expenses) { - for (const expense of state.currentList.expenses) { - const split = expense.splits.find(s => s.id === splitId); - if (split && split.settlement_activities) { - totalPaid = split.settlement_activities.reduce((sum, activity) => { - return sum + parseFloat(activity.amount_paid); - }, 0); - break; + getPaidAmountForSplit: + (state: ListDetailState) => + (splitId: number): number => { + let totalPaid = 0 + if (state.currentList && state.currentList.expenses) { + for (const expense of state.currentList.expenses) { + const split = expense.splits.find((s) => s.id === splitId) + if (split && split.settlement_activities) { + totalPaid = split.settlement_activities.reduce((sum, activity) => { + return sum + parseFloat(activity.amount_paid) + }, 0) + break + } } } - } - return totalPaid; - }, - getExpenseSplitById: (state: ListDetailState) => (splitId: number): ExpenseSplit | undefined => { - if (!state.currentList || !state.currentList.expenses) return undefined; + return totalPaid + }, + getExpenseSplitById: + (state: ListDetailState) => + (splitId: number): ExpenseSplit | undefined => { + if (!state.currentList || !state.currentList.expenses) return undefined for (const expense of state.currentList.expenses) { - const split = expense.splits.find(s => s.id === splitId); - if (split) return split; + const split = expense.splits.find((s) => s.id === splitId) + if (split) return split } - return undefined; - } + return undefined + }, }, -}); +}) // Assuming List interface might be defined in fe/src/types/list.ts // If not, it should be defined like this: diff --git a/fe/src/types/item.ts b/fe/src/types/item.ts new file mode 100644 index 0000000..8652ed6 --- /dev/null +++ b/fe/src/types/item.ts @@ -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 +} diff --git a/fe/src/types/list.ts b/fe/src/types/list.ts new file mode 100644 index 0000000..3d912c1 --- /dev/null +++ b/fe/src/types/list.ts @@ -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[] +}