Skip to content

Commit 728aced

Browse files
feat(web): polish layouts and docs
1 parent c2e91f6 commit 728aced

10 files changed

Lines changed: 512 additions & 371 deletions

File tree

apps/backend/src/settlements/settlements.service.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,33 @@ describe('SettlementsService', () => {
8888
service.create('group-1', 'payer-1', { payerId: 'payer-1', receiverId: 'receiver-1', amountCents: '0' }),
8989
).rejects.toBeInstanceOf(BadRequestException);
9090
});
91+
92+
it('generates a fallback idempotency key and prevents duplicates in the same minute', async () => {
93+
const prisma: any = {
94+
settlement: {
95+
findUnique: jest.fn().mockResolvedValue({
96+
id: 'existing-1',
97+
groupId: 'g1',
98+
payerId: 'u1',
99+
receiverId: 'u2',
100+
amountCents: 5000n,
101+
createdAt: new Date(),
102+
}),
103+
},
104+
groupMember: {
105+
findMany: jest.fn().mockResolvedValue([{ userId: 'u1' }, { userId: 'u2' }]),
106+
},
107+
};
108+
const service = new SettlementsService(prisma, {} as any, {} as any, {} as any, {} as any);
109+
110+
// This should hit the findUnique with the fallback key and return existing
111+
const result = await service.create('g1', 'u1', {
112+
payerId: 'u1',
113+
receiverId: 'u2',
114+
amountCents: '5000',
115+
});
116+
117+
expect(result.id).toBe('existing-1');
118+
expect(prisma.settlement.findUnique).toHaveBeenCalled();
119+
});
91120
});

