From 52fc33b472bc9f55758707502b87ac7b31fea7e8 Mon Sep 17 00:00:00 2001 From: "Mohamad.Elsena" Date: Thu, 22 May 2025 13:05:49 +0200 Subject: [PATCH] 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. --- fe/src/components/CreateExpenseForm.vue | 346 ++++++++++++++++++++ fe/src/components/SettleShareModal.vue | 405 +++++++++++------------- fe/src/config/api-config.ts | 208 ++++++------ fe/src/pages/ListDetailPage.vue | 216 +++++++++++-- fe/src/services/api.ts | 106 ++++--- fe/src/stores/auth.ts | 112 +++---- fe/src/stores/listDetailStore.ts | 145 +++++---- fe/src/types/item.ts | 11 + fe/src/types/list.ts | 18 ++ 9 files changed, 1050 insertions(+), 517 deletions(-) create mode 100644 fe/src/components/CreateExpenseForm.vue create mode 100644 fe/src/types/item.ts create mode 100644 fe/src/types/list.ts 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 @@ + + + + + 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 @@ 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

+
+

Expenses

+ +

Loading expenses...

@@ -123,7 +131,7 @@ Paid by: {{ expense.paid_by_user?.name || expense.paid_by_user?.email || `User ID: ${expense.paid_by_user_id}` }} on {{ new Date(expense.expense_date).toLocaleDateString() }}
- +
@@ -136,6 +144,14 @@ Paid: {{ getPaidAmountForSplitDisplay(split) }} on {{ new Date(split.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 @@
+ + + + + + @@ -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(null); // This is for items +const list = ref(null); const loading = ref(true); // For initial list (items) loading const error = ref(null); // For initial list (items) loading const addingItem = ref(false); @@ -363,10 +423,24 @@ const listCostSummary = ref(null); const costSummaryLoading = ref(false); const costSummaryError = ref(null); +// Settle Share +const authStore = useAuthStore(); +const showSettleModal = ref(false); +const settleModalRef = ref(null); +const selectedSplitForSettlement = ref(null); +const parentExpenseOfSelectedSplit = ref(null); +const settleAmount = ref(''); +const settleAmountError = ref(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)); + } +}; + \ No newline at end of file + 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(localStorage.getItem('token')); - const refreshToken = ref(localStorage.getItem('refreshToken')); - const user = ref(null); + const accessToken = ref(localStorage.getItem('token')) + const refreshToken = ref(localStorage.getItem('refreshToken')) + const user = ref(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 { - 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[] +}