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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user