Files
HOA_Financial_Platform/frontend/src/pages/board-planning/ScenarioComparisonPage.tsx
olsch01 c8d77aaa48 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>
2026-03-16 09:52:10 -04:00

211 lines
8.0 KiB
TypeScript

import { useState } from 'react';
import {
Title, Text, Stack, Group, Card, MultiSelect, Loader, Center, Badge,
SimpleGrid, Table,
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
ResponsiveContainer,
} from 'recharts';
import api from '../../services/api';
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
const COLORS = ['#228be6', '#40c057', '#7950f2', '#fd7e14'];
export function ScenarioComparisonPage() {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Load all scenarios for the selector
const { data: allScenarios } = useQuery<any[]>({
queryKey: ['board-planning-scenarios-all'],
queryFn: async () => {
const { data } = await api.get('/board-planning/scenarios');
return data;
},
});
// Load comparison data when scenarios are selected
const { data: comparison, isLoading: compLoading } = useQuery({
queryKey: ['board-planning-compare', selectedIds],
queryFn: async () => {
const { data } = await api.get(`/board-planning/compare?ids=${selectedIds.join(',')}`);
return data;
},
enabled: selectedIds.length >= 1,
});
const selectorData = (allScenarios || []).map((s) => ({
value: s.id,
label: `${s.name} (${s.scenario_type})`,
}));
// Build merged chart data with all scenarios
const chartData = (() => {
if (!comparison?.scenarios?.length) return [];
const firstScenario = comparison.scenarios[0];
if (!firstScenario?.projection?.datapoints) return [];
return firstScenario.projection.datapoints.map((_: any, idx: number) => {
const point: any = { month: firstScenario.projection.datapoints[idx].month };
comparison.scenarios.forEach((s: any, sIdx: number) => {
const dp = s.projection?.datapoints?.[idx];
if (dp) {
point[`total_${sIdx}`] =
dp.operating_cash + dp.operating_investments + dp.reserve_cash + dp.reserve_investments;
}
});
return point;
});
})();
return (
<Stack>
<div>
<Title order={2}>Compare Scenarios</Title>
<Text c="dimmed" size="sm">
Select up to 4 scenarios to compare their projected financial impact side-by-side
</Text>
</div>
<MultiSelect
label="Select Scenarios"
placeholder="Choose scenarios to compare..."
data={selectorData}
value={selectedIds}
onChange={setSelectedIds}
maxValues={4}
searchable
/>
{compLoading && <Center py="xl"><Loader size="lg" /></Center>}
{comparison?.scenarios?.length > 0 && (
<>
{/* Overlaid Line Chart */}
<Card withBorder p="lg">
<Title order={4} mb="md">Total Liquidity Projection</Title>
<ResponsiveContainer width="100%" height={400}>
<LineChart data={chartData}>
<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
formatter={(v: number) => fmt(v)}
labelStyle={{ fontWeight: 600 }}
/>
<Legend />
{comparison.scenarios.map((s: any, idx: number) => (
<Line
key={s.id}
type="monotone"
dataKey={`total_${idx}`}
name={s.name}
stroke={COLORS[idx]}
strokeWidth={2}
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</Card>
{/* Summary Metrics Comparison */}
<Card withBorder p="lg">
<Title order={4} mb="md">Summary Comparison</Title>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Metric</Table.Th>
{comparison.scenarios.map((s: any, idx: number) => (
<Table.Th key={s.id} ta="right">
<Group gap={4} justify="flex-end">
<div style={{ width: 10, height: 10, borderRadius: 2, background: COLORS[idx] }} />
<Text size="sm" fw={600}>{s.name}</Text>
</Group>
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Table.Tr>
<Table.Td fw={500}>End Liquidity</Table.Td>
{comparison.scenarios.map((s: any) => (
<Table.Td key={s.id} ta="right" ff="monospace" fw={600}>
{fmt(s.projection?.summary?.end_liquidity || 0)}
</Table.Td>
))}
</Table.Tr>
<Table.Tr>
<Table.Td fw={500}>Minimum Liquidity</Table.Td>
{comparison.scenarios.map((s: any) => (
<Table.Td key={s.id} ta="right" ff="monospace" fw={600}
c={(s.projection?.summary?.min_liquidity || 0) < 0 ? 'red' : undefined}
>
{fmt(s.projection?.summary?.min_liquidity || 0)}
</Table.Td>
))}
</Table.Tr>
<Table.Tr>
<Table.Td fw={500}>Period Change</Table.Td>
{comparison.scenarios.map((s: any) => {
const change = s.projection?.summary?.period_change || 0;
return (
<Table.Td key={s.id} ta="right" ff="monospace" fw={600} c={change >= 0 ? 'green' : 'red'}>
{change >= 0 ? '+' : ''}{fmt(change)}
</Table.Td>
);
})}
</Table.Tr>
<Table.Tr>
<Table.Td fw={500}>Reserve Coverage</Table.Td>
{comparison.scenarios.map((s: any) => (
<Table.Td key={s.id} ta="right" ff="monospace" fw={600}>
{(s.projection?.summary?.reserve_coverage_months || 0).toFixed(1)} months
</Table.Td>
))}
</Table.Tr>
<Table.Tr>
<Table.Td fw={500}>End Operating Cash</Table.Td>
{comparison.scenarios.map((s: any) => (
<Table.Td key={s.id} ta="right" ff="monospace">
{fmt(s.projection?.summary?.end_operating_cash || 0)}
</Table.Td>
))}
</Table.Tr>
<Table.Tr>
<Table.Td fw={500}>End Reserve Cash</Table.Td>
{comparison.scenarios.map((s: any) => (
<Table.Td key={s.id} ta="right" ff="monospace">
{fmt(s.projection?.summary?.end_reserve_cash || 0)}
</Table.Td>
))}
</Table.Tr>
</Table.Tbody>
</Table>
</Card>
{/* Risk Flags */}
{comparison.scenarios.some((s: any) => (s.projection?.summary?.min_liquidity || 0) < 0) && (
<Card withBorder p="lg" bg="red.0">
<Title order={4} c="red" mb="sm">Liquidity Warnings</Title>
{comparison.scenarios.filter((s: any) => (s.projection?.summary?.min_liquidity || 0) < 0).map((s: any) => (
<Text key={s.id} size="sm" c="red">
{s.name}: projected negative liquidity of {fmt(s.projection.summary.min_liquidity)}
</Text>
))}
</Card>
)}
</>
)}
{selectedIds.length === 0 && (
<Center py="xl">
<Text c="dimmed">Select one or more scenarios above to compare their financial projections</Text>
</Center>
)}
</Stack>
);
}