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:
2026-03-16 16:18:40 -04:00
parent 7ba5c414b1
commit 159c59734e
4 changed files with 213 additions and 88 deletions

View File

@@ -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<string, number> = {};
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<string, number> = {};
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<string, number> = {},
) {
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,
};
}
}