Files
HOA_Financial_Platform/frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx
olsch01 159c59734e 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>
2026-03-16 16:18:40 -04:00

331 lines
14 KiB
TypeScript

import { useState } from 'react';
import {
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
Loader, Center, Select, Modal, TextInput, Alert, SimpleGrid, Tooltip,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import {
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';
import { notifications } from '@mantine/notifications';
import api from '../../services/api';
import { InvestmentForm } from './components/InvestmentForm';
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',
};
export function InvestmentScenarioDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [addOpen, setAddOpen] = useState(false);
const [editInv, setEditInv] = useState<any>(null);
const [executeInv, setExecuteInv] = useState<any>(null);
const [executionDate, setExecutionDate] = useState<Date | null>(new Date());
const { data: scenario, isLoading } = useQuery({
queryKey: ['board-planning-scenario', id],
queryFn: async () => {
const { data } = await api.get(`/board-planning/scenarios/${id}`);
return data;
},
});
const { data: projection, isLoading: projLoading } = useQuery({
queryKey: ['board-planning-projection', id],
queryFn: async () => {
const { data } = await api.get(`/board-planning/scenarios/${id}/projection`);
return data;
},
enabled: !!id,
});
const addMutation = useMutation({
mutationFn: (dto: any) => api.post(`/board-planning/scenarios/${id}/investments`, dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
setAddOpen(false);
notifications.show({ message: 'Investment added', color: 'green' });
},
});
const updateMutation = useMutation({
mutationFn: ({ invId, ...dto }: any) => api.put(`/board-planning/investments/${invId}`, dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
setEditInv(null);
notifications.show({ message: 'Investment updated', color: 'green' });
},
});
const removeMutation = useMutation({
mutationFn: (invId: string) => api.delete(`/board-planning/investments/${invId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
notifications.show({ message: 'Investment removed', color: 'orange' });
},
});
const executeMutation = useMutation({
mutationFn: ({ invId, executionDate }: { invId: string; executionDate: string }) =>
api.post(`/board-planning/investments/${invId}/execute`, { executionDate }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
setExecuteInv(null);
notifications.show({ message: 'Investment executed and recorded', color: 'green' });
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Execution failed', color: 'red' });
},
});
const statusMutation = useMutation({
mutationFn: (status: string) => api.put(`/board-planning/scenarios/${id}`, { status }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
},
});
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>
{/* Header */}
<Group justify="space-between" align="flex-start">
<Group>
<ActionIcon variant="subtle" onClick={() => navigate('/board-planning/investments')}>
<IconArrowLeft size={20} />
</ActionIcon>
<div>
<Group gap="xs">
<Title order={2}>{scenario.name}</Title>
<Badge color={statusColors[scenario.status]}>{scenario.status}</Badge>
</Group>
{scenario.description && <Text c="dimmed" size="sm">{scenario.description}</Text>}
</div>
</Group>
<Group>
<Select
size="xs"
value={scenario.status}
onChange={(v) => v && statusMutation.mutate(v)}
data={[
{ value: 'draft', label: 'Draft' },
{ value: 'active', label: 'Active' },
{ value: 'approved', label: 'Approved' },
]}
/>
<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>
{investments.length > 0 ? (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Label</Table.Th>
<Table.Th>Type</Table.Th>
<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={100}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{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 && (
<Tooltip label="Execute">
<ActionIcon variant="subtle" color="green" size="sm" onClick={() => { setExecuteInv(inv); setExecutionDate(new Date()); }}>
<IconPlayerPlay size={16} />
</ActionIcon>
</Tooltip>
)}
<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 &quot;Add Investment&quot; to model an investment allocation.
</Text>
)}
</Card>
{/* Investment Timeline */}
{investments.length > 0 && <InvestmentTimeline investments={investments} />}
{/* Projection Chart */}
{projection && (
<ProjectionChart
datapoints={projection.datapoints || []}
title="Scenario Projection"
summary={projection.summary}
/>
)}
{projLoading && <Center py="xl"><Loader /></Center>}
{/* Add/Edit Investment Modal */}
<InvestmentForm
opened={addOpen || !!editInv}
onClose={() => { setAddOpen(false); setEditInv(null); }}
onSubmit={(data) => {
if (editInv) {
updateMutation.mutate({ invId: editInv.id, ...data });
} else {
addMutation.mutate(data);
}
}}
initialData={editInv}
loading={addMutation.isPending || updateMutation.isPending}
/>
{/* Execute Confirmation Modal */}
<Modal opened={!!executeInv} onClose={() => setExecuteInv(null)} title="Execute Investment">
<Stack>
<Alert color="blue" variant="light">
This will create a real investment account record and post a journal entry transferring funds.
</Alert>
{executeInv && (
<>
<Text size="sm"><strong>Investment:</strong> {executeInv.label}</Text>
<Text size="sm"><strong>Amount:</strong> {fmt(parseFloat(executeInv.principal))}</Text>
<DateInput
label="Execution Date"
required
value={executionDate}
onChange={setExecutionDate}
description="The date the investment is actually purchased"
/>
</>
)}
<Group justify="flex-end">
<Button variant="default" onClick={() => setExecuteInv(null)}>Cancel</Button>
<Button
color="green"
leftSection={<IconPlayerPlay size={16} />}
onClick={() => {
if (executeInv && executionDate) {
executeMutation.mutate({
invId: executeInv.id,
executionDate: executionDate.toISOString().split('T')[0],
});
}
}}
loading={executeMutation.isPending}
>
Execute Investment
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}