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,154 @@
import { Card, Title, Text, Group, Badge, Tooltip } from '@mantine/core';
import { useMemo } from 'react';
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
const typeColors: Record<string, string> = {
cd: '#228be6',
money_market: '#40c057',
treasury: '#7950f2',
savings: '#fd7e14',
other: '#868e96',
};
interface Props {
investments: any[];
}
export function InvestmentTimeline({ investments }: Props) {
const { items, startDate, endDate, totalMonths } = useMemo(() => {
const now = new Date();
const items = investments
.filter((inv: any) => inv.purchase_date || inv.maturity_date)
.map((inv: any) => ({
...inv,
start: inv.purchase_date ? new Date(inv.purchase_date) : now,
end: inv.maturity_date ? new Date(inv.maturity_date) : null,
}));
if (!items.length) return { items: [], startDate: now, endDate: now, totalMonths: 1 };
const allDates = items.flatMap((i: any) => [i.start, i.end].filter(Boolean)) as Date[];
const startDate = new Date(Math.min(...allDates.map((d) => d.getTime())));
const endDate = new Date(Math.max(...allDates.map((d) => d.getTime())));
const totalMonths = Math.max(
(endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + 1,
1,
);
return { items, startDate, endDate, totalMonths };
}, [investments]);
if (!items.length) return null;
const getPercent = (date: Date) => {
const months = (date.getFullYear() - startDate.getFullYear()) * 12 + (date.getMonth() - startDate.getMonth());
return Math.max(0, Math.min(100, (months / totalMonths) * 100));
};
// Generate year labels
const yearLabels: { year: number; percent: number }[] = [];
for (let y = startDate.getFullYear(); y <= endDate.getFullYear(); y++) {
const janDate = new Date(y, 0, 1);
if (janDate >= startDate && janDate <= endDate) {
yearLabels.push({ year: y, percent: getPercent(janDate) });
}
}
return (
<Card withBorder p="lg">
<Title order={4} mb="md">Investment Timeline</Title>
{/* Year markers */}
<div style={{ position: 'relative', height: 20, marginBottom: 8 }}>
{yearLabels.map((yl) => (
<Text
key={yl.year}
size="xs"
c="dimmed"
fw={700}
style={{ position: 'absolute', left: `${yl.percent}%`, transform: 'translateX(-50%)' }}
>
{yl.year}
</Text>
))}
</div>
{/* Timeline bars */}
<div style={{ position: 'relative', minHeight: items.length * 40 + 10 }}>
{/* Background grid */}
<div style={{
position: 'absolute', inset: 0, borderLeft: '1px solid var(--mantine-color-gray-3)',
borderRight: '1px solid var(--mantine-color-gray-3)',
}}>
{yearLabels.map((yl) => (
<div
key={yl.year}
style={{
position: 'absolute', left: `${yl.percent}%`, top: 0, bottom: 0,
borderLeft: '1px dashed var(--mantine-color-gray-3)',
}}
/>
))}
</div>
{items.map((inv: any, idx: number) => {
const leftPct = getPercent(inv.start);
const rightPct = inv.end ? getPercent(inv.end) : leftPct + 2;
const widthPct = Math.max(rightPct - leftPct, 1);
const color = typeColors[inv.investment_type] || '#868e96';
return (
<Tooltip
key={inv.id}
label={
<div>
<Text size="xs" fw={600}>{inv.label}</Text>
<Text size="xs">{fmt(parseFloat(inv.principal))} @ {parseFloat(inv.interest_rate || 0).toFixed(2)}%</Text>
{inv.purchase_date && <Text size="xs">Start: {new Date(inv.purchase_date).toLocaleDateString()}</Text>}
{inv.maturity_date && <Text size="xs">Maturity: {new Date(inv.maturity_date).toLocaleDateString()}</Text>}
</div>
}
position="top"
multiline
withArrow
>
<div
style={{
position: 'absolute',
left: `${leftPct}%`,
width: `${widthPct}%`,
top: idx * 40 + 4,
height: 28,
borderRadius: 4,
background: color,
opacity: inv.executed_investment_id ? 0.5 : 0.85,
display: 'flex',
alignItems: 'center',
paddingLeft: 8,
paddingRight: 8,
cursor: 'pointer',
minWidth: 60,
}}
>
<Text size="xs" c="white" fw={600} truncate style={{ lineHeight: 1 }}>
{inv.label} {fmt(parseFloat(inv.principal))}
</Text>
</div>
</Tooltip>
);
})}
</div>
{/* Legend */}
<Group gap="md" mt="md">
{Object.entries(typeColors).map(([type, color]) => (
<Group key={type} gap={4}>
<div style={{ width: 12, height: 12, borderRadius: 2, background: color }} />
<Text size="xs" c="dimmed">{type.replace('_', ' ')}</Text>
</Group>
))}
</Group>
</Card>
);
}