Skip to content

Commit 9a63f82

Browse files
Fix(web): expense details, dashboard currency formatting, and stale realtime copy (#12)
* feat(web): add expense detail page with receipt preview (#1) * fix(web): correct dashboard currency and refresh copy (#4, #6)
1 parent b66c0f3 commit 9a63f82

9 files changed

Lines changed: 195 additions & 39 deletions

File tree

apps/backend/src/expenses/expenses.service.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,14 @@ export class ExpensesService {
107107
payerId: dto.payerId,
108108
totalAmountCents: totalAmount.toString(),
109109
category: dto.category ?? null,
110+
currency: dto.currency,
110111
},
111112
},
112113
});
113114

114115
return tx.expense.findUniqueOrThrow({
115116
where: { id: createdExpense.id },
116-
include: { splits: true },
117+
include: { splits: true, receipt: true },
117118
});
118119
});
119120

@@ -133,7 +134,8 @@ export class ExpensesService {
133134
expenseId: expense.id,
134135
payerId: expense.payerId,
135136
totalAmountCents: expense.totalAmountCents.toString(),
136-
category: expense.category as ExpenseDto['category'],
137+
category: expense.category,
138+
currency: expense.currency,
137139
});
138140
incrementExpenseCreated(groupId);
139141

@@ -152,7 +154,7 @@ export class ExpensesService {
152154
} else {
153155
const rows = await this.prisma.expense.findMany({
154156
where: { groupId },
155-
include: { splits: true },
157+
include: { splits: true, receipt: true },
156158
orderBy: { createdAt: 'desc' },
157159
});
158160
expenses = rows.map((row) => this.toExpenseDto(row));
@@ -170,7 +172,7 @@ export class ExpensesService {
170172
}
171173

172174
async getById(id: string): Promise<ExpenseDto> {
173-
const expense = await this.prisma.expense.findUnique({ where: { id }, include: { splits: true } });
175+
const expense = await this.prisma.expense.findUnique({ where: { id }, include: { splits: true, receipt: true } });
174176
if (!expense) {
175177
throw new NotFoundException('Expense not found');
176178
}
@@ -185,15 +187,15 @@ export class ExpensesService {
185187
description: dto.description,
186188
category: dto.category,
187189
},
188-
include: { splits: true },
190+
include: { splits: true, receipt: true },
189191
});
190192

191193
await this.activityService.log({
192194
groupId: expense.groupId,
193195
actorUserId,
194196
type: 'expense_updated',
195197
entityId: expense.id,
196-
metadata: { description: dto.description ?? null, category: dto.category ?? null },
198+
metadata: { description: dto.description ?? null, category: dto.category ?? null, currency: expense.currency },
197199
});
198200

