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:
@@ -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>,
|
||||
])}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user