From a115dfd0d037f0fec6b8f750160dd1f305fa9967 Mon Sep 17 00:00:00 2001 From: botamihnea Date: Tue, 9 Dec 2025 18:04:54 +0200 Subject: [PATCH 1/4] Changed the frontend to account for backend changes - currently CRUD for expenses and groups working, join code functionality + filtering + sorting + budgeting --- API/routes/auth_routes.py | 2 +- Web/React/src/components/Dashboard.tsx | 51 ++- Web/React/src/components/GroupDetail.tsx | 336 ++++++++++++++++--- Web/React/src/components/Groups.tsx | 341 ++++++++++++++++++-- Web/React/src/components/ReceiptsManual.tsx | 103 ++++-- Web/React/src/components/ReceiptsView.tsx | 98 +++--- Web/React/src/contexts/AuthContext.tsx | 57 ++-- Web/React/src/services/category-service.ts | 32 ++ 8 files changed, 850 insertions(+), 170 deletions(-) create mode 100644 Web/React/src/services/category-service.ts diff --git a/API/routes/auth_routes.py b/API/routes/auth_routes.py index 5fa7a9f..157d36a 100644 --- a/API/routes/auth_routes.py +++ b/API/routes/auth_routes.py @@ -1,4 +1,4 @@ -from api.utils.helpers.jwt_utils import JwtUtils +from utils.helpers.jwt_utils import JwtUtils from dependencies.di import get_user_service from fastapi import APIRouter, Depends, HTTPException, Request, Response from schemas.user import UserCreate, UserLogin, UserPasswordReset diff --git a/Web/React/src/components/Dashboard.tsx b/Web/React/src/components/Dashboard.tsx index 684013f..f17b712 100644 --- a/Web/React/src/components/Dashboard.tsx +++ b/Web/React/src/components/Dashboard.tsx @@ -5,6 +5,7 @@ import { useAuth } from '../hooks/useAuth'; import '../App.css'; import ThemeToggle from './ThemeToggle'; import { useCurrency } from '../contexts/CurrencyContext'; +import categoryService, { Category } from '../services/category-service'; import Groups from './Groups'; import Profile from './Profile'; import Support from './Support'; @@ -16,7 +17,7 @@ import ChatBot from './ChatBot'; import Data from './Data'; import Alerts from './Alerts'; import Notifications from './Notifications'; -import Map from './Map'; +import MapComponent from './Map'; interface Tx { id: number; @@ -62,16 +63,38 @@ const Dashboard: React.FC = () => { }, }); - const items = Array.isArray(res.data) ? res.data : []; - const mapped: Tx[] = items.map((x: any) => ({ - id: x.id, - title: x.title, - cat: x.category || 'Other', - amount: x.amount, - thumb: x.title ? x.title[0].toUpperCase() : 'T', - })); - - setRecentTx(mapped); + // Backend returns APIResponse { success: true, data: [expenses] } or just [expenses] + const responseData = res.data; + const items = Array.isArray(responseData) ? responseData : (Array.isArray(responseData?.data) ? responseData.data : []); + + // Fetch categories to map category_id to title + try { + const categories = await categoryService.getCategories(); + const categoryArray = Array.isArray(categories) ? categories : []; + + const categoryMap = new Map(categoryArray.map((c: any) => [c.id, c.title])); + + const mapped: Tx[] = items.map((x: any) => ({ + id: x.id, + title: x.title, + cat: categoryMap.get(x.category_id) || 'Other', + amount: x.amount, + thumb: x.title ? x.title[0].toUpperCase() : 'T', + })); + + setRecentTx(mapped); + } catch (catErr) { + console.error('Failed to fetch categories, showing without categories', catErr); + // Fallback without categories + const mapped: Tx[] = items.map((x: any) => ({ + id: x.id, + title: x.title, + cat: 'Other', + amount: x.amount, + thumb: x.title ? x.title[0].toUpperCase() : 'T', + })); + setRecentTx(mapped); + } } catch (err) { console.error('Failed to fetch recent transactions', err); setRecentTx([]); @@ -88,7 +111,9 @@ const Dashboard: React.FC = () => { }, }); - const items = Array.isArray(res.data) ? res.data : []; + // Backend returns APIResponse { success: true, data: [expenses] } or just [expenses] + const responseData = res.data; + const items = Array.isArray(responseData) ? responseData : (Array.isArray(responseData?.data) ? responseData.data : []); const total = items.reduce((acc: number, it: any) => acc + (Number(it.amount) || 0), 0); setTotalSpent(total); } catch (err) { @@ -345,7 +370,7 @@ const Dashboard: React.FC = () => { {screen === 'notifications' && } - {screen === 'map' && } + {screen === 'map' && } {/* BOTTOM NAV — kept for mobile (hidden on desktop by CSS) */} diff --git a/Web/React/src/components/GroupDetail.tsx b/Web/React/src/components/GroupDetail.tsx index 426a7cf..8a883a4 100644 --- a/Web/React/src/components/GroupDetail.tsx +++ b/Web/React/src/components/GroupDetail.tsx @@ -1,7 +1,10 @@ // src/components/GroupDetail.tsx import React, { useEffect, useState } from 'react'; +import apiClient from '../services/api-client'; +import { useAuth } from '../hooks/useAuth'; import '../App.css'; import { useCurrency } from '../contexts/CurrencyContext'; +import categoryService, { Category } from '../services/category-service'; interface Expense { id: number; @@ -19,35 +22,134 @@ interface Props { } const GroupDetail: React.FC = ({ groupId, onBack }) => { + const { user } = useAuth(); const [groupName, setGroupName] = useState(null); + const [invitationCode, setInvitationCode] = useState(null); + const [showInvitation, setShowInvitation] = useState(false); const [expenses, setExpenses] = useState([]); + const [loading, setLoading] = useState(false); + const [categories, setCategories] = useState([]); + const [showAddExpense, setShowAddExpense] = useState(false); + const [newExpense, setNewExpense] = useState({ + title: '', + amount: '', + categoryId: null as number | null, + }); - useEffect(() => { + const fetchGroupInfo = async () => { + if (!groupId) return; + + try { + const res = await apiClient.get(`/groups/${groupId}`); + // Backend returns APIResponse { success: true, data: GroupResponse } + const groupData = res.data?.data || res.data; + setGroupName(groupData.name || `Group ${groupId}`); + setInvitationCode(groupData.invitation_code || null); + } catch (err) { + console.error('Failed to fetch group info', err); + setGroupName(`Group ${groupId}`); + } + }; + + const fetchGroupExpenses = async () => { if (!groupId) return; + + setLoading(true); + try { + const res = await apiClient.get(`/expenses/group/${groupId}`); + + // Backend returns APIResponse { success: true, data: [expenses] } + const responseData = res.data; + const items = Array.isArray(responseData) ? responseData : (responseData?.data || []); + + // Fetch categories + const categories = await categoryService.getCategories(); + const categoryMap = new Map(categories.map(c => [c.id, c.title])); + + // Get unique user IDs + const uniqueUserIds = [...new Set(items.map((exp: any) => exp.user_id))]; + + // Fetch user details for each unique user ID + const userDetailsMap: Record = {}; + await Promise.all( + uniqueUserIds.map(async (userId: any) => { + try { + const userRes = await apiClient.get(`/users/${userId}`); + // Backend returns APIResponse { success: true, data: UserResponse } + const userData = userRes.data?.data || userRes.data; + userDetailsMap[userId] = { + first_name: userData.first_name || 'User', + last_name: userData.last_name || '', + }; + } catch (err) { + console.error(`Failed to fetch user ${userId}`, err); + userDetailsMap[userId] = { first_name: `User ${userId}`, last_name: '' }; + } + }) + ); + + const mapped: Expense[] = items.map((exp: any) => { + const userDetails = userDetailsMap[exp.user_id] || { first_name: 'Unknown', last_name: '' }; + const fullName = `${userDetails.first_name}`.trim(); + + return { + id: exp.id, + title: exp.title || 'Untitled', + category: categoryMap.get(exp.category_id) || 'Uncategorized', + amount: exp.amount || 0, + userName: fullName, + userInitial: (userDetails.first_name[0] || 'U').toUpperCase(), + date: exp.created_at ? new Date(exp.created_at).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10), + }; + }); + + setExpenses(mapped); + } catch (err) { + console.error('Failed to fetch group expenses', err); + setExpenses([]); + } finally { + setLoading(false); + } + }; - // Mock fetch group info and expenses - // TODO: replace with API call e.g. GET /groups/{groupId} and GET /groups/{groupId}/expenses - const mockGroupNames: Record = { - 1: 'Vacation', - 2: 'Household', - 3: 'Friends', - }; - - setGroupName(mockGroupNames[groupId] || `Group ${groupId}`); - - // Sample/mock expenses (chat-like) - const mockExpenses: Expense[] = [ - { id: 1, title: 'Hotel booking', category: 'Travel', amount: -420, userName: 'Alice', userInitial: 'A', date: '2025-10-01' }, - { id: 2, title: 'Dinner', category: 'Food', amount: -85, userName: 'Bob', userInitial: 'B', date: '2025-10-02' }, - { id: 3, title: 'Train tickets', category: 'Travel', amount: -120, userName: 'Charlie', userInitial: 'C', date: '2025-10-03' }, - { id: 4, title: 'Refund', category: 'Adjustment', amount: +50, userName: 'Alice', userInitial: 'A', date: '2025-10-04' }, - ]; - - // Simulate network delay for realism (optional) - const t = setTimeout(() => setExpenses(mockExpenses), 120); - return () => clearTimeout(t); + useEffect(() => { + if (!groupId) return; + fetchGroupInfo(); + fetchGroupExpenses(); + fetchCategories(); }, [groupId]); + const fetchCategories = async () => { + try { + const cats = await categoryService.getCategories(); + + // If no categories exist, create default ones + if (cats.length === 0) { + const defaultCategories = ['Food', 'Transport', 'Entertainment', 'Shopping', 'Bills', 'Other']; + for (const title of defaultCategories) { + try { + await categoryService.createCategory(title); + } catch (err) { + console.error(`Failed to create category ${title}`, err); + } + } + // Refetch + const newCats = await categoryService.getCategories(); + setCategories(newCats); + if (newCats.length > 0) { + setNewExpense(prev => ({ ...prev, categoryId: newCats[0].id })); + } + } else { + setCategories(cats); + if (cats.length > 0) { + setNewExpense(prev => ({ ...prev, categoryId: cats[0].id })); + } + } + } catch (err) { + console.error('Failed to fetch categories', err); + } + }; + const cur = useCurrency(); return ( @@ -87,38 +189,186 @@ const GroupDetail: React.FC = ({ groupId, onBack }) => { ))} - {/* footer action — Add expense (mock) */} + {/* footer action — Add expense */}
+ + {/* Add Expense Form */} + {showAddExpense && ( +
+
Add Expense
+ +
{ + e.preventDefault(); + + if (!user?.id || !groupId) { + alert('User or group not available'); + return; + } + + if (!newExpense.title.trim() || !newExpense.amount || !newExpense.categoryId) { + alert('Please fill in all fields'); + return; + } + + const amount = parseFloat(newExpense.amount); + if (amount <= 0) { + alert('Amount must be positive'); + return; + } + + try { + const body = { + title: newExpense.title.trim(), + amount, + category_id: newExpense.categoryId, + group_id: groupId, + }; + + await apiClient.post('/expenses', body); + + // Refresh the expense list + await fetchGroupExpenses(); + + // Reset form + setNewExpense({ + title: '', + amount: '', + categoryId: categories.length > 0 ? categories[0].id : null, + }); + setShowAddExpense(false); + alert('Expense added successfully!'); + } catch (err: any) { + console.error('Failed to create expense', err); + alert(`Failed to add expense: ${err.response?.data?.detail || err.message}`); + } + }} style={{ display: 'grid', gap: 12 }}> + +
+ + setNewExpense(prev => ({ ...prev, title: e.target.value }))} + placeholder="e.g. Lunch at restaurant" + style={{ + width: '100%', + padding: '10px 12px', + borderRadius: 8, + border: '1px solid rgba(0,0,0,0.1)', + fontSize: 14, + }} + required + /> +
+ +
+ + setNewExpense(prev => ({ ...prev, amount: e.target.value }))} + placeholder="0.00" + style={{ + width: '100%', + padding: '10px 12px', + borderRadius: 8, + border: '1px solid rgba(0,0,0,0.1)', + fontSize: 14, + }} + required + /> +
+ +
+ + +
+ + +
+
+ )} + + {/* Invitation Code Display */} + {showInvitation && groupId && invitationCode && ( +
+
Group Invitation
+ +
+ {invitationCode} +
+ +
+ Share this code to invite members +
+
+ )} ); }; diff --git a/Web/React/src/components/Groups.tsx b/Web/React/src/components/Groups.tsx index b9889a7..5579f9f 100644 --- a/Web/React/src/components/Groups.tsx +++ b/Web/React/src/components/Groups.tsx @@ -1,5 +1,7 @@ // src/components/Groups.tsx -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; +import apiClient from '../services/api-client'; +import { useAuth } from '../hooks/useAuth'; import '../App.css'; interface Group { @@ -13,52 +15,327 @@ const Groups: React.FC<{ navigate: (to: 'home'|'groups'|'receipts'|'profile'|'support') => void; openGroup: (groupId: number) => void; }> = ({ navigate, openGroup }) => { - const [groups, setGroups] = useState([ - { id: 1, name: 'Vacation', members: 2, thumb: 'V' }, - { id: 2, name: 'Household', members: 4, thumb: 'H' }, - { id: 3, name: 'Friends', members: 6, thumb: 'F' }, - ]); - - const handleCreateGroup = () => { - const name = prompt('Group name'); - if (!name) return; - const membersRaw = prompt('Number of members', '1'); - const members = membersRaw ? parseInt(membersRaw, 10) || 1 : 1; - const id = (groups[groups.length - 1]?.id || 0) + 1; - const thumb = name.trim()[0]?.toUpperCase() || 'G'; - setGroups(prev => [...prev, { id, name: name.trim(), members, thumb }]); + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(false); + const { user } = useAuth(); + const [userLoaded, setUserLoaded] = useState(false); + const [showCreateForm, setShowCreateForm] = useState(false); + const [showJoinForm, setShowJoinForm] = useState(false); + const [newGroup, setNewGroup] = useState({ name: '', description: '' }); + const [invitationCode, setInvitationCode] = useState(''); + + useEffect(() => { + if (user && user.id) { + setUserLoaded(true); + } + }, [user]); + + const fetchGroups = async () => { + if (!user || !user.id) { + return; + } + + setLoading(true); + try { + const res = await apiClient.get('/groups', { + params: { offset: 0, limit: 1000 } + }); + + // Backend returns APIResponse { success: true, data: [groups] } + const responseData = res.data; + const allGroups = responseData?.data || responseData || []; + + if (!Array.isArray(allGroups)) { + setGroups([]); + return; + } + + // Filter to only groups where user is a member + const userGroupsPromises = allGroups.map(async (g: any) => { + try { + const membersRes = await apiClient.get(`/groups/${g.id}/users`); + // Backend returns APIResponse { success: true, data: [users] } + const responseData = membersRes.data; + const members = responseData?.data || responseData || []; + + if (!Array.isArray(members)) { + return null; + } + + const isMember = members.some((m: any) => m.id === user.id || m.user_id === user.id); + return isMember ? g : null; + } catch (err) { + console.error(`Failed to check membership for group ${g.id}`, err); + return null; + } + }); + + const userGroupsResults = await Promise.all(userGroupsPromises); + const items = userGroupsResults.filter(g => g !== null); + + // Fetch member count for each group + const mapped: Group[] = await Promise.all( + items.map(async (g: any) => { + let memberCount = 0; + try { + const countRes = await apiClient.get(`/groups/${g.id}/users/nr`); + const responseData = countRes.data; + memberCount = typeof responseData === 'number' ? responseData : (responseData?.data || 0); + + if (typeof memberCount !== 'number') { + memberCount = 0; + } + } catch (err) { + console.error(`Failed to fetch member count for group ${g.id}`, err); + } + + return { + id: g.id, + name: g.name || 'Unnamed Group', + members: memberCount, + thumb: (g.name?.[0] || 'G').toUpperCase(), + }; + }) + ); + + setGroups(mapped); + } catch (err) { + console.error('Failed to fetch groups', err); + setGroups([]); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (user && user.id) { + fetchGroups(); + } + }, [user, userLoaded]); + + const handleCreateGroup = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!user || !user.id) { + alert('You must be logged in to create a group. Please wait for authentication to complete.'); + return; + } + + if (!newGroup.name.trim()) { + alert('Please enter a group name'); + return; + } + + try { + const body: any = { + name: newGroup.name.trim(), + }; + if (newGroup.description && newGroup.description.trim()) { + body.description = newGroup.description.trim(); + } + + const createRes = await apiClient.post('/groups', body); + + // Backend returns APIResponse { success: true, data: { id: groupId } } + const responseData = createRes.data; + const groupId = responseData?.data?.id || responseData?.id; + + if (groupId && user.id) { + try { + await apiClient.post(`/groups/${groupId}/users/${user.id}`); + } catch (err: any) { + console.error('Failed to add creator to group', err); + } + } + + await fetchGroups(); + setNewGroup({ name: '', description: '' }); + setShowCreateForm(false); + alert('Group created successfully!'); + } catch (err: any) { + console.error('Failed to create group', err); + alert(err?.response?.data?.detail || 'Failed to create group'); + } + }; + + const handleJoinGroup = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!invitationCode || !invitationCode.trim()) { + alert('Please enter an invitation code'); + return; + } + + try { + await apiClient.post(`/users/join-group/${invitationCode.trim()}`); + await fetchGroups(); + setInvitationCode(''); + setShowJoinForm(false); + alert('Successfully joined the group!'); + } catch (err: any) { + console.error('Failed to join group', err); + alert(err?.response?.data?.detail || 'Failed to join group. Check the invitation code.'); + } }; return ( <>
Groups
+
+ {/* Join Group Form */} + {showJoinForm && ( +
+
Join Group
+ +
+
+ + setInvitationCode(e.target.value)} + placeholder="e.g., A2VC3B" + style={{ + width: '100%', + padding: '10px 12px', + borderRadius: 8, + border: '1px solid rgba(0,0,0,0.1)', + fontSize: 14, + textTransform: 'uppercase', + }} + required + /> +
+ + +
+
+ )} +
My Groups
- {groups.map(g => ( -
openGroup(g.id)} - style={{ cursor: 'pointer' }} - role="button" - aria-label={`Open group ${g.name}`} - > -
{g.thumb}
-
-
{g.name}
-
{g.members} members
-
+ {loading ? ( +
+ Loading groups...
- ))} + ) : groups.length === 0 ? ( +
+ No groups yet. Create your first group! +
+ ) : ( + groups.map(g => ( +
openGroup(g.id)} + style={{ cursor: 'pointer' }} + role="button" + aria-label={`Open group ${g.name}`} + > +
{g.thumb}
+
+
{g.name}
+
{g.members} members
+
+
+ )) + )}
- + + {/* Create Group Form */} + {showCreateForm && ( +
+
Create New Group
+ +
+
+ + setNewGroup(prev => ({ ...prev, name: e.target.value }))} + placeholder="e.g., Vacation 2025" + style={{ + width: '100%', + padding: '10px 12px', + borderRadius: 8, + border: '1px solid rgba(0,0,0,0.1)', + fontSize: 14, + }} + required + /> +
+ +
+ +