Skip to content

Commit 7157b72

Browse files
feat(web): add expense detail page with receipt preview (#1)
1 parent b66c0f3 commit 7157b72

7 files changed

Lines changed: 172 additions & 21 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+
}
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)