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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions prisma/migrations/20260412120000_default_splits/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "public"."GroupDefaultSplit" (
"groupId" INTEGER NOT NULL,
"splitType" "public"."SplitType" NOT NULL,
"shares" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "GroupDefaultSplit_pkey" PRIMARY KEY ("groupId")
);

-- CreateTable
CREATE TABLE "public"."FriendDefaultSplit" (
"userAId" INTEGER NOT NULL,
"userBId" INTEGER NOT NULL,
"splitType" "public"."SplitType" NOT NULL,
"shares" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "FriendDefaultSplit_pkey" PRIMARY KEY ("userAId","userBId")
);

-- AddForeignKey
ALTER TABLE "public"."GroupDefaultSplit" ADD CONSTRAINT "GroupDefaultSplit_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "public"."Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "public"."FriendDefaultSplit" ADD CONSTRAINT "FriendDefaultSplit_userAId_fkey" FOREIGN KEY ("userAId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "public"."FriendDefaultSplit" ADD CONSTRAINT "FriendDefaultSplit_userBId_fkey" FOREIGN KEY ("userBId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
104 changes: 66 additions & 38 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -39,29 +39,31 @@ model Session {
}

model User {
id Int @id @default(autoincrement())
name String?
email String? @unique
emailVerified DateTime?
image String?
currency String @default("USD")
preferredLanguage String @default("")
bankingId String?
obapiProviderId String?
accounts Account[]
friendBalances BalanceView[] @relation("FriendBalanceView")
userBalances BalanceView[] @relation("UserBalanceView")
cachedBankData CachedBankData[] @relation("UserCachedBankData")
addedExpenses Expense[] @relation("AddedByUser")
deletedExpenses Expense[] @relation("DeletedByUser")
paidExpenses Expense[] @relation("PaidByUser")
updatedExpenses Expense[] @relation("UpdatedByUser")
expenseNotes ExpenseNote[]
expenseParticipants ExpenseParticipant[]
groups Group[]
associatedGroups GroupUser[]
sessions Session[]
hiddenFriendIds Int[] @default([])
id Int @id @default(autoincrement())
name String?
email String? @unique
emailVerified DateTime?
image String?
currency String @default("USD")
preferredLanguage String @default("")
bankingId String?
obapiProviderId String?
accounts Account[]
friendBalances BalanceView[] @relation("FriendBalanceView")
userBalances BalanceView[] @relation("UserBalanceView")
cachedBankData CachedBankData[] @relation("UserCachedBankData")
addedExpenses Expense[] @relation("AddedByUser")
deletedExpenses Expense[] @relation("DeletedByUser")
paidExpenses Expense[] @relation("PaidByUser")
updatedExpenses Expense[] @relation("UpdatedByUser")
expenseNotes ExpenseNote[]
expenseParticipants ExpenseParticipant[]
groups Group[]
associatedGroups GroupUser[]
friendDefaultSplitsA FriendDefaultSplit[] @relation("FriendDefaultSplitUserA")
friendDefaultSplitsB FriendDefaultSplit[] @relation("FriendDefaultSplitUserB")
sessions Session[]
hiddenFriendIds Int[] @default([])

@@schema("public")
}
Expand Down Expand Up @@ -92,22 +94,48 @@ model Balance {
}

model Group {
id Int @id @default(autoincrement())
publicId String @unique
name String
image String?
userId Int
defaultCurrency String @default("USD")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
splitwiseGroupId String? @unique
simplifyDebts Boolean @default(false)
archivedAt DateTime?
expenses Expense[]
createdBy User @relation(fields: [userId], references: [id], onDelete: Cascade)
groupUsers GroupUser[]
groupBalances BalanceView[]
id Int @id @default(autoincrement())
publicId String @unique
name String
image String?
userId Int
defaultCurrency String @default("USD")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
splitwiseGroupId String? @unique
simplifyDebts Boolean @default(false)
archivedAt DateTime?
expenses Expense[]
createdBy User @relation(fields: [userId], references: [id], onDelete: Cascade)
groupUsers GroupUser[]
groupBalances BalanceView[]
groupDefaultSplit GroupDefaultSplit?

@@schema("public")
}

model GroupDefaultSplit {
groupId Int @id
splitType SplitType
shares Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)

@@schema("public")
}

