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:
210
frontend/src/pages/board-planning/ScenarioComparisonPage.tsx
Normal file
210
frontend/src/pages/board-planning/ScenarioComparisonPage.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user