feat(reports): Financial report (Initiative 1 Phase 4)
Builds the Financial report on the canonical payments + expenses tables (the CRM records money received; it does not invoice — invoices module is off, dev DB has zero invoice rows). The invoice-centric spec is reframed onto the payments model: "outstanding AR" → expected-deposit shortfall on active deals; "AR aging" → outstanding deposits bucketed by deal age. Service (financial.service.ts): - 7 KPIs: revenue collected (net of refunds), deposits, balance, pipeline expected, outstanding deposits, expenses, net contribution - 6 chart datasets: revenue by month (deposit/balance), collection funnel (EOI→deposit→contract→won), expected-deposit aging, cash flow (inflow vs outflow), expense breakdown by category - 4 tables: outstanding deposits, recent payments, refund log, expense ledger - every money figure normalised to port currency via a shared resolvePortCurrency/normalizeAmount helper (new reports/currency.ts) UI (financial-report-client.tsx): KPI strip + recharts (stacked bar / horizontal bar / line / donut) + month/quarter/year toggle + branded empty states; DateRangePicker + Templates + Export wired. Un-hidden the Financial card on the reports landing. Plumbing: added '1y' (trailing 12mo) preset to the shared range system (financial trends want a year); added 'financial'/'marketing' to the report-template kind enum for template parity. TDD: 6 financial-math unit tests (aging buckets, month keys/range, net contribution). tsc clean; full unit suite green except pre-existing Redis/storage-dependent integration tests. Browser-verified against live data: API 200, KPIs correct ($5,849 expenses / -$5,849 net, $0 revenue correct given 0 payment rows), expense ledger + breakdown populate, payment-derived sections show graceful empty states. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
850
src/components/reports/financial/financial-report-client.tsx
Normal file
850
src/components/reports/financial/financial-report-client.tsx
Normal file
@@ -0,0 +1,850 @@
|
||||
'use client';
|
||||
|
||||
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 { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { DateRangePicker } from '@/components/dashboard/date-range-picker';
|
||||
import { ReportExportButton } from '@/components/reports/shared/report-export-button';
|
||||
import { ReportTemplatesButton } from '@/components/reports/shared/report-templates-button';
|
||||
import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { formatMoney, formatMoneyCompact, formatNumber } from '@/lib/reports/format-currency';
|
||||
import type { ReportPayload } from '@/lib/reports/types';
|
||||
|
||||
// ─── Payload types (mirror the /api/v1/reports/financial response) ───────────
|
||||
|
||||
interface FinancialKpis {
|
||||
revenueCollected: number;
|
||||
depositsCollected: number;
|
||||
balanceCollected: number;
|
||||
refundsIssued: number;
|
||||
pipelineExpected: number;
|
||||
expectedDepositsOutstanding: number;
|
||||
expensesTotal: number;
|
||||
netContribution: number;
|
||||
currency: string;
|
||||
}
|
||||
interface RevenueByMonthRow {
|
||||
month: string;
|
||||
deposit: number;
|
||||
balance: number;
|
||||
}
|
||||
interface CollectionFunnelRow {
|
||||
stage: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
interface AgingRow {
|
||||
bucket: string;
|
||||
count: number;
|
||||
value: number;
|
||||
}
|
||||
interface CashFlowRow {
|
||||
month: string;
|
||||
inflow: number;
|
||||
outflow: number;
|
||||
}
|
||||
interface ExpenseBreakdownRow {
|
||||
category: string;
|
||||
total: number;
|
||||
}
|
||||
interface OutstandingDepositRow {
|
||||
interestId: string;
|
||||
clientName: string;
|
||||
mooring: string | null;
|
||||
expected: number;
|
||||
collected: number;
|
||||
remaining: number;
|
||||
currency: string;
|
||||
daysOutstanding: number;
|
||||
}
|
||||
interface RecentPaymentRow {
|
||||
id: string;
|
||||
receivedAt: string;
|
||||
clientName: string;
|
||||
mooring: string | null;
|
||||
paymentType: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
interface RefundRow {
|
||||
id: string;
|
||||
receivedAt: string;
|
||||
clientName: string;
|
||||
amount: number;
|
||||
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: {
|
||||
kpis: FinancialKpis;
|
||||
revenueByMonth: RevenueByMonthRow[];
|
||||
collectionFunnel: CollectionFunnelRow[];
|
||||
aging: AgingRow[];
|
||||
cashFlow: CashFlowRow[];
|
||||
expenseBreakdown: ExpenseBreakdownRow[];
|
||||
outstandingDeposits: OutstandingDepositRow[];
|
||||
recentPayments: RecentPaymentRow[];
|
||||
refundLog: RefundRow[];
|
||||
expenseLedger: ExpenseLedgerRow[];
|
||||
range: { from: string; to: string };
|
||||
};
|
||||
}
|
||||
|
||||
interface FinancialTemplateConfig extends Record<string, unknown> {
|
||||
kind: 'financial';
|
||||
range: DateRange;
|
||||
}
|
||||
|
||||
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 }: { portSlug: string }) {
|
||||
const searchParams = useSearchParams();
|
||||
const initialTemplateId = searchParams?.get('templateId') ?? null;
|
||||
|
||||
const [range, setRange] = useState<DateRange>('1y');
|
||||
const [granularity, setGranularity] = useState<MonthGranularity>('month');
|
||||
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(initialTemplateId);
|
||||
|
||||
const handleRangeChange = useCallback((next: DateRange) => {
|
||||
setRange(next);
|
||||
setActiveTemplateId(null);
|
||||
}, []);
|
||||
|
||||
const currentConfig: FinancialTemplateConfig = useMemo(
|
||||
() => ({ kind: 'financial', range }),
|
||||
[range],
|
||||
);
|
||||
|
||||
const handleApplyTemplate = useCallback((config: FinancialTemplateConfig) => {
|
||||
if (config.range) setRange(config.range);
|
||||
}, []);
|
||||
|
||||
const bounds = useMemo(() => rangeToBounds(range), [range]);
|
||||
|
||||
const query = useQuery<FinancialPayload>({
|
||||
queryKey: ['reports', 'financial', bounds.from.toISOString(), bounds.to.toISOString()],
|
||||
queryFn: () =>
|
||||
apiFetch<FinancialPayload>(
|
||||
`/api/v1/reports/financial?from=${encodeURIComponent(bounds.from.toISOString())}&to=${encodeURIComponent(bounds.to.toISOString())}`,
|
||||
),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const d = query.data?.data;
|
||||
const kpis = d?.kpis;
|
||||
const currency = kpis?.currency ?? 'EUR';
|
||||
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
|
||||
// loaded) rather than the `?? []` fallback, which would be a fresh
|
||||
// array each render.
|
||||
const revenueSeries = useMemo(
|
||||
() => 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.',
|
||||
filenameSlug: 'financial',
|
||||
range: bounds,
|
||||
kpis: [
|
||||
{ label: 'Revenue collected', value: formatMoney(kpis.revenueCollected, currency) },
|
||||
{ label: 'Deposits collected', value: formatMoney(kpis.depositsCollected, currency) },
|
||||
{ label: 'Balance collected', value: formatMoney(kpis.balanceCollected, currency) },
|
||||
{
|
||||
label: 'Pipeline (expected deposits)',
|
||||
value: formatMoney(kpis.pipelineExpected, currency),
|
||||
},
|
||||
{
|
||||
label: 'Outstanding deposits',
|
||||
value: formatMoney(kpis.expectedDepositsOutstanding, currency),
|
||||
},
|
||||
{ label: 'Expenses', value: formatMoney(kpis.expensesTotal, currency) },
|
||||
{ label: 'Net contribution', value: formatMoney(kpis.netContribution, currency) },
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: 'Revenue by month',
|
||||
columns: [
|
||||
{ key: 'month', label: 'Month' },
|
||||
{ key: 'deposit', label: 'Deposits', align: 'right' },
|
||||
{ key: 'balance', label: 'Balance', align: 'right' },
|
||||
],
|
||||
rows: revenueByMonth.map((r) => ({
|
||||
month: r.month,
|
||||
deposit: formatMoney(r.deposit, currency),
|
||||
balance: formatMoney(r.balance, currency),
|
||||
})),
|
||||
},
|
||||
{
|
||||
title: 'Outstanding deposits',
|
||||
columns: [
|
||||
{ key: 'clientName', label: 'Client' },
|
||||
{ key: 'mooring', label: 'Berth' },
|
||||
{ key: 'remaining', label: 'Remaining', align: 'right' },
|
||||
{ key: 'daysOutstanding', label: 'Age (days)', align: 'right' },
|
||||
],
|
||||
rows: outstandingDeposits.map((r) => ({
|
||||
clientName: r.clientName,
|
||||
mooring: r.mooring ?? '—',
|
||||
remaining: formatMoney(r.remaining, r.currency),
|
||||
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 ?? '—',
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const isLoading = query.isLoading || !kpis;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
eyebrow="Reports"
|
||||
title="Financial"
|
||||
description="Revenue collected, deposits, outstanding balances, cash flow, and expenses. Sourced from recorded payments; the CRM does not invoice."
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<DateRangePicker value={range} onChange={handleRangeChange} />
|
||||
<ReportTemplatesButton<FinancialTemplateConfig>
|
||||
kind="financial"
|
||||
currentConfig={currentConfig}
|
||||
onApply={handleApplyTemplate}
|
||||
activeTemplateId={activeTemplateId}
|
||||
onActiveTemplateChange={setActiveTemplateId}
|
||||
initialTemplateId={initialTemplateId}
|
||||
/>
|
||||
<ReportExportButton buildPayload={buildExportPayload} disabled={!kpis} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI STRIP — 7 tiles */}
|
||||
<section
|
||||
aria-label="Financial KPIs"
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"
|
||||
>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 7 }).map((_, i) => <KpiSkeleton key={i} />)
|
||||
) : (
|
||||
<>
|
||||
<KpiCard
|
||||
label="Revenue collected"
|
||||
value={formatMoney(kpis.revenueCollected, currency)}
|
||||
hint="All payments received in period (net of refunds)"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Deposits collected"
|
||||
value={formatMoney(kpis.depositsCollected, currency)}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Balance collected"
|
||||
value={formatMoney(kpis.balanceCollected, currency)}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Pipeline (expected)"
|
||||
value={formatMoney(kpis.pipelineExpected, currency)}
|
||||
hint="Expected deposits across open deals"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Outstanding deposits"
|
||||
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>
|
||||
|
||||
{/* CHART 1 — Revenue by month (stacked: deposit / balance) */}
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-base">Revenue collected over time</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Payments received per period, split deposits vs balance.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{(['month', 'quarter', 'year'] as MonthGranularity[]).map((g) => (
|
||||
<Button
|
||||
key={g}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={granularity === g ? 'default' : 'outline'}
|
||||
className="h-7 px-2 text-xs capitalize"
|
||||
onClick={() => setGranularity(g)}
|
||||
>
|
||||
{g}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : revenueSeries.every((r) => r.deposit === 0 && r.balance === 0) ? (
|
||||
<EmptyState>
|
||||
No payments recorded in this period. Revenue appears here as deposits and balances are
|
||||
received on the Payments tab.
|
||||
</EmptyState>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={revenueSeries} margin={{ top: 8, right: 8, left: 4, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" vertical={false} />
|
||||
<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 === 'deposit' ? 'Deposits' : 'Balance',
|
||||
]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="deposit"
|
||||
stackId="rev"
|
||||
fill="hsl(var(--chart-1))"
|
||||
radius={[0, 0, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="balance"
|
||||
stackId="rev"
|
||||
fill="hsl(var(--chart-3))"
|
||||
radius={[3, 3, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* CHART 2 + 3 — Collection funnel + AR aging side by side */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Collection funnel</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deals reaching each money milestone in the period. Highlights where revenue leaks.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-[220px] w-full" />
|
||||
) : (
|
||||
<div className="space-y-2 py-2">
|
||||
{collectionFunnel.map((row) => {
|
||||
const pct = fundedCount > 0 ? (row.count / fundedCount) * 100 : 0;
|
||||
return (
|
||||
<div key={row.stage} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium text-foreground">{row.label}</span>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{formatNumber(row.count)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-7 w-full rounded bg-muted/50">
|
||||
<div
|
||||
className="flex h-7 items-center rounded bg-[hsl(var(--chart-1))] px-2"
|
||||
style={{ width: `${Math.max(pct, row.count > 0 ? 8 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Outstanding deposits by age</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Expected deposits not yet collected, bucketed by how long the deal has been open.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-[220px] w-full" />
|
||||
) : aging.every((r) => r.value === 0) ? (
|
||||
<EmptyState>No outstanding deposits. Every open deal is paid up.</EmptyState>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart
|
||||
data={aging}
|
||||
layout="vertical"
|
||||
margin={{ top: 4, right: 12, left: 8, bottom: 4 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-border"
|
||||
horizontal={false}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
tickFormatter={(v) => formatMoneyCompact(Number(v), currency)}
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="bucket"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
width={48}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value, _name, item) => [
|
||||
`${formatMoney(Number(value), currency)} · ${item?.payload?.count ?? 0} deal(s)`,
|
||||
'Outstanding',
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="value" fill="hsl(var(--chart-4))" radius={[0, 3, 3, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</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 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Outstanding deposits</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Open deals with an expected deposit not yet fully collected. The chase list.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
{isLoading ? (
|
||||
<Skeleton className="mx-6 h-[200px]" />
|
||||
) : outstandingDeposits.length === 0 ? (
|
||||
<EmptyState>Nothing outstanding. Every open deal is paid up.</EmptyState>
|
||||
) : (
|
||||
<SimpleTable
|
||||
head={['Client', 'Berth', 'Expected', 'Collected', 'Remaining', 'Age']}
|
||||
rows={outstandingDeposits.slice(0, 12).map((r) => [
|
||||
r.clientName,
|
||||
r.mooring ?? '—',
|
||||
<span key="e" className="tabular-nums">
|
||||
{formatMoney(r.expected, r.currency)}
|
||||
</span>,
|
||||
<span key="c" className="tabular-nums">
|
||||
{formatMoney(r.collected, r.currency)}
|
||||
</span>,
|
||||
<span key="r" className="tabular-nums font-medium">
|
||||
{formatMoney(r.remaining, r.currency)}
|
||||
</span>,
|
||||
`${r.daysOutstanding}d`,
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
{isLoading ? (
|
||||
<Skeleton className="mx-6 h-[200px]" />
|
||||
) : expenseLedger.length === 0 ? (
|
||||
<EmptyState>No expenses booked 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)}
|
||||
</span>,
|
||||
<span key="s" className="capitalize">
|
||||
{r.paymentStatus ?? '—'}
|
||||
</span>,
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Refunds & write-offs</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Refund-type payments issued in the period.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
{isLoading ? (
|
||||
<Skeleton className="mx-6 h-[200px]" />
|
||||
) : refundLog.length === 0 ? (
|
||||
<EmptyState>No refunds in this period.</EmptyState>
|
||||
) : (
|
||||
<SimpleTable
|
||||
head={['Date', 'Client', 'Amount', 'Notes']}
|
||||
rows={refundLog.slice(0, 10).map((r) => [
|
||||
r.receivedAt ? r.receivedAt.slice(0, 10) : '—',
|
||||
r.clientName,
|
||||
<span key="a" className="tabular-nums">
|
||||
{formatMoney(r.amount, r.currency)}
|
||||
</span>,
|
||||
r.notes ?? '—',
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── helpers + primitives ────────────────────────────────────────────────────
|
||||
|
||||
const tooltipStyle = {
|
||||
background: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: 12,
|
||||
} as const;
|
||||
|
||||
function formatMonthLabel(month: string): string {
|
||||
const [year, m] = month.split('-');
|
||||
if (!year || !m) return month;
|
||||
const date = new Date(parseInt(year), parseInt(m) - 1, 1);
|
||||
return date.toLocaleDateString(undefined, { month: 'short', year: '2-digit' });
|
||||
}
|
||||
|
||||
/** Re-bucket the monthly revenue series to month / quarter / year for the toggle. */
|
||||
function rebucketRevenue(
|
||||
rows: RevenueByMonthRow[],
|
||||
granularity: MonthGranularity,
|
||||
): Array<{ label: string; deposit: number; balance: number }> {
|
||||
if (granularity === 'month') {
|
||||
return rows.map((r) => ({
|
||||
label: formatMonthLabel(r.month),
|
||||
deposit: r.deposit,
|
||||
balance: r.balance,
|
||||
}));
|
||||
}
|
||||
const byKey = new Map<string, { deposit: number; balance: number }>();
|
||||
for (const r of rows) {
|
||||
const [year, m] = r.month.split('-');
|
||||
if (!year || !m) continue;
|
||||
const key = granularity === 'year' ? year : `${year}-Q${Math.floor((parseInt(m) - 1) / 3) + 1}`;
|
||||
const acc = byKey.get(key) ?? { deposit: 0, balance: 0 };
|
||||
acc.deposit += r.deposit;
|
||||
acc.balance += r.balance;
|
||||
byKey.set(key, acc);
|
||||
}
|
||||
return Array.from(byKey.entries()).map(([label, v]) => ({ label, ...v }));
|
||||
}
|
||||
|
||||
interface KpiCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
valueTone?: 'positive' | 'negative' | 'neutral';
|
||||
}
|
||||
|
||||
function KpiCard({ label, value, hint, valueTone = 'neutral' }: KpiCardProps) {
|
||||
return (
|
||||
<Card className="h-full p-4 space-y-1.5">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={
|
||||
'text-2xl font-semibold tracking-tight tabular-nums ' +
|
||||
(valueTone === 'positive'
|
||||
? 'text-emerald-600'
|
||||
: valueTone === 'negative'
|
||||
? 'text-rose-600'
|
||||
: 'text-foreground')
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
{hint ? (
|
||||
<p className="text-[11px] text-muted-foreground leading-snug line-clamp-2">{hint}</p>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiSkeleton() {
|
||||
return (
|
||||
<Card className="h-full p-4 space-y-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-7 w-24" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="py-12 flex flex-col items-center justify-center text-center space-y-2">
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
No data
|
||||
</Badge>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SimpleTable({ head, rows }: { head: string[]; rows: React.ReactNode[][] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-start">
|
||||
{head.map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-6 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((cells, i) => (
|
||||
<tr key={i} className="border-b border-border/50 last:border-0">
|
||||
{cells.map((c, j) => (
|
||||
<td key={j} className="px-6 py-2 text-foreground">
|
||||
{c}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import type { ReportTemplate } from '@/lib/db/schema/reports';
|
||||
|
||||
type StandaloneReportKind = 'sales' | 'operational' | 'custom';
|
||||
type StandaloneReportKind = 'sales' | 'operational' | 'financial' | 'marketing' | 'custom';
|
||||
|
||||
interface ListResponse {
|
||||
data: ReportTemplate[];
|
||||
|
||||
Reference in New Issue
Block a user