From c8d77aaa48494e257f3a3b32f1ea9f9568500d4c Mon Sep 17 00:00:00 2001 From: olsch01 Date: Mon, 16 Mar 2026 09:52:10 -0400 Subject: [PATCH] feat: add Board Planning module with investment/assessment scenario modeling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/app.module.ts | 2 + backend/src/database/tenant-schema.service.ts | 61 +++ .../board-planning-projection.service.ts | 473 ++++++++++++++++++ .../board-planning.controller.ts | 130 +++++ .../board-planning/board-planning.module.ts | 11 + .../board-planning/board-planning.service.ts | 335 +++++++++++++ db/migrations/013-board-planning.sql | 83 +++ frontend/src/App.tsx | 10 + frontend/src/components/layout/Sidebar.tsx | 11 + .../AssessmentScenarioDetailPage.tsx | 261 ++++++++++ .../AssessmentScenariosPage.tsx | 128 +++++ .../InvestmentScenarioDetailPage.tsx | 280 +++++++++++ .../InvestmentScenariosPage.tsx | 128 +++++ .../board-planning/ScenarioComparisonPage.tsx | 210 ++++++++ .../components/AssessmentChangeForm.tsx | 164 ++++++ .../components/InvestmentForm.tsx | 110 ++++ .../components/InvestmentTimeline.tsx | 154 ++++++ .../components/ProjectionChart.tsx | 124 +++++ .../components/ScenarioCard.tsx | 74 +++ .../InvestmentPlanningPage.tsx | 153 +++++- 20 files changed, 2901 insertions(+), 1 deletion(-) create mode 100644 backend/src/modules/board-planning/board-planning-projection.service.ts create mode 100644 backend/src/modules/board-planning/board-planning.controller.ts create mode 100644 backend/src/modules/board-planning/board-planning.module.ts create mode 100644 backend/src/modules/board-planning/board-planning.service.ts create mode 100644 db/migrations/013-board-planning.sql create mode 100644 frontend/src/pages/board-planning/AssessmentScenarioDetailPage.tsx create mode 100644 frontend/src/pages/board-planning/AssessmentScenariosPage.tsx create mode 100644 frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx create mode 100644 frontend/src/pages/board-planning/InvestmentScenariosPage.tsx create mode 100644 frontend/src/pages/board-planning/ScenarioComparisonPage.tsx create mode 100644 frontend/src/pages/board-planning/components/AssessmentChangeForm.tsx create mode 100644 frontend/src/pages/board-planning/components/InvestmentForm.tsx create mode 100644 frontend/src/pages/board-planning/components/InvestmentTimeline.tsx create mode 100644 frontend/src/pages/board-planning/components/ProjectionChart.tsx create mode 100644 frontend/src/pages/board-planning/components/ScenarioCard.tsx diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e7e2a09..e0809d1 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -28,6 +28,7 @@ import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals. import { AttachmentsModule } from './modules/attachments/attachments.module'; import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module'; import { HealthScoresModule } from './modules/health-scores/health-scores.module'; +import { BoardPlanningModule } from './modules/board-planning/board-planning.module'; import { ScheduleModule } from '@nestjs/schedule'; @Module({ @@ -79,6 +80,7 @@ import { ScheduleModule } from '@nestjs/schedule'; AttachmentsModule, InvestmentPlanningModule, HealthScoresModule, + BoardPlanningModule, ScheduleModule.forRoot(), ], controllers: [AppController], diff --git a/backend/src/database/tenant-schema.service.ts b/backend/src/database/tenant-schema.service.ts index b304f60..6ea5f48 100644 --- a/backend/src/database/tenant-schema.service.ts +++ b/backend/src/database/tenant-schema.service.ts @@ -366,6 +366,64 @@ export class TenantSchemaService { created_at TIMESTAMPTZ DEFAULT NOW() )`, + // Board Planning - Scenarios + `CREATE TABLE "${s}".board_scenarios ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + description TEXT, + scenario_type VARCHAR(30) NOT NULL CHECK (scenario_type IN ('investment', 'assessment')), + status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'approved', 'archived')), + projection_months INTEGER DEFAULT 36, + projection_cache JSONB, + projection_cached_at TIMESTAMPTZ, + created_by UUID NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Board Planning - Scenario Investments + `CREATE TABLE "${s}".scenario_investments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + scenario_id UUID NOT NULL REFERENCES "${s}".board_scenarios(id) ON DELETE CASCADE, + source_recommendation_id UUID, + label VARCHAR(255) NOT NULL, + investment_type VARCHAR(50) CHECK (investment_type IN ('cd', 'money_market', 'treasury', 'savings', 'other')), + fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN ('operating', 'reserve')), + principal DECIMAL(15,2) NOT NULL, + interest_rate DECIMAL(6,4), + term_months INTEGER, + institution VARCHAR(255), + purchase_date DATE, + maturity_date DATE, + auto_renew BOOLEAN DEFAULT FALSE, + executed_investment_id UUID, + notes TEXT, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Board Planning - Scenario Assessments + `CREATE TABLE "${s}".scenario_assessments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + scenario_id UUID NOT NULL REFERENCES "${s}".board_scenarios(id) ON DELETE CASCADE, + change_type VARCHAR(30) NOT NULL CHECK (change_type IN ('dues_increase', 'special_assessment', 'dues_decrease')), + label VARCHAR(255) NOT NULL, + target_fund VARCHAR(20) CHECK (target_fund IN ('operating', 'reserve', 'both')), + percentage_change DECIMAL(6,3), + flat_amount_change DECIMAL(10,2), + special_total DECIMAL(15,2), + special_per_unit DECIMAL(10,2), + special_installments INTEGER DEFAULT 1, + effective_date DATE NOT NULL, + end_date DATE, + applies_to_group_id UUID, + notes TEXT, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + // Indexes `CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`, `CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`, @@ -378,6 +436,9 @@ export class TenantSchemaService { `CREATE INDEX "idx_${s}_pay_unit" ON "${s}".payments(unit_id)`, `CREATE INDEX "idx_${s}_pay_inv" ON "${s}".payments(invoice_id)`, `CREATE INDEX "idx_${s}_bud_year" ON "${s}".budgets(fiscal_year)`, + `CREATE INDEX "idx_${s}_bs_type_status" ON "${s}".board_scenarios(scenario_type, status)`, + `CREATE INDEX "idx_${s}_si_scenario" ON "${s}".scenario_investments(scenario_id)`, + `CREATE INDEX "idx_${s}_sa_scenario" ON "${s}".scenario_assessments(scenario_id)`, ]; } diff --git a/backend/src/modules/board-planning/board-planning-projection.service.ts b/backend/src/modules/board-planning/board-planning-projection.service.ts new file mode 100644 index 0000000..bcfdd9f --- /dev/null +++ b/backend/src/modules/board-planning/board-planning-projection.service.ts @@ -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 = {}; + 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 = {}; + 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 = {}; + 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 = {}; + 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]), + }; + } +} diff --git a/backend/src/modules/board-planning/board-planning.controller.ts b/backend/src/modules/board-planning/board-planning.controller.ts new file mode 100644 index 0000000..2d5ac8b --- /dev/null +++ b/backend/src/modules/board-planning/board-planning.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/board-planning/board-planning.module.ts b/backend/src/modules/board-planning/board-planning.module.ts new file mode 100644 index 0000000..e71ea65 --- /dev/null +++ b/backend/src/modules/board-planning/board-planning.module.ts @@ -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 {} diff --git a/backend/src/modules/board-planning/board-planning.service.ts b/backend/src/modules/board-planning/board-planning.service.ts new file mode 100644 index 0000000..61ed74e --- /dev/null +++ b/backend/src/modules/board-planning/board-planning.service.ts @@ -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], + ); + } +} diff --git a/db/migrations/013-board-planning.sql b/db/migrations/013-board-planning.sql new file mode 100644 index 0000000..614e77e --- /dev/null +++ b/db/migrations/013-board-planning.sql @@ -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 $$; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ecfaedd..743c669 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,6 +31,11 @@ import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroups import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage'; import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage'; import { InvestmentPlanningPage } from './pages/investment-planning/InvestmentPlanningPage'; +import { InvestmentScenariosPage } from './pages/board-planning/InvestmentScenariosPage'; +import { InvestmentScenarioDetailPage } from './pages/board-planning/InvestmentScenarioDetailPage'; +import { AssessmentScenariosPage } from './pages/board-planning/AssessmentScenariosPage'; +import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentScenarioDetailPage'; +import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token); @@ -137,6 +142,11 @@ export function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 1bf3db6..7661868 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -18,6 +18,9 @@ import { IconClipboardCheck, IconSparkles, IconHeartRateMonitor, + IconCalculator, + IconGitCompare, + IconScale, } from '@tabler/icons-react'; import { useAuthStore } from '../../stores/authStore'; @@ -60,6 +63,14 @@ const navSections = [ { label: 'Vendors', icon: IconUsers, path: '/vendors' }, ], }, + { + label: 'Board Planning', + items: [ + { label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' }, + { label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments' }, + { label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' }, + ], + }, { label: 'Reports', items: [ diff --git a/frontend/src/pages/board-planning/AssessmentScenarioDetailPage.tsx b/frontend/src/pages/board-planning/AssessmentScenarioDetailPage.tsx new file mode 100644 index 0000000..0a08dd7 --- /dev/null +++ b/frontend/src/pages/board-planning/AssessmentScenarioDetailPage.tsx @@ -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 = { + draft: 'gray', active: 'blue', approved: 'green', archived: 'red', +}; + +const changeTypeLabels: Record = { + dues_increase: 'Dues Increase', + dues_decrease: 'Dues Decrease', + special_assessment: 'Special Assessment', +}; + +const changeTypeColors: Record = { + 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(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
; + if (!scenario) return
Scenario not found
; + + const assessments = scenario.assessments || []; + + return ( + + {/* Header */} + + + navigate('/board-planning/assessments')}> + + +
+ + {scenario.name} + {scenario.status} + + {scenario.description && {scenario.description}} +
+
+ +