feat: add Future Year Budget Planning with inflation-adjusted projections

Adds budget planning capability under Board Planning, allowing HOA boards
to model future year budgets with configurable per-year inflation rates.

Backend:
- New budget_plans + budget_plan_lines tables (migration 014)
- BudgetPlanningService: CRUD, inflation generation (per-month preservation),
  status workflow (planning → approved → ratified), ratify-to-official copy
- 8 new API endpoints on board-planning controller
- Projection engine (both board-planning and reports) now falls back to
  planned budgets via UNION ALL query when no official budget exists
- Extended year range from 3 to dynamic based on projection months

Frontend:
- BudgetPlanningPage with monthly grid table (mirrors BudgetsPage pattern)
- Year selector, inflation rate control, status progression buttons
- Inline editing with save, confirmation modals for status changes
- Manual edit tracking with visual indicator
- Summary cards for income/expense totals

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 10:24:18 -04:00
parent c8d77aaa48
commit e6fe2314de
10 changed files with 1052 additions and 17 deletions

View File

@@ -202,13 +202,36 @@ export class BoardPlanningProjectionService {
`SELECT frequency, regular_assessment, special_assessment, unit_count FROM assessment_groups WHERE is_active = true`,
);
// Budgets
// Budgets (official + planned budget fallback)
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],
);
const endYear = startYear + Math.ceil(months / 12) + 1;
for (let yr = startYear; yr <= endYear; yr++) {
let budgetRows: any[];
try {
budgetRows = await this.tenant.query(
`SELECT fund_type, account_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt FROM (
SELECT b.account_id, 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,
1 as source_priority
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1
UNION ALL
SELECT bpl.account_id, bpl.fund_type, a.account_type,
bpl.jan, bpl.feb, bpl.mar, bpl.apr, bpl.may, bpl.jun, bpl.jul, bpl.aug, bpl.sep, bpl.oct, bpl.nov, bpl.dec_amt,
2 as source_priority
FROM budget_plan_lines bpl
JOIN budget_plans bp ON bp.id = bpl.budget_plan_id
JOIN accounts a ON a.id = bpl.account_id
WHERE bp.fiscal_year = $1
) combined
ORDER BY account_id, fund_type, source_priority`, [yr],
);
} catch {
// budget_plan_lines may not exist yet - fall back to official only
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 };