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:
@@ -49,6 +49,8 @@ export class BoardPlanningProjectionService {
|
|||||||
// ── 2. Build month-by-month projection ──
|
// ── 2. Build month-by-month projection ──
|
||||||
let { opCash, resCash, opInv, resInv } = baseline.openingBalances;
|
let { opCash, resCash, opInv, resInv } = baseline.openingBalances;
|
||||||
const datapoints: any[] = [];
|
const datapoints: any[] = [];
|
||||||
|
let totalInterestEarned = 0;
|
||||||
|
const interestByInvestment: Record<string, number> = {};
|
||||||
|
|
||||||
for (let i = 0; i < months; i++) {
|
for (let i = 0; i < months; i++) {
|
||||||
const year = startYear + Math.floor(i / 12);
|
const year = startYear + Math.floor(i / 12);
|
||||||
@@ -65,6 +67,10 @@ export class BoardPlanningProjectionService {
|
|||||||
|
|
||||||
// Scenario investment deltas for this month
|
// Scenario investment deltas for this month
|
||||||
const invDelta = this.computeInvestmentDelta(investments, year, month);
|
const invDelta = this.computeInvestmentDelta(investments, year, month);
|
||||||
|
totalInterestEarned += invDelta.interestEarned;
|
||||||
|
for (const [invId, amt] of Object.entries(invDelta.interestByInvestment)) {
|
||||||
|
interestByInvestment[invId] = (interestByInvestment[invId] || 0) + amt;
|
||||||
|
}
|
||||||
|
|
||||||
// Scenario assessment deltas for this month
|
// Scenario assessment deltas for this month
|
||||||
const asmtDelta = this.computeAssessmentDelta(assessments, baseline.assessmentGroups, year, month);
|
const asmtDelta = this.computeAssessmentDelta(assessments, baseline.assessmentGroups, year, month);
|
||||||
@@ -113,7 +119,7 @@ export class BoardPlanningProjectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. Summary metrics ──
|
// ── 3. Summary metrics ──
|
||||||
const summary = this.computeSummary(datapoints, baseline, assessments);
|
const summary = this.computeSummary(datapoints, baseline, assessments, investments, totalInterestEarned, interestByInvestment);
|
||||||
|
|
||||||
const result = { datapoints, summary };
|
const result = { datapoints, summary };
|
||||||
|
|
||||||
@@ -363,6 +369,8 @@ export class BoardPlanningProjectionService {
|
|||||||
let resCashFlow = 0;
|
let resCashFlow = 0;
|
||||||
let opInvChange = 0;
|
let opInvChange = 0;
|
||||||
let resInvChange = 0;
|
let resInvChange = 0;
|
||||||
|
let interestEarned = 0;
|
||||||
|
const interestByInvestment: Record<string, number> = {};
|
||||||
|
|
||||||
for (const inv of investments) {
|
for (const inv of investments) {
|
||||||
if (inv.executed_investment_id) continue; // skip already-executed investments
|
if (inv.executed_investment_id) continue; // skip already-executed investments
|
||||||
@@ -386,8 +394,11 @@ export class BoardPlanningProjectionService {
|
|||||||
if (md.getFullYear() === year && md.getMonth() + 1 === month) {
|
if (md.getFullYear() === year && md.getMonth() + 1 === month) {
|
||||||
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
|
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
|
||||||
const daysHeld = Math.max((md.getTime() - purchaseDate.getTime()) / 86400000, 1);
|
const daysHeld = Math.max((md.getTime() - purchaseDate.getTime()) / 86400000, 1);
|
||||||
const interestEarned = principal * (rate / 100) * (daysHeld / 365);
|
const invInterest = principal * (rate / 100) * (daysHeld / 365);
|
||||||
const maturityTotal = principal + interestEarned;
|
const maturityTotal = principal + invInterest;
|
||||||
|
|
||||||
|
interestEarned += invInterest;
|
||||||
|
interestByInvestment[inv.id] = (interestByInvestment[inv.id] || 0) + invInterest;
|
||||||
|
|
||||||
if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; }
|
if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; }
|
||||||
else { resCashFlow += maturityTotal; resInvChange -= principal; }
|
else { resCashFlow += maturityTotal; resInvChange -= principal; }
|
||||||
@@ -401,7 +412,7 @@ export class BoardPlanningProjectionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { opCashFlow, resCashFlow, opInvChange, resInvChange };
|
return { opCashFlow, resCashFlow, opInvChange, resInvChange, interestEarned, interestByInvestment };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute assessment income delta for a given month from scenario assessment changes. */
|
/** Compute assessment income delta for a given month from scenario assessment changes. */
|
||||||
@@ -471,7 +482,10 @@ export class BoardPlanningProjectionService {
|
|||||||
return { operating, reserve };
|
return { operating, reserve };
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeSummary(datapoints: any[], baseline: any, scenarioAssessments: any[]) {
|
private computeSummary(
|
||||||
|
datapoints: any[], baseline: any, scenarioAssessments: any[],
|
||||||
|
investments?: any[], totalInterestEarned = 0, interestByInvestment: Record<string, number> = {},
|
||||||
|
) {
|
||||||
if (!datapoints.length) return {};
|
if (!datapoints.length) return {};
|
||||||
|
|
||||||
const last = datapoints[datapoints.length - 1];
|
const last = datapoints[datapoints.length - 1];
|
||||||
@@ -496,13 +510,23 @@ export class BoardPlanningProjectionService {
|
|||||||
? (last.reserve_cash + last.reserve_investments) / avgMonthlyReserveExpenditure
|
? (last.reserve_cash + last.reserve_investments) / avgMonthlyReserveExpenditure
|
||||||
: 0; // No planned projects = show 0 (N/A)
|
: 0; // No planned projects = show 0 (N/A)
|
||||||
|
|
||||||
// Estimate total investment income from scenario investments
|
// Calculate total principal from scenario investments
|
||||||
const totalInterestEarned = datapoints.reduce((sum, d, i) => {
|
let totalPrincipal = 0;
|
||||||
if (i === 0) return 0;
|
const investmentInterestDetails: Array<{ id: string; label: string; principal: number; interest: number }> = [];
|
||||||
const prev = datapoints[i - 1];
|
if (investments) {
|
||||||
// Rough: increase in total that isn't from assessment/budget
|
for (const inv of investments) {
|
||||||
return sum;
|
if (inv.executed_investment_id) continue;
|
||||||
}, 0);
|
const principal = parseFloat(inv.principal) || 0;
|
||||||
|
totalPrincipal += principal;
|
||||||
|
const interest = interestByInvestment[inv.id] || 0;
|
||||||
|
investmentInterestDetails.push({
|
||||||
|
id: inv.id,
|
||||||
|
label: inv.label,
|
||||||
|
principal: round2(principal),
|
||||||
|
interest: round2(interest),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
end_liquidity: round2(endLiquidity),
|
end_liquidity: round2(endLiquidity),
|
||||||
@@ -513,6 +537,10 @@ export class BoardPlanningProjectionService {
|
|||||||
end_operating_investments: last.operating_investments,
|
end_operating_investments: last.operating_investments,
|
||||||
end_reserve_investments: last.reserve_investments,
|
end_reserve_investments: last.reserve_investments,
|
||||||
period_change: round2(endLiquidity - allLiquidity[0]),
|
period_change: round2(endLiquidity - allLiquidity[0]),
|
||||||
|
total_interest_earned: round2(totalInterestEarned),
|
||||||
|
total_principal_invested: round2(totalPrincipal),
|
||||||
|
roi_percentage: totalPrincipal > 0 ? round2((totalInterestEarned / totalPrincipal) * 100) : 0,
|
||||||
|
investment_interest_details: investmentInterestDetails,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,17 +107,30 @@ export class BoardPlanningService {
|
|||||||
async addInvestmentFromRecommendation(scenarioId: string, dto: any) {
|
async addInvestmentFromRecommendation(scenarioId: string, dto: any) {
|
||||||
await this.getScenarioRow(scenarioId);
|
await this.getScenarioRow(scenarioId);
|
||||||
|
|
||||||
|
// Helper: compute maturity date from purchase date + term months
|
||||||
|
const computeMaturityDate = (purchaseDate: string | null, termMonths: number | null): string | null => {
|
||||||
|
if (!purchaseDate || !termMonths) return null;
|
||||||
|
const d = new Date(purchaseDate);
|
||||||
|
d.setMonth(d.getMonth() + termMonths);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDate = dto.startDate || null; // ISO date string e.g. "2026-03-16"
|
||||||
|
|
||||||
// If the recommendation has components (e.g. CD ladder with multiple CDs), create one row per component
|
// If the recommendation has components (e.g. CD ladder with multiple CDs), create one row per component
|
||||||
const components = dto.components as any[] | undefined;
|
const components = dto.components as any[] | undefined;
|
||||||
if (components && Array.isArray(components) && components.length > 0) {
|
if (components && Array.isArray(components) && components.length > 0) {
|
||||||
const results: any[] = [];
|
const results: any[] = [];
|
||||||
for (let i = 0; i < components.length; i++) {
|
for (let i = 0; i < components.length; i++) {
|
||||||
const comp = components[i];
|
const comp = components[i];
|
||||||
|
const termMonths = comp.term_months || null;
|
||||||
|
const maturityDate = computeMaturityDate(startDate, termMonths);
|
||||||
const rows = await this.tenant.query(
|
const rows = await this.tenant.query(
|
||||||
`INSERT INTO scenario_investments
|
`INSERT INTO scenario_investments
|
||||||
(scenario_id, source_recommendation_id, label, investment_type, fund_type,
|
(scenario_id, source_recommendation_id, label, investment_type, fund_type,
|
||||||
principal, interest_rate, term_months, institution, notes, sort_order)
|
principal, interest_rate, term_months, institution, purchase_date, maturity_date,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
notes, sort_order)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
scenarioId, dto.sourceRecommendationId || null,
|
scenarioId, dto.sourceRecommendationId || null,
|
||||||
@@ -125,7 +138,8 @@ export class BoardPlanningService {
|
|||||||
comp.investment_type || dto.investmentType || null,
|
comp.investment_type || dto.investmentType || null,
|
||||||
dto.fundType || 'reserve',
|
dto.fundType || 'reserve',
|
||||||
comp.amount || 0, comp.rate || null,
|
comp.amount || 0, comp.rate || null,
|
||||||
comp.term_months || null, comp.bank_name || dto.bankName || null,
|
termMonths, comp.bank_name || dto.bankName || null,
|
||||||
|
startDate, maturityDate,
|
||||||
dto.rationale || dto.notes || null,
|
dto.rationale || dto.notes || null,
|
||||||
i,
|
i,
|
||||||
],
|
],
|
||||||
@@ -137,18 +151,21 @@ export class BoardPlanningService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Single investment (no components)
|
// Single investment (no components)
|
||||||
|
const termMonths = dto.termMonths || null;
|
||||||
|
const maturityDate = computeMaturityDate(startDate, termMonths);
|
||||||
const rows = await this.tenant.query(
|
const rows = await this.tenant.query(
|
||||||
`INSERT INTO scenario_investments
|
`INSERT INTO scenario_investments
|
||||||
(scenario_id, source_recommendation_id, label, investment_type, fund_type,
|
(scenario_id, source_recommendation_id, label, investment_type, fund_type,
|
||||||
principal, interest_rate, term_months, institution, notes)
|
principal, interest_rate, term_months, institution, purchase_date, maturity_date, notes)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
scenarioId, dto.sourceRecommendationId || null,
|
scenarioId, dto.sourceRecommendationId || null,
|
||||||
dto.title || dto.label || 'AI Recommendation',
|
dto.title || dto.label || 'AI Recommendation',
|
||||||
dto.investmentType || null, dto.fundType || 'reserve',
|
dto.investmentType || null, dto.fundType || 'reserve',
|
||||||
dto.suggestedAmount || 0, dto.suggestedRate || null,
|
dto.suggestedAmount || 0, dto.suggestedRate || null,
|
||||||
dto.termMonths || null, dto.bankName || null,
|
termMonths, dto.bankName || null,
|
||||||
|
startDate, maturityDate,
|
||||||
dto.rationale || dto.notes || null,
|
dto.rationale || dto.notes || null,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
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';
|
} from '@mantine/core';
|
||||||
import { DateInput } from '@mantine/dates';
|
import { DateInput } from '@mantine/dates';
|
||||||
import {
|
import {
|
||||||
IconPlus, IconArrowLeft, IconDots, IconTrash, IconEdit,
|
IconPlus, IconArrowLeft, IconTrash, IconEdit,
|
||||||
IconPlayerPlay, IconRefresh, IconAlertTriangle,
|
IconPlayerPlay, IconCoin, IconTrendingUp,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
@@ -17,6 +17,7 @@ import { ProjectionChart } from './components/ProjectionChart';
|
|||||||
import { InvestmentTimeline } from './components/InvestmentTimeline';
|
import { InvestmentTimeline } from './components/InvestmentTimeline';
|
||||||
|
|
||||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
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> = {
|
const statusColors: Record<string, string> = {
|
||||||
draft: 'gray', active: 'blue', approved: 'green', archived: 'red',
|
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],
|
queryKey: ['board-planning-projection', id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get(`/board-planning/scenarios/${id}/projection`);
|
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 (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||||
if (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
|
if (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
|
||||||
|
|
||||||
const investments = scenario.investments || [];
|
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 (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
@@ -139,15 +141,51 @@ export function InvestmentScenarioDetailPage() {
|
|||||||
{ value: 'approved', label: 'Approved' },
|
{ 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)}>
|
<Button size="sm" leftSection={<IconPlus size={16} />} onClick={() => setAddOpen(true)}>
|
||||||
Add Investment
|
Add Investment
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</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 */}
|
{/* Investments Table */}
|
||||||
<Card withBorder p="lg">
|
<Card withBorder p="lg">
|
||||||
<Title order={4} mb="md">Planned Investments ({investments.length})</Title>
|
<Title order={4} mb="md">Planned Investments ({investments.length})</Title>
|
||||||
@@ -160,50 +198,62 @@ export function InvestmentScenarioDetailPage() {
|
|||||||
<Table.Th>Fund</Table.Th>
|
<Table.Th>Fund</Table.Th>
|
||||||
<Table.Th ta="right">Principal</Table.Th>
|
<Table.Th ta="right">Principal</Table.Th>
|
||||||
<Table.Th ta="right">Rate</Table.Th>
|
<Table.Th ta="right">Rate</Table.Th>
|
||||||
|
<Table.Th ta="right">Est. Interest</Table.Th>
|
||||||
<Table.Th>Purchase</Table.Th>
|
<Table.Th>Purchase</Table.Th>
|
||||||
<Table.Th>Maturity</Table.Th>
|
<Table.Th>Maturity</Table.Th>
|
||||||
<Table.Th>Status</Table.Th>
|
<Table.Th>Status</Table.Th>
|
||||||
<Table.Th w={80}>Actions</Table.Th>
|
<Table.Th w={100}>Actions</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{investments.map((inv: any) => (
|
{investments.map((inv: any) => {
|
||||||
|
const detail = interestDetailMap[inv.id];
|
||||||
|
return (
|
||||||
<Table.Tr key={inv.id}>
|
<Table.Tr key={inv.id}>
|
||||||
<Table.Td fw={500}>{inv.label}</Table.Td>
|
<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" 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><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" 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">{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 ta="right" ff="monospace" c={detail?.interest ? 'green' : 'dimmed'}>
|
||||||
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}</Table.Td>
|
{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>
|
<Table.Td>
|
||||||
{inv.executed_investment_id
|
{inv.executed_investment_id
|
||||||
? <Badge size="sm" color="green">Executed</Badge>
|
? <Badge size="sm" color="green">Executed</Badge>
|
||||||
: <Badge size="sm" color="gray">Planned</Badge>}
|
: <Badge size="sm" color="gray">Planned</Badge>}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Menu withinPortal position="bottom-end" shadow="sm">
|
<Group gap={4} wrap="nowrap">
|
||||||
<Menu.Target>
|
<Tooltip label="Edit">
|
||||||
<ActionIcon variant="subtle" color="gray"><IconDots size={16} /></ActionIcon>
|
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => setEditInv(inv)}>
|
||||||
</Menu.Target>
|
<IconEdit size={16} />
|
||||||
<Menu.Dropdown>
|
</ActionIcon>
|
||||||
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => setEditInv(inv)}>Edit</Menu.Item>
|
</Tooltip>
|
||||||
{!inv.executed_investment_id && (
|
{!inv.executed_investment_id && (
|
||||||
<Menu.Item leftSection={<IconPlayerPlay size={14} />} color="green" onClick={() => { setExecuteInv(inv); setExecutionDate(new Date()); }}>
|
<Tooltip label="Execute">
|
||||||
Execute
|
<ActionIcon variant="subtle" color="green" size="sm" onClick={() => { setExecuteInv(inv); setExecutionDate(new Date()); }}>
|
||||||
</Menu.Item>
|
<IconPlayerPlay size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={() => removeMutation.mutate(inv.id)}>Remove</Menu.Item>
|
<Tooltip label="Remove">
|
||||||
</Menu.Dropdown>
|
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => removeMutation.mutate(inv.id)}>
|
||||||
</Menu>
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
) : (
|
) : (
|
||||||
<Text ta="center" c="dimmed" py="lg">
|
<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>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Title,
|
Title,
|
||||||
Text,
|
Text,
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
Progress,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconBulb,
|
IconBulb,
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
IconChevronUp,
|
IconChevronUp,
|
||||||
IconPlaylistAdd,
|
IconPlaylistAdd,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import { DateInput } from '@mantine/dates';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -381,6 +383,7 @@ export function InvestmentPlanningPage() {
|
|||||||
const [selectedRec, setSelectedRec] = useState<Recommendation | null>(null);
|
const [selectedRec, setSelectedRec] = useState<Recommendation | null>(null);
|
||||||
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
|
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
|
||||||
const [newScenarioName, setNewScenarioName] = useState('');
|
const [newScenarioName, setNewScenarioName] = useState('');
|
||||||
|
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
|
||||||
|
|
||||||
// Load investment scenarios for the "Add to Plan" modal
|
// Load investment scenarios for the "Add to Plan" modal
|
||||||
const { data: investmentScenarios } = useQuery<any[]>({
|
const { data: investmentScenarios } = useQuery<any[]>({
|
||||||
@@ -403,6 +406,7 @@ export function InvestmentPlanningPage() {
|
|||||||
bankName: rec.bank_name,
|
bankName: rec.bank_name,
|
||||||
rationale: rec.rationale,
|
rationale: rec.rationale,
|
||||||
components: rec.components || undefined,
|
components: rec.components || undefined,
|
||||||
|
startDate: investmentStartDate ? investmentStartDate.toISOString().split('T')[0] : null,
|
||||||
});
|
});
|
||||||
return scenarioId;
|
return scenarioId;
|
||||||
},
|
},
|
||||||
@@ -433,6 +437,7 @@ export function InvestmentPlanningPage() {
|
|||||||
bankName: rec.bank_name,
|
bankName: rec.bank_name,
|
||||||
rationale: rec.rationale,
|
rationale: rec.rationale,
|
||||||
components: rec.components || undefined,
|
components: rec.components || undefined,
|
||||||
|
startDate: investmentStartDate ? investmentStartDate.toISOString().split('T')[0] : null,
|
||||||
});
|
});
|
||||||
return scenario.id;
|
return scenario.id;
|
||||||
},
|
},
|
||||||
@@ -453,6 +458,7 @@ export function InvestmentPlanningPage() {
|
|||||||
setSelectedRec(rec);
|
setSelectedRec(rec);
|
||||||
setTargetScenarioId(null);
|
setTargetScenarioId(null);
|
||||||
setNewScenarioName('');
|
setNewScenarioName('');
|
||||||
|
setInvestmentStartDate(new Date());
|
||||||
setPlanModalOpen(true);
|
setPlanModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -507,20 +513,31 @@ export function InvestmentPlanningPage() {
|
|||||||
}
|
}
|
||||||
}, [savedRec?.status, isTriggering]);
|
}, [savedRec?.status, isTriggering]);
|
||||||
|
|
||||||
|
// Ref for scrolling to AI section on completion
|
||||||
|
const aiSectionRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Show notification when processing completes (transition from processing)
|
// Show notification when processing completes (transition from processing)
|
||||||
const prevStatusRef = useState<string | null>(null);
|
const prevStatusRef = useState<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const [prevStatus, setPrevStatus] = prevStatusRef;
|
const [prevStatus, setPrevStatus] = prevStatusRef;
|
||||||
if (prevStatus === 'processing' && savedRec?.status === 'complete') {
|
if (prevStatus === 'processing' && savedRec?.status === 'complete') {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
|
title: 'AI Analysis Complete',
|
||||||
message: `Generated ${savedRec.recommendations.length} investment recommendations`,
|
message: `Generated ${savedRec.recommendations.length} investment recommendations`,
|
||||||
color: 'green',
|
color: 'green',
|
||||||
|
autoClose: 8000,
|
||||||
});
|
});
|
||||||
|
// Scroll the AI section into view so user sees the new results
|
||||||
|
setTimeout(() => {
|
||||||
|
aiSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}, 300);
|
||||||
}
|
}
|
||||||
if (prevStatus === 'processing' && savedRec?.status === 'error') {
|
if (prevStatus === 'processing' && savedRec?.status === 'error') {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
|
title: 'AI Analysis Failed',
|
||||||
message: savedRec.error_message || 'AI recommendation analysis failed',
|
message: savedRec.error_message || 'AI recommendation analysis failed',
|
||||||
color: 'red',
|
color: 'red',
|
||||||
|
autoClose: 8000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setPrevStatus(savedRec?.status || null);
|
setPrevStatus(savedRec?.status || null);
|
||||||
@@ -791,7 +808,7 @@ export function InvestmentPlanningPage() {
|
|||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* ── Section 4: AI Investment Recommendations ── */}
|
{/* ── Section 4: AI Investment Recommendations ── */}
|
||||||
<Card withBorder p="lg">
|
<Card withBorder p="lg" ref={aiSectionRef}>
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<ThemeIcon variant="light" color="grape" size="md">
|
<ThemeIcon variant="light" color="grape" size="md">
|
||||||
@@ -815,19 +832,22 @@ export function InvestmentPlanningPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Processing State */}
|
{/* Processing State - shown as banner when refreshing with existing results */}
|
||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
<Center py="xl">
|
<Alert variant="light" color="grape" mb="md" styles={{ root: { overflow: 'visible' } }}>
|
||||||
<Stack align="center" gap="sm">
|
<Group gap="sm">
|
||||||
<Loader size="lg" type="dots" />
|
<Loader size="sm" color="grape" />
|
||||||
<Text c="dimmed" size="sm">
|
<div style={{ flex: 1 }}>
|
||||||
Analyzing your financial data and market rates...
|
<Text size="sm" fw={500}>
|
||||||
|
{aiResult ? 'Refreshing AI analysis...' : 'Running AI analysis...'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text c="dimmed" size="xs">
|
<Text size="xs" c="dimmed">
|
||||||
You can navigate away — results will appear when ready
|
Analyzing your financial data, accounts, budgets, and current market rates
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</div>
|
||||||
</Center>
|
</Group>
|
||||||
|
<Progress value={100} animated color="grape" size="xs" mt="xs" />
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error State (no cached data) */}
|
{/* Error State (no cached data) */}
|
||||||
@@ -839,17 +859,19 @@ export function InvestmentPlanningPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results (with optional failure watermark) */}
|
{/* Results - keep visible even while refreshing (with optional failure watermark) */}
|
||||||
{aiResult && !isProcessing && (
|
{aiResult && (
|
||||||
|
<div style={isProcessing ? { opacity: 0.5, pointerEvents: 'none' } : undefined}>
|
||||||
<RecommendationsDisplay
|
<RecommendationsDisplay
|
||||||
aiResult={aiResult}
|
aiResult={aiResult}
|
||||||
lastUpdated={savedRec?.created_at || undefined}
|
lastUpdated={savedRec?.created_at || undefined}
|
||||||
lastFailed={lastFailed}
|
lastFailed={lastFailed}
|
||||||
onAddToPlan={handleAddToPlan}
|
onAddToPlan={handleAddToPlan}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State - only when no results and not processing */}
|
||||||
{!aiResult && !isProcessing && !hasError && (
|
{!aiResult && !isProcessing && !hasError && (
|
||||||
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
|
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
|
||||||
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
|
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
|
||||||
@@ -890,6 +912,14 @@ export function InvestmentPlanningPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DateInput
|
||||||
|
label="Start Date"
|
||||||
|
description="Purchase date for the investment(s). Maturity dates are calculated automatically from term length."
|
||||||
|
value={investmentStartDate}
|
||||||
|
onChange={setInvestmentStartDate}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
|
||||||
{investmentScenarios && investmentScenarios.length > 0 && (
|
{investmentScenarios && investmentScenarios.length > 0 && (
|
||||||
<Select
|
<Select
|
||||||
label="Add to existing scenario"
|
label="Add to existing scenario"
|
||||||
|
|||||||
Reference in New Issue
Block a user