@@ -164,13 +165,13 @@ export const Friends = () => {
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className={`p-6 flex items-center justify-between ${isNeo
- ? 'bg-emerald-100 border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] rounded-none'
- : 'bg-emerald-500/10 border border-emerald-500/20 rounded-3xl'
+ ? 'bg-emerald-100 border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] rounded-none'
+ : 'bg-emerald-500/10 border border-emerald-500/20 rounded-3xl'
}`}
>
@@ -182,13 +183,13 @@ export const Friends = () => {
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className={`p-6 flex items-center justify-between ${isNeo
- ? 'bg-orange-100 border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] rounded-none'
- : 'bg-orange-500/10 border border-orange-500/20 rounded-3xl'
+ ? 'bg-orange-100 border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] rounded-none'
+ : 'bg-orange-500/10 border border-orange-500/20 rounded-3xl'
}`}
>
Total You Owe
-
{formatCurrency(totalYouOwe)}
+
{formatPrice(totalYouOwe)}
@@ -204,7 +205,7 @@ export const Friends = () => {
className={`p-4 flex items-center justify-between ${isNeo
? 'bg-red-100 border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] rounded-none'
: 'bg-red-500/10 border border-red-500/20 rounded-2xl'
- }`}
+ }`}
>
{error}
@@ -243,24 +244,24 @@ export const Friends = () => {
exit={{ opacity: 0, scale: 0.9 }}
transition={{ delay: index * 0.05 }}
className={`group relative overflow-hidden flex flex-col transition-all duration-300 ${isNeo
- ? 'bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:-translate-y-1 hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] rounded-none'
- : 'bg-white/5 border border-white/10 hover:bg-white/10 hover:border-white/20 backdrop-blur-sm rounded-3xl'
+ ? 'bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:-translate-y-1 hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] rounded-none'
+ : 'bg-white/5 border border-white/10 hover:bg-white/10 hover:border-white/20 backdrop-blur-sm rounded-3xl'
}`}
>
))}
@@ -302,8 +303,8 @@ export const Friends = () => {
No active groups
)}
diff --git a/web/pages/GroupDetails.tsx b/web/pages/GroupDetails.tsx
index d47ebc06..9fe6b321 100644
--- a/web/pages/GroupDetails.tsx
+++ b/web/pages/GroupDetails.tsx
@@ -1,12 +1,13 @@
import { AnimatePresence, motion } from 'framer-motion';
-import { ArrowRight, Banknote, Check, Copy, DollarSign, Hash, Layers, LogOut, PieChart, Plus, Receipt, Settings, Share2, Trash2, UserMinus } from 'lucide-react';
+import { ArrowRight, Banknote, Check, Copy, DollarSign, Hash, Layers, LogOut, PieChart, Plus, Receipt, Search, Settings, Share2, Trash2, UserMinus } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
+import { AnalyticsContent } from '../components/AnalyticsContent';
import { Button } from '../components/ui/Button';
import { Input } from '../components/ui/Input';
import { Modal } from '../components/ui/Modal';
import { Skeleton } from '../components/ui/Skeleton';
-import { THEMES } from '../constants';
+import { CURRENCIES, THEMES } from '../constants';
import { useAuth } from '../contexts/AuthContext';
import { useTheme } from '../contexts/ThemeContext';
import { useToast } from '../contexts/ToastContext';
@@ -16,6 +17,7 @@ import {
deleteExpense,
deleteGroup,
getExpenses,
+ getGroupAnalytics,
getGroupDetails,
getGroupMembers,
getOptimizedSettlements,
@@ -23,7 +25,8 @@ import {
updateExpense,
updateGroup
} from '../services/api';
-import { Expense, Group, GroupMember, SplitType } from '../types';
+import { Expense, Group, GroupAnalytics, GroupMember, SplitType } from '../types';
+import { formatCurrency } from '../utils/formatters';
type UnequalMode = 'amount' | 'percentage' | 'shares';
@@ -44,10 +47,23 @@ export const GroupDetails = () => {
const [group, setGroup] = useState
(null);
const [expenses, setExpenses] = useState([]);
+ const [totalSummary, setTotalSummary] = useState(null); // Summary for ALL expenses in group
const [members, setMembers] = useState([]);
const [settlements, setSettlements] = useState([]);
const [loading, setLoading] = useState(true);
- const [activeTab, setActiveTab] = useState<'expenses' | 'settlements'>('expenses');
+ const [activeTab, setActiveTab] = useState<'expenses' | 'settlements' | 'analytics'>('expenses');
+
+ // Search and Filter State
+ const [searchQuery, setSearchQuery] = useState('');
+ const [analytics, setAnalytics] = useState(null);
+ const [timeframe, setTimeframe] = useState<'month' | '6months' | 'year'>('month');
+ const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
+ const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth() + 1);
+
+ // Pagination State
+ const [page, setPage] = useState(1);
+ const [hasMore, setHasMore] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
// Modals
const [isExpenseModalOpen, setIsExpenseModalOpen] = useState(false);
@@ -72,6 +88,7 @@ export const GroupDetails = () => {
// Group Settings State
const [editGroupName, setEditGroupName] = useState('');
+ const [editGroupCurrency, setEditGroupCurrency] = useState('USD');
const [settingsTab, setSettingsTab] = useState<'info' | 'members' | 'danger'>('info');
const [copied, setCopied] = useState(false);
@@ -81,6 +98,39 @@ export const GroupDetails = () => {
return me?.role === 'admin';
}, [members, user?._id]);
+ // Calculate group totals using totalSummary (for ALL expenses, not filtered)
+ const groupTotals = useMemo(() => {
+ if (!totalSummary) {
+ // Fallback to calculating from current expenses if totalSummary not available yet
+ const totalSpent = expenses.reduce((sum, e) => sum + e.amount, 0);
+ const myContribution = expenses
+ .filter(e => e.paidBy === user?._id)
+ .reduce((sum, e) => sum + e.amount, 0);
+ const myShare = expenses.reduce((sum, e) => {
+ const mySplit = e.splits.find(s => s.userId === user?._id);
+ return sum + (mySplit?.amount || 0);
+ }, 0);
+ const netBalance = myContribution - myShare;
+
+ return {
+ totalSpent,
+ myContribution,
+ myShare,
+ netBalance,
+ expenseCount: expenses.length
+ };
+ }
+
+ // Use totalSummary from backend (includes ALL expenses)
+ return {
+ totalSpent: totalSummary.totalAmount || 0,
+ myContribution: 0, // This would need to be added to backend summary
+ myShare: 0, // This would need to be added to backend summary
+ netBalance: 0, // This would need to be added to backend summary
+ expenseCount: totalSummary.expenseCount || 0
+ };
+ }, [totalSummary, expenses, user?._id]);
+
useEffect(() => {
if (id) fetchData();
}, [id]);
@@ -100,7 +150,25 @@ export const GroupDetails = () => {
if (other) setPaymentPayeeId(other.userId);
}
}
- }, [members, group, user, editingExpenseId]);
+ }, [members, group, user, editingExpenseId, payerId, paymentPayerId, paymentPayeeId]);
+
+ // Search with debounce
+ const [debouncedSearch, setDebouncedSearch] = useState('');
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedSearch(searchQuery);
+ }, 300); // 300ms debounce
+
+ return () => clearTimeout(handler);
+ }, [searchQuery]);
+
+ // Refetch when search changes
+ useEffect(() => {
+ if (id && !loading) {
+ fetchData();
+ }
+ }, [debouncedSearch]);
const fetchData = async () => {
if (!id) return;
@@ -108,15 +176,23 @@ export const GroupDetails = () => {
try {
const [groupRes, expRes, memRes, setRes] = await Promise.all([
getGroupDetails(id),
- getExpenses(id),
+ getExpenses(id, 1, 20, debouncedSearch || undefined),
getGroupMembers(id),
getOptimizedSettlements(id)
]);
setGroup(groupRes.data);
setExpenses(expRes.data.expenses);
+ setPage(1);
+ setHasMore(expRes.data.pagination?.page < expRes.data.pagination?.totalPages);
setMembers(memRes.data);
setSettlements(setRes.data.optimizedSettlements);
setEditGroupName(groupRes.data.name);
+ setEditGroupCurrency(groupRes.data.currency || 'USD');
+
+ // Store totalSummary separately
+ if (expRes.data.totalSummary) {
+ setTotalSummary(expRes.data.totalSummary);
+ }
} catch (err) {
console.error(err);
} finally {
@@ -124,6 +200,77 @@ export const GroupDetails = () => {
}
};
+ const loadMoreExpenses = async () => {
+ if (!id || !hasMore || loadingMore) return;
+ setLoadingMore(true);
+ try {
+ const nextPage = page + 1;
+ const res = await getExpenses(id, nextPage);
+ setExpenses(prev => [...prev, ...res.data.expenses]);
+ setPage(nextPage);
+ setHasMore(res.data.pagination?.page < res.data.pagination?.totalPages);
+ } catch (err) {
+ console.error(err);
+ addToast("Failed to load more expenses", "error");
+ } finally {
+ setLoadingMore(false);
+ }
+ };
+
+ const fetchAnalytics = async () => {
+ if (!id) return;
+ try {
+ // For 6 months, don't pass month/year as backend handles it
+ if (timeframe === '6months') {
+ const res = await getGroupAnalytics(id, '6months', undefined, undefined);
+ setAnalytics(res.data);
+ } else if (timeframe === 'year') {
+ const res = await getGroupAnalytics(id, 'year', selectedYear, undefined);
+ setAnalytics(res.data);
+ } else {
+ // month - use selected year and month
+ const res = await getGroupAnalytics(id, 'month', selectedYear, selectedMonth);
+ setAnalytics(res.data);
+ }
+ } catch (err) {
+ console.error(err);
+ addToast("Failed to load analytics", "error");
+ }
+ };
+
+ // Fetch analytics when switching to analytics tab
+ useEffect(() => {
+ if (activeTab === 'analytics' && !analytics) {
+ fetchAnalytics();
+ }
+ }, [activeTab]);
+
+ // Refetch analytics when timeframe, year, or month changes
+ useEffect(() => {
+ if (activeTab === 'analytics' && analytics) {
+ fetchAnalytics();
+ }
+ }, [timeframe, selectedYear, selectedMonth]);
+
+ // Fetch settlements
+ const fetchSettlements = async () => {
+ if (!id) return;
+ try {
+ const res = await getOptimizedSettlements(id);
+ setSettlements(res.data.optimizedSettlements);
+ } catch (err) {
+ console.error(err);
+ addToast("Failed to load settlements", "error");
+ }
+ };
+
+ // Fetch settlements when switching to settlements tab
+ useEffect(() => {
+ if (activeTab === 'settlements') {
+ fetchSettlements();
+ }
+ }, [activeTab]);
+
const copyToClipboard = () => {
if (group?.joinCode) {
navigator.clipboard.writeText(group.joinCode)
@@ -237,6 +384,7 @@ export const GroupDetails = () => {
paidBy: payerId,
splitType,
splits: requestSplits,
+ currency,
};
try {
@@ -272,7 +420,7 @@ export const GroupDetails = () => {
const handleRecordPayment = async (e: React.FormEvent) => {
e.preventDefault();
if (!id) return;
-
+
const numAmount = parseFloat(paymentAmount);
if (paymentPayerId === paymentPayeeId) {
alert('Payer and payee cannot be the same');
@@ -282,7 +430,7 @@ export const GroupDetails = () => {
alert('Please enter a valid amount');
return;
}
-
+
try {
await createSettlement(id, {
payer_id: paymentPayerId,
@@ -302,9 +450,12 @@ export const GroupDetails = () => {
e.preventDefault();
if (!id) return;
try {
- await updateGroup(id, { name: editGroupName });
- setIsSettingsModalOpen(false);
+ await updateGroup(id, {
+ name: editGroupName,
+ currency: editGroupCurrency
+ });
fetchData();
+ setIsSettingsModalOpen(false);
addToast('Group updated successfully!', 'success');
} catch (err) {
addToast("Failed to update group", 'error');
@@ -430,6 +581,34 @@ export const GroupDetails = () => {
@@ -460,6 +646,37 @@ export const GroupDetails = () => {
exit={{ opacity: 0, y: -20 }}
className="space-y-4 max-w-3xl mx-auto"
>
+ {/* Search Bar */}
+
+
+ {/* Search Results Count */}
+ {searchQuery && (
+
+ Found {expenses.length} expense{expenses.length !== 1 ? 's' : ''} matching "{searchQuery}"
+
+ )}
+
{loading ? Array(3).fill(0).map((_, i) =>
) :
expenses.map((expense, idx) => (