Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 275 additions & 0 deletions frontend/app/(root)/trips/[id]/bill-splitting/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
'use client';

import { useEffect, useState } from 'react';
import { getToken, logout } from '@/lib/auth';
import { Alert } from '@/components/ui/alert';
import { Banknote, Users, ListOrdered, ArrowLeft } from 'lucide-react';
import { useTrip } from '@/context/TripContext';
import { useRouter, useSearchParams } from 'next/navigation';
import ExpensesHistoryTab from '@/components/bill-splitting/tabs/ExpensesHistoryTab';
import BalancesTab from '@/components/bill-splitting/tabs/BalancesTab';
import SettlementsTab from '@/components/bill-splitting/tabs/SettlementsTab';
import { appStore } from '@/store/appStore';
import { toast } from 'sonner';
import axiosInstance from '@/lib/axiosInstance';
import { Button } from '@/components/ui/button';

const ITEMS_PER_PAGE = 5;

export default function GroupExpensesPage() {
const { trip, refreshTrip } = useTrip();
const [error, setError] = useState<string | null>(null);
const [groupBalance, setGroupBalance] = useState([]);
const [expenses, setExpenses] = useState([]);
const [settlements, setSettlements] = useState([]);
const [settlementsHistory, setSettlementsHistory] = useState([]);
Comment on lines +22 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Consider providing specific types for your state arrays instead of [] (which defaults to any[] or never[] depending on inference). This improves type safety and makes it clearer what data these states are expected to hold.

For example:

interface GroupBalanceItem {
  userId: number;
  balance: number;
  // ... other properties
}

interface ExpenseItem {
  id: string; // or number
  description: string;
  totalAmount: number;
  paidByUserId: number;
  createdAt: string;
  userShares: Record<string, number>;
  // ... other properties
}

// Similar interfaces for SettlementItem and SettlementHistoryItem

const [groupBalance, setGroupBalance] = useState<GroupBalanceItem[]>([]);
const [expenses, setExpenses] = useState<ExpenseItem[]>([]);
// etc.

What are the expected shapes for groupBalance, expenses, settlements, and settlementsHistory items?

const [loading, setLoading] = useState(true);
const [showDialog, setShowDialog] = useState(false);
const [activeTab, setActiveTab] = useState<
'history' | 'balances' | 'settlements'
>('history');
const searchParams = useSearchParams();
const user = appStore((state) => state.user);
const router = useRouter();

useEffect(() => {
if (!trip || !trip.id) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (code-quality): Use block braces for ifs, whiles, etc. (use-braces)

Suggested change
if (!trip || !trip.id) return;
if (!trip || !trip.id) {


ExplanationIt is recommended to always use braces and create explicit statement blocks.

Using the allowed syntax to just write a single statement can lead to very confusing
situations, especially where subsequently a developer might add another statement
while forgetting to add the braces (meaning that this wouldn't be included in the condition).

fetchData();
}, [trip]);

const fetchData = async () => {
try {
const [balanceRes, expensesRes, settlementsRes, settlementsHistoryRes] =
await Promise.all([
axiosInstance.get(`/groups/${trip.id}/balance/balances`),
axiosInstance.get(`/groups/${trip.id}/expenses`),
axiosInstance.get(`/groups/${trip.id}/balance/minimum-transactions`),
axiosInstance.get(`/groups/${trip.id}/settlements`),
]);

setGroupBalance(balanceRes.data);
setExpenses(expensesRes.data.content);
setSettlements(settlementsRes.data);
setSettlementsHistory(settlementsHistoryRes.data);
} catch (err) {
setError(err.message || 'An error occurred');
} finally {
Comment on lines +54 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Accessing err.message on unknown error type

Narrow the type of err before accessing message, for example with if (err instanceof Error) or axios.isAxiosError to ensure type safety.

Suggested change
} catch (err) {
setError(err.message || 'An error occurred');
} finally {
} catch (err) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('An error occurred');
}
} finally {

setLoading(false);
}
};

const upsertExpense = async (values, id?: number) => {
try {
const splitTypeMap = {
amount: 'AMOUNT',
share: 'SHARES',
percentage: 'PERCENTAGE',
equally: 'EQUALLY',
};
const userShares = {};
if (values.splitMethod === 'equally') {
trip.memberships.forEach((u) => {
userShares[u.userId] = 0;
});
} else {
values.shares.forEach((share) => {
if (
share.included &&
share.value !== undefined &&
share.value !== null &&
share.value !== ''
) {
userShares[share.userId] = Number(share.value);
}
});
}
const payload = {
groupId: trip.id,
paidByUserId: user.id,
description: values.description,
totalAmount: Number(values.totalAmount),
splitType: splitTypeMap[values.splitMethod],
userShares,
expenseDate: values.expenseDate || new Date().toISOString(),
};
if (id) {
await axiosInstance.put(`/groups/${trip.id}/expenses/${id}`, payload);
toast('Expense updated!');
} else {
await axiosInstance.post(`/groups/${trip.id}/expenses`, payload);
toast('Expense added successfully!');
}
setShowDialog(false);
refreshTrip();
} catch (err) {
setError(err.message || 'An error occurred while adding the expense');
}
};

const upsertSettlement = async (values, id?: number) => {
try {
const token = getToken();
if (!token) {
logout();
return;
}
const payload = {
groupId: trip.id,
debtorId: String(values.debtorId),
creditorId: String(values.creditorId),
amount: Number(values.amount),
};

if (id) {
await axiosInstance.put(
`/groups/${trip.id}/settlements/${id}`,
payload,
);
toast('Settlement updated!');
} else {
await axiosInstance.post(`/groups/${trip.id}/settlements`, payload);
toast('Settlement added successfully!');
}
setShowDialog(false);
refreshTrip();
} catch (err) {
setError(err.message || 'An error occurred while saving the settlement');
}
};

const handleSettlementDelete = async (settlementId: number) => {
try {
await axiosInstance.delete(
`/groups/${trip.id}/settlements/${settlementId}`,
);
toast('Settlement deleted!');
refreshTrip();
} catch (err) {
toast.error(err.message || 'Failed to delete settlement');
}
};

const handleExpenseDelete = async (expenseId: number) => {
try {
await axiosInstance.delete(`/groups/${trip.id}/expenses`, {
params: { expenseId },
});
toast('Expense deleted!');
refreshTrip();
} catch (err) {
toast.error(err.message || 'Failed to delete expense');
}
};

const balanceMap = new Map(groupBalance.map((b) => [b.userId, b.balance]));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The item b in groupBalance.map((b) => [b.userId, b.balance])) is untyped. If groupBalance state is typed (as suggested in a previous comment), this b would automatically infer its type. What is the expected shape of objects in the groupBalance array?


