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 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 { 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.',
|
||||
icon: Layers,
|
||||
},
|
||||
{
|
||||
href: 'financial',
|
||||
label: 'Financial',
|
||||
description:
|
||||
'Revenue collected, deposits, outstanding balances, cash flow, and expense breakdown.',
|
||||
icon: Wallet,
|
||||
},
|
||||
{
|
||||
href: 'custom',
|
||||
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 +
|
||||
// filters) that the report client applies on load. 'custom' is the
|
||||
// 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),
|
||||
description: z.string().max(400).nullable().optional(),
|
||||
// Config is the raw discriminated-union payload; the
|
||||
|
||||
Reference in New Issue
Block a user