- 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>
278 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|