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:
@@ -20,6 +20,7 @@ import { InvestmentsModule } from './modules/investments/investments.module';
|
||||
import { CapitalProjectsModule } from './modules/capital-projects/capital-projects.module';
|
||||
import { ReportsModule } from './modules/reports/reports.module';
|
||||
import { AssessmentGroupsModule } from './modules/assessment-groups/assessment-groups.module';
|
||||
import { ProjectsModule } from './modules/projects/projects.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -54,6 +55,7 @@ import { AssessmentGroupsModule } from './modules/assessment-groups/assessment-g
|
||||
CapitalProjectsModule,
|
||||
ReportsModule,
|
||||
AssessmentGroupsModule,
|
||||
ProjectsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
})
|
||||
|
||||
@@ -19,7 +19,6 @@ export class TenantSchemaService {
|
||||
await queryRunner.query(statement);
|
||||
}
|
||||
|
||||
await this.seedDefaultChartOfAccounts(queryRunner, schemaName);
|
||||
await this.seedDefaultFiscalPeriods(queryRunner, schemaName);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
@@ -45,6 +44,7 @@ export class TenantSchemaService {
|
||||
is_1099_reportable BOOLEAN DEFAULT FALSE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_system BOOLEAN DEFAULT FALSE,
|
||||
is_primary BOOLEAN DEFAULT FALSE,
|
||||
balance DECIMAL(15,2) DEFAULT 0.00,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
@@ -110,6 +110,7 @@ export class TenantSchemaService {
|
||||
special_assessment DECIMAL(10,2) DEFAULT 0.00,
|
||||
unit_count INTEGER DEFAULT 0,
|
||||
frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')),
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
@@ -281,6 +282,37 @@ export class TenantSchemaService {
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// Unified Projects (replaces reserve_components + capital_projects for new features)
|
||||
`CREATE TABLE "${s}".projects (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR(100),
|
||||
estimated_cost DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||
actual_cost DECIMAL(15,2),
|
||||
current_fund_balance DECIMAL(15,2) DEFAULT 0.00,
|
||||
annual_contribution DECIMAL(12,2) DEFAULT 0.00,
|
||||
fund_source VARCHAR(20) CHECK (fund_source IN ('operating', 'reserve', 'special_assessment')),
|
||||
funded_percentage DECIMAL(5,2) DEFAULT 0,
|
||||
useful_life_years INTEGER,
|
||||
remaining_life_years DECIMAL(5,1),
|
||||
condition_rating INTEGER CHECK (condition_rating BETWEEN 1 AND 10),
|
||||
last_replacement_date DATE,
|
||||
next_replacement_date DATE,
|
||||
planned_date DATE,
|
||||
target_year INTEGER,
|
||||
target_month INTEGER CHECK (target_month BETWEEN 1 AND 12),
|
||||
status VARCHAR(20) DEFAULT 'planned' CHECK (status IN (
|
||||
'planned', 'approved', 'in_progress', 'completed', 'deferred', 'cancelled'
|
||||
)),
|
||||
priority INTEGER DEFAULT 3 CHECK (priority BETWEEN 1 AND 5),
|
||||
account_id UUID REFERENCES "${s}".accounts(id),
|
||||
notes TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// Indexes
|
||||
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
|
||||
`CREATE INDEX "idx_${s}_je_fiscal" ON "${s}".journal_entries(fiscal_period_id)`,
|
||||
|
||||
@@ -26,6 +26,21 @@ export class AccountsController {
|
||||
return this.accountsService.getTrialBalance(asOfDate);
|
||||
}
|
||||
|
||||
@Put(':id/set-primary')
|
||||
@ApiOperation({ summary: 'Set account as primary for its fund type' })
|
||||
setPrimary(@Param('id') id: string) {
|
||||
return this.accountsService.setPrimary(id);
|
||||
}
|
||||
|
||||
@Post(':id/adjust-balance')
|
||||
@ApiOperation({ summary: 'Adjust account balance to a target amount' })
|
||||
adjustBalance(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
||||
) {
|
||||
return this.accountsService.adjustBalance(id, dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get account by ID' })
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -109,6 +109,16 @@ export class AccountsService {
|
||||
throw new BadRequestException('Cannot change type of system account');
|
||||
}
|
||||
|
||||
// Handle isPrimary: clear other primary accounts in the same fund_type first
|
||||
if (dto.isPrimary === true) {
|
||||
await this.tenant.query(
|
||||
`UPDATE accounts SET is_primary = false
|
||||
WHERE fund_type = (SELECT fund_type FROM accounts WHERE id = $1)
|
||||
AND is_primary = true`,
|
||||
[id],
|
||||
);
|
||||
}
|
||||
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
@@ -120,6 +130,7 @@ export class AccountsService {
|
||||
if (dto.fundType !== undefined) { sets.push(`fund_type = $${idx++}`); params.push(dto.fundType); }
|
||||
if (dto.is1099Reportable !== undefined) { sets.push(`is_1099_reportable = $${idx++}`); params.push(dto.is1099Reportable); }
|
||||
if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); }
|
||||
if (dto.isPrimary !== undefined) { sets.push(`is_primary = $${idx++}`); params.push(dto.isPrimary); }
|
||||
|
||||
if (!sets.length) return account;
|
||||
|
||||
@@ -133,6 +144,136 @@ export class AccountsService {
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async setPrimary(id: string) {
|
||||
const account = await this.findOne(id);
|
||||
|
||||
// Clear other primary accounts in the same fund_type
|
||||
await this.tenant.query(
|
||||
`UPDATE accounts SET is_primary = false
|
||||
WHERE fund_type = $1 AND is_primary = true`,
|
||||
[account.fund_type],
|
||||
);
|
||||
|
||||
// Set this account as primary
|
||||
await this.tenant.query(
|
||||
`UPDATE accounts SET is_primary = true, updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async adjustBalance(id: string, dto: { targetBalance: number; asOfDate: string; memo?: string }) {
|
||||
const account = await this.findOne(id);
|
||||
|
||||
// Get current balance for this account using trial balance logic
|
||||
const balanceRows = await this.tenant.query(
|
||||
`SELECT
|
||||
CASE
|
||||
WHEN a.account_type IN ('asset', 'expense')
|
||||
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||
END as balance
|
||||
FROM accounts a
|
||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
AND je.entry_date <= $1
|
||||
WHERE a.id = $2
|
||||
GROUP BY a.id, a.account_type`,
|
||||
[dto.asOfDate, id],
|
||||
);
|
||||
|
||||
const currentBalance = balanceRows.length ? parseFloat(balanceRows[0].balance) : 0;
|
||||
const difference = dto.targetBalance - currentBalance;
|
||||
|
||||
if (difference === 0) {
|
||||
return { message: 'No adjustment needed' };
|
||||
}
|
||||
|
||||
// Find fiscal period for the asOfDate
|
||||
const asOf = new Date(dto.asOfDate);
|
||||
const year = asOf.getFullYear();
|
||||
const month = asOf.getMonth() + 1;
|
||||
|
||||
const periods = await this.tenant.query(
|
||||
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
|
||||
[year, month],
|
||||
);
|
||||
if (!periods.length) {
|
||||
throw new BadRequestException(`No fiscal period found for ${year}-${String(month).padStart(2, '0')}`);
|
||||
}
|
||||
const fiscalPeriodId = periods[0].id;
|
||||
|
||||
// Determine the equity offset account based on fund_type
|
||||
const equityAccountNumber = account.fund_type === 'reserve' ? 3100 : 3000;
|
||||
const equityRows = await this.tenant.query(
|
||||
'SELECT id, account_type FROM accounts WHERE account_number = $1',
|
||||
[equityAccountNumber],
|
||||
);
|
||||
if (!equityRows.length) {
|
||||
throw new BadRequestException(
|
||||
`Equity offset account ${equityAccountNumber} not found`,
|
||||
);
|
||||
}
|
||||
const equityAccount = equityRows[0];
|
||||
|
||||
// Calculate debit/credit for the target account line
|
||||
// For debit-normal accounts (asset, expense): increase = debit, decrease = credit
|
||||
// For credit-normal accounts (liability, equity, income): increase = credit, decrease = debit
|
||||
const isDebitNormal = ['asset', 'expense'].includes(account.account_type);
|
||||
const absDifference = Math.abs(difference);
|
||||
|
||||
let targetDebit: number;
|
||||
let targetCredit: number;
|
||||
|
||||
if (isDebitNormal) {
|
||||
// Debit-normal: positive difference means we need more debit
|
||||
targetDebit = difference > 0 ? absDifference : 0;
|
||||
targetCredit = difference > 0 ? 0 : absDifference;
|
||||
} else {
|
||||
// Credit-normal: positive difference means we need more credit
|
||||
targetDebit = difference > 0 ? 0 : absDifference;
|
||||
targetCredit = difference > 0 ? absDifference : 0;
|
||||
}
|
||||
|
||||
// Balancing line to equity account is the opposite
|
||||
const equityDebit = targetCredit > 0 ? targetCredit : 0;
|
||||
const equityCredit = targetDebit > 0 ? targetDebit : 0;
|
||||
|
||||
const memo = dto.memo || `Balance adjustment to ${dto.targetBalance}`;
|
||||
|
||||
// Create journal entry
|
||||
const jeRows = await this.tenant.query(
|
||||
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
||||
VALUES ($1, $2, 'adjustment', $3, true, NOW(), $4)
|
||||
RETURNING *`,
|
||||
[
|
||||
dto.asOfDate,
|
||||
memo,
|
||||
fiscalPeriodId,
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
],
|
||||
);
|
||||
|
||||
const journalEntry = jeRows[0];
|
||||
|
||||
// Create the two journal entry lines
|
||||
await this.tenant.query(
|
||||
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[journalEntry.id, id, targetDebit, targetCredit, memo],
|
||||
);
|
||||
|
||||
await this.tenant.query(
|
||||
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[journalEntry.id, equityAccount.id, equityDebit, equityCredit, memo],
|
||||
);
|
||||
|
||||
return journalEntry;
|
||||
}
|
||||
|
||||
async getTrialBalance(asOfDate?: string) {
|
||||
const dateFilter = asOfDate
|
||||
? `AND je.entry_date <= $1`
|
||||
|
||||
@@ -36,4 +36,9 @@ export class UpdateAccountDto {
|
||||
@IsIn(['operating', 'reserve'])
|
||||
@IsOptional()
|
||||
fundType?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ export class AssessmentGroupsController {
|
||||
@Get('summary')
|
||||
getSummary() { return this.service.getSummary(); }
|
||||
|
||||
@Get('default')
|
||||
getDefault() { return this.service.getDefault(); }
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||
|
||||
@@ -24,4 +27,7 @@ export class AssessmentGroupsController {
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||
|
||||
@Put(':id/set-default')
|
||||
setDefault(@Param('id') id: string) { return this.service.setDefault(id); }
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ export class AssessmentGroupsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async findAll() {
|
||||
// Normalize all income calculations to monthly equivalent
|
||||
// monthly: amount * units (already monthly)
|
||||
// quarterly: amount/3 * units (convert to monthly)
|
||||
// annual: amount/12 * units (convert to monthly)
|
||||
return this.tenant.query(`
|
||||
SELECT ag.*,
|
||||
(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id) as actual_unit_count,
|
||||
@@ -39,17 +35,38 @@ export class AssessmentGroupsService {
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async create(dto: any) {
|
||||
async getDefault() {
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0, dto.unitCount || 0, dto.frequency || 'monthly'],
|
||||
'SELECT * FROM assessment_groups WHERE is_default = true AND is_active = true LIMIT 1',
|
||||
);
|
||||
return rows.length ? rows[0] : null;
|
||||
}
|
||||
|
||||
async create(dto: any) {
|
||||
const existingGroups = await this.tenant.query('SELECT COUNT(*) as cnt FROM assessment_groups');
|
||||
const isFirstGroup = parseInt(existingGroups[0].cnt) === 0;
|
||||
const shouldBeDefault = dto.isDefault || isFirstGroup;
|
||||
|
||||
if (shouldBeDefault) {
|
||||
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
|
||||
}
|
||||
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, is_default)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0,
|
||||
dto.unitCount || 0, dto.frequency || 'monthly', shouldBeDefault],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async update(id: string, dto: any) {
|
||||
await this.findOne(id);
|
||||
|
||||
if (dto.isDefault === true) {
|
||||
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
|
||||
}
|
||||
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
@@ -61,6 +78,7 @@ export class AssessmentGroupsService {
|
||||
if (dto.unitCount !== undefined) { sets.push(`unit_count = $${idx++}`); params.push(dto.unitCount); }
|
||||
if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); }
|
||||
if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); }
|
||||
if (dto.isDefault !== undefined) { sets.push(`is_default = $${idx++}`); params.push(dto.isDefault); }
|
||||
|
||||
if (!sets.length) return this.findOne(id);
|
||||
|
||||
@@ -74,6 +92,16 @@ export class AssessmentGroupsService {
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async setDefault(id: string) {
|
||||
await this.findOne(id);
|
||||
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
|
||||
await this.tenant.query(
|
||||
'UPDATE assessment_groups SET is_default = true, updated_at = NOW() WHERE id = $1',
|
||||
[id],
|
||||
);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async getSummary() {
|
||||
const rows = await this.tenant.query(`
|
||||
SELECT
|
||||
|
||||
@@ -6,7 +6,30 @@ export class InvestmentsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.tenant.query('SELECT * FROM investment_accounts WHERE is_active = true ORDER BY name');
|
||||
return this.tenant.query(`
|
||||
SELECT ia.*,
|
||||
CASE
|
||||
WHEN ia.purchase_date IS NOT NULL AND ia.interest_rate IS NOT NULL AND ia.interest_rate > 0 THEN
|
||||
ROUND(ia.principal * (ia.interest_rate / 100.0) *
|
||||
(LEAST(COALESCE(ia.maturity_date, CURRENT_DATE), CURRENT_DATE) - ia.purchase_date)::numeric
|
||||
/ 365.0, 2)
|
||||
ELSE NULL
|
||||
END as interest_earned,
|
||||
CASE
|
||||
WHEN ia.purchase_date IS NOT NULL AND ia.maturity_date IS NOT NULL AND ia.interest_rate IS NOT NULL THEN
|
||||
ROUND(ia.principal * (1 + ia.interest_rate / 100.0 *
|
||||
(ia.maturity_date - ia.purchase_date)::numeric / 365.0), 2)
|
||||
ELSE NULL
|
||||
END as maturity_value,
|
||||
CASE
|
||||
WHEN ia.maturity_date IS NOT NULL THEN
|
||||
GREATEST(ia.maturity_date - CURRENT_DATE, 0)
|
||||
ELSE NULL
|
||||
END as days_remaining
|
||||
FROM investment_accounts ia
|
||||
WHERE ia.is_active = true
|
||||
ORDER BY ia.name
|
||||
`);
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,29 @@ export class UnitsService {
|
||||
const existing = await this.tenant.query('SELECT id FROM units WHERE unit_number = $1', [dto.unit_number]);
|
||||
if (existing.length) throw new BadRequestException(`Unit ${dto.unit_number} already exists`);
|
||||
|
||||
// Resolve assessment group: use provided, fall back to default, or error
|
||||
let groupId = dto.assessment_group_id || null;
|
||||
if (!groupId) {
|
||||
const defaultGroup = await this.tenant.query(
|
||||
'SELECT id FROM assessment_groups WHERE is_default = true AND is_active = true LIMIT 1',
|
||||
);
|
||||
if (defaultGroup.length) {
|
||||
groupId = defaultGroup[0].id;
|
||||
} else {
|
||||
// Check if any groups exist at all
|
||||
const anyGroup = await this.tenant.query('SELECT id FROM assessment_groups WHERE is_active = true LIMIT 1');
|
||||
if (!anyGroup.length) {
|
||||
throw new BadRequestException('An assessment group must exist before creating units. Please create an assessment group first.');
|
||||
}
|
||||
// Use the first available group
|
||||
groupId = anyGroup[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO units (unit_number, address_line1, city, state, zip_code, owner_name, owner_email, owner_phone, monthly_assessment, assessment_group_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
|
||||
[dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment || 0, dto.assessment_group_id || null],
|
||||
[dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment || 0, groupId],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user