model FriendDefaultSplit {
userAId Int
userBId Int
splitType SplitType
shares Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userA User @relation("FriendDefaultSplitUserA", fields: [userAId], references: [id], onDelete: Cascade)
userB User @relation("FriendDefaultSplitUserB", fields: [userBId], references: [id], onDelete: Cascade)

@@id([userAId, userBId])
@@schema("public")
}

Expand Down
14 changes: 14 additions & 0 deletions public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@
"group_info": {
"actions": "Actions",
"archive_group": "Archive group",
"configure_default_split": "Configure default split",
"default_split": "Default split",
"edit_group": "Edit group",
"archive_group_details": {
"can_archive": "This group will be archived and hidden from your main groups list. You can still access it later if needed.",
Expand Down Expand Up @@ -297,6 +299,8 @@
"total_expenses": "Total expenses"
},
"messages": {
"default_split_cleared": "Default split cleared",
"default_split_updated": "Default split updated",
"balances_recalculated": "Balances recalculated successfully",
"group_archived": "Group archived successfully",
"group_name_updated": "Updated group details"
Expand Down Expand Up @@ -330,6 +334,16 @@
"description": "Split Expenses with your friends for free",
"title": "SplitPro: Split Expenses with your friends for free"
},
"balances": {
"user_preferences": {
"title": "Preferences"
},
"default_split": {
"cleared": "Default split cleared",
"configure": "Default split",
"updated": "Default split updated"
}
},
"navigation": {
"account": "Account",
"activity": "Activity",
Expand Down
54 changes: 47 additions & 7 deletions src/components/AddExpense/SplitTypeSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ import {
Plus,
X,
} from 'lucide-react';
import React, { type ChangeEvent, type PropsWithChildren, useCallback, useMemo } from 'react';
import React, {
type ChangeEvent,
type PropsWithChildren,
useCallback,
useEffect,
useMemo,
} from 'react';
import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils';

import { type Participant, useAddExpenseStore } from '~/store/addStore';
Expand Down Expand Up @@ -66,7 +72,14 @@ const PayerRow = ({ p, isPaying }: { p: Participant; isPaying: boolean }) => {
);
};

export const SplitExpenseForm: React.FC<PropsWithChildren> = ({ children }) => {
export const SplitExpenseForm: React.FC<
PropsWithChildren<{
allowedSplitTypes?: readonly SplitType[];
onSave?: () => void;
onOpenChange?: (open: boolean) => void;
onTriggerClick?: () => void;
}>
> = ({ children, allowedSplitTypes, onSave, onOpenChange, onTriggerClick }) => {
const { t } = useTranslation();
const splitType = useAddExpenseStore((s) => s.splitType);
const { setSplitType } = useAddExpenseStore((s) => s.actions);
Expand All @@ -75,30 +88,57 @@ export const SplitExpenseForm: React.FC<PropsWithChildren> = ({ children }) => {

const { setSplitScreenOpen } = useAddExpenseStore((s) => s.actions);

const handleOpenChange = useCallback(
(open: boolean) => {
setSplitScreenOpen(open);
onOpenChange?.(open);
},
[onOpenChange, setSplitScreenOpen],
);

const onTabChange = useCallback(
(value: string) => {
setSplitType(value as SplitType);
},
[setSplitType],
);

const splitProps = useMemo(() => getSplitProps(t), [t]);
const splitProps = useMemo(() => {
const allSplitProps = getSplitProps(t);
if (!allowedSplitTypes || 0 === allowedSplitTypes.length) {
return allSplitProps;
}

return allSplitProps.filter((props) => allowedSplitTypes.includes(props.splitType));
}, [allowedSplitTypes, t]);

const activeSplitType = splitProps.some((props) => props.splitType === splitType)
? splitType
: (splitProps[0]?.splitType ?? SplitType.EQUAL);

useEffect(() => {
if (activeSplitType !== splitType) {
setSplitType(activeSplitType);
}
}, [activeSplitType, setSplitType, splitType]);

return (
<AppDrawer
trigger={children}
onTriggerClick={onTriggerClick}
title={t(
`expense_details.add_expense_details.split_type_section.types.${splitType.toLowerCase()}.title`,
`expense_details.add_expense_details.split_type_section.types.${activeSplitType.toLowerCase()}.title`,
)}
className="h-[85vh] lg:h-[70vh]"
shouldCloseOnAction
dismissible={canSplitScreenClosed}
actionTitle={t('actions.save')}
actionOnClick={onSave}
actionDisabled={!canSplitScreenClosed}
open={splitScreenOpen}
onOpenChange={setSplitScreenOpen}
onOpenChange={handleOpenChange}
>
<Tabs value={splitType} className="mx-auto mt-5 w-full" onValueChange={onTabChange}>
<Tabs value={activeSplitType} className="mx-auto mt-5 w-full" onValueChange={onTabChange}>
<TabsList className="w-full justify-between">
{splitProps.map(({ splitType, iconComponent: Icon }) => (
<TabsTrigger key={splitType} value={splitType} className="text-xs">
Expand Down Expand Up @@ -404,7 +444,7 @@ export const UserAndAmount: React.FC<{ user: Participant; currency: CurrencyCode
'max-w-18 truncate text-sm text-gray-400',
)}
>
{0n < (shareAmount ?? 0n) ? '-' : ''} {toUIString(shareAmount)}
{paidBy && 0n < (shareAmount ?? 0n) ? '-' : ''} {toUIString(shareAmount)}
</p>
</div>
</div>
Expand Down
92 changes: 92 additions & 0 deletions src/components/DefaultSplit/DefaultSplitSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { SplitType } from '@prisma/client';
import React, { useCallback, useMemo } from 'react';

import {
DEFAULT_SPLIT_TYPES,
type SerializedDefaultSplitConfig,
deserializeDefaultSplit,
isDefaultSplitType,
serializeDefaultSplit,
} from '~/lib/defaultSplit';
import { type Participant, useAddExpenseStore } from '~/store/addStore';

import { SplitExpenseForm } from '../AddExpense/SplitTypeSection';
import { Button } from '../ui/button';

interface DefaultSplitSettingsProps {
participants: Participant[];
defaultSplit: SerializedDefaultSplitConfig | null | undefined;
triggerLabel: string;
disabled?: boolean;
onSave: (defaultSplit: SerializedDefaultSplitConfig) => void;
}

export const DefaultSplitSettings: React.FC<DefaultSplitSettingsProps> = ({
participants,
defaultSplit,
triggerLabel,
disabled,
onSave,
}) => {
const splitType = useAddExpenseStore((s) => s.splitType);
const splitShares = useAddExpenseStore((s) => s.splitShares);
const editorParticipants = useAddExpenseStore((s) => s.participants);
const { applySplitPreset, resetState, setAmount, setParticipants } = useAddExpenseStore(
(s) => s.actions,
);

const parsedDefaultSplit = useMemo(() => deserializeDefaultSplit(defaultSplit), [defaultSplit]);

const onTriggerClick = useCallback(() => {
resetState();
setParticipants(participants);
setAmount(10000n);

if (parsedDefaultSplit) {
applySplitPreset(parsedDefaultSplit.splitType, parsedDefaultSplit.shares);
}
}, [applySplitPreset, parsedDefaultSplit, participants, resetState, setAmount, setParticipants]);

const onOpenChange = useCallback(
(open: boolean) => {
if (!open) {
resetState();
}
},
[resetState],
);

const onDrawerSave = useCallback(() => {
if (!isDefaultSplitType(splitType)) {
return;
}

const shares = Object.fromEntries(
editorParticipants.map((participant) => {
const fallbackShare = splitType === SplitType.EQUAL ? 1n : 0n;
return [participant.id, splitShares[participant.id]?.[splitType] ?? fallbackShare];
}),
);

onSave(
serializeDefaultSplit({
splitType,
shares,
}),
);
resetState();
}, [editorParticipants, onSave, resetState, splitShares, splitType]);

return (
<SplitExpenseForm
allowedSplitTypes={DEFAULT_SPLIT_TYPES}
onSave={onDrawerSave}
onOpenChange={onOpenChange}
onTriggerClick={onTriggerClick}
>
<Button size="sm" variant="secondary" disabled={disabled}>
{triggerLabel}
</Button>
</SplitExpenseForm>
);
};
Loading
Loading