Skip to content

Commit 17b4d12

Browse files
authored
Feat 373 default split (#616)
* hide secrets during startup * Add split info jsons * Add backend logic for default splits * Make split form more configurable * Create a component for default split input * Use the new component in the frontend * Fix preview for groups * Consistent icon order with expense
1 parent 1dfbeb6 commit 17b4d12

File tree

13 files changed

+741
-87
lines changed

13 files changed

+741
-87
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
-- CreateTable
2+
CREATE TABLE "public"."GroupDefaultSplit" (
3+
"groupId" INTEGER NOT NULL,
4+
"splitType" "public"."SplitType" NOT NULL,
5+
"shares" JSONB NOT NULL,
6+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
7+
"updatedAt" TIMESTAMP(3) NOT NULL,
8+
9+
CONSTRAINT "GroupDefaultSplit_pkey" PRIMARY KEY ("groupId")
10+
);
11+
12+
-- CreateTable
13+
CREATE TABLE "public"."FriendDefaultSplit" (
14+
"userAId" INTEGER NOT NULL,
15+
"userBId" INTEGER NOT NULL,
16+
"splitType" "public"."SplitType" NOT NULL,
17+
"shares" JSONB NOT NULL,
18+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
19+
"updatedAt" TIMESTAMP(3) NOT NULL,
20+
21+
CONSTRAINT "FriendDefaultSplit_pkey" PRIMARY KEY ("userAId","userBId")
22+
);
23+
24+
-- AddForeignKey
25+
ALTER TABLE "public"."GroupDefaultSplit" ADD CONSTRAINT "GroupDefaultSplit_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "public"."Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
26+
27+
-- AddForeignKey
28+
ALTER TABLE "public"."FriendDefaultSplit" ADD CONSTRAINT "FriendDefaultSplit_userAId_fkey" FOREIGN KEY ("userAId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
29+
30+
-- AddForeignKey
31+
ALTER TABLE "public"."FriendDefaultSplit" ADD CONSTRAINT "FriendDefaultSplit_userBId_fkey" FOREIGN KEY ("userBId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

prisma/schema.prisma

Lines changed: 66 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -39,29 +39,31 @@ model Session {
3939
}
4040

4141
model User {
42-
id Int @id @default(autoincrement())
43-
name String?
44-
email String? @unique
45-
emailVerified DateTime?
46-
image String?
47-
currency String @default("USD")
48-
preferredLanguage String @default("")
49-
bankingId String?
50-
obapiProviderId String?
51-
accounts Account[]
52-
friendBalances BalanceView[] @relation("FriendBalanceView")
53-
userBalances BalanceView[] @relation("UserBalanceView")
54-
cachedBankData CachedBankData[] @relation("UserCachedBankData")
55-
addedExpenses Expense[] @relation("AddedByUser")
56-
deletedExpenses Expense[] @relation("DeletedByUser")
57-
paidExpenses Expense[] @relation("PaidByUser")
58-
updatedExpenses Expense[] @relation("UpdatedByUser")
59-
expenseNotes ExpenseNote[]
60-
expenseParticipants ExpenseParticipant[]
61-
groups Group[]
62-
associatedGroups GroupUser[]
63-
sessions Session[]
64-
hiddenFriendIds Int[] @default([])
42+
id Int @id @default(autoincrement())
43+
name String?
44+
email String? @unique
45+
emailVerified DateTime?
46+
image String?
47+
currency String @default("USD")
48+
preferredLanguage String @default("")
49+
bankingId String?
50+
obapiProviderId String?
51+
accounts Account[]
52+
friendBalances BalanceView[] @relation("FriendBalanceView")
53+
userBalances BalanceView[] @relation("UserBalanceView")
54+
cachedBankData CachedBankData[] @relation("UserCachedBankData")
55+
addedExpenses Expense[] @relation("AddedByUser")
56+
deletedExpenses Expense[] @relation("DeletedByUser")
57+
paidExpenses Expense[] @relation("PaidByUser")
58+
updatedExpenses Expense[] @relation("UpdatedByUser")
59+
expenseNotes ExpenseNote[]
60+
expenseParticipants ExpenseParticipant[]
61+
groups Group[]
62+
associatedGroups GroupUser[]
63+
friendDefaultSplitsA FriendDefaultSplit[] @relation("FriendDefaultSplitUserA")
64+
friendDefaultSplitsB FriendDefaultSplit[] @relation("FriendDefaultSplitUserB")
65+
sessions Session[]
66+
hiddenFriendIds Int[] @default([])
6567
6668
@@schema("public")
6769
}
@@ -92,22 +94,48 @@ model Balance {
9294
}
9395

9496
model Group {
95-
id Int @id @default(autoincrement())
96-
publicId String @unique
97-
name String
98-
image String?
99-
userId Int
100-
defaultCurrency String @default("USD")
101-
createdAt DateTime @default(now())
102-
updatedAt DateTime @updatedAt
103-
splitwiseGroupId String? @unique
104-
simplifyDebts Boolean @default(false)
105-
archivedAt DateTime?
106-
expenses Expense[]
107-
createdBy User @relation(fields: [userId], references: [id], onDelete: Cascade)
108-
groupUsers GroupUser[]
109-
groupBalances BalanceView[]
97+
id Int @id @default(autoincrement())
98+
publicId String @unique
99+
name String
100+
image String?
101+
userId Int
102+
defaultCurrency String @default("USD")
103+
createdAt DateTime @default(now())
104+
updatedAt DateTime @updatedAt
105+
splitwiseGroupId String? @unique
106+
simplifyDebts Boolean @default(false)
107+
archivedAt DateTime?
108+
expenses Expense[]
109+
createdBy User @relation(fields: [userId], references: [id], onDelete: Cascade)
110+
groupUsers GroupUser[]
111+
groupBalances BalanceView[]
112+
groupDefaultSplit GroupDefaultSplit?
113+
114+
@@schema("public")
115+
}
116+
117+
model GroupDefaultSplit {
118+
groupId Int @id
119+
splitType SplitType
120+
shares Json
121+
createdAt DateTime @default(now())
122+
updatedAt DateTime @updatedAt
123+
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
124+
125+
@@schema("public")
126+
}
110127

128+
model FriendDefaultSplit {
129+
userAId Int
130+
userBId Int
131+
splitType SplitType
132+
shares Json
133+
createdAt DateTime @default(now())
134+
updatedAt DateTime @updatedAt
135+
userA User @relation("FriendDefaultSplitUserA", fields: [userAId], references: [id], onDelete: Cascade)
136+
userB User @relation("FriendDefaultSplitUserB", fields: [userBId], references: [id], onDelete: Cascade)
137+
138+
@@id([userAId, userBId])
111139
@@schema("public")
112140
}
113141

public/locales/en/common.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,8 @@
257257
"group_info": {
258258
"actions": "Actions",
259259
"archive_group": "Archive group",
260+
"configure_default_split": "Configure default split",
261+
"default_split": "Default split",
260262
"edit_group": "Edit group",
261263
"archive_group_details": {
262264
"can_archive": "This group will be archived and hidden from your main groups list. You can still access it later if needed.",
@@ -297,6 +299,8 @@
297299
"total_expenses": "Total expenses"
298300
},
299301
"messages": {
302+
"default_split_cleared": "Default split cleared",
303+
"default_split_updated": "Default split updated",
300304
"balances_recalculated": "Balances recalculated successfully",
301305
"group_archived": "Group archived successfully",
302306
"group_name_updated": "Updated group details"
@@ -330,6 +334,16 @@
330334
"description": "Split Expenses with your friends for free",
331335
"title": "SplitPro: Split Expenses with your friends for free"
332336
},
337+
"balances": {
338+
"user_preferences": {
339+
"title": "Preferences"
340+
},
341+
"default_split": {
342+
"cleared": "Default split cleared",
343+
"configure": "Default split",
344+
"updated": "Default split updated"
345+
}
346+
},
333347
"navigation": {
334348
"account": "Account",
335349
"activity": "Activity",

src/components/AddExpense/SplitTypeSection.tsx

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ import {
1010
Plus,
1111
X,
1212
} from 'lucide-react';
13-
import React, { type ChangeEvent, type PropsWithChildren, useCallback, useMemo } from 'react';
13+
import React, {
14+
type ChangeEvent,
15+
type PropsWithChildren,
16+
useCallback,
17+
useEffect,
18+
useMemo,
19+
} from 'react';
1420
import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils';
1521

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

69-
export const SplitExpenseForm: React.FC<PropsWithChildren> = ({ children }) => {
75+
export const SplitExpenseForm: React.FC<
76+
PropsWithChildren<{
77+
allowedSplitTypes?: readonly SplitType[];
78+
onSave?: () => void;
79+
onOpenChange?: (open: boolean) => void;
80+
onTriggerClick?: () => void;
81+
}>
82+
> = ({ children, allowedSplitTypes, onSave, onOpenChange, onTriggerClick }) => {
7083
const { t } = useTranslation();
7184
const splitType = useAddExpenseStore((s) => s.splitType);
7285
const { setSplitType } = useAddExpenseStore((s) => s.actions);
@@ -75,30 +88,57 @@ export const SplitExpenseForm: React.FC<PropsWithChildren> = ({ children }) => {
7588

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

91+
const handleOpenChange = useCallback(
92+
(open: boolean) => {
93+
setSplitScreenOpen(open);
94+
onOpenChange?.(open);
95+
},
96+
[onOpenChange, setSplitScreenOpen],
97+
);
98+
7899
const onTabChange = useCallback(
79100
(value: string) => {
80101
setSplitType(value as SplitType);
81102
},
82103
[setSplitType],
83104
);
84105

85-
const splitProps = useMemo(() => getSplitProps(t), [t]);
106+
const splitProps = useMemo(() => {
107+
const allSplitProps = getSplitProps(t);
108+
if (!allowedSplitTypes || 0 === allowedSplitTypes.length) {
109+
return allSplitProps;
110+
}
111+
112+
return allSplitProps.filter((props) => allowedSplitTypes.includes(props.splitType));
113+
}, [allowedSplitTypes, t]);
114+
115+
const activeSplitType = splitProps.some((props) => props.splitType === splitType)
116+
? splitType
117+
: (splitProps[0]?.splitType ?? SplitType.EQUAL);
118+
119+
useEffect(() => {
120+
if (activeSplitType !== splitType) {
121+
setSplitType(activeSplitType);
122+
}
123+
}, [activeSplitType, setSplitType, splitType]);
86124

87125
return (
88126
<AppDrawer
89127
trigger={children}
128+
onTriggerClick={onTriggerClick}
90129
title={t(
91-
`expense_details.add_expense_details.split_type_section.types.${splitType.toLowerCase()}.title`,
130+
`expense_details.add_expense_details.split_type_section.types.${activeSplitType.toLowerCase()}.title`,
92131
)}
93132
className="h-[85vh] lg:h-[70vh]"
94133
shouldCloseOnAction
95134
dismissible={canSplitScreenClosed}
96135
actionTitle={t('actions.save')}
136+
actionOnClick={onSave}
97137
actionDisabled={!canSplitScreenClosed}
98138
open={splitScreenOpen}
99-
onOpenChange={setSplitScreenOpen}
139+
onOpenChange={handleOpenChange}
100140
>
101-
<Tabs value={splitType} className="mx-auto mt-5 w-full" onValueChange={onTabChange}>
141+
<Tabs value={activeSplitType} className="mx-auto mt-5 w-full" onValueChange={onTabChange}>
102142
<TabsList className="w-full justify-between">
103143
{splitProps.map(({ splitType, iconComponent: Icon }) => (
104144
<TabsTrigger key={splitType} value={splitType} className="text-xs">
@@ -404,7 +444,7 @@ export const UserAndAmount: React.FC<{ user: Participant; currency: CurrencyCode
404444
'max-w-18 truncate text-sm text-gray-400',
405445
)}
406446
>
407-
{0n < (shareAmount ?? 0n) ? '-' : ''} {toUIString(shareAmount)}
447+
{paidBy && 0n < (shareAmount ?? 0n) ? '-' : ''} {toUIString(shareAmount)}
408448
</p>
409449
</div>
410450
</div>
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { SplitType } from '@prisma/client';
2+
import React, { useCallback, useMemo } from 'react';
3+
4+
import {
5+
DEFAULT_SPLIT_TYPES,
6+
type SerializedDefaultSplitConfig,
7+
deserializeDefaultSplit,
8+
isDefaultSplitType,
9+
serializeDefaultSplit,
10+
} from '~/lib/defaultSplit';
11+
import { type Participant, useAddExpenseStore } from '~/store/addStore';
12+
13+
import { SplitExpenseForm } from '../AddExpense/SplitTypeSection';
14+
import { Button } from '../ui/button';
15+
16+
interface DefaultSplitSettingsProps {
17+
participants: Participant[];
18+
defaultSplit: SerializedDefaultSplitConfig | null | undefined;
19+
triggerLabel: string;
20+
disabled?: boolean;
21+
onSave: (defaultSplit: SerializedDefaultSplitConfig) => void;
22+
}
23+
24+
export const DefaultSplitSettings: React.FC<DefaultSplitSettingsProps> = ({
25+
participants,
26+
defaultSplit,
27+
triggerLabel,
28+
disabled,
29+
onSave,
30+
}) => {
31+
const splitType = useAddExpenseStore((s) => s.splitType);
32+
const splitShares = useAddExpenseStore((s) => s.splitShares);
33+
const editorParticipants = useAddExpenseStore((s) => s.participants);
34+
const { applySplitPreset, resetState, setAmount, setParticipants } = useAddExpenseStore(
35+
(s) => s.actions,
36+
);
37+
38+
const parsedDefaultSplit = useMemo(() => deserializeDefaultSplit(defaultSplit), [defaultSplit]);
39+
40+
const onTriggerClick = useCallback(() => {
41+
resetState();
42+
setParticipants(participants);
43+
setAmount(10000n);
44+
45+
if (parsedDefaultSplit) {
46+
applySplitPreset(parsedDefaultSplit.splitType, parsedDefaultSplit.shares);
47+
}
48+
}, [applySplitPreset, parsedDefaultSplit, participants, resetState, setAmount, setParticipants]);
49+
50+
const onOpenChange = useCallback(
51+
(open: boolean) => {
52+
if (!open) {
53+
resetState();
54+
}
55+
},
56+
[resetState],
57+
);
58+
59+
const onDrawerSave = useCallback(() => {
60+
if (!isDefaultSplitType(splitType)) {
61+
return;
62+
}
63+
64+
const shares = Object.fromEntries(
65+
editorParticipants.map((participant) => {
66+
const fallbackShare = splitType === SplitType.EQUAL ? 1n : 0n;
67+
return [participant.id, splitShares[participant.id]?.[splitType] ?? fallbackShare];
68+
}),
69+
);
70+
71+
onSave(
72+
serializeDefaultSplit({
73+
splitType,
74+
shares,
75+
}),
76+
);
77+
resetState();
78+
}, [editorParticipants, onSave, resetState, splitShares, splitType]);
79+
80+
return (
81+
<SplitExpenseForm
82+
allowedSplitTypes={DEFAULT_SPLIT_TYPES}
83+
onSave={onDrawerSave}
84+
onOpenChange={onOpenChange}
85+
onTriggerClick={onTriggerClick}
86+
>
87+
<Button size="sm" variant="secondary" disabled={disabled}>
88+
{triggerLabel}
89+
</Button>
90+
</SplitExpenseForm>
91+
);
92+
};

0 commit comments

Comments
 (0)