Implement Phase 2 features: roles, assessment groups, budget import, Kanban

- Add hierarchical roles: SuperUser Admin (is_superadmin flag), Tenant Admin,
  Tenant User with separate /admin route and admin panel
- Add Assessment Groups module for property type-based assessment rates
  (SFHs, Condos, Estate Lots with different regular/special rates)
- Enhance Chart of Accounts: initial balance on create (with journal entry),
  archive/restore accounts, edit all fields including account number & fund type
- Add Budget CSV import with downloadable template and account mapping
- Add Capital Projects Kanban board with drag-and-drop between year columns,
  table/kanban view toggle, and PDF export via browser print
- Update seed data with assessment groups, second test user, superadmin flag
- Create repeatable reseed.sh script for clean database population
- Fix AgingReportPage Mantine v7 Table prop compatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 14:28:46 -05:00
parent e0272f9d8a
commit 01502e07bc
29 changed files with 1792 additions and 142 deletions

View File

@@ -19,6 +19,7 @@ import { ReserveComponentsModule } from './modules/reserve-components/reserve-co
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';
@Module({
imports: [
@@ -52,6 +53,7 @@ import { ReportsModule } from './modules/reports/reports.module';
InvestmentsModule,
CapitalProjectsModule,
ReportsModule,
AssessmentGroupsModule,
],
controllers: [AppController],
})

View File

