feat: investment scenario UX improvements and interest calculations
- Refresh Recommendations now shows inline processing banner with animated progress bar while keeping existing results visible (dimmed). Auto-scrolls to AI section and shows titled notification on completion. - Investment recommendations now auto-calculate purchase and maturity dates from a configurable start date (defaults to today) in the "Add to Plan" modal, so scenarios build projections immediately. - Projection engine computes per-investment and total interest earned, ROI percentage, and total principal invested. Summary cards on the Investment Scenario detail page display these metrics prominently. - Replaced dropdown action menu with inline Edit/Execute/Remove icon buttons matching the assessment scenarios pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
||||
Loader, Center, Menu, Select, Modal, TextInput, Alert,
|
||||
Loader, Center, Select, Modal, TextInput, Alert, SimpleGrid, Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import {
|
||||
IconPlus, IconArrowLeft, IconDots, IconTrash, IconEdit,
|
||||
IconPlayerPlay, IconRefresh, IconAlertTriangle,
|
||||
IconPlus, IconArrowLeft, IconTrash, IconEdit,
|
||||
IconPlayerPlay, IconCoin, IconTrendingUp,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
@@ -17,6 +17,7 @@ import { ProjectionChart } from './components/ProjectionChart';
|
||||
import { InvestmentTimeline } from './components/InvestmentTimeline';
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
const fmtDec = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: 'gray', active: 'blue', approved: 'green', archived: 'red',
|
||||
@@ -39,7 +40,7 @@ export function InvestmentScenarioDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: projection, isLoading: projLoading, refetch: refetchProjection } = useQuery({
|
||||
const { data: projection, isLoading: projLoading } = useQuery({
|
||||
queryKey: ['board-planning-projection', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/board-planning/scenarios/${id}/projection`);
|
||||
@@ -99,18 +100,19 @@ export function InvestmentScenarioDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const refreshProjection = useMutation({
|
||||
mutationFn: () => api.post(`/board-planning/scenarios/${id}/projection/refresh`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||
notifications.show({ message: 'Projection refreshed', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||
if (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
|
||||
|
||||
const investments = scenario.investments || [];
|
||||
const summary = projection?.summary;
|
||||
|
||||
// Build a lookup of per-investment interest from the projection
|
||||
const interestDetailMap: Record<string, { interest: number; principal: number }> = {};
|
||||
if (summary?.investment_interest_details) {
|
||||
for (const d of summary.investment_interest_details) {
|
||||
interestDetailMap[d.id] = { interest: d.interest, principal: d.principal };
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
@@ -139,15 +141,51 @@ export function InvestmentScenarioDetailPage() {
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
]}
|
||||
/>
|
||||
<Button size="sm" variant="light" leftSection={<IconRefresh size={16} />} onClick={() => refreshProjection.mutate()} loading={refreshProjection.isPending}>
|
||||
Refresh Projection
|
||||
</Button>
|
||||
<Button size="sm" leftSection={<IconPlus size={16} />} onClick={() => setAddOpen(true)}>
|
||||
Add Investment
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Principal</Text>
|
||||
<Text fw={700} size="xl" ff="monospace">{fmt(summary.total_principal_invested || 0)}</Text>
|
||||
<Text size="xs" c="dimmed">{investments.filter((i: any) => !i.executed_investment_id).length} planned investments</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Projected Interest Earned</Text>
|
||||
<Text fw={700} size="xl" ff="monospace" c="green">
|
||||
{summary.total_interest_earned > 0 ? `+${fmtDec(summary.total_interest_earned)}` : '$0.00'}
|
||||
</Text>
|
||||
{summary.total_interest_earned > 0 && (
|
||||
<Text size="xs" c="dimmed">Over projection period</Text>
|
||||
)}
|
||||
{summary.total_interest_earned === 0 && investments.length > 0 && (
|
||||
<Text size="xs" c="orange">Set purchase & maturity dates to calculate</Text>
|
||||
)}
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Return on Investment</Text>
|
||||
<Text fw={700} size="xl" ff="monospace" c={summary.roi_percentage > 0 ? 'green' : undefined}>
|
||||
{summary.roi_percentage > 0 ? `${summary.roi_percentage.toFixed(2)}%` : '-'}
|
||||
</Text>
|
||||
{summary.roi_percentage > 0 && (
|
||||
<Text size="xs" c="dimmed">Interest / Principal</Text>
|
||||
)}
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>End Liquidity</Text>
|
||||
<Text fw={700} size="xl" ff="monospace">{fmt(summary.end_liquidity || 0)}</Text>
|
||||
<Text size="xs" c={summary.period_change >= 0 ? 'green' : 'red'}>
|
||||
{summary.period_change >= 0 ? '+' : ''}{fmt(summary.period_change || 0)} over period
|
||||
</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* Investments Table */}
|
||||
<Card withBorder p="lg">
|
||||
<Title order={4} mb="md">Planned Investments ({investments.length})</Title>
|
||||
@@ -160,50 +198,62 @@ export function InvestmentScenarioDetailPage() {
|
||||
<Table.Th>Fund</Table.Th>
|
||||
<Table.Th ta="right">Principal</Table.Th>
|
||||
<Table.Th ta="right">Rate</Table.Th>
|
||||
<Table.Th ta="right">Est. Interest</Table.Th>
|
||||
<Table.Th>Purchase</Table.Th>
|
||||
<Table.Th>Maturity</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th w={80}>Actions</Table.Th>
|
||||
<Table.Th w={100}>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{investments.map((inv: any) => (
|
||||
<Table.Tr key={inv.id}>
|
||||
<Table.Td fw={500}>{inv.label}</Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="light">{inv.investment_type || '-'}</Badge></Table.Td>
|
||||
<Table.Td><Badge size="sm" color={inv.fund_type === 'reserve' ? 'violet' : 'blue'}>{inv.fund_type}</Badge></Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(parseFloat(inv.principal))}</Table.Td>
|
||||
<Table.Td ta="right">{inv.interest_rate ? `${parseFloat(inv.interest_rate).toFixed(2)}%` : '-'}</Table.Td>
|
||||
<Table.Td>{inv.purchase_date ? new Date(inv.purchase_date).toLocaleDateString() : '-'}</Table.Td>
|
||||
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
{inv.executed_investment_id
|
||||
? <Badge size="sm" color="green">Executed</Badge>
|
||||
: <Badge size="sm" color="gray">Planned</Badge>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Menu withinPortal position="bottom-end" shadow="sm">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray"><IconDots size={16} /></ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => setEditInv(inv)}>Edit</Menu.Item>
|
||||
{investments.map((inv: any) => {
|
||||
const detail = interestDetailMap[inv.id];
|
||||
return (
|
||||
<Table.Tr key={inv.id}>
|
||||
<Table.Td fw={500}>{inv.label}</Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="light">{inv.investment_type || '-'}</Badge></Table.Td>
|
||||
<Table.Td><Badge size="sm" color={inv.fund_type === 'reserve' ? 'violet' : 'blue'}>{inv.fund_type}</Badge></Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(parseFloat(inv.principal))}</Table.Td>
|
||||
<Table.Td ta="right">{inv.interest_rate ? `${parseFloat(inv.interest_rate).toFixed(2)}%` : '-'}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c={detail?.interest ? 'green' : 'dimmed'}>
|
||||
{detail?.interest ? `+${fmtDec(detail.interest)}` : '-'}
|
||||
</Table.Td>
|
||||
<Table.Td>{inv.purchase_date ? new Date(inv.purchase_date).toLocaleDateString() : <Text size="sm" c="orange">-</Text>}</Table.Td>
|
||||
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : <Text size="sm" c="orange">-</Text>}</Table.Td>
|
||||
<Table.Td>
|
||||
{inv.executed_investment_id
|
||||
? <Badge size="sm" color="green">Executed</Badge>
|
||||
: <Badge size="sm" color="gray">Planned</Badge>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip label="Edit">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => setEditInv(inv)}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{!inv.executed_investment_id && (
|
||||
<Menu.Item leftSection={<IconPlayerPlay size={14} />} color="green" onClick={() => { setExecuteInv(inv); setExecutionDate(new Date()); }}>
|
||||
Execute
|
||||
</Menu.Item>
|
||||
<Tooltip label="Execute">
|
||||
<ActionIcon variant="subtle" color="green" size="sm" onClick={() => { setExecuteInv(inv); setExecutionDate(new Date()); }}>
|
||||
<IconPlayerPlay size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={() => removeMutation.mutate(inv.id)}>Remove</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
<Tooltip label="Remove">
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => removeMutation.mutate(inv.id)}>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No investments added yet. Click "Add Investment" to model an investment allocation.
|
||||
No investments added yet. Click "Add Investment" to model an investment allocation.
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user