fix(uat): prod UAT batch — reports, sidebar, search, berths, breakpoint

- financial report: drop Expenses KPI, Net Contribution, cash-flow chart,
  expense donut + ledger (expenses are business-trip costs, not net contribution)
- dashboard report PDF: pagination-safe tables (TableSection + per-row wrap)
  so long doc lists no longer overlap/crush
- clients PDF report: rename "Nationality" -> "Country"
- sidebar: hide a section header when all its items gate off (FINANCIAL orphan)
- topbar: move global search into the 1fr grid track so it can't overlap "New"
- clients card: show all linked berths (not just latest interest's primary)
- berths list: hide table-only toggles (ft/m, density, columns) in card mode
- lists: lower table/card breakpoint lg -> md so narrow desktops get tables
- alert-rules: stale floor created_at -> updated_at (survives created_at backfill)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 15:41:31 +02:00
parent 93c6554c95
commit 95724c8e3a
12 changed files with 282 additions and 461 deletions

View File

@@ -3,20 +3,7 @@
import { useMemo, useState, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -35,6 +22,10 @@ import type { Route } from 'next';
import { Wallet } from 'lucide-react';
// ─── Payload types (mirror the /api/v1/reports/financial response) ───────────
// NOTE: the API still returns expense/cash-flow/net-contribution fields; we
// deliberately omit them here. Expenses are business-trip costs tracked in the
// standalone Expenses section and do NOT factor into the financial picture, so
// the Financial report is purely revenue / deposits / collections.
interface FinancialKpis {
revenueCollected: number;
@@ -43,8 +34,6 @@ interface FinancialKpis {
refundsIssued: number;
pipelineExpected: number;
expectedDepositsOutstanding: number;
expensesTotal: number;
netContribution: number;
currency: string;
}
interface RevenueByMonthRow {
@@ -62,15 +51,6 @@ interface AgingRow {
count: number;
value: number;
}
interface CashFlowRow {
month: string;
inflow: number;
outflow: number;
}
interface ExpenseBreakdownRow {
category: string;
total: number;
}
interface OutstandingDepositRow {
interestId: string;
clientName: string;
@@ -98,16 +78,6 @@ interface RefundRow {
currency: string;
notes: string | null;
}
interface ExpenseLedgerRow {
id: string;
expenseDate: string;
payer: string | null;
category: string | null;
establishmentName: string | null;
amount: number;
currency: string;
paymentStatus: string | null;
}
interface FinancialPayload {
data: {
@@ -115,12 +85,9 @@ interface FinancialPayload {
revenueByMonth: RevenueByMonthRow[];
collectionFunnel: CollectionFunnelRow[];
aging: AgingRow[];
cashFlow: CashFlowRow[];
expenseBreakdown: ExpenseBreakdownRow[];
outstandingDeposits: OutstandingDepositRow[];
recentPayments: RecentPaymentRow[];
refundLog: RefundRow[];
expenseLedger: ExpenseLedgerRow[];
range: { from: string; to: string };
hasData: boolean;
};
@@ -133,15 +100,6 @@ interface FinancialTemplateConfig extends Record<string, unknown> {
type MonthGranularity = 'month' | 'quarter' | 'year';
const DONUT_COLORS = [
'hsl(var(--chart-1))',
'hsl(var(--chart-3))',
'hsl(var(--chart-4))',
'hsl(var(--chart-5))',
'hsl(var(--chart-2))',
'hsl(var(--chart-6))',
];
export function FinancialReportClient({ portSlug }: { portSlug: string }) {
const searchParams = useSearchParams();
const initialTemplateId = searchParams?.get('templateId') ?? null;
@@ -181,11 +139,9 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
const revenueByMonth = d?.revenueByMonth ?? [];
const collectionFunnel = d?.collectionFunnel ?? [];
const aging = d?.aging ?? [];
const expenseBreakdown = d?.expenseBreakdown ?? [];
const outstandingDeposits = d?.outstandingDeposits ?? [];
const recentPayments = d?.recentPayments ?? [];
const refundLog = d?.refundLog ?? [];
const expenseLedger = d?.expenseLedger ?? [];
// Re-bucket the monthly revenue series for the quarter/year toggle.
// Depend on the query-data reference (stable across renders once
@@ -195,17 +151,13 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
() => rebucketRevenue(d?.revenueByMonth ?? [], granularity),
[d?.revenueByMonth, granularity],
);
const cashFlowSeries = useMemo(
() => (d?.cashFlow ?? []).map((r) => ({ ...r, label: formatMonthLabel(r.month) })),
[d?.cashFlow],
);
const fundedCount = collectionFunnel.length > 0 ? collectionFunnel[0]!.count : 0;
function buildExportPayload(): ReportPayload {
if (!kpis) throw new Error('Report still loading');
return {
title: 'Financial',
description: 'Revenue collected, deposits, outstanding, cash flow, and expenses.',
description: 'Revenue collected, deposits, outstanding balances, and collections.',
filenameSlug: 'financial',
range: bounds,
kpis: [
@@ -220,8 +172,6 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
label: 'Outstanding deposits',
value: formatMoney(kpis.expectedDepositsOutstanding, currency),
},
{ label: 'Expenses', value: formatMoney(kpis.expensesTotal, currency) },
{ label: 'Net contribution', value: formatMoney(kpis.netContribution, currency) },
],
sections: [
{
@@ -252,23 +202,6 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
daysOutstanding: r.daysOutstanding,
})),
},
{
title: 'Expense ledger',
columns: [
{ key: 'expenseDate', label: 'Date' },
{ key: 'category', label: 'Category' },
{ key: 'payer', label: 'Payer' },
{ key: 'amount', label: 'Amount', align: 'right' },
{ key: 'paymentStatus', label: 'Status' },
],
rows: expenseLedger.map((r) => ({
expenseDate: r.expenseDate ? r.expenseDate.slice(0, 10) : '—',
category: r.category ?? '—',
payer: r.payer ?? '—',
amount: formatMoney(r.amount, r.currency),
paymentStatus: r.paymentStatus ?? '—',
})),
},
],
};
}
@@ -281,14 +214,14 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
<PageHeader
eyebrow="Reports"
title="Financial"
description="Revenue collected, deposits, outstanding balances, cash flow, and expense breakdown."
description="Revenue collected, deposits, and outstanding balances."
/>
<ReportEmptyState
icon={Wallet}
title="No financial activity yet"
body="Record a payment on a deal or log an expense to see revenue, deposits, and cash flow."
actionLabel="Go to expenses"
actionHref={`/${portSlug}/expenses` as Route}
body="Record a payment on a deal to see revenue, deposits, and collections."
actionLabel="View deals"
actionHref={`/${portSlug}/interests` as Route}
/>
</div>
);
@@ -299,7 +232,7 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
<PageHeader
eyebrow="Reports"
title="Financial"
description="Revenue collected, deposits, outstanding balances, cash flow, and expenses. Sourced from recorded payments; the CRM does not invoice."
description="Revenue collected, deposits, and outstanding balances. Sourced from recorded payments; the CRM does not invoice."
actions={
<div className="flex items-center gap-2">
<DateRangePicker value={range} onChange={handleRangeChange} />
@@ -316,13 +249,13 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
}
/>
{/* KPI STRIP — 7 tiles */}
{/* KPI STRIP — 5 tiles (expenses + net-contribution intentionally omitted) */}
<section
aria-label="Financial KPIs"
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"
>
{isLoading ? (
Array.from({ length: 7 }).map((_, i) => <KpiSkeleton key={i} />)
Array.from({ length: 5 }).map((_, i) => <KpiSkeleton key={i} />)
) : (
<>
<KpiCard
@@ -348,21 +281,6 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
value={formatMoney(kpis.expectedDepositsOutstanding, currency)}
hint="Expected but not yet collected"
/>
<KpiCard
label="Expenses"
value={formatMoney(kpis.expensesTotal, currency)}
hint={
kpis.refundsIssued > 0
? `${formatMoney(kpis.refundsIssued, currency)} refunded`
: undefined
}
/>
<KpiCard
label="Net contribution"
value={formatMoney(kpis.netContribution, currency)}
valueTone={kpis.netContribution >= 0 ? 'positive' : 'negative'}
hint="Revenue expenses"
/>
</>
)}
</section>
@@ -526,130 +444,7 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
</Card>
</div>
{/* CHART 4 — Cash flow (inflow vs outflow) */}
<Card>
<CardHeader>
<CardTitle className="text-base">Cash flow</CardTitle>
<p className="text-xs text-muted-foreground">
Money in (payments received) vs money out (expenses booked), per month.
</p>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-[280px] w-full" />
) : (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={cashFlowSeries} margin={{ top: 8, right: 8, left: 4, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
interval="preserveStartEnd"
/>
<YAxis
tickFormatter={(v) => formatMoneyCompact(Number(v), currency)}
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
width={64}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value, name) => [
formatMoney(Number(value), currency),
name === 'inflow' ? 'Inflow' : 'Outflow',
]}
/>
<Line
type="monotone"
dataKey="inflow"
stroke="hsl(var(--chart-1))"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="outflow"
stroke="hsl(var(--chart-4))"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* CHART 5 — Expense breakdown donut + Recent payments table */}
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Expense breakdown</CardTitle>
<p className="text-xs text-muted-foreground">By category, for the selected period.</p>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-[260px] w-full" />
) : expenseBreakdown.length === 0 ? (
<EmptyState>No expenses booked in this period.</EmptyState>
) : (
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={expenseBreakdown}
dataKey="total"
nameKey="category"
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={90}
paddingAngle={2}
>
{expenseBreakdown.map((_, i) => (
<Cell key={i} fill={DONUT_COLORS[i % DONUT_COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value, name) => [
formatMoney(Number(value), currency),
String(name),
]}
/>
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Recent payments</CardTitle>
<p className="text-xs text-muted-foreground">Latest money received.</p>
</CardHeader>
<CardContent className="px-0">
{isLoading ? (
<Skeleton className="mx-6 h-[260px]" />
) : recentPayments.length === 0 ? (
<EmptyState>No payments recorded in this period.</EmptyState>
) : (
<SimpleTable
head={['Date', 'Client', 'Type', 'Amount']}
rows={recentPayments.slice(0, 8).map((p) => [
p.receivedAt ? p.receivedAt.slice(0, 10) : '—',
p.clientName,
<span key="t" className="capitalize">
{p.paymentType}
</span>,
<span key="a" className="tabular-nums">
{formatMoney(p.amount, p.currency)}
</span>,
])}
/>
)}
</CardContent>
</Card>
</div>
{/* TABLES — Outstanding deposits + Expense ledger + Refunds */}
{/* TABLE — Outstanding deposits (the chase list) */}
<Card>
<CardHeader>
<CardTitle className="text-base">Outstanding deposits</CardTitle>
@@ -684,29 +479,29 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
</CardContent>
</Card>
{/* TABLES — Recent payments + Refunds */}
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Expense ledger</CardTitle>
<p className="text-xs text-muted-foreground">Expenses booked in the period.</p>
<CardTitle className="text-base">Recent payments</CardTitle>
<p className="text-xs text-muted-foreground">Latest money received.</p>
</CardHeader>
<CardContent className="px-0">
{isLoading ? (
<Skeleton className="mx-6 h-[200px]" />
) : expenseLedger.length === 0 ? (
<EmptyState>No expenses booked in this period.</EmptyState>
<Skeleton className="mx-6 h-[260px]" />
) : recentPayments.length === 0 ? (
<EmptyState>No payments recorded in this period.</EmptyState>
) : (
<SimpleTable
head={['Date', 'Category', 'Payer', 'Amount', 'Status']}
rows={expenseLedger.slice(0, 10).map((r) => [
r.expenseDate ? r.expenseDate.slice(0, 10) : '—',
r.category ?? '—',
r.payer ?? '—',
<span key="a" className="tabular-nums">
{formatMoney(r.amount, r.currency)}
head={['Date', 'Client', 'Type', 'Amount']}
rows={recentPayments.slice(0, 8).map((p) => [
p.receivedAt ? p.receivedAt.slice(0, 10) : '—',
p.clientName,
<span key="t" className="capitalize">
{p.paymentType}
</span>,
<span key="s" className="capitalize">
{r.paymentStatus ?? '—'}
<span key="a" className="tabular-nums">
{formatMoney(p.amount, p.currency)}
</span>,
])}
/>