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 { AttachmentsModule } from './modules/attachments/attachments.module';
|
||||||
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
|
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
|
||||||
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
|
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
|
||||||
|
import { BoardPlanningModule } from './modules/board-planning/board-planning.module';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -79,6 +80,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
AttachmentsModule,
|
AttachmentsModule,
|
||||||
InvestmentPlanningModule,
|
InvestmentPlanningModule,
|
||||||
HealthScoresModule,
|
HealthScoresModule,
|
||||||
|
BoardPlanningModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -366,6 +366,64 @@ export class TenantSchemaService {
|
|||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
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
|
// Indexes
|
||||||
`CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`,
|
`CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`,
|
||||||
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
|
`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_unit" ON "${s}".payments(unit_id)`,
|
||||||
`CREATE INDEX "idx_${s}_pay_inv" ON "${s}".payments(invoice_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}_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 { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
|
||||||
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
|
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
|
||||||
import { InvestmentPlanningPage } from './pages/investment-planning/InvestmentPlanningPage';
|
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 }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const token = useAuthStore((s) => s.token);
|
const token = useAuthStore((s) => s.token);
|
||||||
@@ -137,6 +142,11 @@ export function App() {
|
|||||||
<Route path="reports/sankey" element={<SankeyPage />} />
|
<Route path="reports/sankey" element={<SankeyPage />} />
|
||||||
<Route path="reports/year-end" element={<YearEndPage />} />
|
<Route path="reports/year-end" element={<YearEndPage />} />
|
||||||
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
|
<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="settings" element={<SettingsPage />} />
|
||||||
<Route path="preferences" element={<UserPreferencesPage />} />
|
<Route path="preferences" element={<UserPreferencesPage />} />
|
||||||
<Route path="org-members" element={<OrgMembersPage />} />
|
<Route path="org-members" element={<OrgMembersPage />} />
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import {
|
|||||||
IconClipboardCheck,
|
IconClipboardCheck,
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconHeartRateMonitor,
|
IconHeartRateMonitor,
|
||||||
|
IconCalculator,
|
||||||
|
IconGitCompare,
|
||||||
|
IconScale,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
@@ -60,6 +63,14 @@ const navSections = [
|
|||||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
{ 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',
|
label: 'Reports',
|
||||||
items: [
|
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,
|
Tabs,
|
||||||
Collapse,
|
Collapse,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
TextInput,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconBulb,
|
IconBulb,
|
||||||
@@ -32,9 +35,11 @@ import {
|
|||||||
IconPigMoney,
|
IconPigMoney,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronUp,
|
IconChevronUp,
|
||||||
|
IconPlaylistAdd,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
@@ -188,10 +193,12 @@ function RecommendationsDisplay({
|
|||||||
aiResult,
|
aiResult,
|
||||||
lastUpdated,
|
lastUpdated,
|
||||||
lastFailed,
|
lastFailed,
|
||||||
|
onAddToPlan,
|
||||||
}: {
|
}: {
|
||||||
aiResult: AIResponse;
|
aiResult: AIResponse;
|
||||||
lastUpdated?: string;
|
lastUpdated?: string;
|
||||||
lastFailed?: boolean;
|
lastFailed?: boolean;
|
||||||
|
onAddToPlan?: (rec: Recommendation) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
@@ -327,6 +334,17 @@ function RecommendationsDisplay({
|
|||||||
<Alert variant="light" color="gray" title="Rationale">
|
<Alert variant="light" color="gray" title="Rationale">
|
||||||
<Text size="sm">{rec.rationale}</Text>
|
<Text size="sm">{rec.rationale}</Text>
|
||||||
</Alert>
|
</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>
|
</Stack>
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
@@ -345,8 +363,86 @@ function RecommendationsDisplay({
|
|||||||
// ── Main Component ──
|
// ── Main Component ──
|
||||||
|
|
||||||
export function InvestmentPlanningPage() {
|
export function InvestmentPlanningPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [ratesExpanded, setRatesExpanded] = useState(true);
|
const [ratesExpanded, setRatesExpanded] = useState(true);
|
||||||
const [isTriggering, setIsTriggering] = useState(false);
|
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
|
// Load financial snapshot on mount
|
||||||
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
||||||
@@ -737,6 +833,7 @@ export function InvestmentPlanningPage() {
|
|||||||
aiResult={aiResult}
|
aiResult={aiResult}
|
||||||
lastUpdated={savedRec?.created_at || undefined}
|
lastUpdated={savedRec?.created_at || undefined}
|
||||||
lastFailed={lastFailed}
|
lastFailed={lastFailed}
|
||||||
|
onAddToPlan={handleAddToPlan}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -758,6 +855,60 @@ export function InvestmentPlanningPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</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>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user