feat: add Board Planning module with investment/assessment scenario modeling
Implements Phase 11 Forecasting Tools - a "what-if" scenario planning system for HOA boards to model financial decisions before committing. Backend: - 3 new tenant-scoped tables: board_scenarios, scenario_investments, scenario_assessments - Migration script (013) for existing tenants - Full CRUD service for scenarios, investments, and assessments - Projection engine adapted from cash flow forecast with investment/assessment deltas - Scenario comparison endpoint (up to 4 scenarios) - Investment execution flow: converts planned → real investment_accounts + journal entry Frontend: - New "Board Planning" sidebar section with 3 pages - Investment Scenarios: list, create, detail with investments table + timeline - Assessment Scenarios: list, create, detail with changes table - Scenario Comparison: multi-select overlay chart + summary metrics - Shared components: ProjectionChart, InvestmentTimeline, ScenarioCard, forms - AI Recommendation → Investment Plan integration (Story 1A) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.
|
||||
import { AttachmentsModule } from './modules/attachments/attachments.module';
|
||||
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
|
||||
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
|
||||
import { BoardPlanningModule } from './modules/board-planning/board-planning.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
|
||||
@Module({
|
||||
@@ -79,6 +80,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||
AttachmentsModule,
|
||||
InvestmentPlanningModule,
|
||||
HealthScoresModule,
|
||||
BoardPlanningModule,
|
||||
ScheduleModule.forRoot(),
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -366,6 +366,64 @@ export class TenantSchemaService {
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// Board Planning - Scenarios
|
||||
`CREATE TABLE "${s}".board_scenarios (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
scenario_type VARCHAR(30) NOT NULL CHECK (scenario_type IN ('investment', 'assessment')),
|
||||
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'approved', 'archived')),
|
||||
projection_months INTEGER DEFAULT 36,
|
||||
projection_cache JSONB,
|
||||
projection_cached_at TIMESTAMPTZ,
|
||||
created_by UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// Board Planning - Scenario Investments
|
||||
`CREATE TABLE "${s}".scenario_investments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
scenario_id UUID NOT NULL REFERENCES "${s}".board_scenarios(id) ON DELETE CASCADE,
|
||||
source_recommendation_id UUID,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
investment_type VARCHAR(50) CHECK (investment_type IN ('cd', 'money_market', 'treasury', 'savings', 'other')),
|
||||
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN ('operating', 'reserve')),
|
||||
principal DECIMAL(15,2) NOT NULL,
|
||||
interest_rate DECIMAL(6,4),
|
||||
term_months INTEGER,
|
||||
institution VARCHAR(255),
|
||||
purchase_date DATE,
|
||||
maturity_date DATE,
|
||||
auto_renew BOOLEAN DEFAULT FALSE,
|
||||
executed_investment_id UUID,
|
||||
notes TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// Board Planning - Scenario Assessments
|
||||
`CREATE TABLE "${s}".scenario_assessments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
scenario_id UUID NOT NULL REFERENCES "${s}".board_scenarios(id) ON DELETE CASCADE,
|
||||
change_type VARCHAR(30) NOT NULL CHECK (change_type IN ('dues_increase', 'special_assessment', 'dues_decrease')),
|
||||
label VARCHAR(255) NOT NULL,
|
||||
target_fund VARCHAR(20) CHECK (target_fund IN ('operating', 'reserve', 'both')),
|
||||
percentage_change DECIMAL(6,3),
|
||||
flat_amount_change DECIMAL(10,2),
|
||||
special_total DECIMAL(15,2),
|
||||
special_per_unit DECIMAL(10,2),
|
||||
special_installments INTEGER DEFAULT 1,
|
||||
effective_date DATE NOT NULL,
|
||||
end_date DATE,
|
||||
applies_to_group_id UUID,
|
||||
notes TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// Indexes
|
||||
`CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`,
|
||||
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
|
||||
@@ -378,6 +436,9 @@ export class TenantSchemaService {
|
||||
`CREATE INDEX "idx_${s}_pay_unit" ON "${s}".payments(unit_id)`,
|
||||
`CREATE INDEX "idx_${s}_pay_inv" ON "${s}".payments(invoice_id)`,
|
||||
`CREATE INDEX "idx_${s}_bud_year" ON "${s}".budgets(fiscal_year)`,
|
||||
`CREATE INDEX "idx_${s}_bs_type_status" ON "${s}".board_scenarios(scenario_type, status)`,
|
||||
`CREATE INDEX "idx_${s}_si_scenario" ON "${s}".scenario_investments(scenario_id)`,
|
||||
`CREATE INDEX "idx_${s}_sa_scenario" ON "${s}".scenario_assessments(scenario_id)`,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
const monthLabels = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
||||
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
|
||||
@Injectable()
|
||||
export class BoardPlanningProjectionService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
/** Return cached projection if fresh, otherwise compute. */
|
||||
async getProjection(scenarioId: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]);
|
||||
if (!rows.length) throw new NotFoundException('Scenario not found');
|
||||
const scenario = rows[0];
|
||||
|
||||
// Return cache if it exists and is less than 1 hour old
|
||||
if (scenario.projection_cache && scenario.projection_cached_at) {
|
||||
const age = Date.now() - new Date(scenario.projection_cached_at).getTime();
|
||||
if (age < 3600000) return scenario.projection_cache;
|
||||
}
|
||||
|
||||
return this.computeProjection(scenarioId);
|
||||
}
|
||||
|
||||
/** Compute full projection for a scenario. */
|
||||
async computeProjection(scenarioId: string) {
|
||||
const scenarioRows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]);
|
||||
if (!scenarioRows.length) throw new NotFoundException('Scenario not found');
|
||||
const scenario = scenarioRows[0];
|
||||
|
||||
const investments = await this.tenant.query(
|
||||
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY purchase_date', [scenarioId],
|
||||
);
|
||||
const assessments = await this.tenant.query(
|
||||
'SELECT * FROM scenario_assessments WHERE scenario_id = $1 ORDER BY effective_date', [scenarioId],
|
||||
);
|
||||
|
||||
const months = scenario.projection_months || 36;
|
||||
const now = new Date();
|
||||
const startYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth() + 1;
|
||||
|
||||
// ── 1. Baseline state (mirrors reports.service.ts getCashFlowForecast) ──
|
||||
const baseline = await this.getBaselineState(startYear, months);
|
||||
|
||||
// ── 2. Build month-by-month projection ──
|
||||
let { opCash, resCash, opInv, resInv } = baseline.openingBalances;
|
||||
const datapoints: any[] = [];
|
||||
|
||||
for (let i = 0; i < months; i++) {
|
||||
const year = startYear + Math.floor(i / 12);
|
||||
const month = (i % 12) + 1;
|
||||
const key = `${year}-${month}`;
|
||||
const label = `${monthLabels[month - 1]} ${year}`;
|
||||
const isHistorical = year < startYear || (year === startYear && month < currentMonth);
|
||||
|
||||
// Baseline income/expenses from budget
|
||||
const budget = baseline.budgetsByYearMonth[key] || { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||
const baseAssessment = this.getAssessmentIncome(baseline.assessmentGroups, month);
|
||||
const existingMaturity = baseline.maturityIndex[key] || { operating: 0, reserve: 0 };
|
||||
const project = baseline.projectIndex[key] || { operating: 0, reserve: 0 };
|
||||
|
||||
// Scenario investment deltas for this month
|
||||
const invDelta = this.computeInvestmentDelta(investments, year, month);
|
||||
|
||||
// Scenario assessment deltas for this month
|
||||
const asmtDelta = this.computeAssessmentDelta(assessments, baseline.assessmentGroups, year, month);
|
||||
|
||||
if (isHistorical) {
|
||||
// Historical months: use actual changes + scenario deltas
|
||||
const opChange = baseline.histIndex[`${year}-${month}-operating`] || 0;
|
||||
const resChange = baseline.histIndex[`${year}-${month}-reserve`] || 0;
|
||||
opCash += opChange + invDelta.opCashFlow + asmtDelta.operating;
|
||||
resCash += resChange + invDelta.resCashFlow + asmtDelta.reserve;
|
||||
} else {
|
||||
// Forecast months: budget + assessments + scenario deltas
|
||||
const opIncomeMonth = (budget.opIncome > 0 ? budget.opIncome : baseAssessment.operating) + asmtDelta.operating;
|
||||
const resIncomeMonth = (budget.resIncome > 0 ? budget.resIncome : baseAssessment.reserve) + asmtDelta.reserve;
|
||||
|
||||
opCash += opIncomeMonth - budget.opExpense - project.operating + existingMaturity.operating + invDelta.opCashFlow;
|
||||
resCash += resIncomeMonth - budget.resExpense - project.reserve + existingMaturity.reserve + invDelta.resCashFlow;
|
||||
|
||||
// Existing maturities reduce investment balances
|
||||
if (existingMaturity.operating > 0) {
|
||||
opInv -= existingMaturity.operating * 0.96; // approximate principal
|
||||
if (opInv < 0) opInv = 0;
|
||||
}
|
||||
if (existingMaturity.reserve > 0) {
|
||||
resInv -= existingMaturity.reserve * 0.96;
|
||||
if (resInv < 0) resInv = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario investment balance changes
|
||||
opInv += invDelta.opInvChange;
|
||||
resInv += invDelta.resInvChange;
|
||||
if (opInv < 0) opInv = 0;
|
||||
if (resInv < 0) resInv = 0;
|
||||
|
||||
datapoints.push({
|
||||
month: label,
|
||||
year,
|
||||
monthNum: month,
|
||||
is_forecast: !isHistorical,
|
||||
operating_cash: round2(opCash),
|
||||
operating_investments: round2(opInv),
|
||||
reserve_cash: round2(resCash),
|
||||
reserve_investments: round2(resInv),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. Summary metrics ──
|
||||
const summary = this.computeSummary(datapoints, baseline, assessments);
|
||||
|
||||
const result = { datapoints, summary };
|
||||
|
||||
// ── 4. Cache ──
|
||||
await this.tenant.query(
|
||||
`UPDATE board_scenarios SET projection_cache = $1, projection_cached_at = NOW() WHERE id = $2`,
|
||||
[JSON.stringify(result), scenarioId],
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Compare multiple scenarios side-by-side. */
|
||||
async compareScenarios(scenarioIds: string[]) {
|
||||
if (!scenarioIds.length || scenarioIds.length > 4) {
|
||||
throw new NotFoundException('Provide 1 to 4 scenario IDs');
|
||||
}
|
||||
|
||||
const scenarios = await Promise.all(
|
||||
scenarioIds.map(async (id) => {
|
||||
const rows = await this.tenant.query('SELECT id, name, scenario_type, status FROM board_scenarios WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException(`Scenario ${id} not found`);
|
||||
const projection = await this.getProjection(id);
|
||||
return { ...rows[0], projection };
|
||||
}),
|
||||
);
|
||||
|
||||
return { scenarios };
|
||||
}
|
||||
|
||||
// ── Private Helpers ──
|
||||
|
||||
private async getBaselineState(startYear: number, months: number) {
|
||||
// Current balances from asset accounts
|
||||
const opCashRows = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`);
|
||||
const resCashRows = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`);
|
||||
const opInvRows = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true
|
||||
`);
|
||||
const resInvRows = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
|
||||
`);
|
||||
|
||||
// Opening balances at start of startYear
|
||||
const openingOp = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false AND je.entry_date < $1::date
|
||||
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`, [`${startYear}-01-01`]);
|
||||
const openingRes = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false AND je.entry_date < $1::date
|
||||
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`, [`${startYear}-01-01`]);
|
||||
|
||||
// Assessment groups
|
||||
const assessmentGroups = await this.tenant.query(
|
||||
`SELECT frequency, regular_assessment, special_assessment, unit_count FROM assessment_groups WHERE is_active = true`,
|
||||
);
|
||||
|
||||
// Budgets
|
||||
const budgetsByYearMonth: Record<string, any> = {};
|
||||
for (const yr of [startYear, startYear + 1, startYear + 2]) {
|
||||
const budgetRows = await this.tenant.query(
|
||||
`SELECT b.fund_type, a.account_type, b.jan, b.feb, b.mar, b.apr, b.may, b.jun, b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
|
||||
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1`, [yr],
|
||||
);
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const key = `${yr}-${m + 1}`;
|
||||
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||
for (const row of budgetRows) {
|
||||
const amt = parseFloat(row[monthNames[m]]) || 0;
|
||||
if (amt === 0) continue;
|
||||
const isOp = row.fund_type === 'operating';
|
||||
if (row.account_type === 'income') {
|
||||
if (isOp) budgetsByYearMonth[key].opIncome += amt;
|
||||
else budgetsByYearMonth[key].resIncome += amt;
|
||||
} else if (row.account_type === 'expense') {
|
||||
if (isOp) budgetsByYearMonth[key].opExpense += amt;
|
||||
else budgetsByYearMonth[key].resExpense += amt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Historical cash changes
|
||||
const historicalCash = await this.tenant.query(`
|
||||
SELECT EXTRACT(YEAR FROM je.entry_date)::int as yr, EXTRACT(MONTH FROM je.entry_date)::int as mo,
|
||||
a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as net_change
|
||||
FROM journal_entry_lines jel
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||
JOIN accounts a ON a.id = jel.account_id AND a.account_type = 'asset' AND a.is_active = true
|
||||
WHERE je.entry_date >= $1::date
|
||||
GROUP BY yr, mo, a.fund_type ORDER BY yr, mo
|
||||
`, [`${startYear}-01-01`]);
|
||||
|
||||
const histIndex: Record<string, number> = {};
|
||||
for (const row of historicalCash) {
|
||||
histIndex[`${row.yr}-${row.mo}-${row.fund_type}`] = parseFloat(row.net_change) || 0;
|
||||
}
|
||||
|
||||
// Investment maturities
|
||||
const maturities = await this.tenant.query(`
|
||||
SELECT fund_type, current_value, maturity_date, interest_rate, purchase_date
|
||||
FROM investment_accounts WHERE is_active = true AND maturity_date IS NOT NULL AND maturity_date > CURRENT_DATE
|
||||
`);
|
||||
const maturityIndex: Record<string, { operating: number; reserve: number }> = {};
|
||||
for (const inv of maturities) {
|
||||
const d = new Date(inv.maturity_date);
|
||||
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
||||
if (!maturityIndex[key]) maturityIndex[key] = { operating: 0, reserve: 0 };
|
||||
const val = parseFloat(inv.current_value) || 0;
|
||||
const rate = parseFloat(inv.interest_rate) || 0;
|
||||
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
|
||||
const matDate = new Date(inv.maturity_date);
|
||||
const daysHeld = Math.max((matDate.getTime() - purchaseDate.getTime()) / 86400000, 1);
|
||||
const interestEarned = val * (rate / 100) * (daysHeld / 365);
|
||||
const maturityTotal = val + interestEarned;
|
||||
if (inv.fund_type === 'operating') maturityIndex[key].operating += maturityTotal;
|
||||
else maturityIndex[key].reserve += maturityTotal;
|
||||
}
|
||||
|
||||
// Capital project expenses
|
||||
const projectExpenses = await this.tenant.query(`
|
||||
SELECT estimated_cost, target_year, target_month, fund_source
|
||||
FROM projects WHERE is_active = true AND status IN ('planned', 'in_progress') AND target_year IS NOT NULL AND estimated_cost > 0
|
||||
`);
|
||||
const projectIndex: Record<string, { operating: number; reserve: number }> = {};
|
||||
for (const p of projectExpenses) {
|
||||
const yr = parseInt(p.target_year);
|
||||
const mo = parseInt(p.target_month) || 6;
|
||||
const key = `${yr}-${mo}`;
|
||||
if (!projectIndex[key]) projectIndex[key] = { operating: 0, reserve: 0 };
|
||||
const cost = parseFloat(p.estimated_cost) || 0;
|
||||
if (p.fund_source === 'operating') projectIndex[key].operating += cost;
|
||||
else projectIndex[key].reserve += cost;
|
||||
}
|
||||
|
||||
return {
|
||||
openingBalances: {
|
||||
opCash: parseFloat(openingOp[0]?.total || '0'),
|
||||
resCash: parseFloat(openingRes[0]?.total || '0'),
|
||||
opInv: parseFloat(opInvRows[0]?.total || '0'),
|
||||
resInv: parseFloat(resInvRows[0]?.total || '0'),
|
||||
},
|
||||
assessmentGroups,
|
||||
budgetsByYearMonth,
|
||||
histIndex,
|
||||
maturityIndex,
|
||||
projectIndex,
|
||||
};
|
||||
}
|
||||
|
||||
private getAssessmentIncome(assessmentGroups: any[], month: number) {
|
||||
let operating = 0;
|
||||
let reserve = 0;
|
||||
for (const g of assessmentGroups) {
|
||||
const units = parseInt(g.unit_count) || 0;
|
||||
const regular = parseFloat(g.regular_assessment) || 0;
|
||||
const special = parseFloat(g.special_assessment) || 0;
|
||||
const freq = g.frequency || 'monthly';
|
||||
let applies = false;
|
||||
if (freq === 'monthly') applies = true;
|
||||
else if (freq === 'quarterly') applies = [1, 4, 7, 10].includes(month);
|
||||
else if (freq === 'annual') applies = month === 1;
|
||||
if (applies) {
|
||||
operating += regular * units;
|
||||
reserve += special * units;
|
||||
}
|
||||
}
|
||||
return { operating, reserve };
|
||||
}
|
||||
|
||||
/** Compute investment cash flow and balance deltas for a given month from scenario investments. */
|
||||
private computeInvestmentDelta(investments: any[], year: number, month: number) {
|
||||
let opCashFlow = 0;
|
||||
let resCashFlow = 0;
|
||||
let opInvChange = 0;
|
||||
let resInvChange = 0;
|
||||
|
||||
for (const inv of investments) {
|
||||
if (inv.executed_investment_id) continue; // skip already-executed investments
|
||||
|
||||
const principal = parseFloat(inv.principal) || 0;
|
||||
const rate = parseFloat(inv.interest_rate) || 0;
|
||||
const isOp = inv.fund_type === 'operating';
|
||||
|
||||
// Purchase: cash leaves, investment balance increases
|
||||
if (inv.purchase_date) {
|
||||
const pd = new Date(inv.purchase_date);
|
||||
if (pd.getFullYear() === year && pd.getMonth() + 1 === month) {
|
||||
if (isOp) { opCashFlow -= principal; opInvChange += principal; }
|
||||
else { resCashFlow -= principal; resInvChange += principal; }
|
||||
}
|
||||
}
|
||||
|
||||
// Maturity: investment returns to cash with interest
|
||||
if (inv.maturity_date) {
|
||||
const md = new Date(inv.maturity_date);
|
||||
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;
|
||||
|
||||
if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; }
|
||||
else { resCashFlow += maturityTotal; resInvChange -= principal; }
|
||||
|
||||
// Auto-renew: immediately reinvest
|
||||
if (inv.auto_renew) {
|
||||
if (isOp) { opCashFlow -= principal; opInvChange += principal; }
|
||||
else { resCashFlow -= principal; resInvChange += principal; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { opCashFlow, resCashFlow, opInvChange, resInvChange };
|
||||
}
|
||||
|
||||
/** Compute assessment income delta for a given month from scenario assessment changes. */
|
||||
private computeAssessmentDelta(scenarioAssessments: any[], assessmentGroups: any[], year: number, month: number) {
|
||||
let operating = 0;
|
||||
let reserve = 0;
|
||||
|
||||
const monthDate = new Date(year, month - 1, 1);
|
||||
|
||||
// Get total units across all assessment groups
|
||||
let totalUnits = 0;
|
||||
for (const g of assessmentGroups) {
|
||||
totalUnits += parseInt(g.unit_count) || 0;
|
||||
}
|
||||
|
||||
for (const a of scenarioAssessments) {
|
||||
const effectiveDate = new Date(a.effective_date);
|
||||
const endDate = a.end_date ? new Date(a.end_date) : null;
|
||||
|
||||
// Only apply if within the active window
|
||||
if (monthDate < effectiveDate) continue;
|
||||
if (endDate && monthDate > endDate) continue;
|
||||
|
||||
if (a.change_type === 'dues_increase' || a.change_type === 'dues_decrease') {
|
||||
const baseIncome = this.getAssessmentIncome(assessmentGroups, month);
|
||||
const pctChange = parseFloat(a.percentage_change) || 0;
|
||||
const flatChange = parseFloat(a.flat_amount_change) || 0;
|
||||
const sign = a.change_type === 'dues_decrease' ? -1 : 1;
|
||||
|
||||
let delta = 0;
|
||||
if (pctChange > 0) {
|
||||
// Percentage change of base assessment income
|
||||
const target = a.target_fund || 'operating';
|
||||
if (target === 'operating' || target === 'both') {
|
||||
delta = baseIncome.operating * (pctChange / 100) * sign;
|
||||
operating += delta;
|
||||
}
|
||||
if (target === 'reserve' || target === 'both') {
|
||||
delta = baseIncome.reserve * (pctChange / 100) * sign;
|
||||
reserve += delta;
|
||||
}
|
||||
} else if (flatChange > 0) {
|
||||
// Flat per-unit change times total units
|
||||
const target = a.target_fund || 'operating';
|
||||
if (target === 'operating' || target === 'both') {
|
||||
operating += flatChange * totalUnits * sign;
|
||||
}
|
||||
if (target === 'reserve' || target === 'both') {
|
||||
reserve += flatChange * totalUnits * sign;
|
||||
}
|
||||
}
|
||||
} else if (a.change_type === 'special_assessment') {
|
||||
// Special assessment distributed across installments
|
||||
const perUnit = parseFloat(a.special_per_unit) || 0;
|
||||
const installments = parseInt(a.special_installments) || 1;
|
||||
const monthsFromStart = (year - effectiveDate.getFullYear()) * 12 + (month - (effectiveDate.getMonth() + 1));
|
||||
|
||||
if (monthsFromStart >= 0 && monthsFromStart < installments) {
|
||||
const monthlyIncome = (perUnit * totalUnits) / installments;
|
||||
const target = a.target_fund || 'reserve';
|
||||
if (target === 'operating' || target === 'both') operating += monthlyIncome;
|
||||
if (target === 'reserve' || target === 'both') reserve += monthlyIncome;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { operating, reserve };
|
||||
}
|
||||
|
||||
private computeSummary(datapoints: any[], baseline: any, scenarioAssessments: any[]) {
|
||||
if (!datapoints.length) return {};
|
||||
|
||||
const last = datapoints[datapoints.length - 1];
|
||||
const first = datapoints[0];
|
||||
|
||||
const allLiquidity = datapoints.map(
|
||||
(d) => d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
|
||||
);
|
||||
const minLiquidity = Math.min(...allLiquidity);
|
||||
const endLiquidity = allLiquidity[allLiquidity.length - 1];
|
||||
|
||||
// Monthly reserve expense from budgets (approximate average)
|
||||
let totalResExpense = 0;
|
||||
let budgetMonths = 0;
|
||||
for (const key of Object.keys(baseline.budgetsByYearMonth)) {
|
||||
const b = baseline.budgetsByYearMonth[key];
|
||||
if (b.resExpense > 0) { totalResExpense += b.resExpense; budgetMonths++; }
|
||||
}
|
||||
const avgMonthlyResExpense = budgetMonths > 0 ? totalResExpense / budgetMonths : 1;
|
||||
const reserveCoverageMonths = (last.reserve_cash + last.reserve_investments) / Math.max(avgMonthlyResExpense, 1);
|
||||
|
||||
// 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);
|
||||
|
||||
return {
|
||||
end_liquidity: round2(endLiquidity),
|
||||
min_liquidity: round2(minLiquidity),
|
||||
reserve_coverage_months: round2(reserveCoverageMonths),
|
||||
end_operating_cash: last.operating_cash,
|
||||
end_reserve_cash: last.reserve_cash,
|
||||
end_operating_investments: last.operating_investments,
|
||||
end_reserve_investments: last.reserve_investments,
|
||||
period_change: round2(endLiquidity - allLiquidity[0]),
|
||||
};
|
||||
}
|
||||
}
|
||||
130
backend/src/modules/board-planning/board-planning.controller.ts
Normal file
130
backend/src/modules/board-planning/board-planning.controller.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||
import { BoardPlanningService } from './board-planning.service';
|
||||
import { BoardPlanningProjectionService } from './board-planning-projection.service';
|
||||
|
||||
@ApiTags('board-planning')
|
||||
@Controller('board-planning')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class BoardPlanningController {
|
||||
constructor(
|
||||
private service: BoardPlanningService,
|
||||
private projection: BoardPlanningProjectionService,
|
||||
) {}
|
||||
|
||||
// ── Scenarios ──
|
||||
|
||||
@Get('scenarios')
|
||||
@AllowViewer()
|
||||
listScenarios(@Query('type') type?: string) {
|
||||
return this.service.listScenarios(type);
|
||||
}
|
||||
|
||||
@Get('scenarios/:id')
|
||||
@AllowViewer()
|
||||
getScenario(@Param('id') id: string) {
|
||||
return this.service.getScenario(id);
|
||||
}
|
||||
|
||||
@Post('scenarios')
|
||||
createScenario(@Body() dto: any, @Req() req: any) {
|
||||
return this.service.createScenario(dto, req.user.sub);
|
||||
}
|
||||
|
||||
@Put('scenarios/:id')
|
||||
updateScenario(@Param('id') id: string, @Body() dto: any) {
|
||||
return this.service.updateScenario(id, dto);
|
||||
}
|
||||
|
||||
@Delete('scenarios/:id')
|
||||
deleteScenario(@Param('id') id: string) {
|
||||
return this.service.deleteScenario(id);
|
||||
}
|
||||
|
||||
// ── Scenario Investments ──
|
||||
|
||||
@Get('scenarios/:scenarioId/investments')
|
||||
@AllowViewer()
|
||||
listInvestments(@Param('scenarioId') scenarioId: string) {
|
||||
return this.service.listInvestments(scenarioId);
|
||||
}
|
||||
|
||||
@Post('scenarios/:scenarioId/investments')
|
||||
addInvestment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||
return this.service.addInvestment(scenarioId, dto);
|
||||
}
|
||||
|
||||
@Post('scenarios/:scenarioId/investments/from-recommendation')
|
||||
addFromRecommendation(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||
return this.service.addInvestmentFromRecommendation(scenarioId, dto);
|
||||
}
|
||||
|
||||
@Put('investments/:id')
|
||||
updateInvestment(@Param('id') id: string, @Body() dto: any) {
|
||||
return this.service.updateInvestment(id, dto);
|
||||
}
|
||||
|
||||
@Delete('investments/:id')
|
||||
removeInvestment(@Param('id') id: string) {
|
||||
return this.service.removeInvestment(id);
|
||||
}
|
||||
|
||||
// ── Scenario Assessments ──
|
||||
|
||||
@Get('scenarios/:scenarioId/assessments')
|
||||
@AllowViewer()
|
||||
listAssessments(@Param('scenarioId') scenarioId: string) {
|
||||
return this.service.listAssessments(scenarioId);
|
||||
}
|
||||
|
||||
@Post('scenarios/:scenarioId/assessments')
|
||||
addAssessment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||
return this.service.addAssessment(scenarioId, dto);
|
||||
}
|
||||
|
||||
@Put('assessments/:id')
|
||||
updateAssessment(@Param('id') id: string, @Body() dto: any) {
|
||||
return this.service.updateAssessment(id, dto);
|
||||
}
|
||||
|
||||
@Delete('assessments/:id')
|
||||
removeAssessment(@Param('id') id: string) {
|
||||
return this.service.removeAssessment(id);
|
||||
}
|
||||
|
||||
// ── Projections ──
|
||||
|
||||
@Get('scenarios/:id/projection')
|
||||
@AllowViewer()
|
||||
getProjection(@Param('id') id: string) {
|
||||
return this.projection.getProjection(id);
|
||||
}
|
||||
|
||||
@Post('scenarios/:id/projection/refresh')
|
||||
refreshProjection(@Param('id') id: string) {
|
||||
return this.projection.computeProjection(id);
|
||||
}
|
||||
|
||||
// ── Comparison ──
|
||||
|
||||
@Get('compare')
|
||||
@AllowViewer()
|
||||
compareScenarios(@Query('ids') ids: string) {
|
||||
const scenarioIds = ids.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return this.projection.compareScenarios(scenarioIds);
|
||||
}
|
||||
|
||||
// ── Execute Investment ──
|
||||
|
||||
@Post('investments/:id/execute')
|
||||
executeInvestment(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: { executionDate: string },
|
||||
@Req() req: any,
|
||||
) {
|
||||
return this.service.executeInvestment(id, dto.executionDate, req.user.sub);
|
||||
}
|
||||
}
|
||||
11
backend/src/modules/board-planning/board-planning.module.ts
Normal file
11
backend/src/modules/board-planning/board-planning.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BoardPlanningController } from './board-planning.controller';
|
||||
import { BoardPlanningService } from './board-planning.service';
|
||||
import { BoardPlanningProjectionService } from './board-planning-projection.service';
|
||||
|
||||
@Module({
|
||||
controllers: [BoardPlanningController],
|
||||
providers: [BoardPlanningService, BoardPlanningProjectionService],
|
||||
exports: [BoardPlanningService],
|
||||
})
|
||||
export class BoardPlanningModule {}
|
||||
335
backend/src/modules/board-planning/board-planning.service.ts
Normal file
335
backend/src/modules/board-planning/board-planning.service.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class BoardPlanningService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
// ── Scenarios ──
|
||||
|
||||
async listScenarios(type?: string) {
|
||||
let sql = `
|
||||
SELECT bs.*,
|
||||
(SELECT COUNT(*) FROM scenario_investments si WHERE si.scenario_id = bs.id) as investment_count,
|
||||
(SELECT COALESCE(SUM(si.principal), 0) FROM scenario_investments si WHERE si.scenario_id = bs.id) as total_principal,
|
||||
(SELECT COUNT(*) FROM scenario_assessments sa WHERE sa.scenario_id = bs.id) as assessment_count
|
||||
FROM board_scenarios bs
|
||||
WHERE bs.status != 'archived'
|
||||
`;
|
||||
const params: any[] = [];
|
||||
if (type) {
|
||||
params.push(type);
|
||||
sql += ` AND bs.scenario_type = $${params.length}`;
|
||||
}
|
||||
sql += ' ORDER BY bs.updated_at DESC';
|
||||
return this.tenant.query(sql, params);
|
||||
}
|
||||
|
||||
async getScenario(id: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Scenario not found');
|
||||
const scenario = rows[0];
|
||||
|
||||
const investments = await this.tenant.query(
|
||||
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY sort_order, purchase_date',
|
||||
[id],
|
||||
);
|
||||
const assessments = await this.tenant.query(
|
||||
'SELECT * FROM scenario_assessments WHERE scenario_id = $1 ORDER BY sort_order, effective_date',
|
||||
[id],
|
||||
);
|
||||
|
||||
return { ...scenario, investments, assessments };
|
||||
}
|
||||
|
||||
async createScenario(dto: any, userId: string) {
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO board_scenarios (name, description, scenario_type, projection_months, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[dto.name, dto.description || null, dto.scenarioType, dto.projectionMonths || 36, userId],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updateScenario(id: string, dto: any) {
|
||||
await this.getScenarioRow(id);
|
||||
const rows = await this.tenant.query(
|
||||
`UPDATE board_scenarios SET
|
||||
name = COALESCE($2, name),
|
||||
description = COALESCE($3, description),
|
||||
status = COALESCE($4, status),
|
||||
projection_months = COALESCE($5, projection_months),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[id, dto.name, dto.description, dto.status, dto.projectionMonths],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async deleteScenario(id: string) {
|
||||
await this.getScenarioRow(id);
|
||||
await this.tenant.query(
|
||||
`UPDATE board_scenarios SET status = 'archived', updated_at = NOW() WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scenario Investments ──
|
||||
|
||||
async listInvestments(scenarioId: string) {
|
||||
return this.tenant.query(
|
||||
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY sort_order, purchase_date',
|
||||
[scenarioId],
|
||||
);
|
||||
}
|
||||
|
||||
async addInvestment(scenarioId: string, dto: any) {
|
||||
await this.getScenarioRow(scenarioId);
|
||||
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, purchase_date, maturity_date,
|
||||
auto_renew, notes, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING *`,
|
||||
[
|
||||
scenarioId, dto.sourceRecommendationId || null, dto.label,
|
||||
dto.investmentType || null, dto.fundType,
|
||||
dto.principal, dto.interestRate || null, dto.termMonths || null,
|
||||
dto.institution || null, dto.purchaseDate || null, dto.maturityDate || null,
|
||||
dto.autoRenew || false, dto.notes || null, dto.sortOrder || 0,
|
||||
],
|
||||
);
|
||||
await this.invalidateProjectionCache(scenarioId);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async addInvestmentFromRecommendation(scenarioId: string, dto: any) {
|
||||
await this.getScenarioRow(scenarioId);
|
||||
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)
|
||||
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,
|
||||
dto.rationale || dto.notes || null,
|
||||
],
|
||||
);
|
||||
await this.invalidateProjectionCache(scenarioId);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updateInvestment(id: string, dto: any) {
|
||||
const inv = await this.getInvestmentRow(id);
|
||||
const rows = await this.tenant.query(
|
||||
`UPDATE scenario_investments SET
|
||||
label = COALESCE($2, label),
|
||||
investment_type = COALESCE($3, investment_type),
|
||||
fund_type = COALESCE($4, fund_type),
|
||||
principal = COALESCE($5, principal),
|
||||
interest_rate = COALESCE($6, interest_rate),
|
||||
term_months = COALESCE($7, term_months),
|
||||
institution = COALESCE($8, institution),
|
||||
purchase_date = COALESCE($9, purchase_date),
|
||||
maturity_date = COALESCE($10, maturity_date),
|
||||
auto_renew = COALESCE($11, auto_renew),
|
||||
notes = COALESCE($12, notes),
|
||||
sort_order = COALESCE($13, sort_order),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[
|
||||
id, dto.label, dto.investmentType, dto.fundType,
|
||||
dto.principal, dto.interestRate, dto.termMonths,
|
||||
dto.institution, dto.purchaseDate, dto.maturityDate,
|
||||
dto.autoRenew, dto.notes, dto.sortOrder,
|
||||
],
|
||||
);
|
||||
await this.invalidateProjectionCache(inv.scenario_id);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async removeInvestment(id: string) {
|
||||
const inv = await this.getInvestmentRow(id);
|
||||
await this.tenant.query('DELETE FROM scenario_investments WHERE id = $1', [id]);
|
||||
await this.invalidateProjectionCache(inv.scenario_id);
|
||||
}
|
||||
|
||||
// ── Scenario Assessments ──
|
||||
|
||||
async listAssessments(scenarioId: string) {
|
||||
return this.tenant.query(
|
||||
'SELECT * FROM scenario_assessments WHERE scenario_id = $1 ORDER BY sort_order, effective_date',
|
||||
[scenarioId],
|
||||
);
|
||||
}
|
||||
|
||||
async addAssessment(scenarioId: string, dto: any) {
|
||||
await this.getScenarioRow(scenarioId);
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO scenario_assessments
|
||||
(scenario_id, change_type, label, target_fund, percentage_change,
|
||||
flat_amount_change, special_total, special_per_unit, special_installments,
|
||||
effective_date, end_date, applies_to_group_id, notes, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING *`,
|
||||
[
|
||||
scenarioId, dto.changeType, dto.label, dto.targetFund || 'operating',
|
||||
dto.percentageChange || null, dto.flatAmountChange || null,
|
||||
dto.specialTotal || null, dto.specialPerUnit || null,
|
||||
dto.specialInstallments || 1, dto.effectiveDate,
|
||||
dto.endDate || null, dto.appliesToGroupId || null,
|
||||
dto.notes || null, dto.sortOrder || 0,
|
||||
],
|
||||
);
|
||||
await this.invalidateProjectionCache(scenarioId);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updateAssessment(id: string, dto: any) {
|
||||
const asmt = await this.getAssessmentRow(id);
|
||||
const rows = await this.tenant.query(
|
||||
`UPDATE scenario_assessments SET
|
||||
change_type = COALESCE($2, change_type),
|
||||
label = COALESCE($3, label),
|
||||
target_fund = COALESCE($4, target_fund),
|
||||
percentage_change = COALESCE($5, percentage_change),
|
||||
flat_amount_change = COALESCE($6, flat_amount_change),
|
||||
special_total = COALESCE($7, special_total),
|
||||
special_per_unit = COALESCE($8, special_per_unit),
|
||||
special_installments = COALESCE($9, special_installments),
|
||||
effective_date = COALESCE($10, effective_date),
|
||||
end_date = COALESCE($11, end_date),
|
||||
applies_to_group_id = COALESCE($12, applies_to_group_id),
|
||||
notes = COALESCE($13, notes),
|
||||
sort_order = COALESCE($14, sort_order),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[
|
||||
id, dto.changeType, dto.label, dto.targetFund,
|
||||
dto.percentageChange, dto.flatAmountChange,
|
||||
dto.specialTotal, dto.specialPerUnit, dto.specialInstallments,
|
||||
dto.effectiveDate, dto.endDate, dto.appliesToGroupId,
|
||||
dto.notes, dto.sortOrder,
|
||||
],
|
||||
);
|
||||
await this.invalidateProjectionCache(asmt.scenario_id);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async removeAssessment(id: string) {
|
||||
const asmt = await this.getAssessmentRow(id);
|
||||
await this.tenant.query('DELETE FROM scenario_assessments WHERE id = $1', [id]);
|
||||
await this.invalidateProjectionCache(asmt.scenario_id);
|
||||
}
|
||||
|
||||
// ── Execute Investment (Story 1D) ──
|
||||
|
||||
async executeInvestment(investmentId: string, executionDate: string, userId: string) {
|
||||
const inv = await this.getInvestmentRow(investmentId);
|
||||
if (inv.executed_investment_id) {
|
||||
throw new BadRequestException('This investment has already been executed');
|
||||
}
|
||||
|
||||
// 1. Create real investment_accounts record
|
||||
const invRows = await this.tenant.query(
|
||||
`INSERT INTO investment_accounts
|
||||
(name, institution, investment_type, fund_type, principal, interest_rate,
|
||||
maturity_date, purchase_date, current_value, notes, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true)
|
||||
RETURNING *`,
|
||||
[
|
||||
inv.label, inv.institution, inv.investment_type || 'cd',
|
||||
inv.fund_type, inv.principal, inv.interest_rate || 0,
|
||||
inv.maturity_date, executionDate, inv.principal,
|
||||
`Executed from scenario investment. ${inv.notes || ''}`.trim(),
|
||||
],
|
||||
);
|
||||
const realInvestment = invRows[0];
|
||||
|
||||
// 2. Create journal entry at the execution date
|
||||
const entryDate = new Date(executionDate);
|
||||
const year = entryDate.getFullYear();
|
||||
const month = entryDate.getMonth() + 1;
|
||||
|
||||
const periods = await this.tenant.query(
|
||||
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
|
||||
[year, month],
|
||||
);
|
||||
if (periods.length) {
|
||||
const primaryRows = await this.tenant.query(
|
||||
`SELECT id, name FROM accounts WHERE is_primary = true AND fund_type = $1 AND is_active = true LIMIT 1`,
|
||||
[inv.fund_type],
|
||||
);
|
||||
const equityAccountNumber = inv.fund_type === 'reserve' ? '3100' : '3000';
|
||||
const equityRows = await this.tenant.query(
|
||||
'SELECT id FROM accounts WHERE account_number = $1',
|
||||
[equityAccountNumber],
|
||||
);
|
||||
|
||||
if (primaryRows.length && equityRows.length) {
|
||||
const memo = `Transfer to investment: ${inv.label}`;
|
||||
const jeRows = await this.tenant.query(
|
||||
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
||||
VALUES ($1, $2, 'transfer', $3, true, NOW(), $4)
|
||||
RETURNING *`,
|
||||
[executionDate, memo, periods[0].id, userId],
|
||||
);
|
||||
const je = jeRows[0];
|
||||
// Credit primary asset account (reduces cash)
|
||||
await this.tenant.query(
|
||||
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||
VALUES ($1, $2, 0, $3, $4)`,
|
||||
[je.id, primaryRows[0].id, inv.principal, memo],
|
||||
);
|
||||
// Debit equity offset account
|
||||
await this.tenant.query(
|
||||
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||
VALUES ($1, $2, $3, 0, $4)`,
|
||||
[je.id, equityRows[0].id, inv.principal, memo],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Link back to scenario investment
|
||||
await this.tenant.query(
|
||||
`UPDATE scenario_investments SET executed_investment_id = $1, updated_at = NOW() WHERE id = $2`,
|
||||
[realInvestment.id, investmentId],
|
||||
);
|
||||
|
||||
await this.invalidateProjectionCache(inv.scenario_id);
|
||||
return realInvestment;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
private async getScenarioRow(id: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Scenario not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private async getInvestmentRow(id: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM scenario_investments WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Scenario investment not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private async getAssessmentRow(id: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM scenario_assessments WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Scenario assessment not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async invalidateProjectionCache(scenarioId: string) {
|
||||
await this.tenant.query(
|
||||
`UPDATE board_scenarios SET projection_cache = NULL, projection_cached_at = NULL, updated_at = NOW() WHERE id = $1`,
|
||||
[scenarioId],
|
||||
);
|
||||
}
|
||||
}
|
||||
83
db/migrations/013-board-planning.sql
Normal file
83
db/migrations/013-board-planning.sql
Normal file
@@ -0,0 +1,83 @@
|
||||
-- Migration 013: Board Planning tables (scenarios, investments, assessments)
|
||||
-- Applies to all existing tenant schemas
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
tenant_schema TEXT;
|
||||
BEGIN
|
||||
FOR tenant_schema IN
|
||||
SELECT schema_name FROM information_schema.schemata
|
||||
WHERE schema_name LIKE 'tenant_%'
|
||||
LOOP
|
||||
-- Board Scenarios
|
||||
EXECUTE format('
|
||||
CREATE TABLE IF NOT EXISTS %I.board_scenarios (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
scenario_type VARCHAR(30) NOT NULL CHECK (scenario_type IN (''investment'', ''assessment'')),
|
||||
status VARCHAR(20) DEFAULT ''draft'' CHECK (status IN (''draft'', ''active'', ''approved'', ''archived'')),
|
||||
projection_months INTEGER DEFAULT 36,
|
||||
projection_cache JSONB,
|
||||
projection_cached_at TIMESTAMPTZ,
|
||||
created_by UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)', tenant_schema);
|
||||
|
||||
-- Scenario Investments
|
||||
EXECUTE format('
|
||||
CREATE TABLE IF NOT EXISTS %I.scenario_investments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
scenario_id UUID NOT NULL REFERENCES %I.board_scenarios(id) ON DELETE CASCADE,
|
||||
source_recommendation_id UUID,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
investment_type VARCHAR(50) CHECK (investment_type IN (''cd'', ''money_market'', ''treasury'', ''savings'', ''other'')),
|
||||
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN (''operating'', ''reserve'')),
|
||||
principal DECIMAL(15,2) NOT NULL,
|
||||
interest_rate DECIMAL(6,4),
|
||||
term_months INTEGER,
|
||||
institution VARCHAR(255),
|
||||
purchase_date DATE,
|
||||
maturity_date DATE,
|
||||
auto_renew BOOLEAN DEFAULT FALSE,
|
||||
executed_investment_id UUID,
|
||||
notes TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)', tenant_schema, tenant_schema);
|
||||
|
||||
-- Scenario Assessments
|
||||
EXECUTE format('
|
||||
CREATE TABLE IF NOT EXISTS %I.scenario_assessments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
scenario_id UUID NOT NULL REFERENCES %I.board_scenarios(id) ON DELETE CASCADE,
|
||||
change_type VARCHAR(30) NOT NULL CHECK (change_type IN (''dues_increase'', ''special_assessment'', ''dues_decrease'')),
|
||||
label VARCHAR(255) NOT NULL,
|
||||
target_fund VARCHAR(20) CHECK (target_fund IN (''operating'', ''reserve'', ''both'')),
|
||||
percentage_change DECIMAL(6,3),
|
||||
flat_amount_change DECIMAL(10,2),
|
||||
special_total DECIMAL(15,2),
|
||||
special_per_unit DECIMAL(10,2),
|
||||
special_installments INTEGER DEFAULT 1,
|
||||
effective_date DATE NOT NULL,
|
||||
end_date DATE,
|
||||
applies_to_group_id UUID,
|
||||
notes TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)', tenant_schema, tenant_schema);
|
||||
|
||||
-- Indexes
|
||||
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_bs_type_status ON %I.board_scenarios(scenario_type, status)',
|
||||
replace(tenant_schema, '.', '_'), tenant_schema);
|
||||
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_si_scenario ON %I.scenario_investments(scenario_id)',
|
||||
replace(tenant_schema, '.', '_'), tenant_schema);
|
||||
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_sa_scenario ON %I.scenario_assessments(scenario_id)',
|
||||
replace(tenant_schema, '.', '_'), tenant_schema);
|
||||
|
||||
RAISE NOTICE 'Board planning tables created for schema: %', tenant_schema;
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -31,6 +31,11 @@ import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroups
|
||||
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
|
||||
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
|
||||
import { InvestmentPlanningPage } from './pages/investment-planning/InvestmentPlanningPage';
|
||||
import { InvestmentScenariosPage } from './pages/board-planning/InvestmentScenariosPage';
|
||||
import { InvestmentScenarioDetailPage } from './pages/board-planning/InvestmentScenarioDetailPage';
|
||||
import { AssessmentScenariosPage } from './pages/board-planning/AssessmentScenariosPage';
|
||||
import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentScenarioDetailPage';
|
||||
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
@@ -137,6 +142,11 @@ export function App() {
|
||||
<Route path="reports/sankey" element={<SankeyPage />} />
|
||||
<Route path="reports/year-end" element={<YearEndPage />} />
|
||||
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
|
||||
<Route path="board-planning/investments" element={<InvestmentScenariosPage />} />
|
||||
<Route path="board-planning/investments/:id" element={<InvestmentScenarioDetailPage />} />
|
||||
<Route path="board-planning/assessments" element={<AssessmentScenariosPage />} />
|
||||
<Route path="board-planning/assessments/:id" element={<AssessmentScenarioDetailPage />} />
|
||||
<Route path="board-planning/compare" element={<ScenarioComparisonPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="preferences" element={<UserPreferencesPage />} />
|
||||
<Route path="org-members" element={<OrgMembersPage />} />
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
IconClipboardCheck,
|
||||
IconSparkles,
|
||||
IconHeartRateMonitor,
|
||||
IconCalculator,
|
||||
IconGitCompare,
|
||||
IconScale,
|
||||
} from '@tabler/icons-react';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
@@ -60,6 +63,14 @@ const navSections = [
|
||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Board Planning',
|
||||
items: [
|
||||
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' },
|
||||
{ label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments' },
|
||||
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Reports',
|
||||
items: [
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
||||
Loader, Center, Menu, Select, SimpleGrid,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconPlus, IconArrowLeft, IconDots, IconTrash, IconEdit, IconRefresh,
|
||||
} 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 { AssessmentChangeForm } from './components/AssessmentChangeForm';
|
||||
import { ProjectionChart } from './components/ProjectionChart';
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: 'gray', active: 'blue', approved: 'green', archived: 'red',
|
||||
};
|
||||
|
||||
const changeTypeLabels: Record<string, string> = {
|
||||
dues_increase: 'Dues Increase',
|
||||
dues_decrease: 'Dues Decrease',
|
||||
special_assessment: 'Special Assessment',
|
||||
};
|
||||
|
||||
const changeTypeColors: Record<string, string> = {
|
||||
dues_increase: 'green',
|
||||
dues_decrease: 'orange',
|
||||
special_assessment: 'violet',
|
||||
};
|
||||
|
||||
export function AssessmentScenarioDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editAsmt, setEditAsmt] = useState<any>(null);
|
||||
|
||||
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}/assessments`, dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||
setAddOpen(false);
|
||||
notifications.show({ message: 'Assessment change added', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ asmtId, ...dto }: any) => api.put(`/board-planning/assessments/${asmtId}`, dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||
setEditAsmt(null);
|
||||
notifications.show({ message: 'Assessment change updated', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (asmtId: string) => api.delete(`/board-planning/assessments/${asmtId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||
notifications.show({ message: 'Assessment change removed', color: 'orange' });
|
||||
},
|
||||
});
|
||||
|
||||
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'] });
|
||||
},
|
||||
});
|
||||
|
||||
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 (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
|
||||
|
||||
const assessments = scenario.assessments || [];
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Group>
|
||||
<ActionIcon variant="subtle" onClick={() => navigate('/board-planning/assessments')}>
|
||||
<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" 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)}>
|
||||
Add Change
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{projection?.summary && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>End Liquidity</Text>
|
||||
<Text fw={700} size="xl" ff="monospace">{fmt(projection.summary.end_liquidity || 0)}</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Period Change</Text>
|
||||
<Text fw={700} size="xl" ff="monospace" c={projection.summary.period_change >= 0 ? 'green' : 'red'}>
|
||||
{projection.summary.period_change >= 0 ? '+' : ''}{fmt(projection.summary.period_change || 0)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Min Liquidity</Text>
|
||||
<Text fw={700} size="xl" ff="monospace" c={projection.summary.min_liquidity < 0 ? 'red' : undefined}>
|
||||
{fmt(projection.summary.min_liquidity || 0)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Coverage</Text>
|
||||
<Text fw={700} size="xl" ff="monospace">
|
||||
{(projection.summary.reserve_coverage_months || 0).toFixed(1)} mo
|
||||
</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* Assessment Changes Table */}
|
||||
<Card withBorder p="lg">
|
||||
<Title order={4} mb="md">Assessment Changes ({assessments.length})</Title>
|
||||
{assessments.length > 0 ? (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Label</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Target Fund</Table.Th>
|
||||
<Table.Th ta="right">Change</Table.Th>
|
||||
<Table.Th>Effective</Table.Th>
|
||||
<Table.Th>End</Table.Th>
|
||||
<Table.Th w={80}>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{assessments.map((a: any) => (
|
||||
<Table.Tr key={a.id}>
|
||||
<Table.Td fw={500}>{a.label}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={changeTypeColors[a.change_type] || 'gray'}>
|
||||
{changeTypeLabels[a.change_type] || a.change_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light">
|
||||
{a.target_fund}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">
|
||||
{a.change_type === 'special_assessment'
|
||||
? `${fmt(parseFloat(a.special_per_unit) || 0)}/unit x ${a.special_installments || 1} mo`
|
||||
: a.percentage_change
|
||||
? `${parseFloat(a.percentage_change).toFixed(1)}%`
|
||||
: a.flat_amount_change
|
||||
? `${fmt(parseFloat(a.flat_amount_change))}/unit/mo`
|
||||
: '-'}
|
||||
</Table.Td>
|
||||
<Table.Td>{a.effective_date ? new Date(a.effective_date).toLocaleDateString() : '-'}</Table.Td>
|
||||
<Table.Td>{a.end_date ? new Date(a.end_date).toLocaleDateString() : 'Ongoing'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Menu withinPortal position="bottom-end" shadow="sm">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray"><IconDots size={16} /></ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => setEditAsmt(a)}>Edit</Menu.Item>
|
||||
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={() => removeMutation.mutate(a.id)}>Remove</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No assessment changes added yet. Click "Add Change" to model a dues increase or special assessment.
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Projection Chart */}
|
||||
{projection && (
|
||||
<ProjectionChart
|
||||
datapoints={projection.datapoints || []}
|
||||
title="Assessment Impact Projection"
|
||||
summary={projection.summary}
|
||||
/>
|
||||
)}
|
||||
{projLoading && <Center py="xl"><Loader /></Center>}
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<AssessmentChangeForm
|
||||
opened={addOpen || !!editAsmt}
|
||||
onClose={() => { setAddOpen(false); setEditAsmt(null); }}
|
||||
onSubmit={(data) => {
|
||||
if (editAsmt) {
|
||||
updateMutation.mutate({ asmtId: editAsmt.id, ...data });
|
||||
} else {
|
||||
addMutation.mutate(data);
|
||||
}
|
||||
}}
|
||||
initialData={editAsmt}
|
||||
loading={addMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
128
frontend/src/pages/board-planning/AssessmentScenariosPage.tsx
Normal file
128
frontend/src/pages/board-planning/AssessmentScenariosPage.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
import { Title, Text, Stack, Group, Button, SimpleGrid, Modal, TextInput, Textarea, Loader, Center } from '@mantine/core';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import api from '../../services/api';
|
||||
import { ScenarioCard } from './components/ScenarioCard';
|
||||
|
||||
export function AssessmentScenariosPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editScenario, setEditScenario] = useState<any>(null);
|
||||
const [form, setForm] = useState({ name: '', description: '' });
|
||||
|
||||
const { data: scenarios, isLoading } = useQuery<any[]>({
|
||||
queryKey: ['board-planning-scenarios', 'assessment'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/board-planning/scenarios?type=assessment');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (dto: any) => api.post('/board-planning/scenarios', dto),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||
setCreateOpen(false);
|
||||
setForm({ name: '', description: '' });
|
||||
notifications.show({ message: 'Scenario created', color: 'green' });
|
||||
navigate(`/board-planning/assessments/${res.data.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, ...dto }: any) => api.put(`/board-planning/scenarios/${id}`, dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||
setEditScenario(null);
|
||||
notifications.show({ message: 'Scenario updated', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/board-planning/scenarios/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||
notifications.show({ message: 'Scenario archived', color: 'orange' });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={2}>Assessment Scenarios</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Model dues increases, special assessments, and their impact on cash flow and reserves
|
||||
</Text>
|
||||
</div>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={() => setCreateOpen(true)}>
|
||||
New Scenario
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{scenarios && scenarios.length > 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||
{scenarios.map((s) => (
|
||||
<ScenarioCard
|
||||
key={s.id}
|
||||
scenario={s}
|
||||
onClick={() => navigate(`/board-planning/assessments/${s.id}`)}
|
||||
onEdit={() => { setEditScenario(s); setForm({ name: s.name, description: s.description || '' }); }}
|
||||
onDelete={() => deleteMutation.mutate(s.id)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="sm">
|
||||
<Text c="dimmed">No assessment scenarios yet</Text>
|
||||
<Text size="sm" c="dimmed" maw={400} ta="center">
|
||||
Create a scenario to model dues increases, special assessments, and multi-year assessment planning.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal opened={createOpen} onClose={() => setCreateOpen(false)} title="New Assessment Scenario">
|
||||
<Stack>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. 5% Annual Increase" />
|
||||
<Textarea label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Describe this assessment strategy..." />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => createMutation.mutate({ name: form.name, description: form.description, scenarioType: 'assessment' })}
|
||||
loading={createMutation.isPending}
|
||||
disabled={!form.name}
|
||||
>
|
||||
Create Scenario
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal opened={!!editScenario} onClose={() => setEditScenario(null)} title="Edit Scenario">
|
||||
<Stack>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
<Textarea label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setEditScenario(null)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => updateMutation.mutate({ id: editScenario.id, name: form.name, description: form.description })}
|
||||
loading={updateMutation.isPending}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
||||
Loader, Center, Menu, Select, Modal, TextInput, Alert,
|
||||
} from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import {
|
||||
IconPlus, IconArrowLeft, IconDots, IconTrash, IconEdit,
|
||||
IconPlayerPlay, IconRefresh, IconAlertTriangle,
|
||||
} 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 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, refetch: refetchProjection } = 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'] });
|
||||
},
|
||||
});
|
||||
|
||||
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 (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
|
||||
|
||||
const investments = scenario.investments || [];
|
||||
|
||||
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" 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)}>
|
||||
Add Investment
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* 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>Purchase</Table.Th>
|
||||
<Table.Th>Maturity</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th w={80}>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{investments.map((inv: any) => (
|
||||
<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>{inv.purchase_date ? new Date(inv.purchase_date).toLocaleDateString() : '-'}</Table.Td>
|
||||
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}</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>
|
||||
<Menu withinPortal position="bottom-end" shadow="sm">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray"><IconDots size={16} /></ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => setEditInv(inv)}>Edit</Menu.Item>
|
||||
{!inv.executed_investment_id && (
|
||||
<Menu.Item leftSection={<IconPlayerPlay size={14} />} color="green" onClick={() => { setExecuteInv(inv); setExecutionDate(new Date()); }}>
|
||||
Execute
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={() => removeMutation.mutate(inv.id)}>Remove</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No investments added yet. Click "Add Investment" 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>
|
||||
);
|
||||
}
|
||||
128
frontend/src/pages/board-planning/InvestmentScenariosPage.tsx
Normal file
128
frontend/src/pages/board-planning/InvestmentScenariosPage.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
import { Title, Text, Stack, Group, Button, SimpleGrid, Modal, TextInput, Textarea, Loader, Center } from '@mantine/core';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import api from '../../services/api';
|
||||
import { ScenarioCard } from './components/ScenarioCard';
|
||||
|
||||
export function InvestmentScenariosPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editScenario, setEditScenario] = useState<any>(null);
|
||||
const [form, setForm] = useState({ name: '', description: '' });
|
||||
|
||||
const { data: scenarios, isLoading } = useQuery<any[]>({
|
||||
queryKey: ['board-planning-scenarios', 'investment'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/board-planning/scenarios?type=investment');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (dto: any) => api.post('/board-planning/scenarios', dto),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||
setCreateOpen(false);
|
||||
setForm({ name: '', description: '' });
|
||||
notifications.show({ message: 'Scenario created', color: 'green' });
|
||||
navigate(`/board-planning/investments/${res.data.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, ...dto }: any) => api.put(`/board-planning/scenarios/${id}`, dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||
setEditScenario(null);
|
||||
notifications.show({ message: 'Scenario updated', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/board-planning/scenarios/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||
notifications.show({ message: 'Scenario archived', color: 'orange' });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={2}>Investment Scenarios</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Model different investment strategies and compare their impact on liquidity and income
|
||||
</Text>
|
||||
</div>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={() => setCreateOpen(true)}>
|
||||
New Scenario
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{scenarios && scenarios.length > 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||
{scenarios.map((s) => (
|
||||
<ScenarioCard
|
||||
key={s.id}
|
||||
scenario={s}
|
||||
onClick={() => navigate(`/board-planning/investments/${s.id}`)}
|
||||
onEdit={() => { setEditScenario(s); setForm({ name: s.name, description: s.description || '' }); }}
|
||||
onDelete={() => deleteMutation.mutate(s.id)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="sm">
|
||||
<Text c="dimmed">No investment scenarios yet</Text>
|
||||
<Text size="sm" c="dimmed" maw={400} ta="center">
|
||||
Create a scenario to model investment allocations, timing, and their impact on reserves and liquidity.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal opened={createOpen} onClose={() => setCreateOpen(false)} title="New Investment Scenario">
|
||||
<Stack>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. Conservative CD Ladder" />
|
||||
<Textarea label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Describe this investment strategy..." />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => createMutation.mutate({ name: form.name, description: form.description, scenarioType: 'investment' })}
|
||||
loading={createMutation.isPending}
|
||||
disabled={!form.name}
|
||||
>
|
||||
Create Scenario
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal opened={!!editScenario} onClose={() => setEditScenario(null)} title="Edit Scenario">
|
||||
<Stack>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
<Textarea label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setEditScenario(null)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => updateMutation.mutate({ id: editScenario.id, name: form.name, description: form.description })}
|
||||
loading={updateMutation.isPending}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
210
frontend/src/pages/board-planning/ScenarioComparisonPage.tsx
Normal file
210
frontend/src/pages/board-planning/ScenarioComparisonPage.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Text, Stack, Group, Card, MultiSelect, Loader, Center, Badge,
|
||||
SimpleGrid, Table,
|
||||
} from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import api from '../../services/api';
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
|
||||
const COLORS = ['#228be6', '#40c057', '#7950f2', '#fd7e14'];
|
||||
|
||||
export function ScenarioComparisonPage() {
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
// Load all scenarios for the selector
|
||||
const { data: allScenarios } = useQuery<any[]>({
|
||||
queryKey: ['board-planning-scenarios-all'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/board-planning/scenarios');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Load comparison data when scenarios are selected
|
||||
const { data: comparison, isLoading: compLoading } = useQuery({
|
||||
queryKey: ['board-planning-compare', selectedIds],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/board-planning/compare?ids=${selectedIds.join(',')}`);
|
||||
return data;
|
||||
},
|
||||
enabled: selectedIds.length >= 1,
|
||||
});
|
||||
|
||||
const selectorData = (allScenarios || []).map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name} (${s.scenario_type})`,
|
||||
}));
|
||||
|
||||
// Build merged chart data with all scenarios
|
||||
const chartData = (() => {
|
||||
if (!comparison?.scenarios?.length) return [];
|
||||
const firstScenario = comparison.scenarios[0];
|
||||
if (!firstScenario?.projection?.datapoints) return [];
|
||||
|
||||
return firstScenario.projection.datapoints.map((_: any, idx: number) => {
|
||||
const point: any = { month: firstScenario.projection.datapoints[idx].month };
|
||||
comparison.scenarios.forEach((s: any, sIdx: number) => {
|
||||
const dp = s.projection?.datapoints?.[idx];
|
||||
if (dp) {
|
||||
point[`total_${sIdx}`] =
|
||||
dp.operating_cash + dp.operating_investments + dp.reserve_cash + dp.reserve_investments;
|
||||
}
|
||||
});
|
||||
return point;
|
||||
});
|
||||
})();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<div>
|
||||
<Title order={2}>Compare Scenarios</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Select up to 4 scenarios to compare their projected financial impact side-by-side
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<MultiSelect
|
||||
label="Select Scenarios"
|
||||
placeholder="Choose scenarios to compare..."
|
||||
data={selectorData}
|
||||
value={selectedIds}
|
||||
onChange={setSelectedIds}
|
||||
maxValues={4}
|
||||
searchable
|
||||
/>
|
||||
|
||||
{compLoading && <Center py="xl"><Loader size="lg" /></Center>}
|
||||
|
||||
{comparison?.scenarios?.length > 0 && (
|
||||
<>
|
||||
{/* Overlaid Line Chart */}
|
||||
<Card withBorder p="lg">
|
||||
<Title order={4} mb="md">Total Liquidity Projection</Title>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
|
||||
<Tooltip
|
||||
formatter={(v: number) => fmt(v)}
|
||||
labelStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
<Legend />
|
||||
{comparison.scenarios.map((s: any, idx: number) => (
|
||||
<Line
|
||||
key={s.id}
|
||||
type="monotone"
|
||||
dataKey={`total_${idx}`}
|
||||
name={s.name}
|
||||
stroke={COLORS[idx]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
{/* Summary Metrics Comparison */}
|
||||
<Card withBorder p="lg">
|
||||
<Title order={4} mb="md">Summary Comparison</Title>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Metric</Table.Th>
|
||||
{comparison.scenarios.map((s: any, idx: number) => (
|
||||
<Table.Th key={s.id} ta="right">
|
||||
<Group gap={4} justify="flex-end">
|
||||
<div style={{ width: 10, height: 10, borderRadius: 2, background: COLORS[idx] }} />
|
||||
<Text size="sm" fw={600}>{s.name}</Text>
|
||||
</Group>
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
<Table.Td fw={500}>End Liquidity</Table.Td>
|
||||
{comparison.scenarios.map((s: any) => (
|
||||
<Table.Td key={s.id} ta="right" ff="monospace" fw={600}>
|
||||
{fmt(s.projection?.summary?.end_liquidity || 0)}
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td fw={500}>Minimum Liquidity</Table.Td>
|
||||
{comparison.scenarios.map((s: any) => (
|
||||
<Table.Td key={s.id} ta="right" ff="monospace" fw={600}
|
||||
c={(s.projection?.summary?.min_liquidity || 0) < 0 ? 'red' : undefined}
|
||||
>
|
||||
{fmt(s.projection?.summary?.min_liquidity || 0)}
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td fw={500}>Period Change</Table.Td>
|
||||
{comparison.scenarios.map((s: any) => {
|
||||
const change = s.projection?.summary?.period_change || 0;
|
||||
return (
|
||||
<Table.Td key={s.id} ta="right" ff="monospace" fw={600} c={change >= 0 ? 'green' : 'red'}>
|
||||
{change >= 0 ? '+' : ''}{fmt(change)}
|
||||
</Table.Td>
|
||||
);
|
||||
})}
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td fw={500}>Reserve Coverage</Table.Td>
|
||||
{comparison.scenarios.map((s: any) => (
|
||||
<Table.Td key={s.id} ta="right" ff="monospace" fw={600}>
|
||||
{(s.projection?.summary?.reserve_coverage_months || 0).toFixed(1)} months
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td fw={500}>End Operating Cash</Table.Td>
|
||||
{comparison.scenarios.map((s: any) => (
|
||||
<Table.Td key={s.id} ta="right" ff="monospace">
|
||||
{fmt(s.projection?.summary?.end_operating_cash || 0)}
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td fw={500}>End Reserve Cash</Table.Td>
|
||||
{comparison.scenarios.map((s: any) => (
|
||||
<Table.Td key={s.id} ta="right" ff="monospace">
|
||||
{fmt(s.projection?.summary?.end_reserve_cash || 0)}
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{/* Risk Flags */}
|
||||
{comparison.scenarios.some((s: any) => (s.projection?.summary?.min_liquidity || 0) < 0) && (
|
||||
<Card withBorder p="lg" bg="red.0">
|
||||
<Title order={4} c="red" mb="sm">Liquidity Warnings</Title>
|
||||
{comparison.scenarios.filter((s: any) => (s.projection?.summary?.min_liquidity || 0) < 0).map((s: any) => (
|
||||
<Text key={s.id} size="sm" c="red">
|
||||
{s.name}: projected negative liquidity of {fmt(s.projection.summary.min_liquidity)}
|
||||
</Text>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedIds.length === 0 && (
|
||||
<Center py="xl">
|
||||
<Text c="dimmed">Select one or more scenarios above to compare their financial projections</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Modal, TextInput, Select, NumberInput, Group, Button, Stack, Text } from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: any) => void;
|
||||
initialData?: any;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function AssessmentChangeForm({ opened, onClose, onSubmit, initialData, loading }: Props) {
|
||||
const [form, setForm] = useState({
|
||||
changeType: 'dues_increase' as string,
|
||||
label: '',
|
||||
targetFund: 'operating',
|
||||
percentageChange: 0,
|
||||
flatAmountChange: 0,
|
||||
specialTotal: 0,
|
||||
specialPerUnit: 0,
|
||||
specialInstallments: 1,
|
||||
effectiveDate: null as Date | null,
|
||||
endDate: null as Date | null,
|
||||
notes: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setForm({
|
||||
changeType: initialData.change_type || initialData.changeType || 'dues_increase',
|
||||
label: initialData.label || '',
|
||||
targetFund: initialData.target_fund || initialData.targetFund || 'operating',
|
||||
percentageChange: parseFloat(initialData.percentage_change || initialData.percentageChange) || 0,
|
||||
flatAmountChange: parseFloat(initialData.flat_amount_change || initialData.flatAmountChange) || 0,
|
||||
specialTotal: parseFloat(initialData.special_total || initialData.specialTotal) || 0,
|
||||
specialPerUnit: parseFloat(initialData.special_per_unit || initialData.specialPerUnit) || 0,
|
||||
specialInstallments: initialData.special_installments || initialData.specialInstallments || 1,
|
||||
effectiveDate: initialData.effective_date ? new Date(initialData.effective_date) : null,
|
||||
endDate: initialData.end_date ? new Date(initialData.end_date) : null,
|
||||
notes: initialData.notes || '',
|
||||
});
|
||||
} else {
|
||||
setForm({
|
||||
changeType: 'dues_increase', label: '', targetFund: 'operating',
|
||||
percentageChange: 0, flatAmountChange: 0, specialTotal: 0, specialPerUnit: 0,
|
||||
specialInstallments: 1, effectiveDate: null, endDate: null, notes: '',
|
||||
});
|
||||
}
|
||||
}, [initialData, opened]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit({
|
||||
...form,
|
||||
effectiveDate: form.effectiveDate?.toISOString().split('T')[0] || null,
|
||||
endDate: form.endDate?.toISOString().split('T')[0] || null,
|
||||
});
|
||||
};
|
||||
|
||||
const isSpecial = form.changeType === 'special_assessment';
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={initialData ? 'Edit Assessment Change' : 'Add Assessment Change'} size="lg">
|
||||
<Stack>
|
||||
<Select
|
||||
label="Change Type"
|
||||
value={form.changeType}
|
||||
onChange={(v) => setForm({ ...form, changeType: v || 'dues_increase' })}
|
||||
data={[
|
||||
{ value: 'dues_increase', label: 'Dues Increase' },
|
||||
{ value: 'dues_decrease', label: 'Dues Decrease' },
|
||||
{ value: 'special_assessment', label: 'Special Assessment' },
|
||||
]}
|
||||
/>
|
||||
<TextInput
|
||||
label="Label"
|
||||
required
|
||||
value={form.label}
|
||||
onChange={(e) => setForm({ ...form, label: e.target.value })}
|
||||
placeholder={isSpecial ? 'e.g. Roof Replacement Assessment' : 'e.g. 5% Annual Increase'}
|
||||
/>
|
||||
<Select
|
||||
label="Target Fund"
|
||||
value={form.targetFund}
|
||||
onChange={(v) => setForm({ ...form, targetFund: v || 'operating' })}
|
||||
data={[
|
||||
{ value: 'operating', label: 'Operating' },
|
||||
{ value: 'reserve', label: 'Reserve' },
|
||||
{ value: 'both', label: 'Both' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{!isSpecial && (
|
||||
<>
|
||||
<Text size="sm" fw={500} c="dimmed">Set either a percentage or flat amount (not both):</Text>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Percentage Change (%)"
|
||||
value={form.percentageChange}
|
||||
onChange={(v) => setForm({ ...form, percentageChange: Number(v) || 0, flatAmountChange: 0 })}
|
||||
min={0}
|
||||
max={100}
|
||||
decimalScale={2}
|
||||
suffix="%"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Flat Amount Change ($/unit/mo)"
|
||||
value={form.flatAmountChange}
|
||||
onChange={(v) => setForm({ ...form, flatAmountChange: Number(v) || 0, percentageChange: 0 })}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
prefix="$"
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isSpecial && (
|
||||
<>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Total Amount"
|
||||
value={form.specialTotal}
|
||||
onChange={(v) => setForm({ ...form, specialTotal: Number(v) || 0 })}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
thousandSeparator=","
|
||||
prefix="$"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Per Unit Amount"
|
||||
value={form.specialPerUnit}
|
||||
onChange={(v) => setForm({ ...form, specialPerUnit: Number(v) || 0 })}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
prefix="$"
|
||||
/>
|
||||
</Group>
|
||||
<NumberInput
|
||||
label="Installments"
|
||||
description="1 = one-time lump sum, 6 = spread over 6 months, etc."
|
||||
value={form.specialInstallments}
|
||||
onChange={(v) => setForm({ ...form, specialInstallments: Number(v) || 1 })}
|
||||
min={1}
|
||||
max={60}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group grow>
|
||||
<DateInput label="Effective Date" required value={form.effectiveDate} onChange={(v) => setForm({ ...form, effectiveDate: v })} />
|
||||
<DateInput label="End Date (optional)" value={form.endDate} onChange={(v) => setForm({ ...form, endDate: v })} clearable />
|
||||
</Group>
|
||||
<TextInput label="Notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} loading={loading} disabled={!form.label || !form.effectiveDate}>
|
||||
{initialData ? 'Update' : 'Add Change'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
110
frontend/src/pages/board-planning/components/InvestmentForm.tsx
Normal file
110
frontend/src/pages/board-planning/components/InvestmentForm.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Modal, TextInput, Select, NumberInput, Group, Button, Stack, Switch } from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: any) => void;
|
||||
initialData?: any;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function InvestmentForm({ opened, onClose, onSubmit, initialData, loading }: Props) {
|
||||
const [form, setForm] = useState({
|
||||
label: '',
|
||||
investmentType: 'cd',
|
||||
fundType: 'reserve',
|
||||
principal: 0,
|
||||
interestRate: 0,
|
||||
termMonths: 12,
|
||||
institution: '',
|
||||
purchaseDate: null as Date | null,
|
||||
maturityDate: null as Date | null,
|
||||
autoRenew: false,
|
||||
notes: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setForm({
|
||||
label: initialData.label || '',
|
||||
investmentType: initialData.investment_type || initialData.investmentType || 'cd',
|
||||
fundType: initialData.fund_type || initialData.fundType || 'reserve',
|
||||
principal: parseFloat(initialData.principal) || 0,
|
||||
interestRate: parseFloat(initialData.interest_rate || initialData.interestRate) || 0,
|
||||
termMonths: initialData.term_months || initialData.termMonths || 12,
|
||||
institution: initialData.institution || '',
|
||||
purchaseDate: initialData.purchase_date ? new Date(initialData.purchase_date) : null,
|
||||
maturityDate: initialData.maturity_date ? new Date(initialData.maturity_date) : null,
|
||||
autoRenew: initialData.auto_renew || initialData.autoRenew || false,
|
||||
notes: initialData.notes || '',
|
||||
});
|
||||
} else {
|
||||
setForm({
|
||||
label: '', investmentType: 'cd', fundType: 'reserve', principal: 0,
|
||||
interestRate: 0, termMonths: 12, institution: '', purchaseDate: null,
|
||||
maturityDate: null, autoRenew: false, notes: '',
|
||||
});
|
||||
}
|
||||
}, [initialData, opened]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit({
|
||||
...form,
|
||||
purchaseDate: form.purchaseDate?.toISOString().split('T')[0] || null,
|
||||
maturityDate: form.maturityDate?.toISOString().split('T')[0] || null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={initialData ? 'Edit Investment' : 'Add Investment'} size="lg">
|
||||
<Stack>
|
||||
<TextInput label="Label" required value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} placeholder="e.g. 6-Month Treasury" />
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Type"
|
||||
value={form.investmentType}
|
||||
onChange={(v) => setForm({ ...form, investmentType: v || 'cd' })}
|
||||
data={[
|
||||
{ value: 'cd', label: 'CD' },
|
||||
{ value: 'money_market', label: 'Money Market' },
|
||||
{ value: 'treasury', label: 'Treasury' },
|
||||
{ value: 'savings', label: 'Savings' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Fund"
|
||||
value={form.fundType}
|
||||
onChange={(v) => setForm({ ...form, fundType: v || 'reserve' })}
|
||||
data={[
|
||||
{ value: 'operating', label: 'Operating' },
|
||||
{ value: 'reserve', label: 'Reserve' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Principal ($)" required value={form.principal} onChange={(v) => setForm({ ...form, principal: Number(v) || 0 })} min={0} decimalScale={2} thousandSeparator="," prefix="$" />
|
||||
<NumberInput label="Interest Rate (%)" value={form.interestRate} onChange={(v) => setForm({ ...form, interestRate: Number(v) || 0 })} min={0} max={20} decimalScale={3} suffix="%" />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Term (months)" value={form.termMonths} onChange={(v) => setForm({ ...form, termMonths: Number(v) || 0 })} min={1} max={120} />
|
||||
<TextInput label="Institution" value={form.institution} onChange={(e) => setForm({ ...form, institution: e.target.value })} placeholder="e.g. First National Bank" />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<DateInput label="Purchase Date" value={form.purchaseDate} onChange={(v) => setForm({ ...form, purchaseDate: v })} clearable />
|
||||
<DateInput label="Maturity Date" value={form.maturityDate} onChange={(v) => setForm({ ...form, maturityDate: v })} clearable />
|
||||
</Group>
|
||||
<Switch label="Auto-renew at maturity" checked={form.autoRenew} onChange={(e) => setForm({ ...form, autoRenew: e.currentTarget.checked })} />
|
||||
<TextInput label="Notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} loading={loading} disabled={!form.label || !form.principal}>
|
||||
{initialData ? 'Update' : 'Add Investment'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Card, Title, Text, Group, Badge, Tooltip } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
cd: '#228be6',
|
||||
money_market: '#40c057',
|
||||
treasury: '#7950f2',
|
||||
savings: '#fd7e14',
|
||||
other: '#868e96',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
investments: any[];
|
||||
}
|
||||
|
||||
export function InvestmentTimeline({ investments }: Props) {
|
||||
const { items, startDate, endDate, totalMonths } = useMemo(() => {
|
||||
const now = new Date();
|
||||
const items = investments
|
||||
.filter((inv: any) => inv.purchase_date || inv.maturity_date)
|
||||
.map((inv: any) => ({
|
||||
...inv,
|
||||
start: inv.purchase_date ? new Date(inv.purchase_date) : now,
|
||||
end: inv.maturity_date ? new Date(inv.maturity_date) : null,
|
||||
}));
|
||||
|
||||
if (!items.length) return { items: [], startDate: now, endDate: now, totalMonths: 1 };
|
||||
|
||||
const allDates = items.flatMap((i: any) => [i.start, i.end].filter(Boolean)) as Date[];
|
||||
const startDate = new Date(Math.min(...allDates.map((d) => d.getTime())));
|
||||
const endDate = new Date(Math.max(...allDates.map((d) => d.getTime())));
|
||||
const totalMonths = Math.max(
|
||||
(endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + 1,
|
||||
1,
|
||||
);
|
||||
|
||||
return { items, startDate, endDate, totalMonths };
|
||||
}, [investments]);
|
||||
|
||||
if (!items.length) return null;
|
||||
|
||||
const getPercent = (date: Date) => {
|
||||
const months = (date.getFullYear() - startDate.getFullYear()) * 12 + (date.getMonth() - startDate.getMonth());
|
||||
return Math.max(0, Math.min(100, (months / totalMonths) * 100));
|
||||
};
|
||||
|
||||
// Generate year labels
|
||||
const yearLabels: { year: number; percent: number }[] = [];
|
||||
for (let y = startDate.getFullYear(); y <= endDate.getFullYear(); y++) {
|
||||
const janDate = new Date(y, 0, 1);
|
||||
if (janDate >= startDate && janDate <= endDate) {
|
||||
yearLabels.push({ year: y, percent: getPercent(janDate) });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card withBorder p="lg">
|
||||
<Title order={4} mb="md">Investment Timeline</Title>
|
||||
|
||||
{/* Year markers */}
|
||||
<div style={{ position: 'relative', height: 20, marginBottom: 8 }}>
|
||||
{yearLabels.map((yl) => (
|
||||
<Text
|
||||
key={yl.year}
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
fw={700}
|
||||
style={{ position: 'absolute', left: `${yl.percent}%`, transform: 'translateX(-50%)' }}
|
||||
>
|
||||
{yl.year}
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Timeline bars */}
|
||||
<div style={{ position: 'relative', minHeight: items.length * 40 + 10 }}>
|
||||
{/* Background grid */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderLeft: '1px solid var(--mantine-color-gray-3)',
|
||||
borderRight: '1px solid var(--mantine-color-gray-3)',
|
||||
}}>
|
||||
{yearLabels.map((yl) => (
|
||||
<div
|
||||
key={yl.year}
|
||||
style={{
|
||||
position: 'absolute', left: `${yl.percent}%`, top: 0, bottom: 0,
|
||||
borderLeft: '1px dashed var(--mantine-color-gray-3)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{items.map((inv: any, idx: number) => {
|
||||
const leftPct = getPercent(inv.start);
|
||||
const rightPct = inv.end ? getPercent(inv.end) : leftPct + 2;
|
||||
const widthPct = Math.max(rightPct - leftPct, 1);
|
||||
const color = typeColors[inv.investment_type] || '#868e96';
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={inv.id}
|
||||
label={
|
||||
<div>
|
||||
<Text size="xs" fw={600}>{inv.label}</Text>
|
||||
<Text size="xs">{fmt(parseFloat(inv.principal))} @ {parseFloat(inv.interest_rate || 0).toFixed(2)}%</Text>
|
||||
{inv.purchase_date && <Text size="xs">Start: {new Date(inv.purchase_date).toLocaleDateString()}</Text>}
|
||||
{inv.maturity_date && <Text size="xs">Maturity: {new Date(inv.maturity_date).toLocaleDateString()}</Text>}
|
||||
</div>
|
||||
}
|
||||
position="top"
|
||||
multiline
|
||||
withArrow
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${leftPct}%`,
|
||||
width: `${widthPct}%`,
|
||||
top: idx * 40 + 4,
|
||||
height: 28,
|
||||
borderRadius: 4,
|
||||
background: color,
|
||||
opacity: inv.executed_investment_id ? 0.5 : 0.85,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
cursor: 'pointer',
|
||||
minWidth: 60,
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="white" fw={600} truncate style={{ lineHeight: 1 }}>
|
||||
{inv.label} — {fmt(parseFloat(inv.principal))}
|
||||
</Text>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<Group gap="md" mt="md">
|
||||
{Object.entries(typeColors).map(([type, color]) => (
|
||||
<Group key={type} gap={4}>
|
||||
<div style={{ width: 12, height: 12, borderRadius: 2, background: color }} />
|
||||
<Text size="xs" c="dimmed">{type.replace('_', ' ')}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
124
frontend/src/pages/board-planning/components/ProjectionChart.tsx
Normal file
124
frontend/src/pages/board-planning/components/ProjectionChart.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Card, Title, Text, Group, Badge, SegmentedControl, Stack } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
||||
ResponsiveContainer, ReferenceLine,
|
||||
} from 'recharts';
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
|
||||
interface Datapoint {
|
||||
month: string;
|
||||
year: number;
|
||||
monthNum: number;
|
||||
is_forecast: boolean;
|
||||
operating_cash: number;
|
||||
operating_investments: number;
|
||||
reserve_cash: number;
|
||||
reserve_investments: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
datapoints: Datapoint[];
|
||||
title?: string;
|
||||
summary?: any;
|
||||
}
|
||||
|
||||
export function ProjectionChart({ datapoints, title = 'Financial Projection', summary }: Props) {
|
||||
const [fundFilter, setFundFilter] = useState('all');
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
return datapoints.map((d) => ({
|
||||
...d,
|
||||
label: `${d.month}`,
|
||||
total: d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
|
||||
}));
|
||||
}, [datapoints]);
|
||||
|
||||
// Find first forecast month for reference line
|
||||
const forecastStart = chartData.findIndex((d) => d.is_forecast);
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<Card shadow="sm" p="xs" withBorder style={{ background: 'var(--mantine-color-body)' }}>
|
||||
<Text fw={600} size="sm" mb={4}>{label}</Text>
|
||||
{payload.map((p: any) => (
|
||||
<Group key={p.name} justify="space-between" gap="xl">
|
||||
<Text size="xs" c={p.color}>{p.name}</Text>
|
||||
<Text size="xs" fw={600} ff="monospace">{fmt(p.value)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const showOp = fundFilter === 'all' || fundFilter === 'operating';
|
||||
const showRes = fundFilter === 'all' || fundFilter === 'reserve';
|
||||
|
||||
return (
|
||||
<Card withBorder p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={4}>{title}</Title>
|
||||
{summary && (
|
||||
<Group gap="md" mt={4}>
|
||||
<Badge variant="light" color="teal">End Liquidity: {fmt(summary.end_liquidity || 0)}</Badge>
|
||||
<Badge variant="light" color="orange">Min Liquidity: {fmt(summary.min_liquidity || 0)}</Badge>
|
||||
{summary.reserve_coverage_months != null && (
|
||||
<Badge variant="light" color="violet">
|
||||
Reserve Coverage: {summary.reserve_coverage_months.toFixed(1)} mo
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={fundFilter}
|
||||
onChange={setFundFilter}
|
||||
data={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Operating', value: 'operating' },
|
||||
{ label: 'Reserve', value: 'reserve' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#228be6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#228be6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#7950f2" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#b197fc" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
{forecastStart > 0 && (
|
||||
<ReferenceLine x={chartData[forecastStart]?.month} stroke="#aaa" strokeDasharray="5 5" label="Forecast" />
|
||||
)}
|
||||
{showOp && <Area type="monotone" dataKey="operating_cash" name="Operating Cash" stroke="#228be6" fill="url(#opCash)" stackId="1" />}
|
||||
{showOp && <Area type="monotone" dataKey="operating_investments" name="Operating Investments" stroke="#74c0fc" fill="url(#opInv)" stackId="1" />}
|
||||
{showRes && <Area type="monotone" dataKey="reserve_cash" name="Reserve Cash" stroke="#7950f2" fill="url(#resCash)" stackId="1" />}
|
||||
{showRes && <Area type="monotone" dataKey="reserve_investments" name="Reserve Investments" stroke="#b197fc" fill="url(#resInv)" stackId="1" />}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Card, Group, Text, Badge, ActionIcon, Menu } from '@mantine/core';
|
||||
import { IconDots, IconTrash, IconEdit, IconPlayerPlay } from '@tabler/icons-react';
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: 'gray',
|
||||
active: 'blue',
|
||||
approved: 'green',
|
||||
archived: 'red',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
scenario: any;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function ScenarioCard({ scenario, onClick, onEdit, onDelete }: Props) {
|
||||
return (
|
||||
<Card withBorder p="lg" style={{ cursor: 'pointer' }} onClick={onClick}>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>{scenario.name}</Text>
|
||||
<Badge size="xs" color={statusColors[scenario.status] || 'gray'}>
|
||||
{scenario.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Menu withinPortal position="bottom-end" shadow="sm">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={(e: any) => e.stopPropagation()}>
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEdit size={14} />} onClick={(e: any) => { e.stopPropagation(); onEdit(); }}>
|
||||
Edit
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={(e: any) => { e.stopPropagation(); onDelete(); }}>
|
||||
Archive
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
{scenario.description && (
|
||||
<Text size="sm" c="dimmed" mb="sm" lineClamp={2}>{scenario.description}</Text>
|
||||
)}
|
||||
<Group gap="lg">
|
||||
{scenario.scenario_type === 'investment' && (
|
||||
<>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">Investments</Text>
|
||||
<Text fw={600}>{scenario.investment_count || 0}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">Total Principal</Text>
|
||||
<Text fw={600} ff="monospace">{fmt(parseFloat(scenario.total_principal) || 0)}</Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{scenario.scenario_type === 'assessment' && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">Changes</Text>
|
||||
<Text fw={600}>{scenario.assessment_count || 0}</Text>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt="sm">
|
||||
Updated {new Date(scenario.updated_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
Tabs,
|
||||
Collapse,
|
||||
ActionIcon,
|
||||
Modal,
|
||||
Select,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBulb,
|
||||
@@ -32,9 +35,11 @@ import {
|
||||
IconPigMoney,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconPlaylistAdd,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
|
||||
// ── Types ──
|
||||
@@ -188,10 +193,12 @@ function RecommendationsDisplay({
|
||||
aiResult,
|
||||
lastUpdated,
|
||||
lastFailed,
|
||||
onAddToPlan,
|
||||
}: {
|
||||
aiResult: AIResponse;
|
||||
lastUpdated?: string;
|
||||
lastFailed?: boolean;
|
||||
onAddToPlan?: (rec: Recommendation) => void;
|
||||
}) {
|
||||
return (
|
||||
<Stack>
|
||||
@@ -327,6 +334,17 @@ function RecommendationsDisplay({
|
||||
<Alert variant="light" color="gray" title="Rationale">
|
||||
<Text size="sm">{rec.rationale}</Text>
|
||||
</Alert>
|
||||
|
||||
{onAddToPlan && rec.type !== 'liquidity_warning' && rec.type !== 'general' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconPlaylistAdd size={16} />}
|
||||
onClick={() => onAddToPlan(rec)}
|
||||
>
|
||||
Add to Investment Plan
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
@@ -345,8 +363,86 @@ function RecommendationsDisplay({
|
||||
// ── Main Component ──
|
||||
|
||||
export function InvestmentPlanningPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [ratesExpanded, setRatesExpanded] = useState(true);
|
||||
const [isTriggering, setIsTriggering] = useState(false);
|
||||
const [planModalOpen, setPlanModalOpen] = useState(false);
|
||||
const [selectedRec, setSelectedRec] = useState<Recommendation | null>(null);
|
||||
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
|
||||
const [newScenarioName, setNewScenarioName] = useState('');
|
||||
|
||||
// Load investment scenarios for the "Add to Plan" modal
|
||||
const { data: investmentScenarios } = useQuery<any[]>({
|
||||
queryKey: ['board-planning-scenarios', 'investment'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/board-planning/scenarios?type=investment');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const addToPlanMutation = useMutation({
|
||||
mutationFn: async ({ scenarioId, rec }: { scenarioId: string; rec: Recommendation }) => {
|
||||
await api.post(`/board-planning/scenarios/${scenarioId}/investments/from-recommendation`, {
|
||||
title: rec.title,
|
||||
investmentType: rec.type === 'cd_ladder' ? 'cd' : rec.type === 'new_investment' ? undefined : undefined,
|
||||
fundType: rec.fund_type || 'reserve',
|
||||
suggestedAmount: rec.suggested_amount,
|
||||
suggestedRate: rec.suggested_rate,
|
||||
termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null,
|
||||
bankName: rec.bank_name,
|
||||
rationale: rec.rationale,
|
||||
});
|
||||
return scenarioId;
|
||||
},
|
||||
onSuccess: (scenarioId) => {
|
||||
setPlanModalOpen(false);
|
||||
setSelectedRec(null);
|
||||
setTargetScenarioId(null);
|
||||
notifications.show({
|
||||
message: 'Recommendation added to investment scenario',
|
||||
color: 'green',
|
||||
autoClose: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const createAndAddMutation = useMutation({
|
||||
mutationFn: async ({ name, rec }: { name: string; rec: Recommendation }) => {
|
||||
const { data: scenario } = await api.post('/board-planning/scenarios', {
|
||||
name, scenarioType: 'investment',
|
||||
});
|
||||
await api.post(`/board-planning/scenarios/${scenario.id}/investments/from-recommendation`, {
|
||||
title: rec.title,
|
||||
investmentType: rec.type === 'cd_ladder' ? 'cd' : undefined,
|
||||
fundType: rec.fund_type || 'reserve',
|
||||
suggestedAmount: rec.suggested_amount,
|
||||
suggestedRate: rec.suggested_rate,
|
||||
termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null,
|
||||
bankName: rec.bank_name,
|
||||
rationale: rec.rationale,
|
||||
});
|
||||
return scenario.id;
|
||||
},
|
||||
onSuccess: (scenarioId) => {
|
||||
setPlanModalOpen(false);
|
||||
setSelectedRec(null);
|
||||
setNewScenarioName('');
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||
notifications.show({
|
||||
message: 'New scenario created with recommendation',
|
||||
color: 'green',
|
||||
autoClose: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddToPlan = (rec: Recommendation) => {
|
||||
setSelectedRec(rec);
|
||||
setTargetScenarioId(null);
|
||||
setNewScenarioName('');
|
||||
setPlanModalOpen(true);
|
||||
};
|
||||
|
||||
// Load financial snapshot on mount
|
||||
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
||||
@@ -737,6 +833,7 @@ export function InvestmentPlanningPage() {
|
||||
aiResult={aiResult}
|
||||
lastUpdated={savedRec?.created_at || undefined}
|
||||
lastFailed={lastFailed}
|
||||
onAddToPlan={handleAddToPlan}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -758,6 +855,60 @@ export function InvestmentPlanningPage() {
|
||||
</Paper>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Add to Investment Plan Modal */}
|
||||
<Modal opened={planModalOpen} onClose={() => setPlanModalOpen(false)} title="Add to Investment Plan">
|
||||
<Stack>
|
||||
{selectedRec && (
|
||||
<Alert variant="light" color="blue">
|
||||
<Text size="sm" fw={600}>{selectedRec.title}</Text>
|
||||
{selectedRec.suggested_amount != null && (
|
||||
<Text size="sm">Amount: {fmt(selectedRec.suggested_amount)}</Text>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{investmentScenarios && investmentScenarios.length > 0 && (
|
||||
<Select
|
||||
label="Add to existing scenario"
|
||||
placeholder="Select a scenario..."
|
||||
data={investmentScenarios.map((s: any) => ({ value: s.id, label: s.name }))}
|
||||
value={targetScenarioId}
|
||||
onChange={setTargetScenarioId}
|
||||
clearable
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider label="or" labelPosition="center" />
|
||||
|
||||
<TextInput
|
||||
label="Create new scenario"
|
||||
placeholder="e.g. Conservative Strategy"
|
||||
value={newScenarioName}
|
||||
onChange={(e) => { setNewScenarioName(e.target.value); setTargetScenarioId(null); }}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setPlanModalOpen(false)}>Cancel</Button>
|
||||
{targetScenarioId && selectedRec && (
|
||||
<Button
|
||||
onClick={() => addToPlanMutation.mutate({ scenarioId: targetScenarioId, rec: selectedRec })}
|
||||
loading={addToPlanMutation.isPending}
|
||||
>
|
||||
Add to Scenario
|
||||
</Button>
|
||||
)}
|
||||
{newScenarioName && !targetScenarioId && selectedRec && (
|
||||
<Button
|
||||
onClick={() => createAndAddMutation.mutate({ name: newScenarioName, rec: selectedRec })}
|
||||
loading={createAndAddMutation.isPending}
|
||||
>
|
||||
Create & Add
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user