Initial commit: HOA Financial Intelligence Platform MVP
Multi-tenant financial management platform for homeowner associations featuring: - NestJS backend with 16 modules (auth, accounts, transactions, budgets, units, invoices, payments, vendors, reserves, investments, capital projects, reports) - React + Mantine frontend with dashboard, CRUD pages, and financial reports - Schema-per-tenant PostgreSQL isolation with JWT-based tenant resolution - Docker Compose infrastructure (nginx, backend, frontend, postgres, redis) - Comprehensive seed data for Sunrise Valley HOA demo - 39 API endpoints with Swagger documentation - Double-entry bookkeeping with journal entries - Budget vs actual reporting and Sankey cash flow visualization Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
247
frontend/src/pages/reports/SankeyPage.tsx
Normal file
247
frontend/src/pages/reports/SankeyPage.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Title, Group, Stack, Text, Card, Loader, Center, Select, SimpleGrid,
|
||||
} from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
sankey as d3Sankey,
|
||||
sankeyLinkHorizontal,
|
||||
sankeyJustify,
|
||||
SankeyNode,
|
||||
SankeyLink,
|
||||
} from 'd3-sankey';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface FlowNode {
|
||||
name: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface FlowLink {
|
||||
source: number;
|
||||
target: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface CashFlowData {
|
||||
nodes: FlowNode[];
|
||||
links: FlowLink[];
|
||||
total_income: number;
|
||||
total_expenses: number;
|
||||
net_cash_flow: number;
|
||||
}
|
||||
|
||||
type SNode = SankeyNode<FlowNode, FlowLink>;
|
||||
type SLink = SankeyLink<FlowNode, FlowLink>;
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
income: '#40c057',
|
||||
expense: '#fa5252',
|
||||
reserve: '#7950f2',
|
||||
transfer: '#228be6',
|
||||
net: '#868e96',
|
||||
operating: '#15aabf',
|
||||
};
|
||||
|
||||
function getNodeColor(node: FlowNode): string {
|
||||
return CATEGORY_COLORS[node.category] || '#868e96';
|
||||
}
|
||||
|
||||
export function SankeyPage() {
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 900, height: 500 });
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
|
||||
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||
const y = new Date().getFullYear() - 2 + i;
|
||||
return { value: String(y), label: String(y) };
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useQuery<CashFlowData>({
|
||||
queryKey: ['sankey', year],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/reports/cash-flow-sankey?year=${year}`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Resize observer
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width } = entry.contentRect;
|
||||
if (width > 0) {
|
||||
setDimensions({ width: Math.max(width - 32, 400), height: Math.max(400, Math.min(600, width * 0.5)) });
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const renderSankey = useCallback(() => {
|
||||
if (!data || !svgRef.current || data.nodes.length === 0 || data.links.length === 0) return;
|
||||
|
||||
const { width, height } = dimensions;
|
||||
const margin = { top: 10, right: 150, bottom: 10, left: 150 };
|
||||
const innerWidth = width - margin.left - margin.right;
|
||||
const innerHeight = height - margin.top - margin.bottom;
|
||||
|
||||
// Build sankey layout
|
||||
const sankeyLayout = d3Sankey<FlowNode, FlowLink>()
|
||||
.nodeWidth(20)
|
||||
.nodePadding(12)
|
||||
.nodeAlign(sankeyJustify)
|
||||
.extent([[0, 0], [innerWidth, innerHeight]]);
|
||||
|
||||
// Deep clone data so d3 can mutate it
|
||||
const graph = sankeyLayout({
|
||||
nodes: data.nodes.map((d) => ({ ...d })),
|
||||
links: data.links.map((d) => ({ ...d })),
|
||||
});
|
||||
|
||||
const svg = svgRef.current;
|
||||
// Clear previous content
|
||||
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
||||
|
||||
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
g.setAttribute('transform', `translate(${margin.left},${margin.top})`);
|
||||
svg.appendChild(g);
|
||||
|
||||
// Render links
|
||||
const linkPath = sankeyLinkHorizontal<SLink, SNode>();
|
||||
(graph.links as SLink[]).forEach((link) => {
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
const d = linkPath(link as any);
|
||||
if (d) path.setAttribute('d', d);
|
||||
const sourceNode = link.source as SNode;
|
||||
path.setAttribute('fill', 'none');
|
||||
path.setAttribute('stroke', getNodeColor(sourceNode));
|
||||
path.setAttribute('stroke-opacity', '0.3');
|
||||
path.setAttribute('stroke-width', String(Math.max(1, (link as any).width || 1)));
|
||||
|
||||
// Hover effect
|
||||
path.addEventListener('mouseenter', () => {
|
||||
path.setAttribute('stroke-opacity', '0.6');
|
||||
});
|
||||
path.addEventListener('mouseleave', () => {
|
||||
path.setAttribute('stroke-opacity', '0.3');
|
||||
});
|
||||
|
||||
// Tooltip
|
||||
const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
|
||||
const sn = link.source as SNode;
|
||||
const tn = link.target as SNode;
|
||||
const val = (link.value || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
title.textContent = `${sn.name} → ${tn.name}: ${val}`;
|
||||
path.appendChild(title);
|
||||
|
||||
g.appendChild(path);
|
||||
});
|
||||
|
||||
// Render nodes
|
||||
(graph.nodes as SNode[]).forEach((node) => {
|
||||
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
rect.setAttribute('x', String(node.x0 || 0));
|
||||
rect.setAttribute('y', String(node.y0 || 0));
|
||||
rect.setAttribute('width', String((node.x1 || 0) - (node.x0 || 0)));
|
||||
rect.setAttribute('height', String(Math.max(1, (node.y1 || 0) - (node.y0 || 0))));
|
||||
rect.setAttribute('fill', getNodeColor(node));
|
||||
rect.setAttribute('rx', '2');
|
||||
|
||||
const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
|
||||
const val = (node.value || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
title.textContent = `${node.name}: ${val}`;
|
||||
rect.appendChild(title);
|
||||
g.appendChild(rect);
|
||||
|
||||
// Label
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
const isLeftSide = (node.x0 || 0) < innerWidth / 2;
|
||||
text.setAttribute('x', String(isLeftSide ? (node.x0 || 0) - 6 : (node.x1 || 0) + 6));
|
||||
text.setAttribute('y', String(((node.y0 || 0) + (node.y1 || 0)) / 2));
|
||||
text.setAttribute('dy', '0.35em');
|
||||
text.setAttribute('text-anchor', isLeftSide ? 'end' : 'start');
|
||||
text.setAttribute('font-size', '11');
|
||||
text.setAttribute('fill', '#495057');
|
||||
text.textContent = node.name;
|
||||
g.appendChild(text);
|
||||
});
|
||||
}, [data, dimensions]);
|
||||
|
||||
useEffect(() => {
|
||||
renderSankey();
|
||||
}, [renderSankey]);
|
||||
|
||||
const fmt = (v: number) =>
|
||||
(v || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
|
||||
|
||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||
|
||||
// Fallback if no data from API yet — show a helpful empty state
|
||||
const hasData = data && data.nodes.length > 0 && data.links.length > 0;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Cash Flow Visualization</Title>
|
||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Income</Text>
|
||||
<Text fw={700} size="xl" c="green">{fmt(data?.total_income || 0)}</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Expenses</Text>
|
||||
<Text fw={700} size="xl" c="red">{fmt(data?.total_expenses || 0)}</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Cash Flow</Text>
|
||||
<Text fw={700} size="xl" c={(data?.net_cash_flow || 0) >= 0 ? 'green' : 'red'}>
|
||||
{fmt(data?.net_cash_flow || 0)}
|
||||
</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card withBorder p="md" ref={containerRef}>
|
||||
{isError ? (
|
||||
<Center h={300}>
|
||||
<Text c="dimmed">Unable to load cash flow data. Ensure the reports API is available.</Text>
|
||||
</Center>
|
||||
) : !hasData ? (
|
||||
<Center h={300}>
|
||||
<Stack align="center" gap="sm">
|
||||
<Text c="dimmed" size="lg">No cash flow data for {year}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
Record income and expense transactions to see the Sankey diagram.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
style={{ display: 'block', margin: '0 auto' }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card withBorder p="sm">
|
||||
<Group gap="lg">
|
||||
{Object.entries(CATEGORY_COLORS).map(([key, color]) => (
|
||||
<Group key={key} gap={4}>
|
||||
<div style={{ width: 12, height: 12, borderRadius: 2, background: color }} />
|
||||
<Text size="xs" tt="capitalize">{key}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user