const currentPage = Number(searchParams.get('page') || 1);
const paginatedExpenses = expenses.slice(
(currentPage - 1) * ITEMS_PER_PAGE,
currentPage * ITEMS_PER_PAGE,
);

const initialValues = {
description: '',
totalAmount: 0,
splitMethod: 'amount',
shares:
trip?.memberships.map((u) => ({
userId: u.userId,
included: true,
value: undefined,
})) || [],
Comment on lines +177 to +181

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The item u in trip?.memberships.map((u) => ...) is untyped. Assuming trip has a type (e.g., Group from your context), u should infer its type from trip.memberships. If trip itself is not strongly typed here, consider typing it. What is the structure of a membership item?

};

if (loading) {
return (
<Alert variant="default" className="p-4">
Loading...
</Alert>
);
}

return (
<>
<div className="flex justify-between">
<Button
onClick={() => router.push(`/trips/${trip.id}`)}
className="bg-white border text-primary-500 hover:bg-gray-100 shadow-sm rounded-full mb-4"
>
<ArrowLeft />
<span>Back To Trip</span>
</Button>
</div>

<div className="section mt-4 flex flex-col gap-10 border bg-white shadow-md p-6 mx-auto">
{error && <Alert variant="destructive">{error}</Alert>}

<div className="flex w-full mb-6">
<button
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 border-b-2 transition-all rounded-none ${
activeTab === 'history'
? 'border-primary-500 text-primary-600 bg-gray-50 font-semibold'
: 'border-transparent text-gray-500 hover:text-primary-500'
}`}
onClick={() => setActiveTab('history')}
>
<ListOrdered className="w-4 h-4" />
Expense history
</button>
<button
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 border-b-2 transition-all rounded-none ${
activeTab === 'balances'
? 'border-primary-500 text-primary-600 bg-gray-50 font-semibold'
: 'border-transparent text-gray-500 hover:text-primary-500'
}`}
onClick={() => setActiveTab('balances')}
>
<Banknote className="w-4 h-4" />
Users balances
</button>
<button
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 border-b-2 transition-all rounded-none ${
activeTab === 'settlements'
? 'border-primary-500 text-primary-600 bg-gray-50 font-semibold'
: 'border-transparent text-gray-500 hover:text-primary-500'
}`}
onClick={() => setActiveTab('settlements')}
>
<Users className="w-4 h-4" />
Settlements
</button>
</div>

{activeTab === 'history' && (
<ExpensesHistoryTab
showDialog={showDialog}
setShowDialog={setShowDialog}
trip={trip}
initialValues={initialValues}
onSubmit={async (values) => upsertExpense(values)}
onEditExpense={async (id, values) => upsertExpense(values, id)}
onDelete={async (id) => handleExpenseDelete(id)}
paginatedExpenses={paginatedExpenses}
expenses={expenses}
ITEMS_PER_PAGE={ITEMS_PER_PAGE}
/>
)}

{activeTab === 'balances' && (
<BalancesTab trip={trip} balanceMap={balanceMap} />
)}

{activeTab === 'settlements' && (
<SettlementsTab
settlements={settlements}
trip={trip}
currentUserId={user.id}
history={settlementsHistory}
onSubmit={async (values, id) => upsertSettlement(values, id)}
onDelete={async (id) => handleSettlementDelete(id)}
/>
)}
</div>
</>
);
}
2 changes: 2 additions & 0 deletions frontend/app/(root)/trips/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import ActivityCard from '@/components/activity/ActivityCard';
import ExpensesDashboard from '@/components/bill-splitting/ExpensesDashboard';
const ITEMS_PER_PAGE = 3;

export default function TripPage() {
Expand Down Expand Up @@ -116,6 +117,7 @@ export default function TripPage() {
}
/>

<ExpensesDashboard trip={trip} />
<button
onClick={() => redirect(`/trips/${trip.id}/activities/create`)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Server-side redirect used in client event handler

Use useRouter().push(...) or a <Link> for client-side navigation instead of redirect, which only works server-side.

className="mx-auto relative -mb-6 z-20 bg-white border text-primary-500 hover:bg-gray-100 px-4 py-3 shadow-md flex gap-2 items-center justify-center rounded-full mt-4"
Expand Down
Loading
Loading