From 159c59734e2c2e1bec7b1cc3f223b7f8bda06239 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Mon, 16 Mar 2026 16:18:40 -0400 Subject: [PATCH] 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 --- .../board-planning-projection.service.ts | 52 +++++-- .../board-planning/board-planning.service.ts | 29 +++- .../InvestmentScenarioDetailPage.tsx | 144 ++++++++++++------ .../InvestmentPlanningPage.tsx | 76 ++++++--- 4 files changed, 213 insertions(+), 88 deletions(-) diff --git a/backend/src/modules/board-planning/board-planning-projection.service.ts b/backend/src/modules/board-planning/board-planning-projection.service.ts index 521e884..ebd63ac 100644 --- a/backend/src/modules/board-planning/board-planning-projection.service.ts +++ b/backend/src/modules/board-planning/board-planning-projection.service.ts @@ -49,6 +49,8 @@ export class BoardPlanningProjectionService { // ── 2. Build month-by-month projection ── let { opCash, resCash, opInv, resInv } = baseline.openingBalances; const datapoints: any[] = []; + let totalInterestEarned = 0; + const interestByInvestment: Record = {}; for (let i = 0; i < months; i++) { const year = startYear + Math.floor(i / 12); @@ -65,6 +67,10 @@ export class BoardPlanningProjectionService { // Scenario investment deltas for this 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 const asmtDelta = this.computeAssessmentDelta(assessments, baseline.assessmentGroups, year, month); @@ -113,7 +119,7 @@ export class BoardPlanningProjectionService { } // ── 3. Summary metrics ── - const summary = this.computeSummary(datapoints, baseline, assessments); + const summary = this.computeSummary(datapoints, baseline, assessments, investments, totalInterestEarned, interestByInvestment); const result = { datapoints, summary }; @@ -363,6 +369,8 @@ export class BoardPlanningProjectionService { let resCashFlow = 0; let opInvChange = 0; let resInvChange = 0; + let interestEarned = 0; + const interestByInvestment: Record = {}; for (const inv of 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) { const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date(); const daysHeld = Math.max((md.getTime() - purchaseDate.getTime()) / 86400000, 1); - const interestEarned = principal * (rate / 100) * (daysHeld / 365); - const maturityTotal = principal + interestEarned; + const invInterest = principal * (rate / 100) * (daysHeld / 365); + const maturityTotal = principal + invInterest; + + interestEarned += invInterest; + interestByInvestment[inv.id] = (interestByInvestment[inv.id] || 0) + invInterest; if (isOp) { opCashFlow += maturityTotal; opInvChange -= 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. */ @@ -471,7 +482,10 @@ export class BoardPlanningProjectionService { 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 = {}, + ) { if (!datapoints.length) return {}; const last = datapoints[datapoints.length - 1]; @@ -496,13 +510,23 @@ export class BoardPlanningProjectionService { ? (last.reserve_cash + last.reserve_investments) / avgMonthlyReserveExpenditure : 0; // No planned projects = show 0 (N/A) - // Estimate total investment income from scenario investments - const totalInterestEarned = datapoints.reduce((sum, d, i) => { - if (i === 0) return 0; - const prev = datapoints[i - 1]; - // Rough: increase in total that isn't from assessment/budget - return sum; - }, 0); + // Calculate total principal from scenario investments + let totalPrincipal = 0; + const investmentInterestDetails: Array<{ id: string; label: string; principal: number; interest: number }> = []; + if (investments) { + for (const inv of investments) { + if (inv.executed_investment_id) continue; + 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 { end_liquidity: round2(endLiquidity), @@ -513,6 +537,10 @@ export class BoardPlanningProjectionService { end_operating_investments: last.operating_investments, end_reserve_investments: last.reserve_investments, 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, }; } } diff --git a/backend/src/modules/board-planning/board-planning.service.ts b/backend/src/modules/board-planning/board-planning.service.ts index a0c5c19..efb65b1 100644 --- a/backend/src/modules/board-planning/board-planning.service.ts +++ b/backend/src/modules/board-planning/board-planning.service.ts @@ -107,17 +107,30 @@ export class BoardPlanningService { async addInvestmentFromRecommendation(scenarioId: string, dto: any) { 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 const components = dto.components as any[] | undefined; if (components && Array.isArray(components) && components.length > 0) { const results: any[] = []; for (let i = 0; i < components.length; i++) { const comp = components[i]; + const termMonths = comp.term_months || null; + const maturityDate = computeMaturityDate(startDate, termMonths); const rows = await this.tenant.query( `INSERT INTO scenario_investments (scenario_id, source_recommendation_id, label, investment_type, fund_type, - principal, interest_rate, term_months, institution, notes, sort_order) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + principal, interest_rate, term_months, institution, purchase_date, maturity_date, + notes, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *`, [ scenarioId, dto.sourceRecommendationId || null, @@ -125,7 +138,8 @@ export class BoardPlanningService { comp.investment_type || dto.investmentType || null, dto.fundType || 'reserve', 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, i, ], @@ -137,18 +151,21 @@ export class BoardPlanningService { } // Single investment (no components) + const termMonths = dto.termMonths || null; + const maturityDate = computeMaturityDate(startDate, termMonths); const rows = await this.tenant.query( `INSERT INTO scenario_investments (scenario_id, source_recommendation_id, label, investment_type, fund_type, - principal, interest_rate, term_months, institution, notes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + principal, interest_rate, term_months, institution, purchase_date, maturity_date, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`, [ scenarioId, dto.sourceRecommendationId || null, dto.title || dto.label || 'AI Recommendation', dto.investmentType || null, dto.fundType || 'reserve', dto.suggestedAmount || 0, dto.suggestedRate || null, - dto.termMonths || null, dto.bankName || null, + termMonths, dto.bankName || null, + startDate, maturityDate, dto.rationale || dto.notes || null, ], ); diff --git a/frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx b/frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx index 7625ed9..83e487f 100644 --- a/frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx +++ b/frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx @@ -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 = { 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
; if (!scenario) return
Scenario not found
; const investments = scenario.investments || []; + const summary = projection?.summary; + + // Build a lookup of per-investment interest from the projection + const interestDetailMap: Record = {}; + if (summary?.investment_interest_details) { + for (const d of summary.investment_interest_details) { + interestDetailMap[d.id] = { interest: d.interest, principal: d.principal }; + } + } return ( @@ -139,15 +141,51 @@ export function InvestmentScenarioDetailPage() { { value: 'approved', label: 'Approved' }, ]} /> - + {/* Summary Cards */} + {summary && ( + + + Total Principal + {fmt(summary.total_principal_invested || 0)} + {investments.filter((i: any) => !i.executed_investment_id).length} planned investments + + + Projected Interest Earned + + {summary.total_interest_earned > 0 ? `+${fmtDec(summary.total_interest_earned)}` : '$0.00'} + + {summary.total_interest_earned > 0 && ( + Over projection period + )} + {summary.total_interest_earned === 0 && investments.length > 0 && ( + Set purchase & maturity dates to calculate + )} + + + Return on Investment + 0 ? 'green' : undefined}> + {summary.roi_percentage > 0 ? `${summary.roi_percentage.toFixed(2)}%` : '-'} + + {summary.roi_percentage > 0 && ( + Interest / Principal + )} + + + End Liquidity + {fmt(summary.end_liquidity || 0)} + = 0 ? 'green' : 'red'}> + {summary.period_change >= 0 ? '+' : ''}{fmt(summary.period_change || 0)} over period + + + + )} + {/* Investments Table */} Planned Investments ({investments.length}) @@ -160,50 +198,62 @@ export function InvestmentScenarioDetailPage() { Fund Principal Rate + Est. Interest Purchase Maturity Status - Actions + Actions - {investments.map((inv: any) => ( - - {inv.label} - {inv.investment_type || '-'} - {inv.fund_type} - {fmt(parseFloat(inv.principal))} - {inv.interest_rate ? `${parseFloat(inv.interest_rate).toFixed(2)}%` : '-'} - {inv.purchase_date ? new Date(inv.purchase_date).toLocaleDateString() : '-'} - {inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'} - - {inv.executed_investment_id - ? Executed - : Planned} - - - - - - - - } onClick={() => setEditInv(inv)}>Edit + {investments.map((inv: any) => { + const detail = interestDetailMap[inv.id]; + return ( + + {inv.label} + {inv.investment_type || '-'} + {inv.fund_type} + {fmt(parseFloat(inv.principal))} + {inv.interest_rate ? `${parseFloat(inv.interest_rate).toFixed(2)}%` : '-'} + + {detail?.interest ? `+${fmtDec(detail.interest)}` : '-'} + + {inv.purchase_date ? new Date(inv.purchase_date).toLocaleDateString() : -} + {inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : -} + + {inv.executed_investment_id + ? Executed + : Planned} + + + + + setEditInv(inv)}> + + + {!inv.executed_investment_id && ( - } color="green" onClick={() => { setExecuteInv(inv); setExecutionDate(new Date()); }}> - Execute - + + { setExecuteInv(inv); setExecutionDate(new Date()); }}> + + + )} - } color="red" onClick={() => removeMutation.mutate(inv.id)}>Remove - - - - - ))} + + removeMutation.mutate(inv.id)}> + + + + + + + ); + })} ) : ( - No investments added yet. Click "Add Investment" to model an investment allocation. + No investments added yet. Click "Add Investment" to model an investment allocation. )} diff --git a/frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx b/frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx index b651876..0249d96 100644 --- a/frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx +++ b/frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { Title, Text, @@ -22,6 +22,7 @@ import { Modal, Select, TextInput, + Progress, } from '@mantine/core'; import { IconBulb, @@ -37,6 +38,7 @@ import { IconChevronUp, IconPlaylistAdd, } from '@tabler/icons-react'; +import { DateInput } from '@mantine/dates'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { notifications } from '@mantine/notifications'; import { useNavigate } from 'react-router-dom'; @@ -381,6 +383,7 @@ export function InvestmentPlanningPage() { const [selectedRec, setSelectedRec] = useState(null); const [targetScenarioId, setTargetScenarioId] = useState(null); const [newScenarioName, setNewScenarioName] = useState(''); + const [investmentStartDate, setInvestmentStartDate] = useState(new Date()); // Load investment scenarios for the "Add to Plan" modal const { data: investmentScenarios } = useQuery({ @@ -403,6 +406,7 @@ export function InvestmentPlanningPage() { bankName: rec.bank_name, rationale: rec.rationale, components: rec.components || undefined, + startDate: investmentStartDate ? investmentStartDate.toISOString().split('T')[0] : null, }); return scenarioId; }, @@ -433,6 +437,7 @@ export function InvestmentPlanningPage() { bankName: rec.bank_name, rationale: rec.rationale, components: rec.components || undefined, + startDate: investmentStartDate ? investmentStartDate.toISOString().split('T')[0] : null, }); return scenario.id; }, @@ -453,6 +458,7 @@ export function InvestmentPlanningPage() { setSelectedRec(rec); setTargetScenarioId(null); setNewScenarioName(''); + setInvestmentStartDate(new Date()); setPlanModalOpen(true); }; @@ -507,20 +513,31 @@ export function InvestmentPlanningPage() { } }, [savedRec?.status, isTriggering]); + // Ref for scrolling to AI section on completion + const aiSectionRef = useRef(null); + // Show notification when processing completes (transition from processing) const prevStatusRef = useState(null); useEffect(() => { const [prevStatus, setPrevStatus] = prevStatusRef; if (prevStatus === 'processing' && savedRec?.status === 'complete') { notifications.show({ + title: 'AI Analysis Complete', message: `Generated ${savedRec.recommendations.length} investment recommendations`, 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') { notifications.show({ + title: 'AI Analysis Failed', message: savedRec.error_message || 'AI recommendation analysis failed', color: 'red', + autoClose: 8000, }); } setPrevStatus(savedRec?.status || null); @@ -791,7 +808,7 @@ export function InvestmentPlanningPage() { {/* ── Section 4: AI Investment Recommendations ── */} - + @@ -815,19 +832,22 @@ export function InvestmentPlanningPage() { - {/* Processing State */} + {/* Processing State - shown as banner when refreshing with existing results */} {isProcessing && ( -
- - - - Analyzing your financial data and market rates... - - - You can navigate away — results will appear when ready - - -
+ + + +
+ + {aiResult ? 'Refreshing AI analysis...' : 'Running AI analysis...'} + + + Analyzing your financial data, accounts, budgets, and current market rates + +
+
+ +
)} {/* Error State (no cached data) */} @@ -839,17 +859,19 @@ export function InvestmentPlanningPage() { )} - {/* Results (with optional failure watermark) */} - {aiResult && !isProcessing && ( - + {/* Results - keep visible even while refreshing (with optional failure watermark) */} + {aiResult && ( +
+ +
)} - {/* Empty State */} + {/* Empty State - only when no results and not processing */} {!aiResult && !isProcessing && !hasError && ( @@ -890,6 +912,14 @@ export function InvestmentPlanningPage() { )} + + {investmentScenarios && investmentScenarios.length > 0 && (