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,
};
}
}

View File

@@ -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,
],
);