Phase 3: Optimize & clean up — unified projects, account enhancements, new tenant fix
- Unify reserve_components + capital_projects into single projects model with full CRUD backend and new Projects page frontend - Rewrite Capital Planning to read from unified projects/planning endpoint; add empty state directing users to Projects page when no planning items exist - Add default designation to assessment groups with auto-set on first creation; units now require an assessment group (pre-populated with default) - Add primary account designation (one per fund type) and balance adjustment via journal entries against equity offset accounts (3000/3100) - Add computed investment fields (interest earned, maturity value, days remaining) with PostgreSQL date arithmetic fix for DATE - DATE integer result - Restructure sidebar: investments in Accounts tab, Year-End under Reports, Planning section with Projects and Capital Planning - Fix new tenant creation seeding unwanted default chart of accounts — new tenants now start with a blank slate Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
32
backend/src/modules/projects/projects.controller.ts
Normal file
32
backend/src/modules/projects/projects.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { ProjectsService } from './projects.service';
|
||||
|
||||
@ApiTags('projects')
|
||||
@Controller('projects')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ProjectsController {
|
||||
constructor(private service: ProjectsService) {}
|
||||
|
||||
@Get()
|
||||
findAll() { return this.service.findAll(); }
|
||||
|
||||
@Get('planning')
|
||||
findForPlanning() { return this.service.findForPlanning(); }
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: any) { return this.service.create(dto); }
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||
|
||||
@Put(':id/planned-date')
|
||||
updatePlannedDate(@Param('id') id: string, @Body() dto: { planned_date: string }) {
|
||||
return this.service.updatePlannedDate(id, dto.planned_date);
|
||||
}
|
||||
}
|
||||
12
backend/src/modules/projects/projects.module.ts
Normal file
12
backend/src/modules/projects/projects.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from '../../database/database.module';
|
||||
import { ProjectsService } from './projects.service';
|
||||
import { ProjectsController } from './projects.controller';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [ProjectsController],
|
||||
providers: [ProjectsService],
|
||||
exports: [ProjectsService],
|
||||
})
|
||||
export class ProjectsModule {}
|
||||
108
backend/src/modules/projects/projects.service.ts
Normal file
108
backend/src/modules/projects/projects.service.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class ProjectsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async findAll() {
|
||||
// Return all active projects ordered by name
|
||||
return this.tenant.query('SELECT * FROM projects WHERE is_active = true ORDER BY name');
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM projects WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Project not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async findForPlanning() {
|
||||
// Only return projects that have target_year set (for the Capital Planning kanban)
|
||||
return this.tenant.query(`
|
||||
SELECT * FROM projects
|
||||
WHERE is_active = true AND target_year IS NOT NULL
|
||||
ORDER BY target_year, target_month NULLS LAST, priority
|
||||
`);
|
||||
}
|
||||
|
||||
async create(dto: any) {
|
||||
// Default planned_date to next_replacement_date if not provided
|
||||
const plannedDate = dto.planned_date || dto.next_replacement_date || null;
|
||||
// If fund_source is not 'reserve', funded_percentage stays 0
|
||||
const fundedPct = dto.fund_source === 'reserve' ? (dto.funded_percentage || 0) : 0;
|
||||
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO projects (
|
||||
name, description, category, estimated_cost, actual_cost,
|
||||
current_fund_balance, annual_contribution, fund_source, funded_percentage,
|
||||
useful_life_years, remaining_life_years, condition_rating,
|
||||
last_replacement_date, next_replacement_date, planned_date,
|
||||
target_year, target_month, status, priority, account_id, notes
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
|
||||
RETURNING *`,
|
||||
[
|
||||
dto.name, dto.description || null, dto.category || null,
|
||||
dto.estimated_cost || 0, dto.actual_cost || null,
|
||||
dto.current_fund_balance || 0, dto.annual_contribution || 0,
|
||||
dto.fund_source || 'reserve', fundedPct,
|
||||
dto.useful_life_years || null, dto.remaining_life_years || null,
|
||||
dto.condition_rating || null,
|
||||
dto.last_replacement_date || null, dto.next_replacement_date || null,
|
||||
plannedDate,
|
||||
dto.target_year || null, dto.target_month || null,
|
||||
dto.status || 'planned', dto.priority || 3,
|
||||
dto.account_id || null, dto.notes || null,
|
||||
],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async update(id: string, dto: any) {
|
||||
await this.findOne(id);
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
// Build dynamic SET clause
|
||||
const fields: [string, string][] = [
|
||||
['name', 'name'], ['description', 'description'], ['category', 'category'],
|
||||
['estimated_cost', 'estimated_cost'], ['actual_cost', 'actual_cost'],
|
||||
['current_fund_balance', 'current_fund_balance'], ['annual_contribution', 'annual_contribution'],
|
||||
['fund_source', 'fund_source'], ['funded_percentage', 'funded_percentage'],
|
||||
['useful_life_years', 'useful_life_years'], ['remaining_life_years', 'remaining_life_years'],
|
||||
['condition_rating', 'condition_rating'],
|
||||
['last_replacement_date', 'last_replacement_date'], ['next_replacement_date', 'next_replacement_date'],
|
||||
['planned_date', 'planned_date'],
|
||||
['target_year', 'target_year'], ['target_month', 'target_month'],
|
||||
['status', 'status'], ['priority', 'priority'],
|
||||
['account_id', 'account_id'], ['notes', 'notes'], ['is_active', 'is_active'],
|
||||
];
|
||||
|
||||
for (const [dtoKey, dbCol] of fields) {
|
||||
if (dto[dtoKey] !== undefined) {
|
||||
sets.push(`${dbCol} = $${idx++}`);
|
||||
params.push(dto[dtoKey]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sets.length) return this.findOne(id);
|
||||
|
||||
sets.push('updated_at = NOW()');
|
||||
params.push(id);
|
||||
|
||||
const rows = await this.tenant.query(
|
||||
`UPDATE projects SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
params,
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updatePlannedDate(id: string, planned_date: string) {
|
||||
await this.findOne(id);
|
||||
const rows = await this.tenant.query(
|
||||
'UPDATE projects SET planned_date = $2, updated_at = NOW() WHERE id = $1 RETURNING *',
|
||||
[id, planned_date],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user