feat: investment chart alignment, auto-renew records, fund transfers, capital planning report, and upcoming activities (v2026.3.24)

- 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>
This commit is contained in:
2026-03-24 14:41:02 -04:00
parent ae856bfb2f
commit 2b331bb3ef
15 changed files with 801 additions and 21 deletions

View File

@@ -15,6 +15,8 @@ import {
IconHeartbeat,
IconRefresh,
IconInfoCircle,
IconCoin,
IconCalendarEvent,
} from '@tabler/icons-react';
import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
@@ -362,6 +364,16 @@ export function DashboardPage() {
enabled: !!currentOrg,
});
const { data: investmentActivities } = useQuery<{
maturing_investments: any[];
upcoming_scenario_investments: any[];
total_activities: number;
}>({
queryKey: ['upcoming-investment-activities'],
queryFn: async () => { const { data } = await api.get('/reports/upcoming-investment-activities'); return data; },
enabled: !!currentOrg,
});
const { data: healthScores } = useQuery<HealthScoresData>({
queryKey: ['health-scores'],
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
@@ -531,6 +543,97 @@ export function DashboardPage() {
</Card>
</SimpleGrid>
{/* Upcoming Investment Activities */}
{(investmentActivities?.total_activities || 0) > 0 && (
<Card withBorder padding="lg" radius="md">
<Group justify="space-between" mb="sm">
<Group gap="xs">
<ThemeIcon color="teal" variant="light" size={28} radius="md">
<IconCalendarEvent size={16} />
</ThemeIcon>
<Title order={4}>Upcoming Investment Activities</Title>
</Group>
<Badge variant="light" color="teal">{investmentActivities?.total_activities} upcoming</Badge>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Activity</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th>Timeline</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(investmentActivities?.maturing_investments || []).map((inv: any) => (
<Table.Tr key={`mat-${inv.id}`}>
<Table.Td>
<Group gap={6}>
<IconCoin size={14} color="var(--mantine-color-orange-6)" />
<Text size="sm" fw={500}>{inv.name}</Text>
</Group>
{inv.institution && <Text size="xs" c="dimmed">{inv.institution}</Text>}
</Table.Td>
<Table.Td>
<Badge size="xs" color="orange" variant="light">Maturing</Badge>
</Table.Td>
<Table.Td>
<Badge size="xs" color={inv.fund_type === 'reserve' ? 'violet' : 'blue'} variant="light">
{inv.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">
<Text size="sm" fw={500}>{fmt(inv.maturity_value)}</Text>
<Text size="xs" c="green">+{fmt(inv.interest_earned)} interest</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{new Date(inv.maturity_date).toLocaleDateString()}</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" color={inv.days_remaining <= 14 ? 'red' : inv.days_remaining <= 30 ? 'yellow' : 'gray'} variant="light">
{inv.days_remaining} days
</Badge>
</Table.Td>
</Table.Tr>
))}
{(investmentActivities?.upcoming_scenario_investments || []).map((si: any) => (
<Table.Tr key={`plan-${si.id}`}>
<Table.Td>
<Group gap={6}>
<IconTrendingUp size={14} color="var(--mantine-color-blue-6)" />
<Text size="sm" fw={500}>{si.label}</Text>
</Group>
<Text size="xs" c="dimmed">Scenario: {si.scenario_name}</Text>
</Table.Td>
<Table.Td>
<Badge size="xs" color="blue" variant="light">Planned Purchase</Badge>
</Table.Td>
<Table.Td>
<Badge size="xs" color={si.fund_type === 'reserve' ? 'violet' : 'blue'} variant="light">
{si.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">
<Text size="sm" fw={500}>{fmt(si.principal)}</Text>
{si.interest_rate && <Text size="xs" c="dimmed">{parseFloat(si.interest_rate).toFixed(2)}% APY</Text>}
</Table.Td>
<Table.Td>
<Text size="sm">{new Date(si.purchase_date).toLocaleDateString()}</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" color={si.days_until <= 14 ? 'red' : si.days_until <= 30 ? 'yellow' : 'gray'} variant="light">
{si.days_until} days
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
<SimpleGrid cols={{ base: 1, md: 2 }}>
<Card withBorder padding="lg" radius="md">
<Title order={4}>Quick Stats</Title>