-
Notifications
You must be signed in to change notification settings - Fork 0
Frontend/tix61 bill splitting #91
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f7d0233
116ac9b
2a778d3
6c13ae8
be2953a
553f288
955ab59
93adaa8
c3f0c9a
66fdc13
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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([]); | ||||||||||||||||||||||
| 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; | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (code-quality): Use block braces for ifs, whiles, etc. (
Suggested change
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 |
||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (bug_risk): Accessing Narrow the type of
Suggested change
|
||||||||||||||||||||||
| 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])); | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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> | ||||||||||||||||||||||
| </> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() { | ||
|
|
@@ -116,6 +117,7 @@ export default function TripPage() { | |
| } | ||
| /> | ||
|
|
||
| <ExpensesDashboard trip={trip} /> | ||
| <button | ||
| onClick={() => redirect(`/trips/${trip.id}/activities/create`)} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (bug_risk): Server-side Use |
||
| 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" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider providing specific types for your state arrays instead of
[](which defaults toany[]ornever[]depending on inference). This improves type safety and makes it clearer what data these states are expected to hold.For example:
What are the expected shapes for
groupBalance,expenses,settlements, andsettlementsHistoryitems?