@@ -101,6 +101,19 @@ export class TenantSchemaService {
CHECK (NOT (debit > 0 AND credit > 0))
)`,
// Assessment Groups
`CREATE TABLE "${s}".assessment_groups (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
description TEXT,
regular_assessment DECIMAL(10,2) NOT NULL DEFAULT 0.00,
special_assessment DECIMAL(10,2) DEFAULT 0.00,
unit_count INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Units (homeowners/lots)
`CREATE TABLE "${s}".units (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
@@ -118,6 +131,7 @@ export class TenantSchemaService {
owner_phone VARCHAR(20),
is_rented BOOLEAN DEFAULT FALSE,
monthly_assessment DECIMAL(10,2),
assessment_group_id UUID REFERENCES "${s}".assessment_groups(id),
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'sold')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()

View File

@@ -16,8 +16,8 @@ export class AccountsController {
@Get()
@ApiOperation({ summary: 'List all accounts' })
findAll(@Query('fundType') fundType?: string) {
return this.accountsService.findAll(fundType);
findAll(@Query('fundType') fundType?: string, @Query('includeArchived') includeArchived?: string) {
return this.accountsService.findAll(fundType, includeArchived === 'true');
}
@Get('trial-balance')

View File

@@ -7,12 +7,21 @@ import { UpdateAccountDto } from './dto/update-account.dto';
export class AccountsService {
constructor(private tenant: TenantService) {}
async findAll(fundType?: string) {
let sql = 'SELECT * FROM accounts WHERE is_active = true';
async findAll(fundType?: string, includeArchived?: boolean) {
let sql = 'SELECT * FROM accounts';
const params: any[] = [];
const conditions: string[] = [];
if (!includeArchived) {
conditions.push('is_active = true');
}
if (fundType) {
sql += ' AND fund_type = $1';
params.push(fundType);
conditions.push(`fund_type = $${params.length}`);
}
if (conditions.length) {
sql += ' WHERE ' + conditions.join(' AND ');
}
sql += ' ORDER BY account_number';
return this.tenant.query(sql, params);
@@ -47,7 +56,51 @@ export class AccountsService {
dto.is1099Reportable || false,
],
);
return rows[0];
const account = rows[0];
// Create opening balance journal entry if initialBalance is provided and non-zero
if (dto.initialBalance && dto.initialBalance !== 0) {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
// Find or use the current fiscal period
const periods = await this.tenant.query(
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
[year, month],
);
if (periods.length) {
const fiscalPeriodId = periods[0].id;
const absAmount = Math.abs(dto.initialBalance);
// Determine debit/credit based on account type
const isDebitNormal = ['asset', 'expense'].includes(dto.accountType);
const debit = isDebitNormal ? absAmount : 0;
const credit = isDebitNormal ? 0 : absAmount;
// Create the 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 (CURRENT_DATE, $1, 'opening_balance', $2, true, NOW(), $3)
RETURNING id`,
[
`Opening balance for ${dto.name}`,
fiscalPeriodId,
'00000000-0000-0000-0000-000000000000',
],
);
if (jeRows.length) {
await this.tenant.query(
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
VALUES ($1, $2, $3, $4, $5)`,
[jeRows[0].id, account.id, debit, credit, 'Opening balance'],
);
}
}
}
return account;
}
async update(id: string, dto: UpdateAccountDto) {
@@ -62,6 +115,9 @@ export class AccountsService {
if (dto.name !== undefined) { sets.push(`name = $${idx++}`); params.push(dto.name); }
if (dto.description !== undefined) { sets.push(`description = $${idx++}`); params.push(dto.description); }
if (dto.accountNumber !== undefined) { sets.push(`account_number = $${idx++}`); params.push(dto.accountNumber); }
if (dto.accountType !== undefined) { sets.push(`account_type = $${idx++}`); params.push(dto.accountType); }
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); }

View File

@@ -32,4 +32,8 @@ export class CreateAccountDto {
@IsBoolean()
@IsOptional()
is1099Reportable?: boolean;
@ApiProperty({ required: false, default: 0 })
@IsOptional()
initialBalance?: number;
}

View File

@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsBoolean, IsIn } from 'class-validator';
import { IsString, IsOptional, IsBoolean, IsIn, IsInt } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateAccountDto {
@@ -26,4 +26,14 @@ export class UpdateAccountDto {
@IsBoolean()
@IsOptional()
isActive?: boolean;
@ApiProperty({ required: false })
@IsInt()
@IsOptional()
accountNumber?: number;
@ApiProperty({ required: false })
@IsIn(['operating', 'reserve'])
@IsOptional()
fundType?: string;
}

View File

@@ -0,0 +1,27 @@
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 { AssessmentGroupsService } from './assessment-groups.service';
@ApiTags('assessment-groups')
@Controller('assessment-groups')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class AssessmentGroupsController {
constructor(private service: AssessmentGroupsService) {}
@Get()
findAll() { return this.service.findAll(); }
@Get('summary')
getSummary() { return this.service.getSummary(); }
@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); }
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AssessmentGroupsService } from './assessment-groups.service';
import { AssessmentGroupsController } from './assessment-groups.controller';
import { DatabaseModule } from '../../database/database.module';
@Module({
imports: [DatabaseModule],
controllers: [AssessmentGroupsController],
providers: [AssessmentGroupsService],
exports: [AssessmentGroupsService],
})
export class AssessmentGroupsModule {}

View File

@@ -0,0 +1,72 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
@Injectable()
export class AssessmentGroupsService {
constructor(private tenant: TenantService) {}
async findAll() {
return this.tenant.query(`
SELECT ag.*,
(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id) as actual_unit_count,
ag.regular_assessment * ag.unit_count as monthly_operating_income,
ag.special_assessment * ag.unit_count as monthly_reserve_income,
(ag.regular_assessment + ag.special_assessment) * ag.unit_count as total_monthly_income
FROM assessment_groups ag
ORDER BY ag.name
`);
}
async findOne(id: string) {
const rows = await this.tenant.query('SELECT * FROM assessment_groups WHERE id = $1', [id]);
if (!rows.length) throw new NotFoundException('Assessment group not found');
return rows[0];
}
async create(dto: any) {
const rows = await this.tenant.query(
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0, dto.unitCount || 0],
);
return rows[0];
}
async update(id: string, dto: any) {
await this.findOne(id);
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (dto.name !== undefined) { sets.push(`name = $${idx++}`); params.push(dto.name); }
if (dto.description !== undefined) { sets.push(`description = $${idx++}`); params.push(dto.description); }
if (dto.regularAssessment !== undefined) { sets.push(`regular_assessment = $${idx++}`); params.push(dto.regularAssessment); }
if (dto.specialAssessment !== undefined) { sets.push(`special_assessment = $${idx++}`); params.push(dto.specialAssessment); }
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 (!sets.length) return this.findOne(id);
sets.push('updated_at = NOW()');
params.push(id);
const rows = await this.tenant.query(
`UPDATE assessment_groups SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
params,
);
return rows[0];
}
async getSummary() {
const rows = await this.tenant.query(`
SELECT
COUNT(*) as group_count,
COALESCE(SUM(regular_assessment * unit_count), 0) as total_monthly_operating,
COALESCE(SUM(special_assessment * unit_count), 0) as total_monthly_reserve,
COALESCE(SUM((regular_assessment + special_assessment) * unit_count), 0) as total_monthly_income,
COALESCE(SUM(unit_count), 0) as total_units
FROM assessment_groups WHERE is_active = true
`);
return rows[0];
}
}

