diff --git a/frontend/app/(root)/trips/[id]/bill-splitting/page.tsx b/frontend/app/(root)/trips/[id]/bill-splitting/page.tsx new file mode 100644 index 0000000..214585c --- /dev/null +++ b/frontend/app/(root)/trips/[id]/bill-splitting/page.tsx @@ -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(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; + 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 { + 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])); + + 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, + })) || [], + }; + + if (loading) { + return ( + + Loading... + + ); + } + + return ( + <> +
+ +
+ +
+ {error && {error}} + +
+ + + +
+ + {activeTab === 'history' && ( + 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' && ( + + )} + + {activeTab === 'settlements' && ( + upsertSettlement(values, id)} + onDelete={async (id) => handleSettlementDelete(id)} + /> + )} +
+ + ); +} diff --git a/frontend/app/(root)/trips/[id]/page.tsx b/frontend/app/(root)/trips/[id]/page.tsx index 7c2ebb1..719a5bc 100644 --- a/frontend/app/(root)/trips/[id]/page.tsx +++ b/frontend/app/(root)/trips/[id]/page.tsx @@ -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() { } /> + + + + + Group Expense + + + {({ values, errors, touched, isSubmitting, setFieldValue }) => ( +
+
+ + + ( +
+ +

{msg}

+
+ )} + /> +
+ +
+ + + ( +
+ +

{msg}

+
+ )} + /> +
+ +
+ + + + + + + + ( +
+ +

{msg}

+
+ )} + /> +
+ + {() => ( +
+ {values.shares.map((user: unknown, idx: number) => ( +
+
+ , + ) => + setFieldValue( + `shares.${idx}.included`, + e.target.checked, + ) + } + /> + +
+ {(values.splitMethod === 'percentage' || + values.splitMethod === 'amount' || + values.splitMethod === 'share') && + user.included && ( +
+
+ + {values.splitMethod === 'percentage' + ? '%' + : values.splitMethod === 'amount' + ? 'zł' + : '#'} + + , + ) => + setFieldValue( + `shares.${idx}.value`, + e.target.value === '' + ? '' + : values.splitMethod === 'share' + ? parseInt(e.target.value, 10) + : parseFloat(e.target.value), + ) + } + /> +
+ {errors.shares && + errors.shares[idx] && + errors.shares[idx].value && ( +
+ +

{errors.shares[idx].value}

+
+ )} +
+ )} +
+ ))} + {typeof errors.shares === 'string' && ( +
+ +

{errors.shares}

+
+ )} +
+ )} +
+ +
+ )} +
+
+ + ); +} diff --git a/frontend/components/bill-splitting/AddSettlementDialog.tsx b/frontend/components/bill-splitting/AddSettlementDialog.tsx new file mode 100644 index 0000000..9bf0f55 --- /dev/null +++ b/frontend/components/bill-splitting/AddSettlementDialog.tsx @@ -0,0 +1,109 @@ +import { Formik, Form, Field, ErrorMessage } from 'formik'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { CircleAlert } from 'lucide-react'; +import { toFormikValidationSchema } from 'zod-formik-adapter'; +import { settlementFormSchema } from '@/validation/settlementFormSchema'; + +export default function AddSettlementDialog({ + open, + onOpenChange, + userEmail, + settlementType, + amount, + maxAmount, + debtorId, + creditorId, + groupId, + onSubmit, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + userEmail: string; + settlementType: 'youOwe' | 'theyOwe' | null; + amount: number; + maxAmount: number; + debtorId: number; + creditorId: number; + groupId: number; + onSubmit: (values: { + groupId: number; + debtorId: number; + creditorId: number; + amount: number; + }) => void; +}) { + return ( + + + + Confirm settlement + + { + onSubmit({ + groupId, + debtorId, + creditorId, + amount: Number(values.amount), + }); + setSubmitting(false); + onOpenChange(false); + }} + > + {({ isSubmitting }) => ( +
+
+ {settlementType === 'theyOwe' + ? `Confirm that ${userEmail} paid you:` + : settlementType === 'youOwe' + ? `Confirm that you paid to ${userEmail}:` + : null} +
+ + ( +
+ +

{msg}

+
+ )} + /> +
+ + +
+ + )} +
+
+
+ ); +} diff --git a/frontend/components/bill-splitting/ExpensesDashboard.tsx b/frontend/components/bill-splitting/ExpensesDashboard.tsx new file mode 100644 index 0000000..e3188ab --- /dev/null +++ b/frontend/components/bill-splitting/ExpensesDashboard.tsx @@ -0,0 +1,99 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { getToken, logout } from '@/lib/auth'; +import { Alert } from '@/components/ui/alert'; +import { Banknote } from 'lucide-react'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { useRouter } from 'next/navigation'; +import { Group } from '@/validation/groupSchema'; +import axiosInstance from '@/lib/axiosInstance'; + +export default function ExpansesDashboard({ trip }: { trip: Group }) { + const [error, setError] = useState(null); + const [groupBalance, setGroupBalance] = useState([]); + const router = useRouter(); + + const fetchGroupExpenses = async () => { + try { + const token = getToken(); + if (!token) { + logout(); + return; + } + const response = await axiosInstance.get( + `/groups/${trip.id}/balance/balances`, + ); + setGroupBalance(response.data); + } catch (err) { + setError(err.message || 'An error occurred'); + } + }; + + useEffect(() => { + fetchGroupExpenses(); + }, [trip]); + + if (error) { + return ( + + {error} + + ); + } + + const balanceMap = new Map(groupBalance.map((b) => [b.userId, b.balance])); + + return ( +
+

+ Expenses Dashboard +

+
+
    + {trip.memberships.map((user) => { + const userInfo = trip.memberships.find( + (u) => u.userId === user.userId, + ); + const balance = balanceMap.get(user.userId) ?? 0; + const balanceClass = + balance > 0 + ? 'text-green-500' + : balance < 0 + ? 'text-red-500' + : 'text-gray-500'; + + return ( +
  • +
    + + {userInfo?.userEmail || user.email || 'User'} + +
    + + {balance > 0 ? '+' : ''} + {Number(balance).toFixed(2)} zł + +
  • + ); + })} +
+ +
+
+ ); +} diff --git a/frontend/components/bill-splitting/ExpensesHistory.tsx b/frontend/components/bill-splitting/ExpensesHistory.tsx new file mode 100644 index 0000000..c76b9cd --- /dev/null +++ b/frontend/components/bill-splitting/ExpensesHistory.tsx @@ -0,0 +1,122 @@ +import CustomPagination from '@/components/shared/CustomPagination'; +import { Button } from '@/components/ui/button'; +import { Pencil } from 'lucide-react'; +import { Group } from '@/validation/groupSchema'; +import { appStore } from '@/store/appStore'; +import YesNoModal from '@/components/shared/YesNoModal'; +import { Trash } from 'lucide-react'; + +export interface expenseProp { + id: number; + groupId: number; + description: string; + totalAmount: number; + splitType: 'EQUALLY' | 'PERCENTAGE' | 'AMOUNT' | 'SHARES' | string; + paidByUserId: number; + createdAt: string; + expenseDate: string; + userShares: Record; +} + +interface ExpenseHistoryProps { + paginatedExpenses: expenseProp[]; + expenses: expenseProp[]; + trip: Group; + ITEMS_PER_PAGE: number; + onDelete: (expenseId: number) => void; + onEdit: (expense: expenseProp) => void; +} + +export default function ExpenseHistory({ + paginatedExpenses, + expenses, + trip, + ITEMS_PER_PAGE, + onDelete, + onEdit, +}: ExpenseHistoryProps) { + const user = appStore((state) => state.user); + return ( + <> + {paginatedExpenses.length > 0 ? ( + <> +
    + {paginatedExpenses.map((expense) => ( +
  • +
    + + {expense.description} + + + {new Date(expense.createdAt).toLocaleDateString()} + +
    +
    + Paid by{' '} + + { + trip.memberships.find( + (u) => u.userId === expense.paidByUserId, + )?.userEmail + } + {' '} + — Total:{' '} + + {expense.totalAmount.toFixed(2)} zł + +
    +
    + {Object.entries(expense.userShares).map(([uid, amount]) => ( +
    + {trip.memberships.find((u) => u.userId == uid)?.userEmail} + : {Number(amount).toFixed(2)} zł +
    + ))} +
    +
    + {expense.paidByUserId === user.id && ( + <> + + onDelete(expense.id)} + trigger={ + + } + /> + + )} +
    +
  • + ))} +
+
+ +
+ + ) : ( +
+ No expenses yet. Add your first group expense! +
+ )} + + ); +} diff --git a/frontend/components/bill-splitting/tabs/BalancesTab.tsx b/frontend/components/bill-splitting/tabs/BalancesTab.tsx new file mode 100644 index 0000000..4fe5343 --- /dev/null +++ b/frontend/components/bill-splitting/tabs/BalancesTab.tsx @@ -0,0 +1,48 @@ +import Link from 'next/link'; +import { Banknote } from 'lucide-react'; +import { Group } from '@/validation/groupSchema'; + +export default function BalancesTab({ + trip, + balanceMap, +}: { + trip: Group; + balanceMap: Map; +}) { + return ( +
+

+ + Group Balances +

+
    + {trip?.memberships.map((user) => { + const balance = balanceMap.get(user.userId) ?? 0; + const balanceClass = + balance > 0 + ? 'text-green-500' + : balance < 0 + ? 'text-red-500' + : 'text-gray-500'; + return ( +
  • + + {user.userEmail || 'User'} + + + {balance > 0 ? '+' : ''} + {Number(balance).toFixed(2)} zł + +
  • + ); + })} +
+
+ ); +} diff --git a/frontend/components/bill-splitting/tabs/ExpensesHistoryTab.tsx b/frontend/components/bill-splitting/tabs/ExpensesHistoryTab.tsx new file mode 100644 index 0000000..efc2fc5 --- /dev/null +++ b/frontend/components/bill-splitting/tabs/ExpensesHistoryTab.tsx @@ -0,0 +1,144 @@ +import AddExpenseDialog from '@/components/bill-splitting/AddExpenseDialog'; +import ExpensesHistory from '@/components/bill-splitting/ExpensesHistory'; +import { Group } from '@/validation/groupSchema'; +import { AddExpenseInitialValues } from '@/components/bill-splitting/AddExpenseDialog.tsx'; +import { useState } from 'react'; + +export interface ExpenseShare { + userId: number; + included: boolean; + value?: number; +} + +export interface AddExpenseInitialValues { + description: string; + totalAmount: number; + splitMethod: 'equally' | 'percentage' | 'amount' | 'share'; + shares: ExpenseShare[]; +} + +export default function ExpensesHistoryTab({ + showDialog, + setShowDialog, + trip, + initialValues, + onSubmit, + paginatedExpenses, + expenses, + ITEMS_PER_PAGE, + onEditExpense, + onDelete, +}: { + showDialog: boolean; + setShowDialog: (v: boolean) => void; + trip: Group; + initialValues: AddExpenseInitialValues; + onSubmit: (values: AddExpenseInitialValues) => Promise; + paginatedExpenses: expenseProp[]; + expenses: expenseProp[]; + ITEMS_PER_PAGE: number; + onEditExpense: (id: number, values: AddExpenseInitialValues) => Promise; + onDelete: (id: number) => Promise; +}) { + const [editExpense, setEditExpense] = useState(null); + const [editDialogOpen, setEditDialogOpen] = useState(false); + + function mapExpenseToInitialValues( + expense: expenseProp, + trip: Group, + ): AddExpenseInitialValues { + return { + description: expense.description, + totalAmount: expense.totalAmount, + splitMethod: expense.splitType.toLowerCase() as + | 'equally' + | 'percentage' + | 'amount' + | 'share', + shares: trip.memberships.map((member) => ({ + userId: member.userId, + included: expense.userShares.hasOwnProperty(member.userId), + value: expense.userShares[member.userId], + })), + }; + } + + const handleEdit = (expense: expenseProp) => { + setEditExpense(expense); + setEditDialogOpen(true); + }; + + const handleEditSubmit = async (values: AddExpenseInitialValues) => { + await onEditExpense(editExpense!.id, values); + setEditDialogOpen(false); + setEditExpense(null); + }; + + return ( +
+
+

Expense History

+ +
+ { + onDelete(id); + }} + onEdit={handleEdit} + /> + + {/* + + + Delete expense + +
+ Are you sure you want to delete this expense? This action cannot be + undone. +
+
+ + +
+
+
*/} + + {editExpense && ( + { + setEditDialogOpen(open); + if (!open) setEditExpense(null); + }} + trip={trip} + initialValues={mapExpenseToInitialValues(editExpense, trip)} + onSubmit={handleEditSubmit} + /> + )} +
+ ); +} diff --git a/frontend/components/bill-splitting/tabs/SettlementsTab.tsx b/frontend/components/bill-splitting/tabs/SettlementsTab.tsx new file mode 100644 index 0000000..799dc93 --- /dev/null +++ b/frontend/components/bill-splitting/tabs/SettlementsTab.tsx @@ -0,0 +1,249 @@ +import { useState } from 'react'; +import { Users } from 'lucide-react'; +import AddSettlementDialog from '@/components/bill-splitting/AddSettlementDialog'; +import { Group } from '@/validation/groupSchema'; +import { Trash2 } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +export interface settlementsHistoryProp { + id: number; + groupId: number; + paidByUserId: number; + paidToUserId: number; + amount: number; + paymentDate: string; +} + +export interface settlementProp { + debtorId: number; + creditorId: number; + amount: number; +} + +export default function SettlementsTab({ + settlements, + trip, + currentUserId, + history, + onSubmit, + onDelete, +}: { + settlements: settlementProp; + trip: Group; + currentUserId: number; + history: settlementsProp[]; + onSubmit: (values: { + groupId: number; + debtorId: number; + creditorId: number; + amount: number; + }) => void; + onDelete: (values: number) => void; +}) { + const [showDialog, setShowDialog] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedAmount, setSelectedAmount] = useState(0); + const [settlementType, setSettlementType] = useState< + 'youOwe' | 'theyOwe' | null + >(null); + const [deleteId, setDeleteId] = useState(null); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + const handleOpenSettlement = ( + userId: number, + amount: number, + type: 'youOwe' | 'theyOwe', + ) => { + setSelectedUser(userId); + setSelectedAmount(amount); + setSettlementType(type); + setShowDialog(true); + }; + + const findSettlement = (userId: number) => { + const youOwe = settlements.find( + (s) => s.debtorId === currentUserId && s.creditorId === userId, + ); + const theyOwe = settlements.find( + (s) => s.debtorId === userId && s.creditorId === currentUserId, + ); + return { youOwe, theyOwe }; + }; + + return ( +
+

+ + User settlements +

+
    + {trip?.memberships + .filter((user) => user.userId !== currentUserId) + .map((user) => { + const { youOwe, theyOwe } = findSettlement(user.userId); + const info = youOwe + ? { + text: `You owe ${Number(youOwe.amount).toFixed(2)} zł`, + color: 'text-red-600', + type: 'youOwe', + amount: youOwe.amount, + } + : theyOwe + ? { + text: `They owe ${Number(theyOwe.amount).toFixed(2)} zł`, + color: 'text-green-600', + type: 'theyOwe', + amount: theyOwe.amount, + } + : { + text: 'No settlement', + color: 'text-gray-500', + type: null, + amount: 0, + }; + + return ( +
  • { + if (info.type) { + handleOpenSettlement( + user.userId, + info.amount, + info.type as 'youOwe' | 'theyOwe', + ); + } + }} + > + + {user.userEmail} + + + {info.text} + +
  • + ); + })} +
