Phase 2 tweaks: admin tenant creation, unit delete, frequency, UI overhaul
- Admin panel: create tenants with org + first user, manage org status (active/suspended/archived), contract number and plan level fields - Units: delete with invoice check, assessment group dropdown binding - Assessment groups: frequency field (monthly/quarterly/annual) with income calculations normalized to monthly equivalents - Sidebar: grouped nav sections (Financials, Assessments, Transactions, Planning, Reports, Admin), renamed Chart of Accounts to Accounts - Header: replaced text with SVG logo - Capital projects: Kanban as default view, table-only PDF export, Future category (beyond 5-year plan) - Auth: block login for suspended/archived organizations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -109,6 +109,7 @@ export class TenantSchemaService {
|
||||
regular_assessment DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
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_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
|
||||
@@ -6,12 +6,28 @@ 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,
|
||||
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
|
||||
CASE ag.frequency
|
||||
WHEN 'quarterly' THEN ag.regular_assessment / 3
|
||||
WHEN 'annual' THEN ag.regular_assessment / 12
|
||||
ELSE ag.regular_assessment
|
||||
END * ag.unit_count as monthly_operating_income,
|
||||
CASE ag.frequency
|
||||
WHEN 'quarterly' THEN ag.special_assessment / 3
|
||||
WHEN 'annual' THEN ag.special_assessment / 12
|
||||
ELSE ag.special_assessment
|
||||
END * ag.unit_count as monthly_reserve_income,
|
||||
(CASE ag.frequency
|
||||
WHEN 'quarterly' THEN (ag.regular_assessment + ag.special_assessment) / 3
|
||||
WHEN 'annual' THEN (ag.regular_assessment + ag.special_assessment) / 12
|
||||
ELSE ag.regular_assessment + ag.special_assessment
|
||||
END) * ag.unit_count as total_monthly_income
|
||||
FROM assessment_groups ag
|
||||
ORDER BY ag.name
|
||||
`);
|
||||
@@ -25,9 +41,9 @@ export class AssessmentGroupsService {
|
||||
|
||||
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],
|
||||
`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'],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
@@ -44,6 +60,7 @@ export class AssessmentGroupsService {
|
||||
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 (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); }
|
||||
|
||||
if (!sets.length) return this.findOne(id);
|
||||
|
||||
@@ -61,9 +78,27 @@ export class AssessmentGroupsService {
|
||||
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(
|
||||
CASE frequency
|
||||
WHEN 'quarterly' THEN regular_assessment / 3
|
||||
WHEN 'annual' THEN regular_assessment / 12
|
||||
ELSE regular_assessment
|
||||
END * unit_count
|
||||
), 0) as total_monthly_operating,
|
||||
COALESCE(SUM(
|
||||
CASE frequency
|
||||
WHEN 'quarterly' THEN special_assessment / 3
|
||||
WHEN 'annual' THEN special_assessment / 12
|
||||
ELSE special_assessment
|
||||
END * unit_count
|
||||
), 0) as total_monthly_reserve,
|
||||
COALESCE(SUM(
|
||||
CASE frequency
|
||||
WHEN 'quarterly' THEN (regular_assessment + special_assessment) / 3
|
||||
WHEN 'annual' THEN (regular_assessment + special_assessment) / 12
|
||||
ELSE regular_assessment + special_assessment
|
||||
END * unit_count
|
||||
), 0) as total_monthly_income,
|
||||
COALESCE(SUM(unit_count), 0) as total_units
|
||||
FROM assessment_groups WHERE is_active = true
|
||||
`);
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Controller, Get, Post, Body, Param, UseGuards, Req, ForbiddenException } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Put, Body, Param, UseGuards, Req, ForbiddenException, BadRequestException } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { OrganizationsService } from '../organizations/organizations.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@ApiTags('admin')
|
||||
@Controller('admin')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AdminController {
|
||||
constructor(private usersService: UsersService) {}
|
||||
constructor(
|
||||
private usersService: UsersService,
|
||||
private orgService: OrganizationsService,
|
||||
) {}
|
||||
|
||||
private async requireSuperadmin(req: any) {
|
||||
const user = await this.usersService.findById(req.user.userId || req.user.sub);
|
||||
@@ -42,4 +47,77 @@ export class AdminController {
|
||||
await this.usersService.setSuperadmin(id, body.isSuperadmin);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('tenants')
|
||||
async createTenant(@Req() req: any, @Body() body: {
|
||||
orgName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
addressLine1?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zipCode?: string;
|
||||
contractNumber?: string;
|
||||
planLevel?: string;
|
||||
fiscalYearStartMonth?: number;
|
||||
adminEmail: string;
|
||||
adminPassword: string;
|
||||
adminFirstName: string;
|
||||
adminLastName: string;
|
||||
}) {
|
||||
await this.requireSuperadmin(req);
|
||||
|
||||
if (!body.orgName || !body.adminEmail || !body.adminPassword) {
|
||||
throw new BadRequestException('Organization name, admin email and password are required');
|
||||
}
|
||||
|
||||
// Check if admin email already exists
|
||||
const existingUser = await this.usersService.findByEmail(body.adminEmail);
|
||||
let userId: string;
|
||||
|
||||
if (existingUser) {
|
||||
userId = existingUser.id;
|
||||
} else {
|
||||
// Create the first user for this tenant
|
||||
const passwordHash = await bcrypt.hash(body.adminPassword, 12);
|
||||
const newUser = await this.usersService.create({
|
||||
email: body.adminEmail,
|
||||
passwordHash,
|
||||
firstName: body.adminFirstName,
|
||||
lastName: body.adminLastName,
|
||||
});
|
||||
userId = newUser.id;
|
||||
}
|
||||
|
||||
// Create the organization + tenant schema + membership
|
||||
const org = await this.orgService.create({
|
||||
name: body.orgName,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
addressLine1: body.addressLine1,
|
||||
city: body.city,
|
||||
state: body.state,
|
||||
zipCode: body.zipCode,
|
||||
contractNumber: body.contractNumber,
|
||||
planLevel: body.planLevel || 'standard',
|
||||
fiscalYearStartMonth: body.fiscalYearStartMonth || 1,
|
||||
}, userId);
|
||||
|
||||
return { success: true, organization: org };
|
||||
}
|
||||
|
||||
@Put('organizations/:id/status')
|
||||
async updateOrgStatus(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { status: string },
|
||||
) {
|
||||
await this.requireSuperadmin(req);
|
||||
const validStatuses = ['active', 'suspended', 'trial', 'archived'];
|
||||
if (!validStatuses.includes(body.status)) {
|
||||
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
|
||||
}
|
||||
const org = await this.orgService.updateStatus(id, body.status);
|
||||
return { success: true, organization: org };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { LocalStrategy } from './strategies/local.strategy';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { OrganizationsModule } from '../organizations/organizations.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
OrganizationsModule,
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
|
||||
@@ -50,7 +50,22 @@ export class AuthService {
|
||||
async login(user: User) {
|
||||
await this.usersService.updateLastLogin(user.id);
|
||||
const fullUser = await this.usersService.findByIdWithOrgs(user.id);
|
||||
return this.generateTokenResponse(fullUser || user);
|
||||
const u = fullUser || user;
|
||||
|
||||
// Check if user's organizations are all suspended/archived
|
||||
const orgs = u.userOrganizations || [];
|
||||
if (orgs.length > 0 && !u.isSuperadmin) {
|
||||
const activeOrgs = orgs.filter(
|
||||
(uo) => uo.organization && !['suspended', 'archived'].includes(uo.organization.status),
|
||||
);
|
||||
if (activeOrgs.length === 0) {
|
||||
throw new UnauthorizedException(
|
||||
'Your organization has been suspended. Please contact your administrator.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.generateTokenResponse(u);
|
||||
}
|
||||
|
||||
async getProfile(userId: string) {
|
||||
|
||||
@@ -42,4 +42,14 @@ export class CreateOrganizationDto {
|
||||
@Max(12)
|
||||
@IsOptional()
|
||||
fiscalYearStartMonth?: number;
|
||||
|
||||
@ApiProperty({ example: 'CON-2026-001', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
contractNumber?: string;
|
||||
|
||||
@ApiProperty({ example: 'standard', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
planLevel?: string;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,12 @@ export class Organization {
|
||||
@Column({ name: 'fiscal_year_start_month', default: 1 })
|
||||
fiscalYearStartMonth: number;
|
||||
|
||||
@Column({ name: 'contract_number', nullable: true })
|
||||
contractNumber: string;
|
||||
|
||||
@Column({ name: 'plan_level', default: 'standard' })
|
||||
planLevel: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ export class OrganizationsService {
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
fiscalYearStartMonth: dto.fiscalYearStartMonth || 1,
|
||||
contractNumber: dto.contractNumber,
|
||||
planLevel: dto.planLevel || 'standard',
|
||||
});
|
||||
|
||||
const savedOrg = await this.orgRepository.save(org);
|
||||
@@ -52,6 +54,13 @@ export class OrganizationsService {
|
||||
return savedOrg;
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: string) {
|
||||
const org = await this.orgRepository.findOne({ where: { id } });
|
||||
if (!org) throw new ConflictException('Organization not found');
|
||||
org.status = status;
|
||||
return this.orgRepository.save(org);
|
||||
}
|
||||
|
||||
async findByUser(userId: string) {
|
||||
const memberships = await this.userOrgRepository.find({
|
||||
where: { userId, isActive: true },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { UnitsService } from './units.service';
|
||||
@@ -21,4 +21,7 @@ export class UnitsController {
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); }
|
||||
|
||||
@Delete(':id')
|
||||
delete(@Param('id') id: string) { return this.unitsService.delete(id); }
|
||||
}
|
||||
|
||||
@@ -8,12 +8,17 @@ export class UnitsService {
|
||||
async findAll() {
|
||||
return this.tenant.query(`
|
||||
SELECT u.*,
|
||||
ag.name as assessment_group_name,
|
||||
ag.regular_assessment as group_regular_assessment,
|
||||
ag.frequency as group_frequency,
|
||||
COALESCE((
|
||||
SELECT SUM(i.amount - i.amount_paid)
|
||||
FROM invoices i
|
||||
WHERE i.unit_id = u.id AND i.status NOT IN ('paid', 'void', 'written_off')
|
||||
), 0) as balance_due
|
||||
FROM units u ORDER BY u.unit_number
|
||||
FROM units u
|
||||
LEFT JOIN assessment_groups ag ON ag.id = u.assessment_group_id
|
||||
ORDER BY u.unit_number
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -28,9 +33,9 @@ export class UnitsService {
|
||||
if (existing.length) throw new BadRequestException(`Unit ${dto.unit_number} already exists`);
|
||||
|
||||
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)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 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],
|
||||
`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],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
@@ -42,10 +47,26 @@ export class UnitsService {
|
||||
city = COALESCE($4, city), state = COALESCE($5, state), zip_code = COALESCE($6, zip_code),
|
||||
owner_name = COALESCE($7, owner_name), owner_email = COALESCE($8, owner_email),
|
||||
owner_phone = COALESCE($9, owner_phone), monthly_assessment = COALESCE($10, monthly_assessment),
|
||||
status = COALESCE($11, status), updated_at = NOW()
|
||||
status = COALESCE($11, status), assessment_group_id = $12, updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[id, 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, dto.status],
|
||||
[id, 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, dto.status, dto.assessment_group_id !== undefined ? dto.assessment_group_id : null],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
await this.findOne(id);
|
||||
|
||||
// Check for outstanding invoices
|
||||
const outstanding = await this.tenant.query(
|
||||
`SELECT COUNT(*) as count FROM invoices WHERE unit_id = $1 AND status NOT IN ('paid', 'void', 'written_off')`,
|
||||
[id],
|
||||
);
|
||||
if (parseInt(outstanding[0]?.count) > 0) {
|
||||
throw new BadRequestException('Cannot delete unit with outstanding invoices. Please resolve all invoices first.');
|
||||
}
|
||||
|
||||
await this.tenant.query('DELETE FROM units WHERE id = $1', [id]);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user