Files
HOA_Financial_Platform/frontend/src/pages/reports/SankeyPage.tsx
olsch01 07347a644f QoL tweaks: Cash Flow cards, auto-primary accounts, investment projections, Sankey filters
- Dashboard: Remove tenant name/role subtitle
- Cash Flow: Replace Operating/Reserve net cards with inflow vs outflow
  breakdown showing In/Out amounts and signed net; replace Ending Cash
  card with AI Financial Health status from saved recommendation
- Accounts: Auto-set first asset account per fund_type as primary on creation
- Investments: Add 5th summary card for projected annual interest earnings
- Sankey: Add Actuals/Budget/Forecast data source toggle and
  All Funds/Operating/Reserve fund filter SegmentedControls with
  backend support for budget-based and forecast (actuals+budget) queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:22:37 -05:00

278 lines
9.4 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react';
import {
Title, Group, Stack, Text, Card, Loader, Center, Select, SimpleGrid, SegmentedControl,
} 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 [source, setSource] = useState('actuals');
const [fundFilter, setFundFilter] = useState('all');
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, source, fundFilter],
queryFn: async () => {
const params = new URLSearchParams({ year });
if (source !== 'actuals') params.set('source', source);
if (fundFilter !== 'all') params.set('fundType', fundFilter);
const { data } = await api.get(`/reports/cash-flow-sankey?${params}`);
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>
<Group>
<Text size="sm" fw={500}>Data source:</Text>
<SegmentedControl
size="sm"
value={source}
onChange={setSource}
data={[
{ label: 'Actuals', value: 'actuals' },
{ label: 'Budget', value: 'budget' },
{ label: 'Forecast', value: 'forecast' },
]}
/>
<Text size="sm" fw={500} ml="md">Fund:</Text>
<SegmentedControl
size="sm"
value={fundFilter}
onChange={setFundFilter}
data={[
{ label: 'All Funds', value: 'all' },
{ label: 'Operating', value: 'operating' },
{ label: 'Reserve', value: 'reserve' },
]}
/>
</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>
);
}