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:
2026-02-18 20:00:16 -05:00
parent 01502e07bc
commit 17fdacc0f2
20 changed files with 992 additions and 148 deletions

View File

@@ -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 };
}
}