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>
155 lines
5.3 KiB
TypeScript
155 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}
|