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
20 changes: 13 additions & 7 deletions apps/backend/src/expenses/expenses.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,14 @@ export class ExpensesService {
payerId: dto.payerId,
totalAmountCents: totalAmount.toString(),
category: dto.category ?? null,
currency: dto.currency,
},
},
});

return tx.expense.findUniqueOrThrow({
where: { id: createdExpense.id },
include: { splits: true },
include: { splits: true, receipt: true },
});
});

Expand All @@ -133,7 +134,8 @@ export class ExpensesService {
expenseId: expense.id,
payerId: expense.payerId,
totalAmountCents: expense.totalAmountCents.toString(),
category: expense.category as ExpenseDto['category'],
category: expense.category,
currency: expense.currency,
});
incrementExpenseCreated(groupId);

Expand All @@ -152,7 +154,7 @@ export class ExpensesService {
} else {
const rows = await this.prisma.expense.findMany({
where: { groupId },
include: { splits: true },
include: { splits: true, receipt: true },
orderBy: { createdAt: 'desc' },
});
expenses = rows.map((row) => this.toExpenseDto(row));
Expand All @@ -170,7 +172,7 @@ export class ExpensesService {
}

async getById(id: string): Promise<ExpenseDto> {
const expense = await this.prisma.expense.findUnique({ where: { id }, include: { splits: true } });
const expense = await this.prisma.expense.findUnique({ where: { id }, include: { splits: true, receipt: true } });
if (!expense) {
throw new NotFoundException('Expense not found');
}
Expand All @@ -185,15 +187,15 @@ export class ExpensesService {
description: dto.description,
category: dto.category,
},
include: { splits: true },
include: { splits: true, receipt: true },
});

await this.activityService.log({
groupId: expense.groupId,
actorUserId,
type: 'expense_updated',
entityId: expense.id,
metadata: { description: dto.description ?? null, category: dto.category ?? null },
metadata: { description: dto.description ?? null, category: dto.category ?? null, currency: expense.currency },
});

