diff --git a/backend/src/database/tenant-schema.service.ts b/backend/src/database/tenant-schema.service.ts index 6ea5f48..babe4c5 100644 --- a/backend/src/database/tenant-schema.service.ts +++ b/backend/src/database/tenant-schema.service.ts @@ -424,6 +424,41 @@ export class TenantSchemaService { updated_at TIMESTAMPTZ DEFAULT NOW() )`, + // Budget Plans + `CREATE TABLE "${s}".budget_plans ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + fiscal_year INTEGER NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'planning' CHECK (status IN ('planning', 'approved', 'ratified')), + base_year INTEGER NOT NULL, + inflation_rate DECIMAL(5,2) NOT NULL DEFAULT 2.50, + notes TEXT, + created_by UUID, + approved_by UUID, + approved_at TIMESTAMPTZ, + ratified_by UUID, + ratified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(fiscal_year) + )`, + + // Budget Plan Lines + `CREATE TABLE "${s}".budget_plan_lines ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + budget_plan_id UUID NOT NULL REFERENCES "${s}".budget_plans(id) ON DELETE CASCADE, + account_id UUID NOT NULL REFERENCES "${s}".accounts(id), + fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN ('operating', 'reserve')), + jan DECIMAL(12,2) DEFAULT 0, feb DECIMAL(12,2) DEFAULT 0, + mar DECIMAL(12,2) DEFAULT 0, apr DECIMAL(12,2) DEFAULT 0, + may DECIMAL(12,2) DEFAULT 0, jun DECIMAL(12,2) DEFAULT 0, + jul DECIMAL(12,2) DEFAULT 0, aug DECIMAL(12,2) DEFAULT 0, + sep DECIMAL(12,2) DEFAULT 0, oct DECIMAL(12,2) DEFAULT 0, + nov DECIMAL(12,2) DEFAULT 0, dec_amt DECIMAL(12,2) DEFAULT 0, + is_manually_adjusted BOOLEAN DEFAULT FALSE, + notes TEXT, + UNIQUE(budget_plan_id, account_id, fund_type) + )`, + // 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)`, @@ -439,6 +474,9 @@ export class TenantSchemaService { `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)`, + `CREATE INDEX "idx_${s}_bp_year" ON "${s}".budget_plans(fiscal_year)`, + `CREATE INDEX "idx_${s}_bp_status" ON "${s}".budget_plans(status)`, + `CREATE INDEX "idx_${s}_bpl_plan" ON "${s}".budget_plan_lines(budget_plan_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 index bcfdd9f..bddba51 100644 --- a/backend/src/modules/board-planning/board-planning-projection.service.ts +++ b/backend/src/modules/board-planning/board-planning-projection.service.ts @@ -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 = {}; - 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 }; diff --git a/backend/src/modules/board-planning/board-planning.controller.ts b/backend/src/modules/board-planning/board-planning.controller.ts index 2d5ac8b..76b128d 100644 --- a/backend/src/modules/board-planning/board-planning.controller.ts +++ b/backend/src/modules/board-planning/board-planning.controller.ts @@ -4,6 +4,7 @@ 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'; +import { BudgetPlanningService } from './budget-planning.service'; @ApiTags('board-planning') @Controller('board-planning') @@ -13,6 +14,7 @@ export class BoardPlanningController { constructor( private service: BoardPlanningService, private projection: BoardPlanningProjectionService, + private budgetPlanning: BudgetPlanningService, ) {} // ── Scenarios ── @@ -127,4 +129,49 @@ export class BoardPlanningController { ) { return this.service.executeInvestment(id, dto.executionDate, req.user.sub); } + + // ── Budget Planning ── + + @Get('budget-plans') + @AllowViewer() + listBudgetPlans() { + return this.budgetPlanning.listPlans(); + } + + @Get('budget-plans/available-years') + @AllowViewer() + getAvailableYears() { + return this.budgetPlanning.getAvailableYears(); + } + + @Get('budget-plans/:year') + @AllowViewer() + getBudgetPlan(@Param('year') year: string) { + return this.budgetPlanning.getPlan(parseInt(year, 10)); + } + + @Post('budget-plans') + createBudgetPlan(@Body() dto: { fiscalYear: number; baseYear: number; inflationRate?: number }, @Req() req: any) { + return this.budgetPlanning.createPlan(dto.fiscalYear, dto.baseYear, dto.inflationRate ?? 2.5, req.user.sub); + } + + @Put('budget-plans/:year/lines') + updateBudgetPlanLines(@Param('year') year: string, @Body() dto: { planId: string; lines: any[] }) { + return this.budgetPlanning.updateLines(dto.planId, dto.lines); + } + + @Put('budget-plans/:year/inflation') + updateBudgetPlanInflation(@Param('year') year: string, @Body() dto: { inflationRate: number }) { + return this.budgetPlanning.updateInflation(parseInt(year, 10), dto.inflationRate); + } + + @Put('budget-plans/:year/status') + advanceBudgetPlanStatus(@Param('year') year: string, @Body() dto: { status: string }, @Req() req: any) { + return this.budgetPlanning.advanceStatus(parseInt(year, 10), dto.status, req.user.sub); + } + + @Delete('budget-plans/:year') + deleteBudgetPlan(@Param('year') year: string) { + return this.budgetPlanning.deletePlan(parseInt(year, 10)); + } } diff --git a/backend/src/modules/board-planning/board-planning.module.ts b/backend/src/modules/board-planning/board-planning.module.ts index e71ea65..3e793a4 100644 --- a/backend/src/modules/board-planning/board-planning.module.ts +++ b/backend/src/modules/board-planning/board-planning.module.ts @@ -2,10 +2,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'; +import { BudgetPlanningService } from './budget-planning.service'; @Module({ controllers: [BoardPlanningController], - providers: [BoardPlanningService, BoardPlanningProjectionService], - exports: [BoardPlanningService], + providers: [BoardPlanningService, BoardPlanningProjectionService, BudgetPlanningService], + exports: [BoardPlanningService, BudgetPlanningService], }) export class BoardPlanningModule {} diff --git a/backend/src/modules/board-planning/budget-planning.service.ts b/backend/src/modules/board-planning/budget-planning.service.ts new file mode 100644 index 0000000..2694ca5 --- /dev/null +++ b/backend/src/modules/board-planning/budget-planning.service.ts @@ -0,0 +1,269 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { TenantService } from '../../database/tenant.service'; + +const monthCols = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt']; + +@Injectable() +export class BudgetPlanningService { + constructor(private tenant: TenantService) {} + + // ── Plans CRUD ── + + async listPlans() { + return this.tenant.query( + `SELECT bp.*, + (SELECT COUNT(*) FROM budget_plan_lines bpl WHERE bpl.budget_plan_id = bp.id) as line_count + FROM budget_plans bp ORDER BY bp.fiscal_year`, + ); + } + + async getPlan(fiscalYear: number) { + const plans = await this.tenant.query( + 'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear], + ); + if (!plans.length) return null; + + const plan = plans[0]; + const lines = await this.tenant.query( + `SELECT bpl.*, a.account_number, a.account_name, a.account_type, a.fund_type as account_fund_type + FROM budget_plan_lines bpl + JOIN accounts a ON a.id = bpl.account_id + WHERE bpl.budget_plan_id = $1 + ORDER BY a.account_number`, + [plan.id], + ); + return { ...plan, lines }; + } + + async getAvailableYears() { + // Find the latest year that has official budgets + const result = await this.tenant.query( + 'SELECT MAX(fiscal_year) as max_year FROM budgets', + ); + const latestBudgetYear = result[0]?.max_year || new Date().getFullYear(); + + // Also find years that already have plans + const existingPlans = await this.tenant.query( + 'SELECT fiscal_year, status FROM budget_plans ORDER BY fiscal_year', + ); + const planYears = existingPlans.map((p: any) => ({ + year: p.fiscal_year, + status: p.status, + })); + + // Return next 5 years after latest budget, marking which have plans + const years = []; + for (let i = 1; i <= 5; i++) { + const yr = latestBudgetYear + i; + const existing = planYears.find((p: any) => p.year === yr); + years.push({ + year: yr, + hasPlan: !!existing, + status: existing?.status || null, + }); + } + return { latestBudgetYear, years, existingPlans: planYears }; + } + + async createPlan(fiscalYear: number, baseYear: number, inflationRate: number, userId: string) { + // Check no existing plan for this year + const existing = await this.tenant.query( + 'SELECT id FROM budget_plans WHERE fiscal_year = $1', [fiscalYear], + ); + if (existing.length) { + throw new BadRequestException(`A budget plan already exists for ${fiscalYear}`); + } + + // Create the plan + const rows = await this.tenant.query( + `INSERT INTO budget_plans (fiscal_year, base_year, inflation_rate, created_by) + VALUES ($1, $2, $3, $4) RETURNING *`, + [fiscalYear, baseYear, inflationRate, userId], + ); + const plan = rows[0]; + + // Generate inflated lines from base year + await this.generateLines(plan.id, baseYear, inflationRate); + + return this.getPlan(fiscalYear); + } + + async generateLines(planId: string, baseYear: number, inflationRate: number) { + // Delete existing non-manually-adjusted lines (or all if fresh) + await this.tenant.query( + 'DELETE FROM budget_plan_lines WHERE budget_plan_id = $1 AND is_manually_adjusted = false', + [planId], + ); + + // Try official budgets first, then fall back to budget_plan_lines for base year + let baseLines = await this.tenant.query( + `SELECT b.account_id, b.fund_type, ${monthCols.join(', ')} + FROM budgets b WHERE b.fiscal_year = $1`, + [baseYear], + ); + + if (!baseLines.length) { + // Fall back to budget_plan_lines for base year (for chained plans) + baseLines = await this.tenant.query( + `SELECT bpl.account_id, bpl.fund_type, ${monthCols.join(', ')} + FROM budget_plan_lines bpl + JOIN budget_plans bp ON bp.id = bpl.budget_plan_id + WHERE bp.fiscal_year = $1`, + [baseYear], + ); + } + + if (!baseLines.length) return; + + const multiplier = 1 + inflationRate / 100; + + // Get existing manually-adjusted lines to avoid duplicates + const manualLines = await this.tenant.query( + `SELECT account_id, fund_type FROM budget_plan_lines + WHERE budget_plan_id = $1 AND is_manually_adjusted = true`, + [planId], + ); + const manualKeys = new Set(manualLines.map((l: any) => `${l.account_id}-${l.fund_type}`)); + + for (const line of baseLines) { + const key = `${line.account_id}-${line.fund_type}`; + if (manualKeys.has(key)) continue; // Don't overwrite manual edits + + const inflated = monthCols.map((m) => { + const val = parseFloat(line[m]) || 0; + return Math.round(val * multiplier * 100) / 100; + }); + + await this.tenant.query( + `INSERT INTO budget_plan_lines (budget_plan_id, account_id, fund_type, + jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ON CONFLICT (budget_plan_id, account_id, fund_type) + DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9, + jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15, + is_manually_adjusted=false`, + [planId, line.account_id, line.fund_type, ...inflated], + ); + } + } + + async updateLines(planId: string, lines: any[]) { + for (const line of lines) { + const monthValues = monthCols.map((m) => { + const key = m === 'dec_amt' ? 'dec' : m; + return line[key] ?? line[m] ?? 0; + }); + + await this.tenant.query( + `INSERT INTO budget_plan_lines (budget_plan_id, account_id, fund_type, + jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, is_manually_adjusted) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, true) + ON CONFLICT (budget_plan_id, account_id, fund_type) + DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9, + jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15, + is_manually_adjusted=true`, + [planId, line.accountId, line.fundType, ...monthValues], + ); + } + return { updated: lines.length }; + } + + async updateInflation(fiscalYear: number, inflationRate: number) { + const plans = await this.tenant.query( + 'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear], + ); + if (!plans.length) throw new NotFoundException('Budget plan not found'); + + const plan = plans[0]; + if (plan.status === 'ratified') { + throw new BadRequestException('Cannot modify inflation on a ratified budget'); + } + + await this.tenant.query( + 'UPDATE budget_plans SET inflation_rate = $1, updated_at = NOW() WHERE fiscal_year = $2', + [inflationRate, fiscalYear], + ); + + // Re-generate only non-manually-adjusted lines + await this.generateLines(plan.id, plan.base_year, inflationRate); + + return this.getPlan(fiscalYear); + } + + async advanceStatus(fiscalYear: number, newStatus: string, userId: string) { + const plans = await this.tenant.query( + 'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear], + ); + if (!plans.length) throw new NotFoundException('Budget plan not found'); + + const plan = plans[0]; + const validTransitions: Record = { + planning: ['approved'], + approved: ['planning', 'ratified'], + ratified: ['approved'], + }; + + if (!validTransitions[plan.status]?.includes(newStatus)) { + throw new BadRequestException(`Cannot transition from ${plan.status} to ${newStatus}`); + } + + // If reverting from ratified, remove official budget + if (plan.status === 'ratified' && newStatus === 'approved') { + await this.tenant.query('DELETE FROM budgets WHERE fiscal_year = $1', [fiscalYear]); + } + + const updates: string[] = ['status = $1', 'updated_at = NOW()']; + const params: any[] = [newStatus]; + + if (newStatus === 'approved') { + updates.push(`approved_by = $${params.length + 1}`, `approved_at = NOW()`); + params.push(userId); + } else if (newStatus === 'ratified') { + updates.push(`ratified_by = $${params.length + 1}`, `ratified_at = NOW()`); + params.push(userId); + } + + params.push(fiscalYear); + await this.tenant.query( + `UPDATE budget_plans SET ${updates.join(', ')} WHERE fiscal_year = $${params.length}`, + params, + ); + + // If ratifying, copy to official budgets + if (newStatus === 'ratified') { + await this.ratifyToOfficial(plan.id, fiscalYear); + } + + return this.getPlan(fiscalYear); + } + + private async ratifyToOfficial(planId: string, fiscalYear: number) { + // Clear existing official budgets for this year + await this.tenant.query('DELETE FROM budgets WHERE fiscal_year = $1', [fiscalYear]); + + // Copy plan lines to official budgets + await this.tenant.query( + `INSERT INTO budgets (fiscal_year, account_id, fund_type, + jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, notes) + SELECT $1, bpl.account_id, bpl.fund_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, bpl.notes + FROM budget_plan_lines bpl WHERE bpl.budget_plan_id = $2`, + [fiscalYear, planId], + ); + } + + async deletePlan(fiscalYear: number) { + const plans = await this.tenant.query( + 'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear], + ); + if (!plans.length) throw new NotFoundException('Budget plan not found'); + + if (plans[0].status !== 'planning') { + throw new BadRequestException('Can only delete plans in planning status'); + } + + await this.tenant.query('DELETE FROM budget_plans WHERE fiscal_year = $1', [fiscalYear]); + return { deleted: true }; + } +} diff --git a/backend/src/modules/reports/reports.service.ts b/backend/src/modules/reports/reports.service.ts index b18f7e6..e0c778e 100644 --- a/backend/src/modules/reports/reports.service.ts +++ b/backend/src/modules/reports/reports.service.ts @@ -864,15 +864,37 @@ export class ReportsService { // We need budgets for startYear and startYear+1 to cover 24 months 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], - ); + 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 { + 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 }; diff --git a/db/migrations/014-budget-planning.sql b/db/migrations/014-budget-planning.sql new file mode 100644 index 0000000..31cae30 --- /dev/null +++ b/db/migrations/014-budget-planning.sql @@ -0,0 +1,54 @@ +-- Migration: Add budget_plans and budget_plan_lines tables to all tenant schemas +DO $migration$ +DECLARE + s TEXT; +BEGIN + FOR s IN + SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%' + LOOP + -- budget_plans + EXECUTE format(' + CREATE TABLE IF NOT EXISTS %I.budget_plans ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + fiscal_year INTEGER NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT ''planning'' CHECK (status IN (''planning'', ''approved'', ''ratified'')), + base_year INTEGER NOT NULL, + inflation_rate DECIMAL(5,2) NOT NULL DEFAULT 2.50, + notes TEXT, + created_by UUID, + approved_by UUID, + approved_at TIMESTAMPTZ, + ratified_by UUID, + ratified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(fiscal_year) + )', s); + + -- budget_plan_lines + EXECUTE format(' + CREATE TABLE IF NOT EXISTS %I.budget_plan_lines ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + budget_plan_id UUID NOT NULL REFERENCES %I.budget_plans(id) ON DELETE CASCADE, + account_id UUID NOT NULL REFERENCES %I.accounts(id), + fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN (''operating'', ''reserve'')), + jan DECIMAL(12,2) DEFAULT 0, feb DECIMAL(12,2) DEFAULT 0, + mar DECIMAL(12,2) DEFAULT 0, apr DECIMAL(12,2) DEFAULT 0, + may DECIMAL(12,2) DEFAULT 0, jun DECIMAL(12,2) DEFAULT 0, + jul DECIMAL(12,2) DEFAULT 0, aug DECIMAL(12,2) DEFAULT 0, + sep DECIMAL(12,2) DEFAULT 0, oct DECIMAL(12,2) DEFAULT 0, + nov DECIMAL(12,2) DEFAULT 0, dec_amt DECIMAL(12,2) DEFAULT 0, + is_manually_adjusted BOOLEAN DEFAULT FALSE, + notes TEXT, + UNIQUE(budget_plan_id, account_id, fund_type) + )', s, s, s); + + -- Indexes + EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_bp_year ON %I.budget_plans(fiscal_year)', replace(s, 'tenant_', ''), s); + EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_bp_status ON %I.budget_plans(status)', replace(s, 'tenant_', ''), s); + EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_bpl_plan ON %I.budget_plan_lines(budget_plan_id)', replace(s, 'tenant_', ''), s); + + RAISE NOTICE 'Migrated schema: %', s; + END LOOP; +END; +$migration$; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 743c669..14f3865 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,6 +36,7 @@ import { InvestmentScenarioDetailPage } from './pages/board-planning/InvestmentS import { AssessmentScenariosPage } from './pages/board-planning/AssessmentScenariosPage'; import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentScenarioDetailPage'; import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage'; +import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token); @@ -142,6 +143,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 7661868..a17c425 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -66,6 +66,7 @@ const navSections = [ { label: 'Board Planning', items: [ + { label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets' }, { 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' }, diff --git a/frontend/src/pages/board-planning/BudgetPlanningPage.tsx b/frontend/src/pages/board-planning/BudgetPlanningPage.tsx new file mode 100644 index 0000000..2721924 --- /dev/null +++ b/frontend/src/pages/board-planning/BudgetPlanningPage.tsx @@ -0,0 +1,578 @@ +import { useState, useEffect } from 'react'; +import { + Title, Table, Group, Button, Stack, Text, NumberInput, + Select, Loader, Center, Badge, Card, Alert, Modal, +} from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { + IconDeviceFloppy, IconInfoCircle, IconPencil, IconX, + IconCheck, IconArrowBack, IconTrash, IconRefresh, +} from '@tabler/icons-react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../../services/api'; +import { useIsReadOnly } from '../../stores/authStore'; +import { usePreferencesStore } from '../../stores/preferencesStore'; + +interface PlanLine { + id: string; + account_id: string; + account_number: string; + account_name: string; + account_type: string; + fund_type: string; + is_manually_adjusted: boolean; + jan: number; feb: number; mar: number; apr: number; + may: number; jun: number; jul: number; aug: number; + sep: number; oct: number; nov: number; dec_amt: number; + annual_total: number; +} + +const monthKeys = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt']; +const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 }); + +function hydrateLine(row: any): PlanLine { + const line: any = { ...row }; + for (const m of monthKeys) { + line[m] = Number(line[m]) || 0; + } + line.annual_total = monthKeys.reduce((sum, m) => sum + (line[m] || 0), 0); + return line as PlanLine; +} + +const statusColors: Record = { + planning: 'blue', + approved: 'yellow', + ratified: 'green', +}; + +export function BudgetPlanningPage() { + const queryClient = useQueryClient(); + const isReadOnly = useIsReadOnly(); + const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark'; + const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white'; + const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef'; + const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6'; + const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8'; + + const [selectedYear, setSelectedYear] = useState(null); + const [lineData, setLineData] = useState([]); + const [isEditing, setIsEditing] = useState(false); + const [inflationInput, setInflationInput] = useState(2.5); + const [confirmModal, setConfirmModal] = useState<{ action: string; title: string; message: string } | null>(null); + + // Available years + const { data: availableYears } = useQuery({ + queryKey: ['budget-plan-available-years'], + queryFn: async () => { + const { data } = await api.get('/board-planning/budget-plans/available-years'); + return data; + }, + }); + + // Set default year when available + useEffect(() => { + if (availableYears?.years?.length && !selectedYear) { + setSelectedYear(String(availableYears.years[0].year)); + } + }, [availableYears, selectedYear]); + + // Plan data for selected year + const { data: plan, isLoading } = useQuery({ + queryKey: ['budget-plan', selectedYear], + queryFn: async () => { + const { data } = await api.get(`/board-planning/budget-plans/${selectedYear}`); + return data; + }, + enabled: !!selectedYear, + }); + + // Hydrate lines when plan changes + useEffect(() => { + if (plan?.lines) { + setLineData(plan.lines.map(hydrateLine)); + setInflationInput(parseFloat(plan.inflation_rate) || 2.5); + setIsEditing(false); + } else { + setLineData([]); + } + }, [plan]); + + const yearOptions = (availableYears?.years || []).map((y: any) => ({ + value: String(y.year), + label: `${y.year}${y.hasPlan ? ` (${y.status})` : ''}`, + })); + + // Mutations + const createMutation = useMutation({ + mutationFn: async () => { + const fiscalYear = parseInt(selectedYear!, 10); + const baseYear = availableYears?.latestBudgetYear || new Date().getFullYear(); + const { data } = await api.post('/board-planning/budget-plans', { + fiscalYear, + baseYear, + inflationRate: inflationInput, + }); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['budget-plan'] }); + queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] }); + notifications.show({ message: 'Budget plan created', color: 'green' }); + }, + onError: (err: any) => { + notifications.show({ message: err.response?.data?.message || 'Create failed', color: 'red' }); + }, + }); + + const saveMutation = useMutation({ + mutationFn: async () => { + const payload = lineData.map((l) => ({ + accountId: l.account_id, + fundType: l.fund_type, + jan: l.jan, feb: l.feb, mar: l.mar, apr: l.apr, + may: l.may, jun: l.jun, jul: l.jul, aug: l.aug, + sep: l.sep, oct: l.oct, nov: l.nov, dec: l.dec_amt, + })); + return api.put(`/board-planning/budget-plans/${selectedYear}/lines`, { + planId: plan.id, + lines: payload, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] }); + setIsEditing(false); + notifications.show({ message: 'Budget plan saved', color: 'green' }); + }, + onError: (err: any) => { + notifications.show({ message: err.response?.data?.message || 'Save failed', color: 'red' }); + }, + }); + + const inflationMutation = useMutation({ + mutationFn: () => api.put(`/board-planning/budget-plans/${selectedYear}/inflation`, { + inflationRate: inflationInput, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] }); + notifications.show({ message: 'Inflation rate applied', color: 'green' }); + }, + }); + + const statusMutation = useMutation({ + mutationFn: (status: string) => api.put(`/board-planning/budget-plans/${selectedYear}/status`, { status }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] }); + queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] }); + queryClient.invalidateQueries({ queryKey: ['budgets'] }); + setConfirmModal(null); + notifications.show({ message: 'Status updated', color: 'green' }); + }, + onError: (err: any) => { + notifications.show({ message: err.response?.data?.message || 'Status update failed', color: 'red' }); + setConfirmModal(null); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: () => api.delete(`/board-planning/budget-plans/${selectedYear}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] }); + queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] }); + setConfirmModal(null); + notifications.show({ message: 'Budget plan deleted', color: 'orange' }); + }, + }); + + const updateCell = (idx: number, month: string, value: number) => { + const updated = [...lineData]; + (updated[idx] as any)[month] = value || 0; + updated[idx].annual_total = monthKeys.reduce((s, m) => s + ((updated[idx] as any)[m] || 0), 0); + setLineData(updated); + }; + + const handleCancelEdit = () => { + setIsEditing(false); + queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] }); + }; + + const hasPlan = !!plan?.id; + const status = plan?.status || 'planning'; + const cellsEditable = !isReadOnly && isEditing && status !== 'ratified'; + + const incomeLines = lineData.filter((b) => b.account_type === 'income'); + const operatingIncomeLines = incomeLines.filter((b) => b.fund_type === 'operating'); + const reserveIncomeLines = incomeLines.filter((b) => b.fund_type === 'reserve'); + const expenseLines = lineData.filter((b) => b.account_type === 'expense'); + const totalOperatingIncome = operatingIncomeLines.reduce((sum, l) => sum + (l.annual_total || 0), 0); + const totalReserveIncome = reserveIncomeLines.reduce((sum, l) => sum + (l.annual_total || 0), 0); + const totalExpense = expenseLines.reduce((sum, l) => sum + (l.annual_total || 0), 0); + + return ( + + {/* Header */} + + + Budget Planning + {hasPlan && ( + + {status} + + )} + + +