View File

@@ -0,0 +1,45 @@
import { Controller, Get, Post, Body, Param, UseGuards, Req, ForbiddenException } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { UsersService } from '../users/users.service';
@ApiTags('admin')
@Controller('admin')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class AdminController {
constructor(private usersService: UsersService) {}
private async requireSuperadmin(req: any) {
const user = await this.usersService.findById(req.user.userId || req.user.sub);
if (!user?.isSuperadmin) {
throw new ForbiddenException('SuperUser Admin access required');
}
}
@Get('users')
async listUsers(@Req() req: any) {
await this.requireSuperadmin(req);
const users = await this.usersService.findAllUsers();
return users.map(u => ({
id: u.id, email: u.email, firstName: u.firstName, lastName: u.lastName,
isSuperadmin: u.isSuperadmin, lastLoginAt: u.lastLoginAt, createdAt: u.createdAt,
organizations: u.userOrganizations?.map(uo => ({
id: uo.organizationId, name: uo.organization?.name, role: uo.role,
})) || [],
}));
}
@Get('organizations')
async listOrganizations(@Req() req: any) {
await this.requireSuperadmin(req);
return this.usersService.findAllOrganizations();
}
@Post('users/:id/superadmin')
async toggleSuperadmin(@Req() req: any, @Param('id') id: string, @Body() body: { isSuperadmin: boolean }) {
await this.requireSuperadmin(req);
await this.usersService.setSuperadmin(id, body.isSuperadmin);
return { success: true };
}
}

View File

@@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AdminController } from './admin.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
@@ -21,7 +22,7 @@ import { UsersModule } from '../users/users.module';
}),
}),
],
controllers: [AuthController],
controllers: [AuthController, AdminController],
providers: [AuthService, JwtStrategy, LocalStrategy],
exports: [AuthService],
})

View File

