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:
21
src/app/(dashboard)/[portSlug]/reports/financial/page.tsx
Normal file
21
src/app/(dashboard)/[portSlug]/reports/financial/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { FinancialReportClient } from '@/components/reports/financial/financial-report-client';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Financial report.
|
||||||
|
*
|
||||||
|
* Sibling of the dynamic [kind] route so this page wins over the
|
||||||
|
* placeholder for /reports/financial specifically. Spec lives in
|
||||||
|
* docs/reports-content-spec.md § Report 02 — sourced from the canonical
|
||||||
|
* payments + expenses tables (the CRM records money received; it does
|
||||||
|
* not invoice).
|
||||||
|
*/
|
||||||
|
export default async function FinancialReportPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
return <FinancialReportClient portSlug={portSlug} />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { Route } from 'next';
|
import type { Route } from 'next';
|
||||||
import { BookOpen, Calendar, Clock, Layers, Sparkles, TrendingUp } from 'lucide-react';
|
import { BookOpen, Calendar, Clock, Layers, Sparkles, TrendingUp, Wallet } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -39,6 +39,13 @@ const KIND_CARDS: KindCard[] = [
|
|||||||
'Berth utilisation timeline, occupancy heatmap, tenancy churn, signing turnaround.',
|
'Berth utilisation timeline, occupancy heatmap, tenancy churn, signing turnaround.',
|
||||||
icon: Layers,
|
icon: Layers,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: 'financial',
|
||||||
|
label: 'Financial',
|
||||||
|
description:
|
||||||
|
'Revenue collected, deposits, outstanding balances, cash flow, and expense breakdown.',
|
||||||
|
icon: Wallet,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: 'custom',
|
href: 'custom',
|
||||||
label: 'Custom report',
|
label: 'Custom report',
|
||||||
|
|||||||
100
src/app/api/v1/reports/financial/route.ts
Normal file
100
src/app/api/v1/reports/financial/route.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import {
|
||||||
|
getFinancialKpis,
|
||||||
|
getRevenueByMonth,
|
||||||
|
getCollectionFunnel,
|
||||||
|
getExpectedDepositAging,
|
||||||
|
getCashFlow,
|
||||||
|
getExpenseBreakdown,
|
||||||
|
getOutstandingDeposits,
|
||||||
|
getRecentPayments,
|
||||||
|
getRefundLog,
|
||||||
|
getExpenseLedger,
|
||||||
|
} from '@/lib/services/reports/financial.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/reports/financial?from=&to=
|
||||||
|
*
|
||||||
|
* Financial report payload, sourced from the canonical `payments` +
|
||||||
|
* `expenses` tables (the CRM records money received; it does not invoice
|
||||||
|
* — see docs/reports-content-spec.md § Report 02). "Outstanding AR" is
|
||||||
|
* reframed as expected-deposit shortfall on active deals.
|
||||||
|
*
|
||||||
|
* Permission: `reports.view_dashboard` (same gate as the other report
|
||||||
|
* dashboards).
|
||||||
|
*/
|
||||||
|
const querySchema = z.object({
|
||||||
|
from: z.string().datetime().optional(),
|
||||||
|
to: z.string().datetime().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function resolveRange(from?: string, to?: string): { from: Date; to: Date } {
|
||||||
|
const now = new Date();
|
||||||
|
// Default: trailing 12 months — financial trends read better over a
|
||||||
|
// year than 30 days, and the month/cash-flow charts want the span.
|
||||||
|
const defaultFrom = new Date(now);
|
||||||
|
defaultFrom.setMonth(defaultFrom.getMonth() - 12);
|
||||||
|
return {
|
||||||
|
from: from ? new Date(from) : defaultFrom,
|
||||||
|
to: to ? new Date(to) : now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = withAuth(
|
||||||
|
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||||
|
try {
|
||||||
|
const params = req.nextUrl.searchParams;
|
||||||
|
const { from, to } = querySchema.parse({
|
||||||
|
from: params.get('from') ?? undefined,
|
||||||
|
to: params.get('to') ?? undefined,
|
||||||
|
});
|
||||||
|
const range = resolveRange(from, to);
|
||||||
|
|
||||||
|
const [
|
||||||
|
kpis,
|
||||||
|
revenueByMonth,
|
||||||
|
collectionFunnel,
|
||||||
|
aging,
|
||||||
|
cashFlow,
|
||||||
|
expenseBreakdown,
|
||||||
|
outstandingDeposits,
|
||||||
|
recentPayments,
|
||||||
|
refundLog,
|
||||||
|
expenseLedger,
|
||||||
|
] = await Promise.all([
|
||||||
|
getFinancialKpis(ctx.portId, range),
|
||||||
|
getRevenueByMonth(ctx.portId, range),
|
||||||
|
getCollectionFunnel(ctx.portId, range),
|
||||||
|
getExpectedDepositAging(ctx.portId),
|
||||||
|
getCashFlow(ctx.portId, range),
|
||||||
|
getExpenseBreakdown(ctx.portId, range),
|
||||||
|
getOutstandingDeposits(ctx.portId),
|
||||||
|
getRecentPayments(ctx.portId, range),
|
||||||
|
getRefundLog(ctx.portId, range),
|
||||||
|
getExpenseLedger(ctx.portId, range),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
data: {
|
||||||
|
kpis,
|
||||||
|
revenueByMonth,
|
||||||
|
collectionFunnel,
|
||||||
|
aging,
|
||||||
|
cashFlow,
|
||||||
|
expenseBreakdown,
|
||||||
|
outstandingDeposits,
|
||||||
|
recentPayments,
|
||||||
|
refundLog,
|
||||||
|
expenseLedger,
|
||||||
|
range: { from: range.from.toISOString(), to: range.to.toISOString() },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -12,7 +12,17 @@ const createBodySchema = z.object({
|
|||||||
// for these kinds is a thin view-state snapshot (date range +
|
// for these kinds is a thin view-state snapshot (date range +
|
||||||
// filters) that the report client applies on load. 'custom' is the
|
// filters) that the report client applies on load. 'custom' is the
|
||||||
// ad-hoc composer's saved config — entity + columns + filter.
|
// ad-hoc composer's saved config — entity + columns + filter.
|
||||||
kind: z.enum(['dashboard', 'clients', 'berths', 'interests', 'sales', 'operational', 'custom']),
|
kind: z.enum([
|
||||||
|
'dashboard',
|
||||||
|
'clients',
|
||||||
|
'berths',
|
||||||
|
'interests',
|
||||||
|
'sales',
|
||||||
|
'operational',
|
||||||
|
'financial',
|
||||||
|
'marketing',
|
||||||
|
'custom',
|
||||||
|
]),
|
||||||
name: z.string().min(1).max(120),
|
name: z.string().min(1).max(120),
|
||||||
description: z.string().max(400).nullable().optional(),
|
description: z.string().max(400).nullable().optional(),
|
||||||
// Config is the raw discriminated-union payload; the
|
// Config is the raw discriminated-union payload; the
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ import { WidgetErrorBoundary } from './widget-error-boundary';
|
|||||||
import type { DashboardWidget } from './widget-registry';
|
import type { DashboardWidget } from './widget-registry';
|
||||||
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||||
|
|
||||||
const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = {
|
const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d' | '1y', string> = {
|
||||||
today: 'Today',
|
today: 'Today',
|
||||||
'7d': 'Last 7 days',
|
'7d': 'Last 7 days',
|
||||||
'30d': 'Last 30 days',
|
'30d': 'Last 30 days',
|
||||||
'90d': 'Last 90 days',
|
'90d': 'Last 90 days',
|
||||||
|
'1y': 'Last 12 months',
|
||||||
};
|
};
|
||||||
|
|
||||||
function rangeLabel(range: DateRange): string {
|
function rangeLabel(range: DateRange): string {
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ interface DateRangePickerProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRESETS: Array<{ value: 'today' | '7d' | '30d' | '90d'; label: string }> = [
|
const PRESETS: Array<{ value: 'today' | '7d' | '30d' | '90d' | '1y'; label: string }> = [
|
||||||
{ value: 'today', label: 'Today' },
|
{ value: 'today', label: 'Today' },
|
||||||
{ value: '7d', label: '7d' },
|
{ value: '7d', label: '7d' },
|
||||||
{ value: '30d', label: '30d' },
|
{ value: '30d', label: '30d' },
|
||||||
{ value: '90d', label: '90d' },
|
{ value: '90d', label: '90d' },
|
||||||
|
{ value: '1y', label: '1y' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -28,11 +28,12 @@ interface Props {
|
|||||||
range?: DateRange;
|
range?: DateRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RANGE_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = {
|
const RANGE_LABELS: Record<'today' | '7d' | '30d' | '90d' | '1y', string> = {
|
||||||
today: 'Today',
|
today: 'Today',
|
||||||
'7d': '7 days',
|
'7d': '7 days',
|
||||||
'30d': '30 days',
|
'30d': '30 days',
|
||||||
'90d': '90 days',
|
'90d': '90 days',
|
||||||
|
'1y': '12 months',
|
||||||
};
|
};
|
||||||
|
|
||||||
function shortRangeLabel(range: DateRange): string {
|
function shortRangeLabel(range: DateRange): string {
|
||||||
|
|||||||
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 { toastError } from '@/lib/api/toast-error';
|
||||||
import type { ReportTemplate } from '@/lib/db/schema/reports';
|
import type { ReportTemplate } from '@/lib/db/schema/reports';
|
||||||
|
|
||||||
type StandaloneReportKind = 'sales' | 'operational' | 'custom';
|
type StandaloneReportKind = 'sales' | 'operational' | 'financial' | 'marketing' | 'custom';
|
||||||
|
|
||||||
interface ListResponse {
|
interface ListResponse {
|
||||||
data: ReportTemplate[];
|
data: ReportTemplate[];
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
/**
|
/**
|
||||||
* Preset date ranges used by the dashboard's quick-pick tabs.
|
* Preset date ranges used by the dashboard's quick-pick tabs.
|
||||||
*/
|
*/
|
||||||
export type PresetDateRange = '7d' | '30d' | '90d' | 'today';
|
export type PresetDateRange = '7d' | '30d' | '90d' | '1y' | 'today';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom date range expressed as a pair of ISO date strings (YYYY-MM-DD).
|
* A custom date range expressed as a pair of ISO date strings (YYYY-MM-DD).
|
||||||
@@ -27,7 +27,7 @@ export interface CustomDateRange {
|
|||||||
|
|
||||||
export type DateRange = PresetDateRange | CustomDateRange;
|
export type DateRange = PresetDateRange | CustomDateRange;
|
||||||
|
|
||||||
export const ALL_RANGES: readonly PresetDateRange[] = ['today', '7d', '30d', '90d'] as const;
|
export const ALL_RANGES: readonly PresetDateRange[] = ['today', '7d', '30d', '90d', '1y'] as const;
|
||||||
|
|
||||||
export function isCustomRange(range: DateRange): range is CustomDateRange {
|
export function isCustomRange(range: DateRange): range is CustomDateRange {
|
||||||
return typeof range === 'object' && range.kind === 'custom';
|
return typeof range === 'object' && range.kind === 'custom';
|
||||||
@@ -87,6 +87,8 @@ export function rangeToDays(range: PresetDateRange): number {
|
|||||||
return 30;
|
return 30;
|
||||||
case '90d':
|
case '90d':
|
||||||
return 90;
|
return 90;
|
||||||
|
case '1y':
|
||||||
|
return 365;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
32
src/lib/services/reports/currency.ts
Normal file
32
src/lib/services/reports/currency.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
|
import { convert } from '@/lib/services/currency';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port's default currency for money normalisation. Falls back to USD
|
||||||
|
* when the column is null (legacy ports). Shared across the report
|
||||||
|
* services so every money figure lands in one reporting currency.
|
||||||
|
*/
|
||||||
|
export async function resolvePortCurrency(portId: string): Promise<string> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ defaultCurrency: ports.defaultCurrency })
|
||||||
|
.from(ports)
|
||||||
|
.where(eq(ports.id, portId));
|
||||||
|
return row?.defaultCurrency ?? 'USD';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert `amount` from `from` → `to`. Returns the amount unchanged when
|
||||||
|
* the currencies match or a rate is unavailable (so a missing FX rate
|
||||||
|
* degrades to "report in source units" rather than dropping the figure).
|
||||||
|
*/
|
||||||
|
export async function normalizeAmount(amount: number, from: string, to: string): Promise<number> {
|
||||||
|
if (!amount) return amount;
|
||||||
|
const f = from.toUpperCase();
|
||||||
|
const t = to.toUpperCase();
|
||||||
|
if (f === t) return amount;
|
||||||
|
const converted = await convert(amount, f, t);
|
||||||
|
return converted?.result ?? amount;
|
||||||
|
}
|
||||||
51
src/lib/services/reports/financial-math.ts
Normal file
51
src/lib/services/reports/financial-math.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Pure helpers for the Financial report. No DB / no currency I/O — kept
|
||||||
|
* separate from `financial.service.ts` so the bucketing + date maths are
|
||||||
|
* unit-testable in isolation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AgingBucket = '0-30' | '31-60' | '61-90' | '90+';
|
||||||
|
|
||||||
|
/** Ascending so chart rows render oldest-band-last in a stable order. */
|
||||||
|
export const AGING_BUCKETS: AgingBucket[] = ['0-30', '31-60', '61-90', '90+'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a day count to one of the four age bands. Used for the
|
||||||
|
* "expected deposits outstanding by deal age" chart — the payments-model
|
||||||
|
* analogue of invoice AR aging (we have no invoice due dates, so age is
|
||||||
|
* measured from when the deal started expecting a deposit).
|
||||||
|
*/
|
||||||
|
export function agingBucket(daysOutstanding: number): AgingBucket {
|
||||||
|
if (daysOutstanding <= 30) return '0-30';
|
||||||
|
if (daysOutstanding <= 60) return '31-60';
|
||||||
|
if (daysOutstanding <= 90) return '61-90';
|
||||||
|
return '90+';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zero-padded `YYYY-MM` key in UTC, for grouping money rows by month. */
|
||||||
|
export function monthKey(d: Date): string {
|
||||||
|
const y = d.getUTCFullYear();
|
||||||
|
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
return `${y}-${m}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Continuous ascending list of `YYYY-MM` keys spanning `from`..`to`
|
||||||
|
* inclusive. Gives the month/cash-flow charts a gap-free x-axis even for
|
||||||
|
* months with no rows (which then render as zero, not a missing bar).
|
||||||
|
*/
|
||||||
|
export function monthRange(from: Date, to: Date): string[] {
|
||||||
|
const keys: string[] = [];
|
||||||
|
const cursor = new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), 1));
|
||||||
|
const end = new Date(Date.UTC(to.getUTCFullYear(), to.getUTCMonth(), 1));
|
||||||
|
while (cursor <= end) {
|
||||||
|
keys.push(monthKey(cursor));
|
||||||
|
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Net contribution = revenue collected − expenses. */
|
||||||
|
export function netContribution(revenue: number, expenses: number): number {
|
||||||
|
return revenue - expenses;
|
||||||
|
}
|
||||||
632
src/lib/services/reports/financial.service.ts
Normal file
632
src/lib/services/reports/financial.service.ts
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
import { and, desc, eq, gte, isNull, lte, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
|
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
||||||
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
|
import { payments } from '@/lib/db/schema/pipeline';
|
||||||
|
import { expenses } from '@/lib/db/schema/financial';
|
||||||
|
import { activeInterestsWhere } from '@/lib/services/active-interest';
|
||||||
|
import { resolvePortCurrency, normalizeAmount } from './currency';
|
||||||
|
import {
|
||||||
|
agingBucket,
|
||||||
|
AGING_BUCKETS,
|
||||||
|
monthKey,
|
||||||
|
monthRange,
|
||||||
|
netContribution,
|
||||||
|
type AgingBucket,
|
||||||
|
} from './financial-math';
|
||||||
|
|
||||||
|
import type { DateRange } from './sales.service';
|
||||||
|
|
||||||
|
// ─── KPI strip ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FinancialKpis {
|
||||||
|
/** Σ of every payment received in range (refunds net out — they carry
|
||||||
|
* negative amounts). In port currency. */
|
||||||
|
revenueCollected: number;
|
||||||
|
depositsCollected: number;
|
||||||
|
balanceCollected: number;
|
||||||
|
/** Absolute value of refund-type payments issued in range. */
|
||||||
|
refundsIssued: number;
|
||||||
|
/** Σ expected deposit across active (open, non-archived) deals — the
|
||||||
|
* pipeline forecast in deposit terms. */
|
||||||
|
pipelineExpected: number;
|
||||||
|
/** Σ (expected − collected) across active deals, floored at 0 per deal.
|
||||||
|
* The payments-model analogue of outstanding AR. */
|
||||||
|
expectedDepositsOutstanding: number;
|
||||||
|
/** Σ expenses booked in range, normalised to port currency. */
|
||||||
|
expensesTotal: number;
|
||||||
|
/** revenueCollected − expensesTotal. */
|
||||||
|
netContribution: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-deal deposit position — expected vs collected. Internal; feeds the
|
||||||
|
* pipeline/outstanding KPIs, the aging chart, and the outstanding table. */
|
||||||
|
interface DepositPosition {
|
||||||
|
interestId: string;
|
||||||
|
clientName: string;
|
||||||
|
mooring: string | null;
|
||||||
|
expected: number;
|
||||||
|
collected: number;
|
||||||
|
remaining: number;
|
||||||
|
currency: string;
|
||||||
|
daysOutstanding: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expected-vs-collected deposit position for every active deal that has
|
||||||
|
* an expected deposit set. Collected = Σ deposit-type payments for that
|
||||||
|
* interest. Amounts normalised to port currency. Age measured from deal
|
||||||
|
* creation (no invoice due dates exist in the payments model).
|
||||||
|
*/
|
||||||
|
async function getDepositPositions(portId: string): Promise<DepositPosition[]> {
|
||||||
|
const targetCurrency = await resolvePortCurrency(portId);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
interestId: interests.id,
|
||||||
|
clientName: clients.fullName,
|
||||||
|
mooring: berths.mooringNumber,
|
||||||
|
expected: interests.depositExpectedAmount,
|
||||||
|
expectedCurrency: interests.depositExpectedCurrency,
|
||||||
|
createdAt: interests.createdAt,
|
||||||
|
})
|
||||||
|
.from(interests)
|
||||||
|
.innerJoin(clients, eq(clients.id, interests.clientId))
|
||||||
|
.leftJoin(
|
||||||
|
interestBerths,
|
||||||
|
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
||||||
|
)
|
||||||
|
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
|
||||||
|
.where(and(activeInterestsWhere(portId), sql`${interests.depositExpectedAmount} IS NOT NULL`));
|
||||||
|
|
||||||
|
// Deposits collected per interest (deposit-type payments only).
|
||||||
|
const collectedRows = await db
|
||||||
|
.select({
|
||||||
|
interestId: payments.interestId,
|
||||||
|
amount: payments.amount,
|
||||||
|
currency: payments.currency,
|
||||||
|
})
|
||||||
|
.from(payments)
|
||||||
|
.where(and(eq(payments.portId, portId), eq(payments.paymentType, 'deposit')));
|
||||||
|
|
||||||
|
const collectedByInterest = new Map<string, number>();
|
||||||
|
for (const r of collectedRows) {
|
||||||
|
const amount = await normalizeAmount(
|
||||||
|
Number(r.amount ?? 0),
|
||||||
|
r.currency ?? targetCurrency,
|
||||||
|
targetCurrency,
|
||||||
|
);
|
||||||
|
collectedByInterest.set(r.interestId, (collectedByInterest.get(r.interestId) ?? 0) + amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions: DepositPosition[] = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
const expected = await normalizeAmount(
|
||||||
|
Number(r.expected ?? 0),
|
||||||
|
r.expectedCurrency ?? targetCurrency,
|
||||||
|
targetCurrency,
|
||||||
|
);
|
||||||
|
const collected = collectedByInterest.get(r.interestId) ?? 0;
|
||||||
|
const remaining = Math.max(0, expected - collected);
|
||||||
|
const daysOutstanding = r.createdAt
|
||||||
|
? Math.floor((now - new Date(r.createdAt).getTime()) / 86_400_000)
|
||||||
|
: 0;
|
||||||
|
positions.push({
|
||||||
|
interestId: r.interestId,
|
||||||
|
clientName: r.clientName,
|
||||||
|
mooring: r.mooring,
|
||||||
|
expected,
|
||||||
|
collected,
|
||||||
|
remaining,
|
||||||
|
currency: targetCurrency,
|
||||||
|
daysOutstanding,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Σ of all payments in range, bucketed by payment type, normalised. */
|
||||||
|
async function sumPaymentsInRange(
|
||||||
|
portId: string,
|
||||||
|
range: DateRange,
|
||||||
|
targetCurrency: string,
|
||||||
|
): Promise<{ total: number; deposit: number; balance: number; refund: number }> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
paymentType: payments.paymentType,
|
||||||
|
amount: payments.amount,
|
||||||
|
currency: payments.currency,
|
||||||
|
})
|
||||||
|
.from(payments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(payments.portId, portId),
|
||||||
|
gte(payments.receivedAt, range.from),
|
||||||
|
lte(payments.receivedAt, range.to),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const acc = { total: 0, deposit: 0, balance: 0, refund: 0 };
|
||||||
|
for (const r of rows) {
|
||||||
|
const amount = await normalizeAmount(
|
||||||
|
Number(r.amount ?? 0),
|
||||||
|
r.currency ?? targetCurrency,
|
||||||
|
targetCurrency,
|
||||||
|
);
|
||||||
|
acc.total += amount;
|
||||||
|
if (r.paymentType === 'deposit') acc.deposit += amount;
|
||||||
|
else if (r.paymentType === 'balance') acc.balance += amount;
|
||||||
|
else if (r.paymentType === 'refund') acc.refund += amount; // already negative
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFinancialKpis(portId: string, range: DateRange): Promise<FinancialKpis> {
|
||||||
|
const currency = await resolvePortCurrency(portId);
|
||||||
|
const [paymentsAgg, positions, expensesTotal] = await Promise.all([
|
||||||
|
sumPaymentsInRange(portId, range, currency),
|
||||||
|
getDepositPositions(portId),
|
||||||
|
sumExpensesInRange(portId, range, currency),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pipelineExpected = positions.reduce((s, p) => s + p.expected, 0);
|
||||||
|
const expectedDepositsOutstanding = positions.reduce((s, p) => s + p.remaining, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
revenueCollected: paymentsAgg.total,
|
||||||
|
depositsCollected: paymentsAgg.deposit,
|
||||||
|
balanceCollected: paymentsAgg.balance,
|
||||||
|
refundsIssued: Math.abs(paymentsAgg.refund),
|
||||||
|
pipelineExpected,
|
||||||
|
expectedDepositsOutstanding,
|
||||||
|
expensesTotal,
|
||||||
|
netContribution: netContribution(paymentsAgg.total, expensesTotal),
|
||||||
|
currency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sumExpensesInRange(
|
||||||
|
portId: string,
|
||||||
|
range: DateRange,
|
||||||
|
targetCurrency: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ amount: expenses.amount, currency: expenses.currency })
|
||||||
|
.from(expenses)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(expenses.portId, portId),
|
||||||
|
isNull(expenses.archivedAt),
|
||||||
|
gte(expenses.expenseDate, range.from),
|
||||||
|
lte(expenses.expenseDate, range.to),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let total = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
total += await normalizeAmount(
|
||||||
|
Number(r.amount ?? 0),
|
||||||
|
r.currency ?? targetCurrency,
|
||||||
|
targetCurrency,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Charts ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface RevenueByMonthRow {
|
||||||
|
month: string; // YYYY-MM
|
||||||
|
deposit: number;
|
||||||
|
balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Monthly collected revenue, split deposit vs balance. Continuous month
|
||||||
|
* axis (zero-filled) over the selected range, normalised to port currency. */
|
||||||
|
export async function getRevenueByMonth(
|
||||||
|
portId: string,
|
||||||
|
range: DateRange,
|
||||||
|
): Promise<RevenueByMonthRow[]> {
|
||||||
|
const currency = await resolvePortCurrency(portId);
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
paymentType: payments.paymentType,
|
||||||
|
amount: payments.amount,
|
||||||
|
paymentCurrency: payments.currency,
|
||||||
|
receivedAt: payments.receivedAt,
|
||||||
|
})
|
||||||
|
.from(payments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(payments.portId, portId),
|
||||||
|
gte(payments.receivedAt, range.from),
|
||||||
|
lte(payments.receivedAt, range.to),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const byMonth = new Map<string, { deposit: number; balance: number }>();
|
||||||
|
for (const key of monthRange(range.from, range.to)) byMonth.set(key, { deposit: 0, balance: 0 });
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r.receivedAt) continue;
|
||||||
|
const key = monthKey(new Date(r.receivedAt));
|
||||||
|
const bucket = byMonth.get(key);
|
||||||
|
if (!bucket) continue;
|
||||||
|
const amount = await normalizeAmount(
|
||||||
|
Number(r.amount ?? 0),
|
||||||
|
r.paymentCurrency ?? currency,
|
||||||
|
currency,
|
||||||
|
);
|
||||||
|
if (r.paymentType === 'balance') bucket.balance += amount;
|
||||||
|
else if (r.paymentType !== 'refund') bucket.deposit += amount; // deposit + other → deposit bar
|
||||||
|
}
|
||||||
|
return Array.from(byMonth.entries()).map(([month, v]) => ({ month, ...v }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionFunnelRow {
|
||||||
|
stage: 'eoi' | 'deposit' | 'contract' | 'won';
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Money funnel: EOI sent → deposit received → contract reached → won,
|
||||||
|
* counting distinct deals reaching each step within the range. */
|
||||||
|
export async function getCollectionFunnel(
|
||||||
|
portId: string,
|
||||||
|
range: DateRange,
|
||||||
|
): Promise<CollectionFunnelRow[]> {
|
||||||
|
const [eoiRow] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(interests)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interests.portId, portId),
|
||||||
|
gte(interests.dateEoiSent, range.from),
|
||||||
|
lte(interests.dateEoiSent, range.to),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [depositRow] = await db
|
||||||
|
.select({ count: sql<number>`count(distinct ${payments.interestId})::int` })
|
||||||
|
.from(payments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(payments.portId, portId),
|
||||||
|
eq(payments.paymentType, 'deposit'),
|
||||||
|
gte(payments.receivedAt, range.from),
|
||||||
|
lte(payments.receivedAt, range.to),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [contractRow] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(interests)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interests.portId, portId),
|
||||||
|
sql`${interests.pipelineStage} = 'contract'`,
|
||||||
|
isNull(interests.archivedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [wonRow] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(interests)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interests.portId, portId),
|
||||||
|
eq(interests.outcome, 'won'),
|
||||||
|
gte(interests.outcomeAt, range.from),
|
||||||
|
lte(interests.outcomeAt, range.to),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ stage: 'eoi', label: 'EOI sent', count: Number(eoiRow?.count ?? 0) },
|
||||||
|
{ stage: 'deposit', label: 'Deposit received', count: Number(depositRow?.count ?? 0) },
|
||||||
|
{ stage: 'contract', label: 'At contract', count: Number(contractRow?.count ?? 0) },
|
||||||
|
{ stage: 'won', label: 'Won', count: Number(wonRow?.count ?? 0) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgingRow {
|
||||||
|
bucket: AgingBucket;
|
||||||
|
count: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expected deposits still outstanding, bucketed by deal age. */
|
||||||
|
export async function getExpectedDepositAging(portId: string): Promise<AgingRow[]> {
|
||||||
|
const positions = await getDepositPositions(portId);
|
||||||
|
const byBucket = new Map<AgingBucket, { count: number; value: number }>();
|
||||||
|
for (const b of AGING_BUCKETS) byBucket.set(b, { count: 0, value: 0 });
|
||||||
|
for (const p of positions) {
|
||||||
|
if (p.remaining <= 0) continue;
|
||||||
|
const bucket = byBucket.get(agingBucket(p.daysOutstanding))!;
|
||||||
|
bucket.count += 1;
|
||||||
|
bucket.value += p.remaining;
|
||||||
|
}
|
||||||
|
return AGING_BUCKETS.map((bucket) => ({ bucket, ...byBucket.get(bucket)! }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashFlowRow {
|
||||||
|
month: string;
|
||||||
|
inflow: number;
|
||||||
|
outflow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Monthly inflow (payments received) vs outflow (expenses booked). */
|
||||||
|
export async function getCashFlow(portId: string, range: DateRange): Promise<CashFlowRow[]> {
|
||||||
|
const currency = await resolvePortCurrency(portId);
|
||||||
|
const byMonth = new Map<string, { inflow: number; outflow: number }>();
|
||||||
|
for (const key of monthRange(range.from, range.to)) byMonth.set(key, { inflow: 0, outflow: 0 });
|
||||||
|
|
||||||
|
const paymentRows = await db
|
||||||
|
.select({
|
||||||
|
amount: payments.amount,
|
||||||
|
currency: payments.currency,
|
||||||
|
receivedAt: payments.receivedAt,
|
||||||
|
})
|
||||||
|
.from(payments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(payments.portId, portId),
|
||||||
|
gte(payments.receivedAt, range.from),
|
||||||
|
lte(payments.receivedAt, range.to),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const r of paymentRows) {
|
||||||
|
if (!r.receivedAt) continue;
|
||||||
|
const bucket = byMonth.get(monthKey(new Date(r.receivedAt)));
|
||||||
|
if (!bucket) continue;
|
||||||
|
bucket.inflow += await normalizeAmount(Number(r.amount ?? 0), r.currency ?? currency, currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expenseRows = await db
|
||||||
|
.select({
|
||||||
|
amount: expenses.amount,
|
||||||
|
currency: expenses.currency,
|
||||||
|
expenseDate: expenses.expenseDate,
|
||||||
|
})
|
||||||
|
.from(expenses)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(expenses.portId, portId),
|
||||||
|
isNull(expenses.archivedAt),
|
||||||
|
gte(expenses.expenseDate, range.from),
|
||||||
|
lte(expenses.expenseDate, range.to),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const r of expenseRows) {
|
||||||
|
if (!r.expenseDate) continue;
|
||||||
|
const bucket = byMonth.get(monthKey(new Date(r.expenseDate)));
|
||||||
|
if (!bucket) continue;
|
||||||
|
bucket.outflow += await normalizeAmount(
|
||||||
|
Number(r.amount ?? 0),
|
||||||
|
r.currency ?? currency,
|
||||||
|
currency,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byMonth.entries()).map(([month, v]) => ({ month, ...v }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpenseBreakdownRow {
|
||||||
|
category: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expenses for the period grouped by category, normalised, sorted desc. */
|
||||||
|
export async function getExpenseBreakdown(
|
||||||
|
portId: string,
|
||||||
|
range: DateRange,
|
||||||
|
): Promise<ExpenseBreakdownRow[]> {
|
||||||
|
const currency = await resolvePortCurrency(portId);
|
||||||
|
const rows = await db
|
||||||
|
.select({ category: expenses.category, amount: expenses.amount, currency: expenses.currency })
|
||||||
|
.from(expenses)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(expenses.portId, portId),
|
||||||
|
isNull(expenses.archivedAt),
|
||||||
|
gte(expenses.expenseDate, range.from),
|
||||||
|
lte(expenses.expenseDate, range.to),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const byCategory = new Map<string, number>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const cat = r.category ?? 'Uncategorised';
|
||||||
|
const amount = await normalizeAmount(Number(r.amount ?? 0), r.currency ?? currency, currency);
|
||||||
|
byCategory.set(cat, (byCategory.get(cat) ?? 0) + amount);
|
||||||
|
}
|
||||||
|
return Array.from(byCategory.entries())
|
||||||
|
.map(([category, total]) => ({ category, total }))
|
||||||
|
.sort((a, b) => b.total - a.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tables ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface OutstandingDepositRow {
|
||||||
|
interestId: string;
|
||||||
|
clientName: string;
|
||||||
|
mooring: string | null;
|
||||||
|
expected: number;
|
||||||
|
collected: number;
|
||||||
|
remaining: number;
|
||||||
|
currency: string;
|
||||||
|
daysOutstanding: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active deals with an expected deposit not yet fully collected, sorted
|
||||||
|
* by remaining amount desc. The "chase these" list. */
|
||||||
|
export async function getOutstandingDeposits(portId: string): Promise<OutstandingDepositRow[]> {
|
||||||
|
const positions = await getDepositPositions(portId);
|
||||||
|
return positions
|
||||||
|
.filter((p) => p.remaining > 0)
|
||||||
|
.sort((a, b) => b.remaining - a.remaining)
|
||||||
|
.slice(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentPaymentRow {
|
||||||
|
id: string;
|
||||||
|
receivedAt: string;
|
||||||
|
clientName: string;
|
||||||
|
mooring: string | null;
|
||||||
|
paymentType: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecentPayments(
|
||||||
|
portId: string,
|
||||||
|
range: DateRange,
|
||||||
|
): Promise<RecentPaymentRow[]> {
|
||||||
|
const currency = await resolvePortCurrency(portId);
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: payments.id,
|
||||||
|
receivedAt: payments.receivedAt,
|
||||||
|
clientName: clients.fullName,
|
||||||
|
mooring: berths.mooringNumber,
|
||||||
|
paymentType: payments.paymentType,
|
||||||
|
amount: payments.amount,
|
||||||
|
paymentCurrency: payments.currency,
|
||||||
|
})
|
||||||
|
.from(payments)
|
||||||
|
.innerJoin(clients, eq(clients.id, payments.clientId))
|
||||||
|
.leftJoin(
|
||||||
|
interestBerths,
|
||||||
|
and(eq(interestBerths.interestId, payments.interestId), eq(interestBerths.isPrimary, true)),
|
||||||
|
)
|
||||||
|
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(payments.portId, portId),
|
||||||
|
gte(payments.receivedAt, range.from),
|
||||||
|
lte(payments.receivedAt, range.to),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(payments.receivedAt))
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
const out: RecentPaymentRow[] = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
out.push({
|
||||||
|
id: r.id,
|
||||||
|
receivedAt: r.receivedAt ? new Date(r.receivedAt).toISOString() : '',
|
||||||
|
clientName: r.clientName,
|
||||||
|
mooring: r.mooring,
|
||||||
|
paymentType: r.paymentType,
|
||||||
|
amount: await normalizeAmount(Number(r.amount ?? 0), r.paymentCurrency ?? currency, currency),
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefundRow {
|
||||||
|
id: string;
|
||||||
|
receivedAt: string;
|
||||||
|
clientName: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refund-type payments in range (write-off / refund log analogue). */
|
||||||
|
export async function getRefundLog(portId: string, range: DateRange): Promise<RefundRow[]> {
|
||||||
|
const currency = await resolvePortCurrency(portId);
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: payments.id,
|
||||||
|
receivedAt: payments.receivedAt,
|
||||||
|
clientName: clients.fullName,
|
||||||
|
amount: payments.amount,
|
||||||
|
paymentCurrency: payments.currency,
|
||||||
|
notes: payments.notes,
|
||||||
|
})
|
||||||
|
.from(payments)
|
||||||
|
.innerJoin(clients, eq(clients.id, payments.clientId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(payments.portId, portId),
|
||||||
|
eq(payments.paymentType, 'refund'),
|
||||||
|
gte(payments.receivedAt, range.from),
|
||||||
|
lte(payments.receivedAt, range.to),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(payments.receivedAt))
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
const out: RefundRow[] = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
out.push({
|
||||||
|
id: r.id,
|
||||||
|
receivedAt: r.receivedAt ? new Date(r.receivedAt).toISOString() : '',
|
||||||
|
clientName: r.clientName,
|
||||||
|
amount: Math.abs(
|
||||||
|
await normalizeAmount(Number(r.amount ?? 0), r.paymentCurrency ?? currency, currency),
|
||||||
|
),
|
||||||
|
currency,
|
||||||
|
notes: r.notes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpenseLedgerRow {
|
||||||
|
id: string;
|
||||||
|
expenseDate: string;
|
||||||
|
payer: string | null;
|
||||||
|
category: string | null;
|
||||||
|
establishmentName: string | null;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
paymentStatus: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExpenseLedger(
|
||||||
|
portId: string,
|
||||||
|
range: DateRange,
|
||||||
|
): Promise<ExpenseLedgerRow[]> {
|
||||||
|
const currency = await resolvePortCurrency(portId);
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: expenses.id,
|
||||||
|
expenseDate: expenses.expenseDate,
|
||||||
|
payer: expenses.payer,
|
||||||
|
category: expenses.category,
|
||||||
|
establishmentName: expenses.establishmentName,
|
||||||
|
amount: expenses.amount,
|
||||||
|
expenseCurrency: expenses.currency,
|
||||||
|
paymentStatus: expenses.paymentStatus,
|
||||||
|
})
|
||||||
|
.from(expenses)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(expenses.portId, portId),
|
||||||
|
isNull(expenses.archivedAt),
|
||||||
|
gte(expenses.expenseDate, range.from),
|
||||||
|
lte(expenses.expenseDate, range.to),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(expenses.expenseDate))
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
const out: ExpenseLedgerRow[] = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
out.push({
|
||||||
|
id: r.id,
|
||||||
|
expenseDate: r.expenseDate ? new Date(r.expenseDate).toISOString() : '',
|
||||||
|
payer: r.payer,
|
||||||
|
category: r.category,
|
||||||
|
establishmentName: r.establishmentName,
|
||||||
|
amount: await normalizeAmount(Number(r.amount ?? 0), r.expenseCurrency ?? currency, currency),
|
||||||
|
currency,
|
||||||
|
paymentStatus: r.paymentStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
53
tests/unit/services/reports/financial-math.test.ts
Normal file
53
tests/unit/services/reports/financial-math.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
agingBucket,
|
||||||
|
AGING_BUCKETS,
|
||||||
|
monthKey,
|
||||||
|
monthRange,
|
||||||
|
netContribution,
|
||||||
|
} from '@/lib/services/reports/financial-math';
|
||||||
|
|
||||||
|
describe('agingBucket', () => {
|
||||||
|
it('buckets day counts into the 4 age bands', () => {
|
||||||
|
expect(agingBucket(0)).toBe('0-30');
|
||||||
|
expect(agingBucket(30)).toBe('0-30');
|
||||||
|
expect(agingBucket(31)).toBe('31-60');
|
||||||
|
expect(agingBucket(60)).toBe('31-60');
|
||||||
|
expect(agingBucket(61)).toBe('61-90');
|
||||||
|
expect(agingBucket(90)).toBe('61-90');
|
||||||
|
expect(agingBucket(91)).toBe('90+');
|
||||||
|
expect(agingBucket(400)).toBe('90+');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the buckets in ascending order for stable chart rows', () => {
|
||||||
|
expect(AGING_BUCKETS).toEqual(['0-30', '31-60', '61-90', '90+']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('monthKey', () => {
|
||||||
|
it('formats a date as zero-padded YYYY-MM (UTC)', () => {
|
||||||
|
expect(monthKey(new Date('2026-01-09T00:00:00Z'))).toBe('2026-01');
|
||||||
|
expect(monthKey(new Date('2026-12-31T23:59:59Z'))).toBe('2026-12');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('monthRange', () => {
|
||||||
|
it('produces a continuous ascending month series, inclusive of both ends', () => {
|
||||||
|
const series = monthRange(new Date('2025-11-15T00:00:00Z'), new Date('2026-02-02T00:00:00Z'));
|
||||||
|
expect(series).toEqual(['2025-11', '2025-12', '2026-01', '2026-02']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a single month when from and to share a month', () => {
|
||||||
|
expect(monthRange(new Date('2026-03-01T00:00:00Z'), new Date('2026-03-28T00:00:00Z'))).toEqual([
|
||||||
|
'2026-03',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('netContribution', () => {
|
||||||
|
it('is revenue minus expenses', () => {
|
||||||
|
expect(netContribution(1000, 250)).toBe(750);
|
||||||
|
expect(netContribution(100, 400)).toBe(-300);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user