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>
211 lines
8.0 KiB
TypeScript
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>
|
|
);
|
|
}
|