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; type SLink = SankeyLink; const CATEGORY_COLORS: Record = { 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(null); const containerRef = useRef(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({ 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() .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(); (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
; // Fallback if no data from API yet — show a helpful empty state const hasData = data && data.nodes.length > 0 && data.links.length > 0; return ( Cash Flow Visualization