From d82935205144c5e9f7632fb5b40864ae0a89ee68 Mon Sep 17 00:00:00 2001 From: Arun-kushwaha007 Date: Thu, 19 Mar 2026 23:27:40 +0530 Subject: [PATCH 1/2] feat(expenses): add categories and saved group split defaults --- apps/backend/prisma/schema.prisma | 5 + .../src/expenses/dto/create-expense.dto.ts | 9 +- .../src/expenses/dto/update-expense.dto.ts | 9 +- apps/backend/src/expenses/expenses.service.ts | 9 +- .../dto/update-group-default-split.dto.ts | 28 +++ apps/backend/src/groups/groups.controller.ts | 10 +- apps/backend/src/groups/groups.service.ts | 111 ++++++++++- .../app/dashboard/groups/[groupId]/page.tsx | 25 ++- .../components/groups/CreateExpenseModal.tsx | 183 +++++++++++++++--- apps/web/src/components/groups/ExpenseRow.tsx | 47 +++-- .../src/components/groups/GroupActions.tsx | 11 +- apps/web/src/lib/actions.ts | 38 +++- apps/web/src/lib/split.ts | 4 +- packages/shared-types/src/index.ts | 19 ++ 14 files changed, 421 insertions(+), 87 deletions(-) create mode 100644 apps/backend/src/groups/dto/update-group-default-split.dto.ts diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 781ecac..cbf8d46 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -57,6 +57,8 @@ model Group { name String createdBy String currency String + defaultSplitType String? + defaultSplitConfig String? createdAt DateTime @default(now()) creator User @relation("group_creator", fields: [createdBy], references: [id]) members GroupMember[] @@ -93,6 +95,7 @@ model Expense { description String totalAmountCents BigInt currency String + category String? createdAt DateTime @default(now()) group Group @relation(fields: [groupId], references: [id]) payer User @relation("expense_payer", fields: [payerId], references: [id]) @@ -235,3 +238,5 @@ model Payment { @@index([receiverId]) @@map("payments") } + + diff --git a/apps/backend/src/expenses/dto/create-expense.dto.ts b/apps/backend/src/expenses/dto/create-expense.dto.ts index 2ae4ffb..648a320 100644 --- a/apps/backend/src/expenses/dto/create-expense.dto.ts +++ b/apps/backend/src/expenses/dto/create-expense.dto.ts @@ -1,6 +1,6 @@ -import { IsArray, IsIn, IsString, MinLength, ValidateNested } from 'class-validator'; +import { IsArray, IsIn, IsOptional, IsString, MinLength, ValidateNested } from 'class-validator'; import { Transform, Type } from 'class-transformer'; -import { CreateExpenseRequestDto, CreateExpenseSplitDto } from '@fairshare/shared-types'; +import { CreateExpenseRequestDto, CreateExpenseSplitDto, EXPENSE_CATEGORIES } from '@fairshare/shared-types'; import { sanitizeText } from '../../common/utils/sanitize.util'; export class CreateExpenseSplitInputDto implements CreateExpenseSplitDto { @@ -30,6 +30,11 @@ export class CreateExpenseDto implements CreateExpenseRequestDto { @IsIn(['USD', 'EUR', 'INR']) currency!: 'USD' | 'EUR' | 'INR'; + @IsOptional() + @IsString() + @IsIn([...EXPENSE_CATEGORIES]) + category?: (typeof EXPENSE_CATEGORIES)[number]; + @IsArray() @ValidateNested({ each: true }) @Type(() => CreateExpenseSplitInputDto) diff --git a/apps/backend/src/expenses/dto/update-expense.dto.ts b/apps/backend/src/expenses/dto/update-expense.dto.ts index 58bca9e..91acfff 100644 --- a/apps/backend/src/expenses/dto/update-expense.dto.ts +++ b/apps/backend/src/expenses/dto/update-expense.dto.ts @@ -1,9 +1,14 @@ -import { IsOptional, IsString, MinLength } from 'class-validator'; -import { UpdateExpenseRequestDto } from '@fairshare/shared-types'; +import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; +import { EXPENSE_CATEGORIES, UpdateExpenseRequestDto } from '@fairshare/shared-types'; export class UpdateExpenseDto implements UpdateExpenseRequestDto { @IsOptional() @IsString() @MinLength(2) description?: string; + + @IsOptional() + @IsString() + @IsIn([...EXPENSE_CATEGORIES]) + category?: (typeof EXPENSE_CATEGORIES)[number] | null; } diff --git a/apps/backend/src/expenses/expenses.service.ts b/apps/backend/src/expenses/expenses.service.ts index ff2f30d..16b4ed1 100644 --- a/apps/backend/src/expenses/expenses.service.ts +++ b/apps/backend/src/expenses/expenses.service.ts @@ -73,6 +73,7 @@ export class ExpensesService { description: dto.description, totalAmountCents: totalAmount, currency: dto.currency, + category: dto.category ?? null, }, }); @@ -105,6 +106,7 @@ export class ExpensesService { metadata: { payerId: dto.payerId, totalAmountCents: totalAmount.toString(), + category: dto.category ?? null, }, }, }); @@ -131,6 +133,7 @@ export class ExpensesService { expenseId: expense.id, payerId: expense.payerId, totalAmountCents: expense.totalAmountCents.toString(), + category: expense.category as ExpenseDto['category'], }); incrementExpenseCreated(groupId); @@ -180,6 +183,7 @@ export class ExpensesService { where: { id }, data: { description: dto.description, + category: dto.category, }, include: { splits: true }, }); @@ -189,7 +193,7 @@ export class ExpensesService { actorUserId, type: 'expense_updated', entityId: expense.id, - metadata: { description: dto.description ?? null }, + metadata: { description: dto.description ?? null, category: dto.category ?? null }, }); await this.redis.invalidateGroupCache(expense.groupId); @@ -245,6 +249,7 @@ export class ExpensesService { description: string; totalAmountCents: bigint; currency: string; + category: string | null; createdAt: Date; splits: Array<{ id: string; @@ -260,6 +265,7 @@ export class ExpensesService { description: expense.description, totalAmountCents: expense.totalAmountCents.toString(), currency: expense.currency as 'USD' | 'EUR' | 'INR', + category: expense.category as ExpenseDto['category'], createdAt: expense.createdAt.toISOString(), splits: expense.splits.map((split) => ({ id: split.id, @@ -270,3 +276,4 @@ export class ExpensesService { }; } } + diff --git a/apps/backend/src/groups/dto/update-group-default-split.dto.ts b/apps/backend/src/groups/dto/update-group-default-split.dto.ts new file mode 100644 index 0000000..d8818e5 --- /dev/null +++ b/apps/backend/src/groups/dto/update-group-default-split.dto.ts @@ -0,0 +1,28 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsIn, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { EXPENSE_SPLIT_TYPES, GroupDefaultSplitDto, UpdateGroupDefaultSplitRequestDto } from '@fairshare/shared-types'; + +class GroupDefaultSplitPreferenceDto implements GroupDefaultSplitDto { + @IsString() + @IsIn([...EXPENSE_SPLIT_TYPES]) + splitType!: (typeof EXPENSE_SPLIT_TYPES)[number]; + + @IsArray() + @IsString({ each: true }) + participantUserIds!: string[]; + + @IsOptional() + @IsObject() + exactAmountsCentsByUser?: Record; + + @IsOptional() + @IsObject() + percentagesByUser?: Record; +} + +export class UpdateGroupDefaultSplitDto implements UpdateGroupDefaultSplitRequestDto { + @IsOptional() + @ValidateNested() + @Type(() => GroupDefaultSplitPreferenceDto) + defaultSplitPreference!: GroupDefaultSplitPreferenceDto | null; +} diff --git a/apps/backend/src/groups/groups.controller.ts b/apps/backend/src/groups/groups.controller.ts index 0c14faf..96128da 100644 --- a/apps/backend/src/groups/groups.controller.ts +++ b/apps/backend/src/groups/groups.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../common/decorators/current-user.decorator'; @@ -6,6 +6,7 @@ import { JwtPayload } from '../auth/types/auth.types'; import { GroupsService } from './groups.service'; import { CreateGroupDto } from './dto/create-group.dto'; import { InviteMemberDto } from './dto/invite-member.dto'; +import { UpdateGroupDefaultSplitDto } from './dto/update-group-default-split.dto'; @Controller('groups') @UseGuards(JwtAuthGuard) @@ -42,9 +43,14 @@ export class GroupsController { return this.groupsService.summary(id, user.sub); } + @Patch(':id/default-split') + updateDefaultSplit(@Param('id') id: string, @CurrentUser() user: JwtPayload, @Body() dto: UpdateGroupDefaultSplitDto) { + return this.groupsService.updateDefaultSplit(id, user.sub, dto); + } + @Post(':id/invite') @Throttle({ default: { limit: 10, ttl: 60_000 } }) invite(@Param('id') id: string, @CurrentUser() user: JwtPayload, @Body() dto: InviteMemberDto) { return this.groupsService.invite(id, user.sub, dto); } -} \ No newline at end of file +} diff --git a/apps/backend/src/groups/groups.service.ts b/apps/backend/src/groups/groups.service.ts index be1cb2d..6c91f07 100644 --- a/apps/backend/src/groups/groups.service.ts +++ b/apps/backend/src/groups/groups.service.ts @@ -1,11 +1,12 @@ import { ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; -import { GroupDto, GroupMemberSummaryDto, GroupSummaryDto } from '@fairshare/shared-types'; +import { GroupDefaultSplitDto, GroupDto, GroupMemberSummaryDto, GroupSummaryDto } from '@fairshare/shared-types'; import { PrismaService } from '../common/prisma.service'; import { RedisService } from '../redis/redis.service'; import { NotificationsService } from '../notifications/notifications.service'; import { RealtimeService } from '../realtime/realtime.service'; import { CreateGroupDto } from './dto/create-group.dto'; import { InviteMemberDto } from './dto/invite-member.dto'; +import { UpdateGroupDefaultSplitDto } from './dto/update-group-default-split.dto'; @Injectable() export class GroupsService { @@ -53,6 +54,7 @@ export class GroupsService { currency: group.currency as 'USD' | 'EUR' | 'INR', createdBy: group.createdBy, createdAt: group.createdAt.toISOString(), + defaultSplitPreference: null, }; } @@ -74,6 +76,7 @@ export class GroupsService { currency: group.currency as 'USD' | 'EUR' | 'INR', createdBy: group.createdBy, createdAt: group.createdAt.toISOString(), + defaultSplitPreference: this.parseDefaultSplitPreference(group.defaultSplitType, group.defaultSplitConfig), })); } @@ -100,6 +103,7 @@ export class GroupsService { currency: group.currency as 'USD' | 'EUR' | 'INR', createdBy: group.createdBy, createdAt: group.createdAt.toISOString(), + defaultSplitPreference: this.parseDefaultSplitPreference(group.defaultSplitType, group.defaultSplitConfig), members: group.members.map((member) => ({ id: member.id, userId: member.userId, @@ -209,13 +213,79 @@ export class GroupsService { }; } + async updateDefaultSplit(groupId: string, actorUserId: string, dto: UpdateGroupDefaultSplitDto): Promise { + await this.assertMembership(groupId, actorUserId); + + let normalizedPreference: GroupDefaultSplitDto | null = null; + if (dto.defaultSplitPreference) { + const members = await this.prisma.groupMember.findMany({ + where: { groupId }, + select: { userId: true }, + }); + const memberIds = new Set(members.map((member) => member.userId)); + const participantUserIds = Array.from(new Set(dto.defaultSplitPreference.participantUserIds)); + + if (participantUserIds.length === 0) { + throw new ForbiddenException('Default split must include at least one participant'); + } + + const invalidParticipant = participantUserIds.find((userId) => !memberIds.has(userId)); + if (invalidParticipant) { + throw new ForbiddenException('Default split participants must belong to the group'); + } + + normalizedPreference = { + splitType: dto.defaultSplitPreference.splitType, + participantUserIds, + exactAmountsCentsByUser: dto.defaultSplitPreference.exactAmountsCentsByUser, + percentagesByUser: dto.defaultSplitPreference.percentagesByUser, + }; + } + + await this.prisma.group.update({ + where: { id: groupId }, + data: { + defaultSplitType: normalizedPreference?.splitType ?? null, + defaultSplitConfig: normalizedPreference + ? JSON.stringify({ + participantUserIds: normalizedPreference.participantUserIds, + exactAmountsCentsByUser: normalizedPreference.exactAmountsCentsByUser ?? {}, + percentagesByUser: normalizedPreference.percentagesByUser ?? {}, + }) + : null, + }, + }); + + const group = await this.prisma.group.findUniqueOrThrow({ + where: { id: groupId }, + include: { members: true }, + }); + + await this.redis.invalidateGroupCache(groupId); + + return { + id: group.id, + name: group.name, + currency: group.currency as 'USD' | 'EUR' | 'INR', + createdBy: group.createdBy, + createdAt: group.createdAt.toISOString(), + defaultSplitPreference: this.parseDefaultSplitPreference(group.defaultSplitType, group.defaultSplitConfig), + members: group.members.map((member) => ({ + id: member.id, + userId: member.userId, + groupId: member.groupId, + role: member.role, + joinedAt: member.joinedAt.toISOString(), + })), + }; + } + async invite(groupId: string, actorUserId: string, dto: InviteMemberDto): Promise<{ success: true }> { await this.assertMembership(groupId, actorUserId); const email = dto.email.toLowerCase(); const user = await this.prisma.user.findUnique({ where: { email } }); if (!user) { - // Create or update pending invitation await this.prisma.groupInvite.upsert({ where: { groupId_email: { @@ -229,7 +299,7 @@ export class GroupsService { invitedBy: actorUserId, role: 'MEMBER', }, - update: {}, // Already invited, no-op + update: {}, }); return { success: true }; } @@ -298,7 +368,6 @@ export class GroupsService { await this.prisma.$transaction(async (tx) => { for (const invite of invites) { - // Create membership await tx.groupMember.create({ data: { groupId: invite.groupId, @@ -307,7 +376,6 @@ export class GroupsService { }, }); - // Add activity await tx.activity.create({ data: { groupId: invite.groupId, @@ -318,17 +386,46 @@ export class GroupsService { }, }); - // Delete invite await tx.groupInvite.delete({ where: { id: invite.id }, }); - // Invalidate cache for each group await this.redis.invalidateGroupCache(invite.groupId); } }); } + private parseDefaultSplitPreference(splitType: string | null, splitConfig: string | null): GroupDefaultSplitDto | null { + if (!splitType || !splitConfig) { + return null; + } + + try { + const config = JSON.parse(splitConfig) as { + participantUserIds?: unknown; + exactAmountsCentsByUser?: unknown; + percentagesByUser?: unknown; + }; + + return { + splitType: splitType as GroupDefaultSplitDto['splitType'], + participantUserIds: Array.isArray(config.participantUserIds) + ? config.participantUserIds.filter((value): value is string => typeof value === 'string') + : [], + exactAmountsCentsByUser: + config.exactAmountsCentsByUser && typeof config.exactAmountsCentsByUser === 'object' && !Array.isArray(config.exactAmountsCentsByUser) + ? (config.exactAmountsCentsByUser as Record) + : undefined, + percentagesByUser: + config.percentagesByUser && typeof config.percentagesByUser === 'object' && !Array.isArray(config.percentagesByUser) + ? (config.percentagesByUser as Record) + : undefined, + }; + } catch { + return null; + } + } + private async assertMembership(groupId: string, userId: string): Promise { const membership = await this.prisma.groupMember.findUnique({ where: { diff --git a/apps/web/app/dashboard/groups/[groupId]/page.tsx b/apps/web/app/dashboard/groups/[groupId]/page.tsx index 95ed85b..b6ab518 100644 --- a/apps/web/app/dashboard/groups/[groupId]/page.tsx +++ b/apps/web/app/dashboard/groups/[groupId]/page.tsx @@ -1,14 +1,9 @@ -import { - ExpenseDto, - GroupDto, - GroupMemberSummaryDto, - PaginatedExpensesResponseDto -} from '@fairshare/shared-types'; +import { ExpenseDto, GroupDto, GroupMemberSummaryDto, PaginatedExpensesResponseDto } from '@fairshare/shared-types'; import { notFound } from 'next/navigation'; import { Suspense } from 'react'; import { DashboardLayout } from '../../../../src/components/layout'; -import { MemberList, ExpenseTable, GroupActions } from '../../../../src/components/groups'; +import { ExpenseTable, GroupActions, MemberList } from '../../../../src/components/groups'; import { backendFetch } from '../../../../src/lib/backend'; interface GroupDetailPageProps { @@ -27,7 +22,7 @@ export default async function GroupDetailPage({ params }: GroupDetailPageProps) backendFetch(`/groups/${groupId}/expenses?limit=50`), ]); - const totalExpenses = expenses.items.reduce((sum, e) => sum + Number(e.totalAmountCents), 0) / 100; + const totalExpenses = expenses.items.reduce((sum, expense) => sum + Number(expense.totalAmountCents), 0) / 100; return ( @@ -38,7 +33,7 @@ export default async function GroupDetailPage({ params }: GroupDetailPageProps)

Group

{group.name}

- Created {new Date(group.createdAt).toLocaleDateString()} • ID {groupId.slice(0, 8)} + Created {new Date(group.createdAt).toLocaleDateString()} • ID {groupId.slice(0, 8)}

@@ -62,22 +57,26 @@ export default async function GroupDetailPage({ params }: GroupDetailPageProps)
- +
- +
); - } catch (error) { console.error('Failed to fetch group details:', error); return notFound(); diff --git a/apps/web/src/components/groups/CreateExpenseModal.tsx b/apps/web/src/components/groups/CreateExpenseModal.tsx index f8d69bf..3f71fb9 100644 --- a/apps/web/src/components/groups/CreateExpenseModal.tsx +++ b/apps/web/src/components/groups/CreateExpenseModal.tsx @@ -2,42 +2,100 @@ import { useEffect, useMemo, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; -import { CurrencyCode, GroupMemberSummaryDto } from '@fairshare/shared-types'; -import { X, Sparkles } from 'lucide-react'; -import { createExpenseAction } from '../../lib/actions'; -import { SplitType, equalShares, exactShares, percentageShares, sumShares } from '../../lib/split'; +import { + CurrencyCode, + EXPENSE_CATEGORIES, + EXPENSE_SPLIT_TYPES, + ExpenseCategory, + ExpenseSplitType, + GroupDefaultSplitDto, + GroupMemberSummaryDto, +} from '@fairshare/shared-types'; +import { Sparkles, X } from 'lucide-react'; +import { createExpenseAction, updateGroupDefaultSplitAction } from '../../lib/actions'; +import { equalShares, exactShares, percentageShares, sumShares } from '../../lib/split'; import { useToast } from '../ui/Toaster'; type CreateExpenseModalProps = { groupId: string; currency: CurrencyCode; members: GroupMemberSummaryDto[]; + defaultSplitPreference?: GroupDefaultSplitDto | null; open: boolean; onClose: () => void; onCreated?: () => void; }; -export function CreateExpenseModal({ groupId, currency, members, open, onClose, onCreated }: CreateExpenseModalProps) { +const categoryLabels: Record = { + FOOD: 'Food', + TRAVEL: 'Travel', + UTILITIES: 'Utilities', + GROCERIES: 'Groceries', + ENTERTAINMENT: 'Entertainment', + OTHER: 'Other', +}; + +export function CreateExpenseModal({ + groupId, + currency, + members, + defaultSplitPreference, + open, + onClose, + onCreated, +}: CreateExpenseModalProps) { + const allMemberIds = useMemo(() => members.map((member) => member.userId), [members]); const defaultPayer = members[0]?.userId ?? ''; const [description, setDescription] = useState(''); const [amount, setAmount] = useState(''); + const [category, setCategory] = useState(''); const [payerId, setPayerId] = useState(defaultPayer); - const [splitType, setSplitType] = useState('equal'); - const [participants, setParticipants] = useState(members.map((m) => m.userId)); + const [splitType, setSplitType] = useState('equal'); + const [participants, setParticipants] = useState(allMemberIds); const [exactByUser, setExactByUser] = useState>({}); const [percentagesByUser, setPercentagesByUser] = useState>({}); const [submitting, setSubmitting] = useState(false); + const [savingDefault, setSavingDefault] = useState(false); const [error, setError] = useState(''); const { toast } = useToast(); useEffect(() => { - if (defaultPayer) { - setPayerId(defaultPayer); + if (!open) { + return; } - }, [defaultPayer]); + + setPayerId(defaultPayer); + + const fallbackParticipants = allMemberIds; + if (!defaultSplitPreference) { + setSplitType('equal'); + setParticipants(fallbackParticipants); + setExactByUser({}); + setPercentagesByUser({}); + return; + } + + const preferredParticipants = defaultSplitPreference.participantUserIds.filter((userId) => allMemberIds.includes(userId)); + const nextParticipants = preferredParticipants.length > 0 ? preferredParticipants : fallbackParticipants; + const nextExact: Record = {}; + const nextPercentages: Record = {}; + + nextParticipants.forEach((userId) => { + if (defaultSplitPreference.exactAmountsCentsByUser?.[userId]) { + nextExact[userId] = defaultSplitPreference.exactAmountsCentsByUser[userId]; + } + if (defaultSplitPreference.percentagesByUser?.[userId]) { + nextPercentages[userId] = defaultSplitPreference.percentagesByUser[userId]; + } + }); + + setSplitType(defaultSplitPreference.splitType); + setParticipants(nextParticipants); + setExactByUser(nextExact); + setPercentagesByUser(nextPercentages); + }, [allMemberIds, defaultPayer, defaultSplitPreference, open]); useEffect(() => { - // Ensure payer is always a participant. if (payerId && !participants.includes(payerId)) { setParticipants((prev) => [...prev, payerId]); } @@ -49,14 +107,48 @@ export function CreateExpenseModal({ groupId, currency, members, open, onClose, ); const toggleParticipant = (userId: string) => { - if (userId === payerId) return; // keep payer in the split - if (participants.includes(userId)) { - setParticipants(participants.filter((id) => id !== userId)); - } else { - setParticipants([...participants, userId]); + if (userId === payerId) { + return; + } + + setParticipants((current) => (current.includes(userId) ? current.filter((id) => id !== userId) : [...current, userId])); + }; + + const buildDefaultSplitPreference = (nextSplitType: ExpenseSplitType, nextParticipants: string[]): GroupDefaultSplitDto => ({ + splitType: nextSplitType, + participantUserIds: Array.from(new Set(nextParticipants)), + exactAmountsCentsByUser: nextSplitType === 'exact' ? exactByUser : undefined, + percentagesByUser: nextSplitType === 'percentage' ? percentagesByUser : undefined, + }); + + const saveDefaultSplit = async (nextPreference: GroupDefaultSplitDto) => { + try { + setSavingDefault(true); + const result = await updateGroupDefaultSplitAction(groupId, { defaultSplitPreference: nextPreference }); + if (!result.success) { + throw new Error(result.message); + } + toast('Default split saved'); + onCreated?.(); + } catch (err) { + setError((err as Error).message || 'Unable to save default split'); + } finally { + setSavingDefault(false); } }; + const resetDefaultSplit = async () => { + const nextParticipants = allMemberIds.length > 0 ? allMemberIds : participants; + setSplitType('equal'); + setParticipants(nextParticipants); + setExactByUser({}); + setPercentagesByUser({}); + await saveDefaultSplit({ + splitType: 'equal', + participantUserIds: nextParticipants, + }); + }; + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); @@ -94,6 +186,7 @@ export function CreateExpenseModal({ groupId, currency, members, open, onClose, description: description.trim(), totalAmountCents: String(totalCents), currency, + category: category || undefined, splits, }); @@ -106,6 +199,7 @@ export function CreateExpenseModal({ groupId, currency, members, open, onClose, onClose(); setDescription(''); setAmount(''); + setCategory(''); } catch (err) { setError((err as Error).message || 'Unable to create expense'); } finally { @@ -149,12 +243,12 @@ export function CreateExpenseModal({ groupId, currency, members, open, onClose, className="w-full rounded-xl border border-[var(--fs-border)] bg-[var(--fs-background)] p-3 text-[var(--fs-text-primary)] outline-none focus:border-[var(--fs-primary)]" placeholder="Team dinner, rideshare..." value={description} - onChange={(e) => setDescription(e.target.value)} + onChange={(event) => setDescription(event.target.value)} required /> -
+
setAmount(e.target.value)} + onChange={(event) => setAmount(event.target.value)} required />
@@ -172,7 +266,7 @@ export function CreateExpenseModal({ groupId, currency, members, open, onClose,
+
+ + +
@@ -212,9 +321,19 @@ export function CreateExpenseModal({ groupId, currency, members, open, onClose,
- +
+ + +
- {(['equal', 'exact', 'percentage'] as SplitType[]).map((type) => ( + {EXPENSE_SPLIT_TYPES.map((type) => (
+
{splitType === 'exact' ? ( @@ -237,7 +364,7 @@ export function CreateExpenseModal({ groupId, currency, members, open, onClose,

Exact amounts (cents)

{participants.map((id) => { - const member = participantOptions.find((m) => m.id === id); + const member = participantOptions.find((participant) => participant.id === id); return ( setExactByUser({ ...exactByUser, [id]: e.target.value })} + onChange={(event) => setExactByUser({ ...exactByUser, [id]: event.target.value })} /> ); })} @@ -259,7 +386,7 @@ export function CreateExpenseModal({ groupId, currency, members, open, onClose,

Percentages

{participants.map((id) => { - const member = participantOptions.find((m) => m.id === id); + const member = participantOptions.find((participant) => participant.id === id); return (
setPercentagesByUser({ ...percentagesByUser, [id]: e.target.value })} + onChange={(event) => setPercentagesByUser({ ...percentagesByUser, [id]: event.target.value })} /> {member?.name ?? id}
@@ -279,9 +406,7 @@ export function CreateExpenseModal({ groupId, currency, members, open, onClose, ) : null} {error ? ( -
- {error} -
+
{error}
) : null}
diff --git a/apps/web/src/components/groups/ExpenseRow.tsx b/apps/web/src/components/groups/ExpenseRow.tsx index 707f3ad..9386561 100644 --- a/apps/web/src/components/groups/ExpenseRow.tsx +++ b/apps/web/src/components/groups/ExpenseRow.tsx @@ -4,28 +4,44 @@ import { useState } from 'react'; import { ExpenseDto } from '@fairshare/shared-types'; import dynamic from 'next/dynamic'; -const ReceiptUploadModal = dynamic( - () => import('./ReceiptUploadModal').then((mod) => mod.ReceiptUploadModal), - { ssr: false }, -); +const ReceiptUploadModal = dynamic(() => import('./ReceiptUploadModal').then((mod) => mod.ReceiptUploadModal), { + ssr: false, +}); -export function ExpenseRow({ expense }: { expense: ExpenseDto }) { +const categoryLabels: Record = { + FOOD: 'Food', + TRAVEL: 'Travel', + UTILITIES: 'Utilities', + GROCERIES: 'Groceries', + ENTERTAINMENT: 'Entertainment', + OTHER: 'Other', +}; + +export function ExpenseRow({ expense, payerName }: { expense: ExpenseDto; payerName?: string }) { const [open, setOpen] = useState(false); - const formatUsd = (cents: string): string => { - const dollars = Number(cents) / 100; - return dollars.toLocaleString(undefined, { style: 'currency', currency: expense.currency }); + const formatAmount = (cents: string): string => { + const amount = Number(cents) / 100; + return amount.toLocaleString(undefined, { style: 'currency', currency: expense.currency }); }; return ( <> - {expense.description} - - - {formatUsd(expense.totalAmountCents)} +
+
{expense.description}
+
+ {payerName ? `Paid by ${payerName}` : `Payer ${expense.payerId.slice(0, 6)}`} + {expense.category ? ( + + {categoryLabels[expense.category] ?? expense.category} + + ) : null} +
+
+ {formatAmount(expense.totalAmountCents)} {new Date(expense.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: '2-digit' })} @@ -39,12 +55,7 @@ export function ExpenseRow({ expense }: { expense: ExpenseDto }) { - setOpen(false)} - onUploaded={() => setOpen(false)} - /> + setOpen(false)} onUploaded={() => setOpen(false)} /> ); } diff --git a/apps/web/src/components/groups/GroupActions.tsx b/apps/web/src/components/groups/GroupActions.tsx index ecae1b2..195cb0e 100644 --- a/apps/web/src/components/groups/GroupActions.tsx +++ b/apps/web/src/components/groups/GroupActions.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { CurrencyCode, GroupMemberSummaryDto } from '@fairshare/shared-types'; +import { CurrencyCode, GroupDefaultSplitDto, GroupMemberSummaryDto } from '@fairshare/shared-types'; import Link from 'next/link'; import { PlusCircle, Wallet } from 'lucide-react'; import dynamic from 'next/dynamic'; @@ -16,9 +16,10 @@ type GroupActionsProps = { groupId: string; currency: CurrencyCode; members: GroupMemberSummaryDto[]; + defaultSplitPreference?: GroupDefaultSplitDto | null; }; -export function GroupActions({ groupId, currency, members }: GroupActionsProps) { +export function GroupActions({ groupId, currency, members, defaultSplitPreference }: GroupActionsProps) { const [open, setOpen] = useState(false); const router = useRouter(); @@ -27,10 +28,7 @@ export function GroupActions({ groupId, currency, members }: GroupActionsProps)

Actions

- @@ -51,6 +49,7 @@ export function GroupActions({ groupId, currency, members }: GroupActionsProps) groupId={groupId} currency={currency} members={members} + defaultSplitPreference={defaultSplitPreference} open={open} onClose={() => setOpen(false)} onCreated={() => { diff --git a/apps/web/src/lib/actions.ts b/apps/web/src/lib/actions.ts index 6d9f211..fdcaa0c 100644 --- a/apps/web/src/lib/actions.ts +++ b/apps/web/src/lib/actions.ts @@ -2,12 +2,13 @@ import { CreateExpenseRequestDto, - ExpenseDto, - CreateSettlementRequestDto, - SettlementDto, - PresignedReceiptUrlResponseDto, CreateGroupRequestDto, + CreateSettlementRequestDto, + ExpenseDto, GroupDto, + PresignedReceiptUrlResponseDto, + SettlementDto, + UpdateGroupDefaultSplitRequestDto, } from '@fairshare/shared-types'; import { cookies } from 'next/headers'; import { getBackendBaseUrl } from './env'; @@ -15,7 +16,7 @@ import { authCookies } from './authCookies'; export async function inviteMemberAction(groupId: string, email: string) { const token = (await cookies()).get(authCookies.accessToken)?.value; - + const response = await fetch(`${getBackendBaseUrl()}/groups/${groupId}/invite`, { method: 'POST', headers: { @@ -31,7 +32,7 @@ export async function inviteMemberAction(groupId: string, email: string) { if (!response.ok) { return { success: false, - message: Array.isArray(data.message) ? data.message[0] : (data.message || 'Failed to invite member'), + message: Array.isArray(data.message) ? data.message[0] : data.message || 'Failed to invite member', }; } @@ -63,6 +64,31 @@ export async function createExpenseAction(groupId: string, payload: CreateExpens return { success: true, expense: data as ExpenseDto }; } +export async function updateGroupDefaultSplitAction(groupId: string, payload: UpdateGroupDefaultSplitRequestDto) { + const token = (await cookies()).get(authCookies.accessToken)?.value; + + const response = await fetch(`${getBackendBaseUrl()}/groups/${groupId}/default-split`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(payload), + cache: 'no-store', + }); + + const data = await response.json().catch(() => null); + + if (!response.ok) { + return { + success: false, + message: data?.message ?? 'Failed to update default split', + }; + } + + return { success: true, group: data as GroupDto }; +} + export async function createSettlementAction(groupId: string, payload: CreateSettlementRequestDto) { const token = (await cookies()).get(authCookies.accessToken)?.value; diff --git a/apps/web/src/lib/split.ts b/apps/web/src/lib/split.ts index 239644b..0ddbe42 100644 --- a/apps/web/src/lib/split.ts +++ b/apps/web/src/lib/split.ts @@ -1,4 +1,6 @@ -export type SplitType = 'equal' | 'exact' | 'percentage'; +import { ExpenseSplitType } from '@fairshare/shared-types'; + +export type SplitType = ExpenseSplitType; export function toCents(input: string): number { const parsed = Number(input); diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index eea23a7..a82dbed 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1,4 +1,8 @@ export type CurrencyCode = 'USD' | 'EUR' | 'INR'; +export const EXPENSE_CATEGORIES = ['FOOD', 'TRAVEL', 'UTILITIES', 'GROCERIES', 'ENTERTAINMENT', 'OTHER'] as const; +export type ExpenseCategory = (typeof EXPENSE_CATEGORIES)[number]; +export const EXPENSE_SPLIT_TYPES = ['equal', 'exact', 'percentage'] as const; +export type ExpenseSplitType = (typeof EXPENSE_SPLIT_TYPES)[number]; export interface AuthUserDto { id: string; @@ -66,12 +70,20 @@ export interface GroupSummaryDto { topSpenderUserId: string | null; } +export interface GroupDefaultSplitDto { + splitType: ExpenseSplitType; + participantUserIds: string[]; + exactAmountsCentsByUser?: Record; + percentagesByUser?: Record; +} + export interface GroupDto { id: string; name: string; currency: CurrencyCode; createdBy: string; createdAt: string; + defaultSplitPreference?: GroupDefaultSplitDto | null; members?: GroupMemberDto[]; } @@ -86,11 +98,13 @@ export interface CreateExpenseRequestDto { description: string; totalAmountCents: string; currency: CurrencyCode; + category?: ExpenseCategory; splits: CreateExpenseSplitDto[]; } export interface UpdateExpenseRequestDto { description?: string; + category?: ExpenseCategory | null; } export interface SplitDto { @@ -107,6 +121,7 @@ export interface ExpenseDto { description: string; totalAmountCents: string; currency: CurrencyCode; + category?: ExpenseCategory | null; createdAt: string; splits?: SplitDto[]; } @@ -150,6 +165,10 @@ export interface SimplifySuggestionDto { amountCents: string; } +export interface UpdateGroupDefaultSplitRequestDto { + defaultSplitPreference: GroupDefaultSplitDto | null; +} + export type ActivityType = | 'expense_created' | 'expense_updated' From 7f4fca2827bdb149bccf247dc9cf577e015494d0 Mon Sep 17 00:00:00 2001 From: Arun-kushwaha007 Date: Thu, 19 Mar 2026 23:28:05 +0530 Subject: [PATCH 2/2] feat(web): add group ledger search and filters --- .../src/components/groups/ExpenseTable.tsx | 150 ++++++++++++++++-- 1 file changed, 138 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/groups/ExpenseTable.tsx b/apps/web/src/components/groups/ExpenseTable.tsx index f084790..7aff592 100644 --- a/apps/web/src/components/groups/ExpenseTable.tsx +++ b/apps/web/src/components/groups/ExpenseTable.tsx @@ -1,20 +1,147 @@ -import { ExpenseDto } from '@fairshare/shared-types'; +'use client'; + +import { useMemo, useState } from 'react'; +import { ExpenseDto, EXPENSE_CATEGORIES, GroupMemberSummaryDto } from '@fairshare/shared-types'; import { glassPanel } from '../layout/layoutStyles'; import { ExpenseRow } from './ExpenseRow'; interface ExpenseTableProps { expenses: ExpenseDto[]; + members: GroupMemberSummaryDto[]; } -export function ExpenseTable({ expenses }: ExpenseTableProps) { +const categoryLabels: Record = { + FOOD: 'Food', + TRAVEL: 'Travel', + UTILITIES: 'Utilities', + GROCERIES: 'Groceries', + ENTERTAINMENT: 'Entertainment', + OTHER: 'Other', +}; + +export function ExpenseTable({ expenses, members }: ExpenseTableProps) { + const [query, setQuery] = useState(''); + const [payerId, setPayerId] = useState(''); + const [category, setCategory] = useState(''); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + + const memberNameById = useMemo(() => Object.fromEntries(members.map((member) => [member.userId, member.name])), [members]); + + const filteredExpenses = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + + return expenses.filter((expense) => { + if (payerId && expense.payerId !== payerId) { + return false; + } + + if (category && expense.category !== category) { + return false; + } + + const expenseDate = expense.createdAt.slice(0, 10); + if (startDate && expenseDate < startDate) { + return false; + } + if (endDate && expenseDate > endDate) { + return false; + } + + if (!normalizedQuery) { + return true; + } + + const amount = (Number(expense.totalAmountCents) / 100).toFixed(2); + const dateLabel = new Date(expense.createdAt).toLocaleDateString(); + const haystack = [ + expense.description, + memberNameById[expense.payerId] ?? expense.payerId, + amount, + dateLabel, + expense.category ? categoryLabels[expense.category] ?? expense.category : '', + ] + .join(' ') + .toLowerCase(); + + return haystack.includes(normalizedQuery); + }); + }, [category, endDate, expenses, memberNameById, payerId, query, startDate]); + + const resetFilters = () => { + setQuery(''); + setPayerId(''); + setCategory(''); + setStartDate(''); + setEndDate(''); + }; + return (
-
-
-

Expense history

-

Chronological ledger for this group.

+
+
+
+

Expense history

+

Search the ledger by description, payer, amount, date, or category.

+
+ + {filteredExpenses.length}/{expenses.length} records + +
+ +
+ setQuery(event.target.value)} + /> + + + setStartDate(event.target.value)} + /> +
+ setEndDate(event.target.value)} + /> + +
- records
@@ -27,13 +154,13 @@ export function ExpenseTable({ expenses }: ExpenseTableProps) { - {expenses.map((expense) => ( - + {filteredExpenses.map((expense) => ( + ))} - {expenses.length === 0 && ( + {filteredExpenses.length === 0 && ( )} @@ -41,6 +168,5 @@ export function ExpenseTable({ expenses }: ExpenseTableProps) {
- No expenses have been recorded for this group yet. + No expenses match the current search and filters.
- ); }