feat: add Board Planning module with investment/assessment scenario modeling

Implements Phase 11 Forecasting Tools - a "what-if" scenario planning system
for HOA boards to model financial decisions before committing.

Backend:
- 3 new tenant-scoped tables: board_scenarios, scenario_investments, scenario_assessments
- Migration script (013) for existing tenants
- Full CRUD service for scenarios, investments, and assessments
- Projection engine adapted from cash flow forecast with investment/assessment deltas
- Scenario comparison endpoint (up to 4 scenarios)
- Investment execution flow: converts planned → real investment_accounts + journal entry

Frontend:
- New "Board Planning" sidebar section with 3 pages
- Investment Scenarios: list, create, detail with investments table + timeline
- Assessment Scenarios: list, create, detail with changes table
- Scenario Comparison: multi-select overlay chart + summary metrics
- Shared components: ProjectionChart, InvestmentTimeline, ScenarioCard, forms
- AI Recommendation → Investment Plan integration (Story 1A)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 09:52:10 -04:00
parent b13fbfe8c7
commit c8d77aaa48
20 changed files with 2901 additions and 1 deletions

View File

@@ -0,0 +1,124 @@
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.3} />
<stop offset="95%" stopColor="#228be6" stopOpacity={0} />
</linearGradient>
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.3} />
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0} />
</linearGradient>
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.3} />
<stop offset="95%" stopColor="#7950f2" stopOpacity={0} />
</linearGradient>
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.3} />
<stop offset="95%" stopColor="#b197fc" stopOpacity={0} />
</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>
);
}