apps/web/src/components/groups/CreateExpenseModal.tsx

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@ import {
77
EXPENSE_CATEGORIES,
88
EXPENSE_SPLIT_TYPES,
99
ExpenseCategory,
10+
ExpenseDto,
1011
ExpenseSplitType,
1112
GroupDefaultSplitDto,
1213
GroupMemberSummaryDto,
1314
RECURRING_EXPENSE_FREQUENCIES,
1415
RecurringExpenseFrequency,
1516
} from '@fairshare/shared-types';
1617
import { Sparkles, X } from 'lucide-react';
17-
import { createExpenseAction, updateGroupDefaultSplitAction } from '../../lib/actions';
18+
import { createExpenseAction, updateExpenseAction, updateGroupDefaultSplitAction } from '../../lib/actions';
1819
import { equalShares, exactShares, percentageShares, sumShares } from '../../lib/split';
1920
import { useToast } from '../ui/Toaster';
2021
import { useModalFocusTrap } from '../ui/useModalFocusTrap';
@@ -24,6 +25,7 @@ type CreateExpenseModalProps = {
2425
currency: CurrencyCode;
2526
members: GroupMemberSummaryDto[];
2627
defaultSplitPreference?: GroupDefaultSplitDto | null;
28+
expense?: ExpenseDto | null;
2729
open: boolean;
2830
onClose: () => void;
2931
onCreated?: () => void;
@@ -49,6 +51,7 @@ export function CreateExpenseModal({
4951
currency,
5052
members,
5153
defaultSplitPreference,
54+
expense,
5255
open,
5356
onClose,
5457
onCreated,
@@ -77,9 +80,24 @@ export function CreateExpenseModal({
7780
return;
7881
}
7982

83+
if (expense) {
84+
setDescription(expense.description);
85+
setAmount((Number(expense.totalAmountCents) / 100).toString());
86+
setCategory(expense.category || '');
87+
setPayerId(expense.payerId);
88+
setSplitType('equal'); // Defaulting to equal for now when editing complex splits if not tracked
89+
setParticipants(expense.splits?.map((s) => s.userId) || allMemberIds);
90+
// Note: Full split reconstruction (exact/percentage) is complex without split metadata,
91+
// but for basic hardening we allow editing description/amount/payer.
92+
return;
93+
}
94+
8095
setPayerId(defaultPayer);
8196
setRecurringEnabled(false);
8297
setRecurringFrequency('monthly');
98+
setDescription('');
99+
setAmount('');
100+
setCategory('');
83101

84102
const fallbackParticipants = allMemberIds;
85103
if (!defaultSplitPreference) {
@@ -212,21 +230,26 @@ export function CreateExpenseModal({
212230

213231
try {
214232
setSubmitting(true);
215-
const result = await createExpenseAction(groupId, {
216-
payerId,
217-
description: description.trim(),
218-
totalAmountCents: String(totalCents),
219-
currency,
220-
category: category || undefined,
221-
recurring: recurringEnabled ? { frequency: recurringFrequency } : undefined,
222-
splits,
223-
});
224-
225-
if (!result.success) {
226-
throw new Error(result.message);
233+
if (expense) {
234+
const result = await updateExpenseAction(expense.id, {
235+
description: description.trim(),
236+
category: (category || null) as ExpenseCategory | null,
237+
});
238+
if (!result.success) throw new Error(result.message);
239+
toast('Expense updated');
240+
} else {
241+
const result = await createExpenseAction(groupId, {
242+
payerId,
243+
description: description.trim(),
244+
totalAmountCents: String(totalCents),
245+
currency,
246+
category: category || undefined,
247+
recurring: recurringEnabled ? { frequency: recurringFrequency } : undefined,
248+
splits,
249+
});
250+
if (!result.success) throw new Error(result.message);
251+
toast('Expense recorded');
227252
}
228-
229-
toast('Expense recorded');
230253
onCreated?.();
231254
onClose();
232255
setDescription('');
@@ -273,7 +296,7 @@ export function CreateExpenseModal({
273296
id="create-expense-title"
274297
className="text-2xl font-extrabold tracking-tight text-[var(--fs-text-primary)]"
275298
>
276-
Record new expense
299+
{expense ? 'Edit expense' : 'Record new expense'}
277300
</h3>
278301
</div>
279302
<button
@@ -557,7 +580,7 @@ export function CreateExpenseModal({
557580
Cancel
558581
</button>
559582
<button type="submit" className="btn-royal px-6 py-2" disabled={submitting}>
560-
{submitting ? 'Saving...' : 'Save expense'}
583+
{submitting ? 'Saving...' : expense ? 'Update expense' : 'Save expense'}
561584
</button>
562585
</div>
563586
</div>

apps/web/src/components/groups/ExpenseRow.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ const ReceiptUploadModal = dynamic(() => import('./ReceiptUploadModal').then((mo
1010
ssr: false,
1111
});
1212

13+
const CreateExpenseModal = dynamic(() => import('./CreateExpenseModal').then((mod) => mod.CreateExpenseModal), {
14+
ssr: false,
15+
});
16+
1317
const categoryLabels: Record<string, string> = {
1418
FOOD: 'Food',
1519
TRAVEL: 'Travel',
@@ -22,13 +26,16 @@ const categoryLabels: Record<string, string> = {
2226
export function ExpenseRow({
2327
expense,
2428
payerName,
29+
members = [],
2530
isGuest = false,
2631
}: {
2732
expense: ExpenseDto;
2833
payerName?: string;
34+
members?: any[];
2935
isGuest?: boolean;
3036
}) {
31-
const [open, setOpen] = useState(false);
37+
const [openReceipt, setOpenReceipt] = useState(false);
38+
const [openEdit, setOpenEdit] = useState(false);
3239

3340
const formattedAmount = useMemo(() => {
3441
const amount = Number(expense.totalAmountCents) / 100;
@@ -71,17 +78,35 @@ export function ExpenseRow({
7178
<div className="flex items-center justify-end gap-2">
7279
<button
7380
className="rounded-xl border border-[var(--fs-border)] bg-[var(--fs-background)] px-3 py-2 text-xs font-bold text-[var(--fs-text-primary)] hover:border-[var(--fs-primary)] transition-colors"
74-
onClick={() => setOpen(true)}
81+
onClick={() => setOpenReceipt(true)}
7582
>
7683
{expense.receiptFileKey ? 'Replace receipt' : 'Upload receipt'}
7784
</button>
85+
<button
86+
className="rounded-xl border border-[var(--fs-border)] bg-[var(--fs-background)] px-3 py-2 text-xs font-bold text-[var(--fs-text-primary)] hover:border-[var(--fs-primary)] transition-colors"
87+
onClick={() => setOpenEdit(true)}
88+
>
89+
Edit
90+
</button>
7891
<ExpenseDeleteButton expenseId={expense.id} compact />
7992
</div>
8093
)}
8194
</td>
8295
</tr>
8396

84-
<ReceiptUploadModal expenseId={expense.id} open={open} onClose={() => setOpen(false)} onUploaded={() => setOpen(false)} />
97+
<ReceiptUploadModal expenseId={expense.id} open={openReceipt} onClose={() => setOpenReceipt(false)} onUploaded={() => setOpenReceipt(false)} />
98+
<CreateExpenseModal
99+
groupId={expense.groupId}
100+
currency={expense.currency}
101+
members={members as any}
102+
expense={expense}
103+
open={openEdit}
104+
onClose={() => setOpenEdit(false)}
105+
onCreated={() => {
106+
setOpenEdit(false);
107+
window.location.reload(); // Refresh to show changes
108+
}}
109+
/>
85110
</>
86111
);
87112
}

apps/web/src/components/groups/ExpenseTable.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,8 @@ export function ExpenseTable({ expenses, members, isGuest = false }: ExpenseTabl
150150
<tr className="bg-[var(--fs-background)]/40 text-[11px] font-bold uppercase tracking-[0.14em] text-[var(--fs-text-muted)]">
151151
<th className="px-6 py-3 border-b border-[var(--fs-border)]">Description</th>
152152
<th className="px-6 py-3 border-b border-[var(--fs-border)] text-right">Amount</th>
153-
<th className="px-6 py-3 border-b border-[var(--fs-border)] text-right">Date</th>
154-
<th className="px-6 py-3 border-b border-[var(--fs-border)] text-right">Receipt</th>
153+
<th className="hidden sm:table-cell px-6 py-3 border-b border-[var(--fs-border)] text-right">Recorded</th>
154+
<th className="hidden sm:table-cell px-6 py-3 border-b border-[var(--fs-border)] text-right">Actions</th>
155155
</tr>
156156
</thead>
157157
<tbody className="divide-y divide-[var(--fs-border)]">
@@ -160,6 +160,7 @@ export function ExpenseTable({ expenses, members, isGuest = false }: ExpenseTabl
160160
key={expense.id}
161161
expense={expense}
162162
payerName={memberNameById[expense.payerId]}
163+
members={members}
163164
isGuest={isGuest}
164165
/>
165166
))}

apps/web/src/components/groups/GroupActions.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,9 @@ export function GroupActions({
113113
<>
114114
<div className="rounded-3xl border border-[var(--fs-border)] bg-[var(--fs-card)] p-4 shadow-[var(--fs-shadow-soft)] sm:p-6">
115115
<h3 className="mb-4 text-lg font-bold text-[var(--fs-text-primary)]">Actions</h3>
116-
<div className="grid gap-3">
116+
<div className="grid gap-3 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-1">
117117
<button
118-
className="btn-royal inline-flex w-full items-center justify-center gap-2"
118+
className="btn-royal inline-flex w-full items-center justify-center gap-2 sm:col-span-2 md:col-span-1"
119119
onClick={() => setOpen(true)}
120120
>
121121
<PlusCircle className="h-4 w-4" />

apps/web/src/components/groups/GroupSummaryPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export function GroupSummaryPanel({
5151
</p>
5252
</div>
5353

54-
<div className="mt-5 grid gap-3 sm:grid-cols-2">
54+
<div className="mt-5 grid min-w-0 grid-cols-2 gap-3 lg:grid-cols-2">
5555
<div className="rounded-2xl border border-[var(--fs-border)] bg-[var(--fs-background)]/60 p-4">
5656
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-[var(--fs-text-muted)]">Your balance</p>
5757
<p className={`mt-2 text-xl font-extrabold ${netBalanceCents > 0 ? 'text-emerald-600' : netBalanceCents < 0 ? 'text-rose-600' : 'text-[var(--fs-text-primary)]'}`}>

apps/web/src/components/groups/ReceiptUploadModal.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,24 @@ export function ReceiptUploadModal({
3939
if (!selected) {
4040
setFile(null);
4141
setPreviewUrl(null);
42+
setError('');
4243
return;
4344
}
45+
46+
// Validation: 5MB limit
47+
if (selected.size > 5 * 1024 * 1024) {
48+
setError('File is too large. Maximum size is 5MB.');
49+
return;
50+
}
51+
52+
// Validation: Type
53+
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
54+
if (!allowedTypes.includes(selected.type)) {
55+
setError('Unsupported file type. Please upload JPG, PNG, WebP or PDF.');
56+
return;
57+
}
58+
59+
setError('');
4460
setFile(selected);
4561
const url = URL.createObjectURL(selected);
4662
setPreviewUrl(url);
@@ -118,6 +134,8 @@ export function ReceiptUploadModal({
118134
</div>
119135
<button
120136
onClick={onClose}
137+
aria-label="Close upload modal"
138+
title="Close upload modal"
121139
className="p-2 rounded-lg bg-[var(--fs-background)] hover:bg-[var(--fs-background)]/70 transition-colors"
122140
>
123141
<X className="w-5 h-5 text-[var(--fs-text-muted)]" />

apps/web/src/lib/actions.ts

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ export async function createSettlementAction(groupId: string, payload: CreateSet
179179
return { success: true, settlement: data as SettlementDto };
180180
}
181181

182+
182183
export async function remindSettlementAction(groupId: string, payload: RemindSettlementRequestDto) {
183184
const token = (await cookies()).get(authCookies.accessToken)?.value;
184185

@@ -248,26 +249,6 @@ export async function createGroupAction(payload: CreateGroupRequestDto) {
248249
return { success: true, group: data as GroupDto };
249250
}
250251

251-
export async function deleteExpenseAction(expenseId: string) {
252-
const token = (await cookies()).get(authCookies.accessToken)?.value;
253-
254-
const response = await fetch(`${getBackendBaseUrl()}/expenses/${expenseId}`, {
255-
method: 'DELETE',
256-
headers: {
257-
...(token ? { Authorization: `Bearer ${token}` } : {}),
258-
},
259-
cache: 'no-store',
260-
});
261-
262-
const data = await response.json().catch(() => null);
263-
264-
if (!response.ok) {
265-
return { success: false, message: data?.message ?? 'Failed to delete expense' };
266-
}
267-
268-
return { success: true };
269-
}
270-
271252
export async function exportExpensesCsvAction(groupId: string) {
272253
const token = (await cookies()).get(authCookies.accessToken)?.value;
273254

0 commit comments

Comments
 (0)