Remove the 4 summary cards from the Cash Flow page as they don't properly represent the story over time. Increase gradient opacity on stacked area charts (cash flow and investment scenarios) from 0.3-0.4/0-0.05 to 0.6/0.15 for better visual shading. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
125 lines
5.1 KiB
TypeScript
125 lines
5.1 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { Card, Title, Text, Group, Badge, SegmentedControl, Stack } from '@mantine/core';
|
|
import { useState } from 'react';
|
|
import {
|
|
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
|
ResponsiveContainer, ReferenceLine,
|
|
} from 'recharts';
|
|
|
|
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
|
|
|
interface Datapoint {
|
|
month: string;
|
|
year: number;
|
|
monthNum: number;
|
|
is_forecast: boolean;
|
|
operating_cash: number;
|
|
operating_investments: number;
|
|
reserve_cash: number;
|
|
reserve_investments: number;
|
|
}
|
|
|
|
interface Props {
|
|
datapoints: Datapoint[];
|
|
title?: string;
|
|
summary?: any;
|
|
}
|
|
|
|
export function ProjectionChart({ datapoints, title = 'Financial Projection', summary }: Props) {
|
|
const [fundFilter, setFundFilter] = useState('all');
|
|
|
|
const chartData = useMemo(() => {
|
|
return datapoints.map((d) => ({
|
|
...d,
|
|
label: `${d.month}`,
|
|
total: d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
|
|
}));
|
|
}, [datapoints]);
|
|
|
|
// Find first forecast month for reference line
|
|
const forecastStart = chartData.findIndex((d) => d.is_forecast);
|
|
|
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
|
if (!active || !payload?.length) return null;
|
|
return (
|
|
<Card shadow="sm" p="xs" withBorder style={{ background: 'var(--mantine-color-body)' }}>
|
|
<Text fw={600} size="sm" mb={4}>{label}</Text>
|
|
{payload.map((p: any) => (
|
|
<Group key={p.name} justify="space-between" gap="xl">
|
|
<Text size="xs" c={p.color}>{p.name}</Text>
|
|
<Text size="xs" fw={600} ff="monospace">{fmt(p.value)}</Text>
|
|
</Group>
|
|
))}
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
const showOp = fundFilter === 'all' || fundFilter === 'operating';
|
|
const showRes = fundFilter === 'all' || fundFilter === 'reserve';
|
|
|
|
return (
|
|
<Card withBorder p="lg">
|
|
<Group justify="space-between" mb="md">
|
|
<div>
|
|
<Title order={4}>{title}</Title>
|
|
{summary && (
|
|
<Group gap="md" mt={4}>
|
|
<Badge variant="light" color="teal">End Liquidity: {fmt(summary.end_liquidity || 0)}</Badge>
|
|
<Badge variant="light" color="orange">Min Liquidity: {fmt(summary.min_liquidity || 0)}</Badge>
|
|
{summary.reserve_coverage_months != null && (
|
|
<Badge variant="light" color="violet">
|
|
Reserve Coverage: {summary.reserve_coverage_months.toFixed(1)} mo
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
)}
|
|
</div>
|
|
<SegmentedControl
|
|
size="xs"
|
|
value={fundFilter}
|
|
onChange={setFundFilter}
|
|
data={[
|
|
{ label: 'All', value: 'all' },
|
|
{ label: 'Operating', value: 'operating' },
|
|
{ label: 'Reserve', value: 'reserve' },
|
|
]}
|
|
/>
|
|
</Group>
|
|
<ResponsiveContainer width="100%" height={350}>
|
|
<AreaChart data={chartData}>
|
|
<defs>
|
|
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#228be6" stopOpacity={0.6} />
|
|
<stop offset="95%" stopColor="#228be6" stopOpacity={0.15} />
|
|
</linearGradient>
|
|
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.6} />
|
|
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.15} />
|
|
</linearGradient>
|
|
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.6} />
|
|
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.15} />
|
|
</linearGradient>
|
|
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.6} />
|
|
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.15} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
|
<XAxis dataKey="month" tick={{ fontSize: 11 }} interval="preserveStartEnd" />
|
|
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
|
|
<Tooltip content={<CustomTooltip />} />
|
|
<Legend />
|
|
{forecastStart > 0 && (
|
|
<ReferenceLine x={chartData[forecastStart]?.month} stroke="#aaa" strokeDasharray="5 5" label="Forecast" />
|
|
)}
|
|
{showOp && <Area type="monotone" dataKey="operating_cash" name="Operating Cash" stroke="#228be6" fill="url(#opCash)" stackId="1" />}
|
|
{showOp && <Area type="monotone" dataKey="operating_investments" name="Operating Investments" stroke="#74c0fc" fill="url(#opInv)" stackId="1" />}
|
|
{showRes && <Area type="monotone" dataKey="reserve_cash" name="Reserve Cash" stroke="#7950f2" fill="url(#resCash)" stackId="1" />}
|
|
{showRes && <Area type="monotone" dataKey="reserve_investments" name="Reserve Investments" stroke="#b197fc" fill="url(#resInv)" stackId="1" />}
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</Card>
|
|
);
|
|
}
|