199201
await this.redis.invalidateGroupCache(expense.groupId);
@@ -218,6 +220,7 @@ export class ExpensesService {
218220
entityId: expense.id,
219221
metadata: {
220222
totalAmountCents: expense.totalAmountCents.toString(),
223+
currency: expense.currency,
221224
},
222225
},
223226
});
@@ -257,6 +260,9 @@ export class ExpensesService {
257260
owedAmountCents: bigint;
258261
paidAmountCents: bigint;
259262
}>;
263+
receipt: {
264+
fileKey: string;
265+
} | null;
260266
}): ExpenseDto {
261267
return {
262268
id: expense.id,
@@ -266,6 +272,7 @@ export class ExpensesService {
266272
totalAmountCents: expense.totalAmountCents.toString(),
267273
currency: expense.currency as 'USD' | 'EUR' | 'INR',
268274
category: expense.category as ExpenseDto['category'],
275+
receiptFileKey: expense.receipt?.fileKey ?? null,
269276
createdAt: expense.createdAt.toISOString(),
270277
splits: expense.splits.map((split) => ({
271278
id: split.id,
@@ -276,4 +283,3 @@ export class ExpensesService {
276283
};
277284
}
278285
}
279-
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import Link from 'next/link';
2+
import { ExpenseDto } from '@fairshare/shared-types';
3+
import { notFound } from 'next/navigation';
4+
5+
import { ExpenseDetailCard } from '../../../../src/components/groups';
6+
import { DashboardLayout } from '../../../../src/components/layout';
7+
import { backendFetch } from '../../../../src/lib/backend';
8+
import { getPublicS3BaseUrl } from '../../../../src/lib/env';
9+
10+
interface ExpenseDetailPageProps {
11+
params: {
12+
expenseId: string;
13+
};
14+
}
15+
16+
export default async function ExpenseDetailPage({ params }: ExpenseDetailPageProps) {
17+
try {
18+
const expense = await backendFetch<ExpenseDto>(`/expenses/${params.expenseId}`);
19+
const s3BaseUrl = getPublicS3BaseUrl();
20+
const receiptUrl = expense.receiptFileKey && s3BaseUrl ? `${s3BaseUrl.replace(/\/$/, '')}/${expense.receiptFileKey}` : null;
21+
22+
return (
23+
<DashboardLayout>
24+
<div className="space-y-6">
25+
<Link
26+
href={`/dashboard/groups/${expense.groupId}`}
27+
className="inline-flex items-center rounded-xl border border-[var(--fs-border)] bg-[var(--fs-card)] px-4 py-2 text-sm font-bold text-[var(--fs-text-primary)] hover:border-[var(--fs-primary)]"
28+
>
29+
Back to group
30+
</Link>
31+
<ExpenseDetailCard expense={expense} receiptUrl={receiptUrl} />
32+
</div>
33+
</DashboardLayout>
34+
);
35+
} catch (error) {
36+
console.error('Failed to fetch expense details:', error);
37+
return notFound();
38+
}
39+
}

apps/web/app/dashboard/page.tsx

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ import { backendFetch } from '../../src/lib/backend';
77
type Group = { id: string; name: string; currency: string };
88
type Balance = { id: string; userId: string; counterpartyUserId: string; amountCents: string };
99

10-
function formatUsd(cents: bigint): string {
11-
const dollars = Number(cents) / 100;
12-
return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' });
10+
function formatMoney(cents: bigint, currency: string | null): string {
11+
if (!currency) {
12+
return '—';
13+
}
14+
15+
const amount = Number(cents) / 100;
16+
return amount.toLocaleString(undefined, { style: 'currency', currency });
1317
}
1418

1519
export default async function DashboardPage() {
@@ -18,7 +22,9 @@ export default async function DashboardPage() {
1822
backendFetch<Group[]>('/groups').catch(() => [] as Group[]),
1923
]);
2024

21-
const activeGroupId = groups[0]?.id;
25+
const activeGroup = groups[0] ?? null;
26+
const activeGroupId = activeGroup?.id;
27+
const activeCurrency = activeGroup?.currency ?? null;
2228

2329
const [balances, activity] = await Promise.all([
2430
activeGroupId
@@ -47,21 +53,21 @@ export default async function DashboardPage() {
4753
<div className="flex items-center justify-between">
4854
<h2 className="text-lg font-bold text-[var(--fs-text-primary)]">Balance summary</h2>
4955
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-[var(--fs-text-muted)]">
50-
Updated realtime
56+
Updated on refresh
5157
</span>
5258
</div>
5359
<div className="grid gap-6 md:grid-cols-3">
54-
<SummaryCard title="Active groups" value={groups.length} hint="Registered ensembles" />
55-
<SummaryCard
56-
title="Liabilities"
57-
value={formatUsd(oweCents)}
58-
hint={activeGroupId ? `Group ref: ${activeGroupId.slice(0, 8)}...` : 'No active group'}
59-
/>
60-
<SummaryCard
61-
title="Assets"
62-
value={formatUsd(owedToMeCents)}
63-
hint={me ? `Linked to: ${me.email}` : 'Sign in to track personal totals'}
64-
/>
60+
<SummaryCard title="Active groups" value={groups.length} hint="Registered ensembles" />
61+
<SummaryCard
62+
title="Liabilities"
63+
value={formatMoney(oweCents, activeCurrency)}
64+
hint={activeGroupId ? `${activeGroup?.name} · ${activeCurrency}` : 'No active group selected'}
65+
/>
66+
<SummaryCard
67+
title="Assets"
68+
value={formatMoney(owedToMeCents, activeCurrency)}
69+
hint={activeGroupId ? `Shown in ${activeCurrency}` : 'Select a group to see currency-specific totals'}
70+
/>
6571
</div>
6672
</section>
6773

apps/web/src/components/dashboard/ActivityList.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,11 @@ export function ActivityList({ items, groupId }: { items: ActivityDto[]; groupId
5858
<div className="rounded-2xl border border-[var(--fs-border)] bg-[var(--fs-background)]/50 px-6 py-8 text-center">
5959
<p className="text-sm font-semibold text-[var(--fs-text-primary)] mb-1">No activity yet</p>
6060
<p className="text-[12px] font-medium text-[var(--fs-text-muted)]">
61-
New expenses and settlements will surface here in real time.
61+
Refresh after new expenses or settlements to see the latest activity.
6262
</p>
6363
</div>
6464
) : null}
6565
</div>
6666
</div>
6767
);
6868
}
69-
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { ExpenseDto } from '@fairshare/shared-types';
5+
import dynamic from 'next/dynamic';
6+
7+
const ReceiptUploadModal = dynamic(() => import('./ReceiptUploadModal').then((mod) => mod.ReceiptUploadModal), {
8+
ssr: false,
9+
});
10+
11+
const categoryLabels: Record<string, string> = {
12+
FOOD: 'Food',
13+
TRAVEL: 'Travel',
14+
UTILITIES: 'Utilities',
15+
GROCERIES: 'Groceries',
16+
ENTERTAINMENT: 'Entertainment',
17+
OTHER: 'Other',
18+
};
19+
20+
export function ExpenseDetailCard({ expense, receiptUrl }: { expense: ExpenseDto; receiptUrl: string | null }) {
21+
const [open, setOpen] = useState(false);
22+
23+
const amount = (Number(expense.totalAmountCents) / 100).toLocaleString(undefined, {
24+
style: 'currency',
25+
currency: expense.currency,
26+
});
27+
28+
return (
29+
<>
30+
<div className="rounded-3xl border border-[var(--fs-border)] bg-[var(--fs-card)] p-8 shadow-[var(--fs-shadow-soft)] space-y-8">
31+
<div className="flex flex-wrap items-start justify-between gap-4">
32+
<div className="space-y-3">
33+
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[var(--fs-text-muted)]">Expense detail</p>
34+
<h1 className="text-3xl font-extrabold tracking-tight text-[var(--fs-text-primary)]">{expense.description}</h1>
35+
<div className="flex flex-wrap items-center gap-2 text-[11px] font-bold uppercase tracking-[0.12em] text-[var(--fs-text-muted)]">
36+
<span>Expense ID {expense.id.slice(0, 8)}</span>
37+
{expense.category ? (
38+
<span className="rounded-full border border-[var(--fs-border)] bg-[var(--fs-background)] px-2 py-1 text-[10px] text-[var(--fs-text-primary)]">
39+
{categoryLabels[expense.category] ?? expense.category}
40+
</span>
41+
) : null}
42+
</div>
43+
</div>
44+
<div className="rounded-2xl border border-[var(--fs-border)] bg-[var(--fs-background)]/60 px-5 py-4 text-right">
45+
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-[var(--fs-text-muted)]">Amount</p>
46+
<p className="text-2xl font-extrabold text-[var(--fs-primary)]">{amount}</p>
47+
</div>
48+
</div>
49+
50+
<div className="grid gap-4 md:grid-cols-3">
51+
<div className="rounded-2xl border border-[var(--fs-border)] bg-[var(--fs-background)]/50 p-4">
52+
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-[var(--fs-text-muted)]">Date</p>
53+
<p className="mt-2 text-sm font-semibold text-[var(--fs-text-primary)]">
54+
{new Date(expense.createdAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
55+
</p>
56+
</div>
57+
<div className="rounded-2xl border border-[var(--fs-border)] bg-[var(--fs-background)]/50 p-4">
58+
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-[var(--fs-text-muted)]">Currency</p>
59+
<p className="mt-2 text-sm font-semibold text-[var(--fs-text-primary)]">{expense.currency}</p>
60+
</div>
61+
<div className="rounded-2xl border border-[var(--fs-border)] bg-[var(--fs-background)]/50 p-4">
62+
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-[var(--fs-text-muted)]">Payer</p>
63+
<p className="mt-2 text-sm font-semibold text-[var(--fs-text-primary)]">{expense.payerId}</p>
64+
</div>
65+
</div>
66+
67+
<div className="space-y-4">
68+
<div className="flex items-center justify-between gap-3">
69+
<div>
70+
<h2 className="text-xl font-extrabold tracking-tight text-[var(--fs-text-primary)]">Receipt</h2>
71+
<p className="text-sm font-medium text-[var(--fs-text-muted)]">
72+
{expense.receiptFileKey ? 'Preview the attached receipt or replace it with a new upload.' : 'No receipt attached yet.'}
73+
</p>
74+
</div>
75+
<button onClick={() => setOpen(true)} className="btn-royal px-5 py-2">
76+
{expense.receiptFileKey ? 'Replace receipt' : 'Upload receipt'}
77+
</button>
78+
</div>
79+
80+
{receiptUrl ? (
81+
<div className="overflow-hidden rounded-3xl border border-[var(--fs-border)] bg-[var(--fs-background)]/60 p-3">
82+
<img src={receiptUrl} alt="Receipt preview" className="max-h-[28rem] w-full rounded-2xl object-contain" />
83+
</div>
84+
) : expense.receiptFileKey ? (
85+
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm font-medium text-amber-700">
86+
Receipt is attached, but no public S3 base URL is configured for web preview.
87+
</div>
88+
) : (
89+
<div className="rounded-2xl border border-[var(--fs-border)] bg-[var(--fs-background)]/40 px-4 py-8 text-center text-sm font-medium text-[var(--fs-text-muted)]">
90+
Upload a receipt to keep proof alongside this expense.
91+
</div>
92+
)}
93+
</div>
94+
</div>
95+
96+
<ReceiptUploadModal expenseId={expense.id} open={open} onClose={() => setOpen(false)} onUploaded={() => setOpen(false)} />
97+
</>
98+
);
99+
}

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

3-
import { useState } from 'react';
3+
import Link from 'next/link';
4+
import { useMemo, useState } from 'react';
45
import { ExpenseDto } from '@fairshare/shared-types';
56
import dynamic from 'next/dynamic';
67

@@ -20,28 +21,35 @@ const categoryLabels: Record<string, string> = {
2021
export function ExpenseRow({ expense, payerName }: { expense: ExpenseDto; payerName?: string }) {
2122
const [open, setOpen] = useState(false);
2223

23-
const formatAmount = (cents: string): string => {
24-
const amount = Number(cents) / 100;
24+
const formattedAmount = useMemo(() => {
25+
const amount = Number(expense.totalAmountCents) / 100;
2526
return amount.toLocaleString(undefined, { style: 'currency', currency: expense.currency });
26-
};
27+
}, [expense.currency, expense.totalAmountCents]);
2728

2829
return (
2930
<>
3031
<tr className="hover:bg-[var(--fs-background)]/50 transition-colors group">
3132
<td className="px-6 py-4 text-base font-semibold text-[var(--fs-text-primary)]">
3233
<div className="space-y-1">
33-
<div>{expense.description}</div>
34+
<Link href={`/dashboard/expenses/${expense.id}`} className="hover:text-[var(--fs-primary)] transition-colors">
35+
{expense.description}
36+
</Link>
3437
<div className="flex flex-wrap items-center gap-2 text-[11px] font-bold uppercase tracking-[0.12em] text-[var(--fs-text-muted)]">
3538
<span>{payerName ? `Paid by ${payerName}` : `Payer ${expense.payerId.slice(0, 6)}`}</span>
3639
{expense.category ? (
3740
<span className="rounded-full border border-[var(--fs-border)] bg-[var(--fs-background)] px-2 py-1 text-[10px] text-[var(--fs-text-primary)]">
3841
{categoryLabels[expense.category] ?? expense.category}
3942
</span>
4043
) : null}
44+
{expense.receiptFileKey ? (
45+
<span className="rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-1 text-[10px] text-emerald-600">
46+
Receipt attached
47+
</span>
48+
) : null}
4149
</div>
4250
</div>
4351
</td>
44-
<td className="px-6 py-4 text-right text-base font-bold text-[var(--fs-primary)]">{formatAmount(expense.totalAmountCents)}</td>
52+
<td className="px-6 py-4 text-right text-base font-bold text-[var(--fs-primary)]">{formattedAmount}</td>
4553
<td className="px-6 py-4 text-right text-[12px] font-medium text-[var(--fs-text-muted)] group-hover:text-[var(--fs-text-primary)] transition-colors">
4654
{new Date(expense.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: '2-digit' })}
4755
</td>
@@ -50,7 +58,7 @@ export function ExpenseRow({ expense, payerName }: { expense: ExpenseDto; payerN
5058
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"
5159
onClick={() => setOpen(true)}
5260
>
53-
Upload receipt
61+
{expense.receiptFileKey ? 'Replace receipt' : 'Upload receipt'}
5462
</button>
5563
</td>
5664
</tr>

apps/web/src/components/groups/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ export * from './GroupActions';
66
export * from './SettlementList';
77
export * from './ReceiptUploadModal';
88
export * from './ExpenseRow';
9+
export * from './ExpenseDetailCard';
910
export * from './CreateGroupModal';
1011
export * from './CreateGroupButton';

apps/web/src/lib/env.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
export function getBackendBaseUrl(): string {
2-
// Prefer a non-public env var for server-side calls.
3-
return (
4-
process.env.FAIRSHARE_API_URL ??
5-
process.env.NEXT_PUBLIC_API_URL ??
6-
// Keep parity with existing web client default.
7-
'http://localhost:3001/api/v1'
8-
);
2+
return process.env.FAIRSHARE_API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1';
93
}
104

5+
export function getPublicS3BaseUrl(): string | null {
6+
return process.env.FAIRSHARE_S3_BASE_URL ?? process.env.NEXT_PUBLIC_S3_BASE_URL ?? null;
7+
}

packages/shared-types/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export interface ExpenseDto {
122122
totalAmountCents: string;
123123
currency: CurrencyCode;
124124
category?: ExpenseCategory | null;
125+
receiptFileKey?: string | null;
125126
createdAt: string;
126127
splits?: SplitDto[];
127128
}

0 commit comments

Comments
 (0)