+ +
+

History

+
    + {history.length === 0 && ( +
  • No settlement history.
  • + )} + {history.map((item) => { + const payer = + trip?.memberships.find((u) => u.userId === item.paidByUserId) + ?.userEmail || item.paidByUserId; + const receiver = + trip?.memberships.find((u) => u.userId === item.paidToUserId) + ?.userEmail || item.paidToUserId; + + return ( +
  • +
    + + {Number(item.amount).toFixed(2)} zł + + + {item.paymentDate + ? new Date(item.paymentDate).toLocaleDateString('pl-PL', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + : ''} + +
    + +
    +
    + + {payer}{' '} + paid + + + + {receiver} + + +
    +
    + +
    +
    +
  • + ); + })} +
+ + + + + Delete settlement + +
+ Are you sure you want to delete this settlement? This action + cannot be undone. +
+
+ + +
+
+
+
+ + u.userId === selectedUser)?.userEmail || + '' + } + settlementType={settlementType} + maxAmount={selectedAmount} + amount={selectedAmount} + debtorId={settlementType === 'youOwe' ? currentUserId : selectedUser} + creditorId={settlementType === 'youOwe' ? selectedUser : currentUserId} + groupId={trip?.id} + onSubmit={(values) => onSubmit(values)} + /> +
+ ); +} diff --git a/frontend/components/ui/checkbox.tsx b/frontend/components/ui/checkbox.tsx new file mode 100644 index 0000000..216283d --- /dev/null +++ b/frontend/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +'use client'; + +import * as React from 'react'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { Check } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cf46818..7d3a9d2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,10 +11,11 @@ "@hookform/resolvers": "^3.10.0", "@radix-ui/react-alert-dialog": "^1.1.13", "@radix-ui/react-avatar": "^1.1.2", - "@radix-ui/react-dialog": "^1.1.13", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-label": "^2.1.6", - "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-tabs": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.6", @@ -1725,6 +1726,60 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-dialog": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.13.tgz", + "integrity": "sha512-ARFmqUyhIVS3+riWzwGTe7JLjqwqgnODBUZdqpWar/z1WFs9z76fuOs/2BOWCR+YboRn4/WN9aoaGVwqNRr8VA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.9", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.6", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.8", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.2", + "@radix-ui/react-slot": "1.2.2", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz", + "integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.6.tgz", @@ -1775,6 +1830,59 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz", + "integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.6.tgz", @@ -1801,6 +1909,24 @@ } } }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz", + "integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -1832,22 +1958,22 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.13.tgz", - "integrity": "sha512-ARFmqUyhIVS3+riWzwGTe7JLjqwqgnODBUZdqpWar/z1WFs9z76fuOs/2BOWCR+YboRn4/WN9aoaGVwqNRr8VA==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", + "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.9", + "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", - "@radix-ui/react-focus-scope": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.8", + "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.2", - "@radix-ui/react-slot": "1.2.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" @@ -1867,6 +1993,105 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -2059,6 +2284,24 @@ } } }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz", + "integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.6.tgz", @@ -2162,6 +2405,24 @@ } } }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz", + "integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.9.tgz", @@ -2236,190 +2497,10 @@ } } }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", - "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-popper": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", - "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@radix-ui/react-slot": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz", + "integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -2434,33 +2515,10 @@ } } }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", - "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz", - "integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -2539,6 +2597,24 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz", + "integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 19c251e..ab36804 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,8 @@ "@hookform/resolvers": "^3.10.0", "@radix-ui/react-alert-dialog": "^1.1.13", "@radix-ui/react-avatar": "^1.1.2", - "@radix-ui/react-dialog": "^1.1.13", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-select": "^2.2.5", diff --git a/frontend/validation/expenseFormSchema.ts b/frontend/validation/expenseFormSchema.ts new file mode 100644 index 0000000..0405e35 --- /dev/null +++ b/frontend/validation/expenseFormSchema.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; + +export const splitSchema = z.enum(['equally', 'percentage', 'amount', 'share']); + +export const expenseFormSchema = z + .object({ + description: z.string().min(1, 'Description is required'), + totalAmount: z.number().min(0.01, 'Amount must be positive'), + splitMethod: splitSchema, + shares: z.array( + z.object({ + userId: z.number(), + included: z.boolean(), + value: z + .union([z.number(), z.nan(), z.undefined(), z.null()]) + .optional(), + }), + ), + }) + .superRefine((data, ctx) => { + const includedShares = data.shares.filter((s) => s.included); + + if (data.splitMethod === 'percentage') { + const sum = includedShares.reduce( + (acc, s) => acc + (typeof s.value === 'number' ? s.value : 0), + 0, + ); + if (sum !== 100) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Sum of percentages must be exactly 100%', + path: ['shares'], + }); + } + } + + if (data.splitMethod === 'amount') { + const sum = includedShares.reduce( + (acc, s) => acc + (typeof s.value === 'number' ? s.value : 0), + 0, + ); + if (Math.abs(sum - data.totalAmount) > 0.01) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Sum of amounts must equal total amount', + path: ['shares'], + }); + } + } + + if (data.splitMethod === 'share') { + const sum = includedShares.reduce( + (acc, s) => acc + (typeof s.value === 'number' ? s.value : 0), + 0, + ); + if (includedShares.length < 2) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'At least two users must have shares', + path: ['shares'], + }); + } + if (sum < 2) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Sum of shares must be at least 2', + path: ['shares'], + }); + } + includedShares.forEach((s, i) => { + if ( + typeof s.value !== 'number' || + !Number.isInteger(s.value) || + s.value <= 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Each share must be a positive integer', + path: ['shares', i, 'value'], + }); + } + }); + } + }); diff --git a/frontend/validation/settlementFormSchema.ts b/frontend/validation/settlementFormSchema.ts new file mode 100644 index 0000000..08de355 --- /dev/null +++ b/frontend/validation/settlementFormSchema.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +export const settlementFormSchema = (maxAmount: number) => + z.object({ + amount: z + .number({ + required_error: 'Amount is required', + invalid_type_error: 'Amount must be a number', + }) + .min(0.01, 'Amount must be greater than 0') + .max( + maxAmount, + `Amount cannot be greater than debtor's debt (${maxAmount} zł)`, + ), + }); + +export type SettlementFormValues = z.infer< + ReturnType +>;