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:
2026-03-16 09:52:10 -04:00
parent b13fbfe8c7
commit c8d77aaa48
20 changed files with 2901 additions and 1 deletions

View 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);
}
}