- Lock InvestmentTimeline and ProjectionChart to shared X axis range - Auto-create renewal scenario_investments records when auto_renew is true - Add fund transfer mechanism between asset accounts with journal entries - Add Capital Planning Report (5-year forecast grouped by category) - Add Upcoming Investment Activities dashboard card (maturities + planned purchases) - Bump version to 2026.3.24 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
166 lines
5.8 KiB
TypeScript
166 lines
5.8 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[];
|
|
/** Optional shared time range to align with ProjectionChart */
|
|
sharedStartDate?: Date;
|
|
sharedEndDate?: Date;
|
|
}
|
|
|
|
export function InvestmentTimeline({ investments, sharedStartDate, sharedEndDate }: 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 };
|
|
|
|
// Use shared range if provided (to align with ProjectionChart), otherwise compute from investments
|
|
let startDate: Date;
|
|
let endDate: Date;
|
|
if (sharedStartDate && sharedEndDate) {
|
|
startDate = sharedStartDate;
|
|
endDate = sharedEndDate;
|
|
} else {
|
|
const allDates = items.flatMap((i: any) => [i.start, i.end].filter(Boolean)) as Date[];
|
|
startDate = new Date(Math.min(...allDates.map((d) => d.getTime())));
|
|
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, sharedStartDate, sharedEndDate]);
|
|
|
|
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>
|
|
);
|
|
}
|