await this.redis.invalidateGroupCache(expense.groupId);
Expand All @@ -218,6 +220,7 @@ export class ExpensesService {
entityId: expense.id,
metadata: {
totalAmountCents: expense.totalAmountCents.toString(),
currency: expense.currency,
},
},
});
Expand Down Expand Up @@ -257,6 +260,9 @@ export class ExpensesService {
owedAmountCents: bigint;
paidAmountCents: bigint;
}>;
receipt: {
fileKey: string;
} | null;
}): ExpenseDto {
return {
id: expense.id,
Expand All @@ -266,6 +272,7 @@ export class ExpensesService {
totalAmountCents: expense.totalAmountCents.toString(),
currency: expense.currency as 'USD' | 'EUR' | 'INR',
category: expense.category as ExpenseDto['category'],
receiptFileKey: expense.receipt?.fileKey ?? null,
createdAt: expense.createdAt.toISOString(),
splits: expense.splits.map((split) => ({
id: split.id,
Expand All @@ -276,4 +283,3 @@ export class ExpensesService {
};
}
}

39 changes: 39 additions & 0 deletions apps/web/app/dashboard/expenses/[expenseId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Link from 'next/link';
import { ExpenseDto } from '@fairshare/shared-types';
import { notFound } from 'next/navigation';

import { ExpenseDetailCard } from '../../../../src/components/groups';
import { DashboardLayout } from '../../../../src/components/layout';
import { backendFetch } from '../../../../src/lib/backend';
import { getPublicS3BaseUrl } from '../../../../src/lib/env';

interface ExpenseDetailPageProps {
params: {
expenseId: string;
};
}

export default async function ExpenseDetailPage({ params }: ExpenseDetailPageProps) {
try {
const expense = await backendFetch<ExpenseDto>(`/expenses/${params.expenseId}`);
const s3BaseUrl = getPublicS3BaseUrl();
const receiptUrl = expense.receiptFileKey && s3BaseUrl ? `${s3BaseUrl.replace(/\/$/, '')}/${expense.receiptFileKey}` : null;

return (
<DashboardLayout>
<div className="space-y-6">
<Link
href={`/dashboard/groups/${expense.groupId}`}
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)]"
>
Back to group
</Link>
<ExpenseDetailCard expense={expense} receiptUrl={receiptUrl} />
</div>
</DashboardLayout>
);
} catch (error) {
console.error('Failed to fetch expense details:', error);
return notFound();
}
}
38 changes: 22 additions & 16 deletions apps/web/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ import { backendFetch } from '../../src/lib/backend';
type Group = { id: string; name: string; currency: string };
type Balance = { id: string; userId: string; counterpartyUserId: string; amountCents: string };

function formatUsd(cents: bigint): string {
const dollars = Number(cents) / 100;
return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' });
function formatMoney(cents: bigint, currency: string | null): string {
if (!currency) {
return '—';
}

const amount = Number(cents) / 100;
return amount.toLocaleString(undefined, { style: 'currency', currency });
}

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

const activeGroupId = groups[0]?.id;
const activeGroup = groups[0] ?? null;
const activeGroupId = activeGroup?.id;
const activeCurrency = activeGroup?.currency ?? null;

const [balances, activity] = await Promise.all([
activeGroupId
Expand Down Expand Up @@ -47,21 +53,21 @@ export default async function DashboardPage() {
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-[var(--fs-text-primary)]">Balance summary</h2>
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-[var(--fs-text-muted)]">
Updated realtime
Updated on refresh
</span>
</div>
<div className="grid gap-6 md:grid-cols-3">
<SummaryCard title="Active groups" value={groups.length} hint="Registered ensembles" />
<SummaryCard
title="Liabilities"
value={formatUsd(oweCents)}
hint={activeGroupId ? `Group ref: ${activeGroupId.slice(0, 8)}...` : 'No active group'}
/>
<SummaryCard
title="Assets"
value={formatUsd(owedToMeCents)}
hint={me ? `Linked to: ${me.email}` : 'Sign in to track personal totals'}
/>
<SummaryCard title="Active groups" value={groups.length} hint="Registered ensembles" />
<SummaryCard
title="Liabilities"
value={formatMoney(oweCents, activeCurrency)}
hint={activeGroupId ? `${activeGroup?.name} · ${activeCurrency}` : 'No active group selected'}
/>
<SummaryCard
title="Assets"
value={formatMoney(owedToMeCents, activeCurrency)}
hint={activeGroupId ? `Shown in ${activeCurrency}` : 'Select a group to see currency-specific totals'}
/>
</div>
</section>

Expand Down
3 changes: 1 addition & 2 deletions apps/web/src/components/dashboard/ActivityList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,11 @@ export function ActivityList({ items, groupId }: { items: ActivityDto[]; groupId
<div className="rounded-2xl border border-[var(--fs-border)] bg-[var(--fs-background)]/50 px-6 py-8 text-center">
<p className="text-sm font-semibold text-[var(--fs-text-primary)] mb-1">No activity yet</p>
<p className="text-[12px] font-medium text-[var(--fs-text-muted)]">
New expenses and settlements will surface here in real time.
Refresh after new expenses or settlements to see the latest activity.
</p>
</div>
) : null}
</div>
</div>
);
}

99 changes: 99 additions & 0 deletions apps/web/src/components/groups/ExpenseDetailCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
'use client';

import { useState } from 'react';
import { ExpenseDto } from '@fairshare/shared-types';
import dynamic from 'next/dynamic';

const ReceiptUploadModal = dynamic(() => import('./ReceiptUploadModal').then((mod) => mod.ReceiptUploadModal), {
ssr: false,
});

const categoryLabels: Record<string, string> = {
FOOD: 'Food',
TRAVEL: 'Travel',
UTILITIES: 'Utilities',
GROCERIES: 'Groceries',
ENTERTAINMENT: 'Entertainment',
OTHER: 'Other',
};

export function ExpenseDetailCard({ expense, receiptUrl }: { expense: ExpenseDto; receiptUrl: string | null }) {
const [open, setOpen] = useState(false);

const amount = (Number(expense.totalAmountCents) / 100).toLocaleString(undefined, {
style: 'currency',
currency: expense.currency,
});

return (
<>
<div className="rounded-3xl border border-[var(--fs-border)] bg-[var(--fs-card)] p-8 shadow-[var(--fs-shadow-soft)] space-y-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[var(--fs-text-muted)]">Expense detail</p>
<h1 className="text-3xl font-extrabold tracking-tight text-[var(--fs-text-primary)]">{expense.description}</h1>
<div className="flex flex-wrap items-center gap-2 text-[11px] font-bold uppercase tracking-[0.12em] text-[var(--fs-text-muted)]">
<span>Expense ID {expense.id.slice(0, 8)}</span>
{expense.category ? (
<span className="rounded-full border border-[var(--fs-border)] bg-[var(--fs-background)] px-2 py-1 text-[10px] text-[var(--fs-text-primary)]">
{categoryLabels[expense.category] ?? expense.category}
</span>
) : null}
</div>
</div>
<div className="rounded-2xl border border-[var(--fs-border)] bg-[var(--fs-background)]/60 px-5 py-4 text-right">
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-[var(--fs-text-muted)]">Amount</p>
<p className="text-2xl font-extrabold text-[var(--fs-primary)]">{amount}</p>
</div>
</div>

<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-[var(--fs-border)] bg-[var(--fs-background)]/50 p-4">
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-[var(--fs-text-muted)]">Date</p>
<p className="mt-2 text-sm font-semibold text-[var(--fs-text-primary)]">
{new Date(expense.createdAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
</p>
</div>
<div className="rounded-2xl border border-[var(--fs-border)] bg-[var(--fs-background)]/50 p-4">
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-[var(--fs-text-muted)]">Currency</p>
<p className="mt-2 text-sm font-semibold text-[var(--fs-text-primary)]">{expense.currency}</p>
</div>
<div className="rounded-2xl border border-[var(--fs-border)] bg-[var(--fs-background)]/50 p-4">
<p className="text-[11px] font-bold uppercase tracking-[0.14em] text-[var(--fs-text-muted)]">Payer</p>
<p className="mt-2 text-sm font-semibold text-[var(--fs-text-primary)]">{expense.payerId}</p>
</div>
</div>

<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-extrabold tracking-tight text-[var(--fs-text-primary)]">Receipt</h2>
<p className="text-sm font-medium text-[var(--fs-text-muted)]">
{expense.receiptFileKey ? 'Preview the attached receipt or replace it with a new upload.' : 'No receipt attached yet.'}
</p>
</div>
<button onClick={() => setOpen(true)} className="btn-royal px-5 py-2">
{expense.receiptFileKey ? 'Replace receipt' : 'Upload receipt'}
</button>
</div>

{receiptUrl ? (
<div className="overflow-hidden rounded-3xl border border-[var(--fs-border)] bg-[var(--fs-background)]/60 p-3">
<img src={receiptUrl} alt="Receipt preview" className="max-h-[28rem] w-full rounded-2xl object-contain" />
</div>
) : expense.receiptFileKey ? (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm font-medium text-amber-700">
Receipt is attached, but no public S3 base URL is configured for web preview.
</div>
) : (
<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)]">
Upload a receipt to keep proof alongside this expense.
</div>
)}
</div>
</div>

<ReceiptUploadModal expenseId={expense.id} open={open} onClose={() => setOpen(false)} onUploaded={() => setOpen(false)} />
</>
);
}
22 changes: 15 additions & 7 deletions apps/web/src/components/groups/ExpenseRow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useState } from 'react';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { ExpenseDto } from '@fairshare/shared-types';
import dynamic from 'next/dynamic';

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

const formatAmount = (cents: string): string => {
const amount = Number(cents) / 100;
const formattedAmount = useMemo(() => {
const amount = Number(expense.totalAmountCents) / 100;
return amount.toLocaleString(undefined, { style: 'currency', currency: expense.currency });
};
}, [expense.currency, expense.totalAmountCents]);

return (
<>
<tr className="hover:bg-[var(--fs-background)]/50 transition-colors group">
<td className="px-6 py-4 text-base font-semibold text-[var(--fs-text-primary)]">
<div className="space-y-1">
<div>{expense.description}</div>
<Link href={`/dashboard/expenses/${expense.id}`} className="hover:text-[var(--fs-primary)] transition-colors">
{expense.description}
</Link>
<div className="flex flex-wrap items-center gap-2 text-[11px] font-bold uppercase tracking-[0.12em] text-[var(--fs-text-muted)]">
<span>{payerName ? `Paid by ${payerName}` : `Payer ${expense.payerId.slice(0, 6)}`}</span>
{expense.category ? (
<span className="rounded-full border border-[var(--fs-border)] bg-[var(--fs-background)] px-2 py-1 text-[10px] text-[var(--fs-text-primary)]">
{categoryLabels[expense.category] ?? expense.category}
</span>
) : null}
{expense.receiptFileKey ? (
<span className="rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-1 text-[10px] text-emerald-600">
Receipt attached
</span>
) : null}
</div>
</div>
</td>
<td className="px-6 py-4 text-right text-base font-bold text-[var(--fs-primary)]">{formatAmount(expense.totalAmountCents)}</td>
<td className="px-6 py-4 text-right text-base font-bold text-[var(--fs-primary)]">{formattedAmount}</td>
<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">
{new Date(expense.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: '2-digit' })}
</td>
Expand All @@ -50,7 +58,7 @@ export function ExpenseRow({ expense, payerName }: { expense: ExpenseDto; payerN
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"
onClick={() => setOpen(true)}
>
Upload receipt
{expense.receiptFileKey ? 'Replace receipt' : 'Upload receipt'}
</button>
</td>
</tr>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/groups/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export * from './GroupActions';
export * from './SettlementList';
export * from './ReceiptUploadModal';
export * from './ExpenseRow';
export * from './ExpenseDetailCard';
export * from './CreateGroupModal';
export * from './CreateGroupButton';
11 changes: 4 additions & 7 deletions apps/web/src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
export function getBackendBaseUrl(): string {
// Prefer a non-public env var for server-side calls.
return (
process.env.FAIRSHARE_API_URL ??
process.env.NEXT_PUBLIC_API_URL ??
// Keep parity with existing web client default.
'http://localhost:3001/api/v1'
);
return process.env.FAIRSHARE_API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1';
}

export function getPublicS3BaseUrl(): string | null {
return process.env.FAIRSHARE_S3_BASE_URL ?? process.env.NEXT_PUBLIC_S3_BASE_URL ?? null;
}
1 change: 1 addition & 0 deletions packages/shared-types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export interface ExpenseDto {
totalAmountCents: string;
currency: CurrencyCode;
category?: ExpenseCategory | null;
receiptFileKey?: string | null;
createdAt: string;
splits?: SplitDto[];
}
Expand Down
Loading