@@ -109,6 +109,7 @@ export class AuthService {
const payload: Record<string, any> = {
sub: user.id,
email: user.email,
isSuperadmin: user.isSuperadmin || false,
};
if (defaultOrg) {
@@ -124,6 +125,7 @@ export class AuthService {
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
isSuperadmin: user.isSuperadmin || false,
},
organizations: orgs.map((uo) => ({
id: uo.organizationId,

View File

@@ -20,6 +20,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
orgId: payload.orgId,
orgSchema: payload.orgSchema,
role: payload.role,
isSuperadmin: payload.isSuperadmin || false,
};
}
}

View File

@@ -1,5 +1,6 @@
import { Controller, Get, Put, Body, Param, Query, UseGuards, ParseIntPipe } from '@nestjs/common';
import { Controller, Get, Put, Post, Body, Param, Query, Res, UseGuards, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { BudgetsService } from './budgets.service';
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
@@ -11,6 +12,38 @@ import { UpsertBudgetDto } from './dto/upsert-budget.dto';
export class BudgetsController {
constructor(private budgetsService: BudgetsService) {}
@Post(':year/import')
@ApiOperation({ summary: 'Import budget data from parsed CSV/XLSX lines' })
importBudget(
@Param('year', ParseIntPipe) year: number,
@Body() lines: any[],
) {
return this.budgetsService.importBudget(year, lines);
}
@Get(':year/template')
@ApiOperation({ summary: 'Download budget CSV template for a fiscal year' })
async getTemplate(
@Param('year', ParseIntPipe) year: number,
@Res() res: Response,
) {
const csv = await this.budgetsService.getTemplate(year);
res.set({
'Content-Type': 'text/csv',
'Content-Disposition': `attachment; filename="budget_template_${year}.csv"`,
});
res.send(csv);
}
@Get(':year/vs-actual')
@ApiOperation({ summary: 'Budget vs actual comparison' })
budgetVsActual(
@Param('year', ParseIntPipe) year: number,
@Query('month') month?: string,
) {
return this.budgetsService.getBudgetVsActual(year, month ? parseInt(month) : undefined);
}
@Get(':year')
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
findByYear(@Param('year', ParseIntPipe) year: number) {
@@ -25,13 +58,4 @@ export class BudgetsController {
) {
return this.budgetsService.upsert(year, budgets);
}
@Get(':year/vs-actual')
@ApiOperation({ summary: 'Budget vs actual comparison' })
budgetVsActual(
@Param('year', ParseIntPipe) year: number,
@Query('month') month?: string,
) {
return this.budgetsService.getBudgetVsActual(year, month ? parseInt(month) : undefined);
}
}

View File

@@ -6,6 +6,96 @@ import { UpsertBudgetDto } from './dto/upsert-budget.dto';
export class BudgetsService {
constructor(private tenant: TenantService) {}
async importBudget(year: number, lines: any[]) {
const results = [];
const errors: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const accountNumber = String(line.accountNumber || line.account_number || '').trim();
if (!accountNumber) {
errors.push(`Row ${i + 1}: missing account_number`);
continue;
}
// Look up account by account_number
const accounts = await this.tenant.query(
`SELECT id, fund_type FROM accounts WHERE account_number = $1 AND is_active = true`,
[parseInt(accountNumber, 10)],
);
if (!accounts || accounts.length === 0) {
errors.push(`Row ${i + 1}: account_number ${accountNumber} not found`);
continue;
}
const account = accounts[0];
const fundType = line.fund_type || account.fund_type || 'operating';
const rows = 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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
ON CONFLICT (fiscal_year, 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, notes=$16
RETURNING *`,
[
year,
account.id,
fundType,
parseFloat(line.jan) || 0,
parseFloat(line.feb) || 0,
parseFloat(line.mar) || 0,
parseFloat(line.apr) || 0,
parseFloat(line.may) || 0,
parseFloat(line.jun) || 0,
parseFloat(line.jul) || 0,
parseFloat(line.aug) || 0,
parseFloat(line.sep) || 0,
parseFloat(line.oct) || 0,
parseFloat(line.nov) || 0,
parseFloat(line.dec_amt || line.dec) || 0,
line.notes || null,
],
);
results.push(rows[0]);
}
return { imported: results.length, errors };
}
async getTemplate(year: number): Promise<string> {
// Get all active income/expense accounts with existing budget data
const rows = await this.tenant.query(
`SELECT a.account_number, a.name as account_name,
COALESCE(b.jan, 0) as jan, COALESCE(b.feb, 0) as feb,
COALESCE(b.mar, 0) as mar, COALESCE(b.apr, 0) as apr,
COALESCE(b.may, 0) as may, COALESCE(b.jun, 0) as jun,
COALESCE(b.jul, 0) as jul, COALESCE(b.aug, 0) as aug,
COALESCE(b.sep, 0) as sep, COALESCE(b.oct, 0) as oct,
COALESCE(b.nov, 0) as nov, COALESCE(b.dec_amt, 0) as dec
FROM accounts a
LEFT JOIN budgets b ON b.account_id = a.id AND b.fiscal_year = $1
WHERE a.is_active = true
AND a.account_type IN ('income', 'expense')
ORDER BY a.account_number`,
[year],
);
const header = 'account_number,account_name,jan,feb,mar,apr,may,jun,jul,aug,sep,oct,nov,dec';
const csvLines = rows.map((r: any) => {
const name = String(r.account_name).includes(',')
? `"${r.account_name}"`
: r.account_name;
return [
r.account_number, name,
r.jan, r.feb, r.mar, r.apr, r.may, r.jun,
r.jul, r.aug, r.sep, r.oct, r.nov, r.dec,
].join(',');
});
return [header, ...csvLines].join('\n');
}
async findByYear(year: number) {
return this.tenant.query(
`SELECT b.*, a.account_number, a.name as account_name, a.account_type

View File

@@ -43,6 +43,9 @@ export class User {
@Column({ name: 'oauth_provider_id', nullable: true })
oauthProviderId: string;
@Column({ name: 'is_superadmin', default: false })
isSuperadmin: boolean;
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
lastLoginAt: Date;

View File

@@ -38,4 +38,25 @@ export class UsersService {
async updateLastLogin(id: string): Promise<void> {
await this.usersRepository.update(id, { lastLoginAt: new Date() });
}
async findAllUsers(): Promise<User[]> {
return this.usersRepository.find({
relations: ['userOrganizations', 'userOrganizations.organization'],
order: { createdAt: 'DESC' },
});
}
async findAllOrganizations(): Promise<any[]> {
const dataSource = this.usersRepository.manager.connection;
return dataSource.query(`
SELECT o.*,
(SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id) as member_count
FROM shared.organizations o
ORDER BY o.created_at DESC
`);
}
async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> {
await this.usersRepository.update(userId, { isSuperadmin });
}
}