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:
2026-02-19 14:32:35 -05:00
parent 17fdacc0f2
commit 301f8a7bde
20 changed files with 1760 additions and 145 deletions

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