Add Phase 4 Cash Flow Visualization with forecast endpoint and Recharts chart
New feature: Cash Flow page under Financials showing stacked area chart of operating/reserve cash and investment balances over time. Backend forecast endpoint integrates assessment income schedules, budget expenses, capital project costs, and investment maturities to project 24+ months forward. Historical months show actual journal entry balances; future months are projected. Includes Operating/Reserve/All fund filter, 12-month sliding window navigation, forecast reference line, and monthly detail table. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
475
frontend/src/pages/cash-flow/CashFlowForecastPage.tsx
Normal file
475
frontend/src/pages/cash-flow/CashFlowForecastPage.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Title, Text, Stack, Card, Group, SimpleGrid, ThemeIcon,
|
||||
SegmentedControl, Loader, Center, ActionIcon, Tooltip, Badge,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCash, IconBuildingBank, IconChartAreaLine,
|
||||
IconArrowLeft, IconArrowRight, IconCalendar,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface Datapoint {
|
||||
month: string;
|
||||
year: number;
|
||||
monthNum: number;
|
||||
is_forecast: boolean;
|
||||
operating_cash: number;
|
||||
operating_investments: number;
|
||||
reserve_cash: number;
|
||||
reserve_investments: number;
|
||||
}
|
||||
|
||||
interface ForecastData {
|
||||
start_year: number;
|
||||
months: number;
|
||||
current_month: string;
|
||||
datapoints: Datapoint[];
|
||||
}
|
||||
|
||||
const fmt = (v: number) =>
|
||||
v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
|
||||
const fmtAxis = (v: number) => {
|
||||
if (Math.abs(v) >= 1000000) return `$${(v / 1000000).toFixed(1)}M`;
|
||||
if (Math.abs(v) >= 1000) return `$${(v / 1000).toFixed(0)}K`;
|
||||
return `$${v}`;
|
||||
};
|
||||
|
||||
// Custom tooltip component
|
||||
function CustomTooltip({ active, payload, label }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const dp = payload[0]?.payload as Datapoint;
|
||||
const isForecast = dp?.is_forecast;
|
||||
|
||||
return (
|
||||
<Card shadow="md" p="sm" withBorder style={{ minWidth: 220 }}>
|
||||
<Group gap="xs" mb={6}>
|
||||
<Text fw={700} size="sm">{label}</Text>
|
||||
{isForecast && (
|
||||
<Badge size="xs" variant="light" color="orange">Forecast</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{payload.map((entry: any) => (
|
||||
<Group key={entry.name} justify="space-between" gap="lg">
|
||||
<Group gap={6}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 2, backgroundColor: entry.color }} />
|
||||
<Text size="xs">{entry.name}</Text>
|
||||
</Group>
|
||||
<Text size="xs" fw={600} ff="monospace">{fmt(entry.value)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Group justify="space-between" mt={6} pt={6} style={{ borderTop: '1px solid var(--mantine-color-gray-3)' }}>
|
||||
<Text size="xs" fw={700}>Total</Text>
|
||||
<Text size="xs" fw={700} ff="monospace">
|
||||
{fmt(payload.reduce((s: number, p: any) => s + p.value, 0))}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function CashFlowForecastPage() {
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth() + 1;
|
||||
|
||||
// Filter: All, Operating, Reserve
|
||||
const [fundFilter, setFundFilter] = useState<string>('all');
|
||||
// View window: which 12-month period to show
|
||||
const [viewStartIndex, setViewStartIndex] = useState(0);
|
||||
|
||||
// Fetch 36 months of data starting from current fiscal year (Jan)
|
||||
const { data, isLoading } = useQuery<ForecastData>({
|
||||
queryKey: ['cash-flow-forecast', currentYear],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(
|
||||
`/reports/cash-flow-forecast?startYear=${currentYear}&months=36`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const datapoints = data?.datapoints || [];
|
||||
|
||||
// Determine available view windows (12-month slices)
|
||||
const maxStartIndex = Math.max(0, datapoints.length - 12);
|
||||
|
||||
// Compute the chart data based on the current view window
|
||||
const viewData = useMemo(() => {
|
||||
return datapoints.slice(viewStartIndex, viewStartIndex + 12);
|
||||
}, [datapoints, viewStartIndex]);
|
||||
|
||||
// Compute summary stats for the current view
|
||||
const summaryStats = useMemo(() => {
|
||||
if (!viewData.length) return null;
|
||||
const last = viewData[viewData.length - 1];
|
||||
const first = viewData[0];
|
||||
|
||||
const totalOperating = last.operating_cash + last.operating_investments;
|
||||
const totalReserve = last.reserve_cash + last.reserve_investments;
|
||||
const totalAll = totalOperating + totalReserve;
|
||||
|
||||
const firstTotal = first.operating_cash + first.operating_investments +
|
||||
first.reserve_cash + first.reserve_investments;
|
||||
const netChange = totalAll - firstTotal;
|
||||
|
||||
return {
|
||||
totalOperating,
|
||||
totalReserve,
|
||||
totalAll,
|
||||
netChange,
|
||||
periodStart: first.month,
|
||||
periodEnd: last.month,
|
||||
};
|
||||
}, [viewData]);
|
||||
|
||||
// Determine the first forecast month index within the view
|
||||
const forecastStartLabel = useMemo(() => {
|
||||
const idx = viewData.findIndex((d) => d.is_forecast);
|
||||
return idx >= 0 ? viewData[idx].month : null;
|
||||
}, [viewData]);
|
||||
|
||||
// Transform data for chart based on filter
|
||||
const chartData = useMemo(() => {
|
||||
return viewData.map((d) => {
|
||||
const base: any = { month: d.month, is_forecast: d.is_forecast };
|
||||
if (fundFilter === 'all' || fundFilter === 'operating') {
|
||||
base['Operating Cash'] = d.operating_cash;
|
||||
base['Operating Investments'] = d.operating_investments;
|
||||
}
|
||||
if (fundFilter === 'all' || fundFilter === 'reserve') {
|
||||
base['Reserve Cash'] = d.reserve_cash;
|
||||
base['Reserve Investments'] = d.reserve_investments;
|
||||
}
|
||||
return base;
|
||||
});
|
||||
}, [viewData, fundFilter]);
|
||||
|
||||
// Determine view period label
|
||||
const viewLabel = viewData.length > 0
|
||||
? `${viewData[0].month} - ${viewData[viewData.length - 1].month}`
|
||||
: '';
|
||||
|
||||
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={2}>Cash Flow</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Cash and investment balances over time with forward projections
|
||||
</Text>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
value={fundFilter}
|
||||
onChange={setFundFilter}
|
||||
data={[
|
||||
{ label: 'All Funds', value: 'all' },
|
||||
{ label: 'Operating', value: 'operating' },
|
||||
{ label: 'Reserve', value: 'reserve' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summaryStats && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="blue" size="sm">
|
||||
<IconCash size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Total</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace">
|
||||
{fmt(summaryStats.totalOperating)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="violet" size="sm">
|
||||
<IconBuildingBank size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Total</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace">
|
||||
{fmt(summaryStats.totalReserve)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="teal" size="sm">
|
||||
<IconChartAreaLine size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Combined Total</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace">
|
||||
{fmt(summaryStats.totalAll)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={summaryStats.netChange >= 0 ? 'green' : 'red'}
|
||||
size="sm"
|
||||
>
|
||||
<IconCash size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Period Change</Text>
|
||||
</Group>
|
||||
<Text
|
||||
fw={700}
|
||||
size="xl"
|
||||
ff="monospace"
|
||||
c={summaryStats.netChange >= 0 ? 'green' : 'red'}
|
||||
>
|
||||
{fmt(summaryStats.netChange)}
|
||||
</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* Chart Navigation */}
|
||||
<Card withBorder p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group gap="xs">
|
||||
<Tooltip label="Previous 12 months">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
disabled={viewStartIndex <= 0}
|
||||
onClick={() => setViewStartIndex(Math.max(0, viewStartIndex - 12))}
|
||||
>
|
||||
<IconArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Group gap={6}>
|
||||
<IconCalendar size={16} style={{ color: 'var(--mantine-color-dimmed)' }} />
|
||||
<Text fw={600} size="sm">{viewLabel}</Text>
|
||||
</Group>
|
||||
<Tooltip label="Next 12 months">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
disabled={viewStartIndex >= maxStartIndex}
|
||||
onClick={() => setViewStartIndex(Math.min(maxStartIndex, viewStartIndex + 12))}
|
||||
>
|
||||
<IconArrowRight size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
{forecastStartLabel && (
|
||||
<Badge variant="light" color="orange" size="sm">
|
||||
Forecast begins {forecastStartLabel}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="light" color="blue" size="sm">
|
||||
{viewData.filter((d) => !d.is_forecast).length} actual
|
||||
</Badge>
|
||||
<Badge variant="light" color="orange" size="sm">
|
||||
{viewData.filter((d) => d.is_forecast).length} projected
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Stacked Area Chart */}
|
||||
<ResponsiveContainer width="100%" height={420}>
|
||||
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#339af0" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="#339af0" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#dee2e6' }}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={fmtAxis}
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#dee2e6' }}
|
||||
width={70}
|
||||
/>
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={36}
|
||||
iconType="rect"
|
||||
formatter={(value: string) => (
|
||||
<span style={{ fontSize: 12, color: '#495057' }}>{value}</span>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Draw a reference line where forecast begins */}
|
||||
{forecastStartLabel && (
|
||||
<ReferenceLine
|
||||
x={forecastStartLabel}
|
||||
stroke="#fd7e14"
|
||||
strokeDasharray="5 5"
|
||||
strokeWidth={2}
|
||||
label={{ value: 'Forecast', position: 'top', fill: '#fd7e14', fontSize: 11 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(fundFilter === 'all' || fundFilter === 'operating') && (
|
||||
<>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="Operating Cash"
|
||||
stackId="1"
|
||||
stroke="#339af0"
|
||||
fill="url(#opCash)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="Operating Investments"
|
||||
stackId="1"
|
||||
stroke="#74c0fc"
|
||||
fill="url(#opInv)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(fundFilter === 'all' || fundFilter === 'reserve') && (
|
||||
<>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="Reserve Cash"
|
||||
stackId="1"
|
||||
stroke="#7950f2"
|
||||
fill="url(#resCash)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="Reserve Investments"
|
||||
stackId="1"
|
||||
stroke="#b197fc"
|
||||
fill="url(#resInv)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
{/* Data Table */}
|
||||
<Card withBorder p="lg">
|
||||
<Title order={4} mb="md">Monthly Detail</Title>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid var(--mantine-color-gray-3)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '8px 12px' }}>Month</th>
|
||||
<th style={{ textAlign: 'center', padding: '8px 12px', width: 70 }}>Type</th>
|
||||
{(fundFilter === 'all' || fundFilter === 'operating') && (
|
||||
<>
|
||||
<th style={{ textAlign: 'right', padding: '8px 12px' }}>Op. Cash</th>
|
||||
<th style={{ textAlign: 'right', padding: '8px 12px' }}>Op. Investments</th>
|
||||
<th style={{ textAlign: 'right', padding: '8px 12px' }}>Op. Total</th>
|
||||
</>
|
||||
)}
|
||||
{(fundFilter === 'all' || fundFilter === 'reserve') && (
|
||||
<>
|
||||
<th style={{ textAlign: 'right', padding: '8px 12px' }}>Res. Cash</th>
|
||||
<th style={{ textAlign: 'right', padding: '8px 12px' }}>Res. Investments</th>
|
||||
<th style={{ textAlign: 'right', padding: '8px 12px' }}>Res. Total</th>
|
||||
</>
|
||||
)}
|
||||
<th style={{ textAlign: 'right', padding: '8px 12px', fontWeight: 700 }}>Grand Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{viewData.map((d, i) => {
|
||||
const opTotal = d.operating_cash + d.operating_investments;
|
||||
const resTotal = d.reserve_cash + d.reserve_investments;
|
||||
const grandTotal =
|
||||
(fundFilter === 'all' ? opTotal + resTotal :
|
||||
fundFilter === 'operating' ? opTotal : resTotal);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={d.month}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
||||
backgroundColor: d.is_forecast
|
||||
? 'var(--mantine-color-orange-0)'
|
||||
: i % 2 === 0 ? 'transparent' : 'var(--mantine-color-gray-0)',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '6px 12px', fontWeight: 500 }}>{d.month}</td>
|
||||
<td style={{ textAlign: 'center', padding: '6px 12px' }}>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={d.is_forecast ? 'orange' : 'blue'}
|
||||
>
|
||||
{d.is_forecast ? 'Forecast' : 'Actual'}
|
||||
</Badge>
|
||||
</td>
|
||||
{(fundFilter === 'all' || fundFilter === 'operating') && (
|
||||
<>
|
||||
<td style={{ textAlign: 'right', padding: '6px 12px', fontFamily: 'monospace' }}>
|
||||
{fmt(d.operating_cash)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', padding: '6px 12px', fontFamily: 'monospace' }}>
|
||||
{fmt(d.operating_investments)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', padding: '6px 12px', fontFamily: 'monospace', fontWeight: 600 }}>
|
||||
{fmt(opTotal)}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
{(fundFilter === 'all' || fundFilter === 'reserve') && (
|
||||
<>
|
||||
<td style={{ textAlign: 'right', padding: '6px 12px', fontFamily: 'monospace' }}>
|
||||
{fmt(d.reserve_cash)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', padding: '6px 12px', fontFamily: 'monospace' }}>
|
||||
{fmt(d.reserve_investments)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', padding: '6px 12px', fontFamily: 'monospace', fontWeight: 600 }}>
|
||||
{fmt(resTotal)}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td style={{ textAlign: 'right', padding: '6px 12px', fontFamily: 'monospace', fontWeight: 700 }}>
|
||||
{fmt(grandTotal)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user