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..dfe08f6 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; @@ -11,6 +14,20 @@ interface Expense { userName: string; userInitial: string; date: string; + userId: number; +} + +interface GroupLog { + id: number; + user_id: number; + action: string; + created_at: string; +} + +interface TimelineItem { + type: 'expense' | 'log'; + data: Expense | GroupLog; + timestamp: Date; } interface Props { @@ -19,35 +36,255 @@ 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 [timeline, setTimeline] = useState([]); + const [memberCount, setMemberCount] = useState(0); + 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, + }); + const [splitModalExpenseId, setSplitModalExpenseId] = useState(null); + const [groupUsers, setGroupUsers] = useState>([]); + const [loadingSplit, setLoadingSplit] = useState(false); + const [statistics, setStatistics] = useState<{ + my_share_of_expenses: number; + my_total_paid: number; + net_balance_paid_for_others: number; + rest_of_group_expenses: number; + } | null>(null); + const [showStatistics, setShowStatistics] = useState(false); - 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); + + // Fetch member count + const membersRes = await apiClient.get(`/groups/${groupId}/users/nr`); + const memberData = membersRes.data?.data || membersRes.data; + setMemberCount(memberData || 0); + + // Fetch statistics + const statsRes = await apiClient.get(`/groups/${groupId}/statistics/user-summary`); + const statsData = statsRes.data?.data || statsRes.data; + setStatistics(statsData); + } catch (err) { + console.error('Failed to fetch group info', err); + setGroupName(`Group ${groupId}`); + } + }; - // 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); + const fetchGroupExpenses = async () => { + if (!groupId) return; + + setLoading(true); + try { + // Fetch expenses + 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 group logs + const logsRes = await apiClient.get(`/group_logs/${groupId}`); + const logsData = logsRes.data?.data || logsRes.data || []; + + // Fetch categories + const categories = await categoryService.getCategories(); + const categoryMap = new Map(categories.map(c => [c.id, c.title])); + + // Get unique user IDs from both expenses and logs + const expenseUserIds = items.map((exp: any) => exp.user_id); + const logUserIds = logsData.map((log: any) => log.user_id); + const uniqueUserIds = [...new Set([...expenseUserIds, ...logUserIds])]; + + // 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), + userId: exp.user_id, + }; + }); + + setExpenses(mapped); + + // Create timeline combining expenses and logs + const timelineItems: TimelineItem[] = [ + ...mapped.map(exp => ({ + type: 'expense' as const, + data: exp, + timestamp: new Date(exp.date), + })), + ...logsData.map((log: any) => ({ + type: 'log' as const, + data: { + id: log.id, + user_id: log.user_id, + action: log.action, + created_at: log.created_at, + userName: userDetailsMap[log.user_id]?.first_name || 'User', + }, + timestamp: new Date(log.created_at), + })), + ]; + + // Sort by timestamp + timelineItems.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + + setTimeline(timelineItems); + } catch (err) { + console.error('Failed to fetch group expenses', err); + setExpenses([]); + } finally { + setLoading(false); + } + }; + + 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 openSplitModal = async (expenseId: number) => { + if (!groupId) return; + + setSplitModalExpenseId(expenseId); + setLoadingSplit(true); + + try { + // Fetch all users in the group + const usersRes = await apiClient.get(`/groups/${groupId}/users`); + const usersData = usersRes.data?.data || usersRes.data || []; + + // Fetch payment status for this expense + const paymentsRes = await apiClient.get(`/expenses_payments/${expenseId}/payments`); + const paymentsData = paymentsRes.data?.data || paymentsRes.data || []; + const paidUserIds = new Set(paymentsData.map((p: any) => p.user_id)); + + // Combine user list with payment status + const usersWithPaymentStatus = usersData.map((u: any) => ({ + id: u.id, + first_name: u.first_name || 'User', + paid: paidUserIds.has(u.id), + })); + + setGroupUsers(usersWithPaymentStatus); + } catch (err) { + console.error('Failed to fetch split data', err); + alert('Failed to load split information'); + } finally { + setLoadingSplit(false); + } + }; + + const togglePaymentStatus = async (expenseId: number, userId: number, currentlyPaid: boolean) => { + try { + if (currentlyPaid) { + // Unmark as paid + await apiClient.delete(`/expenses_payments/${expenseId}/pay/${userId}`); + } else { + // Mark as paid + await apiClient.post(`/expenses_payments/${expenseId}/pay/${userId}`); + } + + // Refresh the split modal data + await openSplitModal(expenseId); + } catch (err) { + console.error('Failed to toggle payment status', err); + alert('Failed to update payment status'); + } + }; + + const handleLeaveGroup = async () => { + if (!groupId) return; + + const confirmed = window.confirm('Are you sure you want to leave this group?'); + if (!confirmed) return; + + try { + await apiClient.delete(`/groups/${groupId}/leave`); + alert('You have left the group'); + onBack(); + } catch (err) { + console.error('Failed to leave group', err); + alert('Failed to leave group'); + } + }; + const cur = useCurrency(); return ( @@ -67,58 +304,568 @@ const GroupDetail: React.FC = ({ groupId, onBack }) => {
{groupName ?? 'Group'}
-
Expenses for this group
+ {/* Member count panel with statistics summary */} +
+
+ + + + + + + {memberCount} {memberCount === 1 ? 'member' : 'members'} +
+ + {statistics && ( +
+ + {cur.formatAmount(statistics.my_share_of_expenses)} GOT + + | + + {cur.formatAmount(statistics.my_total_paid)} PAID + + | + + {cur.formatAmount(statistics.rest_of_group_expenses)} EXTRA + +
+ )} +
+ + {/* Statistics Panel */} + {statistics && ( +
+ + + {showStatistics && ( +
+
+ Statistics for this group +
+ +
+
+
+ 1. How much you got from shared expenses +
+
+ {cur.formatAmount(statistics.my_share_of_expenses)} +
+
+ +
+
+ 2. How much you paid for this group +
+
+ {cur.formatAmount(statistics.my_total_paid)} +
+
-
- {expenses.map(e => ( -
-
{e.userInitial}
+
+
+ 3. How much you paid for other expenses +
+
= 0 ? 'green' : 'red' }}> + {cur.formatAmount(statistics.net_balance_paid_for_others)} +
+
-
-
{e.title}
-
{e.category} • {e.userName}
-
{e.date}
+
+
+ 4. Rest of group expenses (excluding yours) +
+
+ {cur.formatAmount(statistics.rest_of_group_expenses)} +
+
+
+ )} +
+ )} + +
Expenses for this group
-
- {e.amount < 0 ? '-' : '+'}{cur.formatAmount(Math.abs(e.amount))} + {/* Chat-like timeline display with expenses and join/leave logs */} +
+ {timeline.map((item, index) => { + if (item.type === 'log') { + const logData = item.data as GroupLog & { userName: string }; + + return ( +
+
+ {logData.userName} {logData.action === 'JOIN' ? 'joined' : 'left'} the group +
+
+ ); + } + + // Expense item + const e = item.data as Expense; + const isCurrentUser = e.userId === user?.id; + + return ( +
+ {/* Username label (only for other users) */} + {!isCurrentUser && ( +
+ {e.userName} +
+ )} + + {/* Message bubble */} +
+
+ {e.title} +
+ +
+ {e.amount < 0 ? '-' : ''}{cur.formatAmount(Math.abs(e.amount))} +
+ +
+ {e.category} + {e.date} +
+ + {/* Split button for current user's expenses */} + {isCurrentUser && ( + + )} +
- - ))} + ); + })}
- {/* footer action — Add expense (mock) */} -
+ {/* footer action — Add expense */} +
+
+ + + +
+ +
- + +
+ )} + + {/* Invitation Code Display */} + {showInvitation && groupId && invitationCode && ( +
+
Group Invitation
+ +
+ {invitationCode} +
+ +
+ Share this code to invite members +
+
+ )} + + {/* Split Modal */} + {splitModalExpenseId !== null && ( +
setSplitModalExpenseId(null)} > - Split summary - -
+
e.stopPropagation()} + > +
+ Split Payment + +
+ + {loadingSplit ? ( +
+ Loading... +
+ ) : ( +
+ {groupUsers.map(user => ( + + ))} +
+ )} +
+
+ )}
); }; diff --git a/Web/React/src/components/Groups.tsx b/Web/React/src/components/Groups.tsx index b9889a7..982b848 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,340 @@ 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()}`); + // Successfully joined + await fetchGroups(); + setInvitationCode(''); + setShowJoinForm(false); + alert('Successfully joined the group!'); + } catch (err: any) { + console.error('Failed to join group', err); + + // Backend has a bug where it returns 500 after successfully adding user to group + // The database operation completes, but the response serialization fails + // So we check if it's a 500 error and try to refresh groups anyway + if (err?.response?.status === 500 || err?.code === 'ERR_NETWORK') { + // Try refreshing groups - user might have been added successfully + await fetchGroups(); + setInvitationCode(''); + setShowJoinForm(false); + alert('You may have joined the group. Please check your groups list.'); + } else { + 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 + /> +
+ +
+ +