From 01502e07bc6f98cf56b09d712ca90ef0906a42d2 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Wed, 18 Feb 2026 14:28:46 -0500 Subject: [PATCH] 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 --- backend/src/app.module.ts | 2 + backend/src/database/tenant-schema.service.ts | 14 + .../modules/accounts/accounts.controller.ts | 4 +- .../src/modules/accounts/accounts.service.ts | 64 ++- .../accounts/dto/create-account.dto.ts | 4 + .../accounts/dto/update-account.dto.ts | 12 +- .../assessment-groups.controller.ts | 27 ++ .../assessment-groups.module.ts | 12 + .../assessment-groups.service.ts | 72 +++ backend/src/modules/auth/admin.controller.ts | 45 ++ backend/src/modules/auth/auth.module.ts | 3 +- backend/src/modules/auth/auth.service.ts | 2 + .../modules/auth/strategies/jwt.strategy.ts | 1 + .../src/modules/budgets/budgets.controller.ts | 44 +- .../src/modules/budgets/budgets.service.ts | 90 ++++ .../src/modules/users/entities/user.entity.ts | 3 + backend/src/modules/users/users.service.ts | 21 + db/init/00-init.sql | 3 +- db/seed/reseed.sh | 84 ++++ db/seed/seed.sql | 80 +++- frontend/src/App.tsx | 21 + frontend/src/components/layout/Sidebar.tsx | 23 +- frontend/src/pages/accounts/AccountsPage.tsx | 212 ++++++--- frontend/src/pages/admin/AdminPage.tsx | 238 ++++++++++ .../AssessmentGroupsPage.tsx | 274 +++++++++++ frontend/src/pages/budgets/BudgetsPage.tsx | 147 +++++- .../capital-projects/CapitalProjectsPage.tsx | 429 +++++++++++++++--- .../src/pages/reports/AgingReportPage.tsx | 2 +- frontend/src/stores/authStore.ts | 1 + 29 files changed, 1792 insertions(+), 142 deletions(-) create mode 100644 backend/src/modules/assessment-groups/assessment-groups.controller.ts create mode 100644 backend/src/modules/assessment-groups/assessment-groups.module.ts create mode 100644 backend/src/modules/assessment-groups/assessment-groups.service.ts create mode 100644 backend/src/modules/auth/admin.controller.ts create mode 100755 db/seed/reseed.sh create mode 100644 frontend/src/pages/admin/AdminPage.tsx create mode 100644 frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e6125fb..212e0f9 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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], }) diff --git a/backend/src/database/tenant-schema.service.ts b/backend/src/database/tenant-schema.service.ts index 771f3eb..c065415 100644 --- a/backend/src/database/tenant-schema.service.ts +++ b/backend/src/database/tenant-schema.service.ts @@ -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() diff --git a/backend/src/modules/accounts/accounts.controller.ts b/backend/src/modules/accounts/accounts.controller.ts index a4c81d2..6702176 100644 --- a/backend/src/modules/accounts/accounts.controller.ts +++ b/backend/src/modules/accounts/accounts.controller.ts @@ -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') diff --git a/backend/src/modules/accounts/accounts.service.ts b/backend/src/modules/accounts/accounts.service.ts index a3852e9..55752b1 100644 --- a/backend/src/modules/accounts/accounts.service.ts +++ b/backend/src/modules/accounts/accounts.service.ts @@ -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); } diff --git a/backend/src/modules/accounts/dto/create-account.dto.ts b/backend/src/modules/accounts/dto/create-account.dto.ts index 51ab11d..9682ae0 100644 --- a/backend/src/modules/accounts/dto/create-account.dto.ts +++ b/backend/src/modules/accounts/dto/create-account.dto.ts @@ -32,4 +32,8 @@ export class CreateAccountDto { @IsBoolean() @IsOptional() is1099Reportable?: boolean; + + @ApiProperty({ required: false, default: 0 }) + @IsOptional() + initialBalance?: number; } diff --git a/backend/src/modules/accounts/dto/update-account.dto.ts b/backend/src/modules/accounts/dto/update-account.dto.ts index d23cffb..2bc209f 100644 --- a/backend/src/modules/accounts/dto/update-account.dto.ts +++ b/backend/src/modules/accounts/dto/update-account.dto.ts @@ -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; } diff --git a/backend/src/modules/assessment-groups/assessment-groups.controller.ts b/backend/src/modules/assessment-groups/assessment-groups.controller.ts new file mode 100644 index 0000000..41042f7 --- /dev/null +++ b/backend/src/modules/assessment-groups/assessment-groups.controller.ts @@ -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); } +} diff --git a/backend/src/modules/assessment-groups/assessment-groups.module.ts b/backend/src/modules/assessment-groups/assessment-groups.module.ts new file mode 100644 index 0000000..f9fecc8 --- /dev/null +++ b/backend/src/modules/assessment-groups/assessment-groups.module.ts @@ -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 {} diff --git a/backend/src/modules/assessment-groups/assessment-groups.service.ts b/backend/src/modules/assessment-groups/assessment-groups.service.ts new file mode 100644 index 0000000..db62c79 --- /dev/null +++ b/backend/src/modules/assessment-groups/assessment-groups.service.ts @@ -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]; + } +} diff --git a/backend/src/modules/auth/admin.controller.ts b/backend/src/modules/auth/admin.controller.ts new file mode 100644 index 0000000..2f851d0 --- /dev/null +++ b/backend/src/modules/auth/admin.controller.ts @@ -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 }; + } +} diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index f2bf466..0d623c1 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -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], }) diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 19ec7b7..282a5e9 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -109,6 +109,7 @@ export class AuthService { const payload: Record = { 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, diff --git a/backend/src/modules/auth/strategies/jwt.strategy.ts b/backend/src/modules/auth/strategies/jwt.strategy.ts index 7146e6e..d837316 100644 --- a/backend/src/modules/auth/strategies/jwt.strategy.ts +++ b/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -20,6 +20,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { orgId: payload.orgId, orgSchema: payload.orgSchema, role: payload.role, + isSuperadmin: payload.isSuperadmin || false, }; } } diff --git a/backend/src/modules/budgets/budgets.controller.ts b/backend/src/modules/budgets/budgets.controller.ts index b023c4b..5120860 100644 --- a/backend/src/modules/budgets/budgets.controller.ts +++ b/backend/src/modules/budgets/budgets.controller.ts @@ -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); - } } diff --git a/backend/src/modules/budgets/budgets.service.ts b/backend/src/modules/budgets/budgets.service.ts index 65b26ee..6eb5430 100644 --- a/backend/src/modules/budgets/budgets.service.ts +++ b/backend/src/modules/budgets/budgets.service.ts @@ -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 { + // 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 diff --git a/backend/src/modules/users/entities/user.entity.ts b/backend/src/modules/users/entities/user.entity.ts index 975fd9a..dad9a8e 100644 --- a/backend/src/modules/users/entities/user.entity.ts +++ b/backend/src/modules/users/entities/user.entity.ts @@ -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; diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts index 9ee97a1..20701d8 100644 --- a/backend/src/modules/users/users.service.ts +++ b/backend/src/modules/users/users.service.ts @@ -38,4 +38,25 @@ export class UsersService { async updateLastLogin(id: string): Promise { await this.usersRepository.update(id, { lastLoginAt: new Date() }); } + + async findAllUsers(): Promise { + return this.usersRepository.find({ + relations: ['userOrganizations', 'userOrganizations.organization'], + order: { createdAt: 'DESC' }, + }); + } + + async findAllOrganizations(): Promise { + 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 { + await this.usersRepository.update(userId, { isSuperadmin }); + } } diff --git a/db/init/00-init.sql b/db/init/00-init.sql index 6fe0ba1..333b45b 100644 --- a/db/init/00-init.sql +++ b/db/init/00-init.sql @@ -42,6 +42,7 @@ CREATE TABLE shared.users ( oauth_provider VARCHAR(50), oauth_provider_id VARCHAR(255), last_login_at TIMESTAMPTZ, + is_superadmin BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); @@ -51,7 +52,7 @@ CREATE TABLE shared.user_organizations ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE, organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE, - role VARCHAR(50) NOT NULL CHECK (role IN ('president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner')), + role VARCHAR(50) NOT NULL CHECK (role IN ('president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer')), is_active BOOLEAN DEFAULT TRUE, joined_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id, organization_id) diff --git a/db/seed/reseed.sh b/db/seed/reseed.sh new file mode 100755 index 0000000..240c916 --- /dev/null +++ b/db/seed/reseed.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# ============================================================ +# HOA LedgerIQ - Repeatable Seed Data Script +# ============================================================ +# Usage: ./db/seed/reseed.sh +# +# This script will: +# 1. Drop the tenant schema (if it exists) +# 2. Remove the test users and organization +# 3. Re-run the full seed SQL to recreate everything fresh +# +# Prerequisites: +# - PostgreSQL container must be running (docker compose up -d postgres) +# - DATABASE_URL or PGHOST/PGUSER etc must be configured +# ============================================================ + +set -e + +# Configuration +DB_HOST="${DB_HOST:-localhost}" +DB_PORT="${DB_PORT:-5432}" +DB_NAME="${DB_NAME:-hoa_platform}" +DB_USER="${DB_USER:-hoa_admin}" +DB_PASS="${DB_PASS:-hoa_secure_pass}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SEED_FILE="$SCRIPT_DIR/seed.sql" + +echo "============================================" +echo " HOA LedgerIQ - Reseed Database" +echo "============================================" +echo "" +echo "Host: $DB_HOST:$DB_PORT" +echo "Database: $DB_NAME" +echo "" + +# Check if seed file exists +if [ ! -f "$SEED_FILE" ]; then + echo "ERROR: Seed file not found at $SEED_FILE" + exit 1 +fi + +CLEANUP_SQL=$(cat <<'EOSQL' + DROP SCHEMA IF EXISTS tenant_sunrise_valley CASCADE; + DELETE FROM shared.user_organizations WHERE user_id IN ( + SELECT id FROM shared.users WHERE email IN ('admin@sunrisevalley.org', 'viewer@sunrisevalley.org') + ); + DELETE FROM shared.users WHERE email IN ('admin@sunrisevalley.org', 'viewer@sunrisevalley.org'); + DELETE FROM shared.organizations WHERE schema_name = 'tenant_sunrise_valley'; +EOSQL +) + +# Check if we can connect directly +export PGPASSWORD="$DB_PASS" +if psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1" > /dev/null 2>&1; then + echo "Step 1: Cleaning existing tenant data..." + echo "$CLEANUP_SQL" | psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" + + echo "Step 2: Running seed SQL..." + psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f "$SEED_FILE" +else + # Try via docker + echo "Direct connection failed. Trying via Docker..." + DOCKER_CMD="docker compose exec -T postgres psql -U $DB_USER -d $DB_NAME" + + echo "Step 1: Cleaning existing tenant data..." + echo "$CLEANUP_SQL" | $DOCKER_CMD + + echo "Step 2: Running seed SQL..." + $DOCKER_CMD < "$SEED_FILE" +fi + +echo "" +echo "============================================" +echo " Reseed complete!" +echo "============================================" +echo "" +echo " Test Accounts:" +echo " Admin: admin@sunrisevalley.org / password123" +echo " (SuperAdmin + President role)" +echo "" +echo " Viewer: viewer@sunrisevalley.org / password123" +echo " (Homeowner role)" +echo "============================================" diff --git a/db/seed/seed.sql b/db/seed/seed.sql index f38df90..138a99e 100644 --- a/db/seed/seed.sql +++ b/db/seed/seed.sql @@ -48,14 +48,15 @@ BEGIN -- Check if user exists SELECT id INTO v_user_id FROM shared.users WHERE email = 'admin@sunrisevalley.org'; IF v_user_id IS NULL THEN - INSERT INTO shared.users (id, email, password_hash, first_name, last_name) + INSERT INTO shared.users (id, email, password_hash, first_name, last_name, is_superadmin) VALUES ( uuid_generate_v4(), 'admin@sunrisevalley.org', -- bcrypt hash of 'password123' '$2b$10$1mtM00QBNQpAsyopajk3BeFY5DdxksvRYuM1E8qB.ePjCIYkfHMHO', 'Sarah', - 'Johnson' + 'Johnson', + true ) RETURNING id INTO v_user_id; END IF; @@ -78,6 +79,26 @@ IF v_org_id IS NULL THEN VALUES (v_user_id, v_org_id, 'president'); END IF; +-- Create a second test user (viewer/homeowner) +DECLARE v_viewer_id UUID; +BEGIN + SELECT id INTO v_viewer_id FROM shared.users WHERE email = 'viewer@sunrisevalley.org'; + IF v_viewer_id IS NULL THEN + INSERT INTO shared.users (id, email, password_hash, first_name, last_name, is_superadmin) + VALUES ( + uuid_generate_v4(), + 'viewer@sunrisevalley.org', + '$2b$10$1mtM00QBNQpAsyopajk3BeFY5DdxksvRYuM1E8qB.ePjCIYkfHMHO', + 'Mike', + 'Resident', + false + ) RETURNING id INTO v_viewer_id; + + INSERT INTO shared.user_organizations (user_id, organization_id, role) + VALUES (v_viewer_id, v_org_id, 'homeowner'); + END IF; +END; + -- ============================================================ -- 2. Create tenant schema (if not exists) -- ============================================================ @@ -147,6 +168,19 @@ CREATE TABLE IF NOT EXISTS %I.journal_entry_lines ( memo TEXT )', v_schema); +EXECUTE format(' +CREATE TABLE IF NOT EXISTS %I.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() +)', v_schema); + EXECUTE format(' CREATE TABLE IF NOT EXISTS %I.units ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), @@ -164,6 +198,7 @@ CREATE TABLE IF NOT EXISTS %I.units ( owner_phone VARCHAR(20), is_rented BOOLEAN DEFAULT FALSE, monthly_assessment DECIMAL(10,2), + assessment_group_id UUID, status VARCHAR(20) DEFAULT ''active'', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() @@ -314,6 +349,7 @@ EXECUTE format('DELETE FROM %I.investment_accounts', v_schema); EXECUTE format('DELETE FROM %I.reserve_components', v_schema); EXECUTE format('DELETE FROM %I.vendors', v_schema); EXECUTE format('DELETE FROM %I.units', v_schema); +EXECUTE format('DELETE FROM %I.assessment_groups', v_schema); EXECUTE format('DELETE FROM %I.fiscal_periods', v_schema); EXECUTE format('DELETE FROM %I.accounts', v_schema); @@ -376,6 +412,15 @@ FOR v_month IN 1..12 LOOP USING v_year - 1, v_month, 'closed'; END LOOP; +-- ============================================================ +-- 4b. Seed Assessment Groups +-- ============================================================ +EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count) VALUES + (''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20), + (''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 425.00, 0.00, 15), + (''Estate Lots'', ''Large estate lots (Units 36-50)'', 500.00, 75.00, 15) +', v_schema); + -- ============================================================ -- 5. Seed 50 units -- ============================================================ @@ -394,17 +439,26 @@ DECLARE 'Mitchell','Carter','Roberts']; v_unit_num INT; v_assess NUMERIC; + v_ag_id UUID; + v_ag_name TEXT; BEGIN FOR v_unit_num IN 1..50 LOOP - -- Vary assessment based on unit size - v_assess := CASE - WHEN v_unit_num <= 20 THEN 350.00 -- standard - WHEN v_unit_num <= 35 THEN 425.00 -- medium - ELSE 500.00 -- large - END; + -- Vary assessment based on unit size and assign assessment group + IF v_unit_num <= 20 THEN + v_assess := 350.00; + v_ag_name := 'Single Family Homes'; + ELSIF v_unit_num <= 35 THEN + v_assess := 425.00; + v_ag_name := 'Patio Homes'; + ELSE + v_assess := 500.00; + v_ag_name := 'Estate Lots'; + END IF; - EXECUTE format('INSERT INTO %I.units (unit_number, address_line1, city, state, zip_code, owner_name, owner_email, owner_phone, monthly_assessment, square_footage) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)', v_schema) + EXECUTE format('SELECT id FROM %I.assessment_groups WHERE name = $1', v_schema) INTO v_ag_id USING v_ag_name; + + EXECUTE format('INSERT INTO %I.units (unit_number, address_line1, city, state, zip_code, owner_name, owner_email, owner_phone, monthly_assessment, square_footage, assessment_group_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)', v_schema) USING LPAD(v_unit_num::TEXT, 3, '0'), (100 + v_unit_num * 2)::TEXT || ' Sunrise Valley Drive', @@ -413,7 +467,8 @@ BEGIN LOWER(v_first_names[v_unit_num]) || '.' || LOWER(v_last_names[v_unit_num]) || '@email.com', '(480) 555-' || LPAD((1000 + v_unit_num)::TEXT, 4, '0'), v_assess, - CASE WHEN v_unit_num <= 20 THEN 1200 WHEN v_unit_num <= 35 THEN 1600 ELSE 2000 END; + CASE WHEN v_unit_num <= 20 THEN 1200 WHEN v_unit_num <= 35 THEN 1600 ELSE 2000 END, + v_ag_id; END LOOP; END; @@ -779,6 +834,7 @@ EXECUTE format('INSERT INTO %I.capital_projects (name, description, estimated_co ', v_schema) USING v_year; RAISE NOTICE 'Seed data created successfully for Sunrise Valley HOA!'; -RAISE NOTICE 'Login: admin@sunrisevalley.org / password123'; +RAISE NOTICE 'Admin Login: admin@sunrisevalley.org / password123 (SuperAdmin + President)'; +RAISE NOTICE 'Viewer Login: viewer@sunrisevalley.org / password123 (Homeowner)'; END $$; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c49f633..6f24427 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,8 @@ import { CashFlowPage } from './pages/reports/CashFlowPage'; import { AgingReportPage } from './pages/reports/AgingReportPage'; import { YearEndPage } from './pages/reports/YearEndPage'; import { SettingsPage } from './pages/settings/SettingsPage'; +import { AdminPage } from './pages/admin/AdminPage'; +import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token); @@ -38,6 +40,14 @@ function OrgRequiredRoute({ children }: { children: React.ReactNode }) { return <>{children}; } +function SuperAdminRoute({ children }: { children: React.ReactNode }) { + const token = useAuthStore((s) => s.token); + const user = useAuthStore((s) => s.user); + if (!token) return ; + if (!user?.isSuperadmin) return ; + return <>{children}; +} + function AuthRoute({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token); const currentOrg = useAuthStore((s) => s.currentOrg); @@ -73,6 +83,16 @@ export function App() { } /> + + + + } + > + } /> + } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 0b3335c..aed41f3 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,4 +1,4 @@ -import { NavLink, ScrollArea } from '@mantine/core'; +import { NavLink, ScrollArea, Divider, Text } from '@mantine/core'; import { useNavigate, useLocation } from 'react-router-dom'; import { IconDashboard, @@ -16,13 +16,17 @@ import { IconUsers, IconFileText, IconSettings, + IconCrown, + IconCategory, } from '@tabler/icons-react'; +import { useAuthStore } from '../../stores/authStore'; const navItems = [ { label: 'Dashboard', icon: IconDashboard, path: '/dashboard' }, { label: 'Chart of Accounts', icon: IconListDetails, path: '/accounts' }, { label: 'Transactions', icon: IconReceipt, path: '/transactions' }, { label: 'Units / Homeowners', icon: IconHome, path: '/units' }, + { label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups' }, { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' }, { label: 'Payments', icon: IconCash, path: '/payments' }, { label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' }, @@ -49,6 +53,7 @@ const navItems = [ export function Sidebar() { const navigate = useNavigate(); const location = useLocation(); + const user = useAuthStore((s) => s.user); return ( @@ -81,6 +86,22 @@ export function Sidebar() { /> ), )} + + {user?.isSuperadmin && ( + <> + + + Platform Admin + + } + active={location.pathname === '/admin'} + onClick={() => navigate('/admin')} + color="red" + /> + + )} ); } diff --git a/frontend/src/pages/accounts/AccountsPage.tsx b/frontend/src/pages/accounts/AccountsPage.tsx index 19b373d..4b65d1b 100644 --- a/frontend/src/pages/accounts/AccountsPage.tsx +++ b/frontend/src/pages/accounts/AccountsPage.tsx @@ -17,11 +17,13 @@ import { Tabs, Loader, Center, + Tooltip, + SimpleGrid, } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; -import { IconPlus, IconEdit, IconSearch } from '@tabler/icons-react'; +import { IconPlus, IconEdit, IconSearch, IconArchive, IconArchiveOff } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; @@ -52,36 +54,41 @@ export function AccountsPage() { const [search, setSearch] = useState(''); const [filterType, setFilterType] = useState(null); const [filterFund, setFilterFund] = useState(null); + const [showArchived, setShowArchived] = useState(false); const queryClient = useQueryClient(); const { data: accounts = [], isLoading } = useQuery({ - queryKey: ['accounts'], + queryKey: ['accounts', showArchived], queryFn: async () => { - const { data } = await api.get('/accounts'); + const params = showArchived ? '?includeArchived=true' : ''; + const { data } = await api.get(`/accounts${params}`); return data; }, }); const form = useForm({ initialValues: { - account_number: 0, + accountNumber: 0, name: '', description: '', - account_type: 'expense', - fund_type: 'operating', - is_1099_reportable: false, + accountType: 'expense', + fundType: 'operating', + is1099Reportable: false, + initialBalance: 0, }, validate: { - account_number: (v) => (v > 0 ? null : 'Required'), + accountNumber: (v) => (v > 0 ? null : 'Required'), name: (v) => (v.length > 0 ? null : 'Required'), }, }); const createMutation = useMutation({ - mutationFn: (values: any) => - editing - ? api.put(`/accounts/${editing.id}`, values) - : api.post('/accounts', values), + mutationFn: (values: any) => { + if (editing) { + return api.put(`/accounts/${editing.id}`, values); + } + return api.post('/accounts', values); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['accounts'] }); notifications.show({ message: editing ? 'Account updated' : 'Account created', color: 'green' }); @@ -94,15 +101,28 @@ export function AccountsPage() { }, }); + const archiveMutation = useMutation({ + mutationFn: (account: Account) => + api.put(`/accounts/${account.id}`, { isActive: !account.is_active }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + notifications.show({ message: 'Account status updated', color: 'green' }); + }, + onError: (err: any) => { + notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); + }, + }); + const handleEdit = (account: Account) => { setEditing(account); form.setValues({ - account_number: account.account_number, + accountNumber: account.account_number, name: account.name, description: account.description || '', - account_type: account.account_type, - fund_type: account.fund_type, - is_1099_reportable: account.is_1099_reportable, + accountType: account.account_type, + fundType: account.fund_type, + is1099Reportable: account.is_1099_reportable, + initialBalance: 0, }); open(); }; @@ -120,11 +140,18 @@ export function AccountsPage() { return true; }); + const activeAccounts = filtered.filter(a => a.is_active); + const archivedAccounts = filtered.filter(a => !a.is_active); + const totalsByType = accounts.reduce((acc, a) => { - acc[a.account_type] = (acc[a.account_type] || 0) + parseFloat(a.balance || '0'); + if (a.is_active) { + acc[a.account_type] = (acc[a.account_type] || 0) + parseFloat(a.balance || '0'); + } return acc; }, {} as Record); + const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); + if (isLoading) { return
; } @@ -133,11 +160,28 @@ export function AccountsPage() { Chart of Accounts - + + setShowArchived(e.currentTarget.checked)} + size="sm" + /> + + + + {Object.entries(totalsByType).map(([type, total]) => ( + + {type} + {fmt(total)} + + ))} + + - All ({accounts.length}) + All ({activeAccounts.length}) Operating Reserve + {showArchived && archivedAccounts.length > 0 && ( + Archived ({archivedAccounts.length}) + )} - + - a.fund_type === 'operating')} onEdit={handleEdit} /> + a.fund_type === 'operating')} onEdit={handleEdit} onArchive={archiveMutation.mutate} /> - a.fund_type === 'reserve')} onEdit={handleEdit} /> + a.fund_type === 'reserve')} onEdit={handleEdit} onArchive={archiveMutation.mutate} /> + {showArchived && ( + + + + )}
createMutation.mutate(values))}> - + - - + + + + + {!editing && ( + + )} @@ -220,7 +283,17 @@ export function AccountsPage() { ); } -function AccountTable({ accounts, onEdit }: { accounts: Account[]; onEdit: (a: Account) => void }) { +function AccountTable({ + accounts, + onEdit, + onArchive, + isArchivedView = false, +}: { + accounts: Account[]; + onEdit: (a: Account) => void; + onArchive: (a: Account) => void; + isArchivedView?: boolean; +}) { const fmt = (v: string) => { const n = parseFloat(v || '0'); return n.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); @@ -240,10 +313,24 @@ function AccountTable({ accounts, onEdit }: { accounts: Account[]; onEdit: (a: A + {accounts.length === 0 && ( + + + + {isArchivedView ? 'No archived accounts' : 'No accounts found'} + + + + )} {accounts.map((a) => ( - + {a.account_number} - {a.name} + +
+ {a.name} + {a.description && {a.description}} +
+
{a.account_type} @@ -255,13 +342,26 @@ function AccountTable({ accounts, onEdit }: { accounts: Account[]; onEdit: (a: A {fmt(a.balance)} - {a.is_1099_reportable ? '1099' : ''} + {a.is_1099_reportable ? 1099 : ''} - {!a.is_system && ( - onEdit(a)}> - - - )} + + + onEdit(a)}> + + + + {!a.is_system && ( + + onArchive(a)} + > + {a.is_active ? : } + + + )} +
))} diff --git a/frontend/src/pages/admin/AdminPage.tsx b/frontend/src/pages/admin/AdminPage.tsx new file mode 100644 index 0000000..2f386ea --- /dev/null +++ b/frontend/src/pages/admin/AdminPage.tsx @@ -0,0 +1,238 @@ +import { useState } from 'react'; +import { + Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center, + ThemeIcon, Tabs, ActionIcon, Switch, TextInput, Avatar, +} from '@mantine/core'; +import { + IconUsers, IconBuilding, IconShieldLock, IconSearch, + IconCrown, IconUser, +} from '@tabler/icons-react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../../services/api'; + +interface AdminUser { + id: string; email: string; firstName: string; lastName: string; + isSuperadmin: boolean; lastLoginAt: string; createdAt: string; + organizations: { id: string; name: string; role: string }[]; +} + +interface AdminOrg { + id: string; name: string; schema_name: string; status: string; + email: string; phone: string; member_count: string; created_at: string; +} + +export function AdminPage() { + const [search, setSearch] = useState(''); + const queryClient = useQueryClient(); + + const { data: users, isLoading: usersLoading } = useQuery({ + queryKey: ['admin-users'], + queryFn: async () => { const { data } = await api.get('/admin/users'); return data; }, + }); + + const { data: orgs, isLoading: orgsLoading } = useQuery({ + queryKey: ['admin-orgs'], + queryFn: async () => { const { data } = await api.get('/admin/organizations'); return data; }, + }); + + const toggleSuperadmin = useMutation({ + mutationFn: async ({ userId, isSuperadmin }: { userId: string; isSuperadmin: boolean }) => { + await api.post(`/admin/users/${userId}/superadmin`, { isSuperadmin }); + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-users'] }), + }); + + const filteredUsers = (users || []).filter(u => + !search || u.email.toLowerCase().includes(search.toLowerCase()) || + `${u.firstName} ${u.lastName}`.toLowerCase().includes(search.toLowerCase()) + ); + + const filteredOrgs = (orgs || []).filter(o => + !search || o.name.toLowerCase().includes(search.toLowerCase()) || + o.schema_name.toLowerCase().includes(search.toLowerCase()) + ); + + return ( + + +
+ Platform Administration + SuperUser Admin Panel — Manage tenants and users +
+ }> + SuperAdmin + +
+ + + + +
+ Total Users + {users?.length || 0} +
+ + + +
+
+ + +
+ Organizations + {orgs?.length || 0} +
+ + + +
+
+ + +
+ SuperAdmins + {(users || []).filter(u => u.isSuperadmin).length} +
+ + + +
+
+
+ + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + /> + + + + }> + Users ({filteredUsers.length}) + + }> + Organizations ({filteredOrgs.length}) + + + + + {usersLoading ? ( +
+ ) : ( + + + + + User + Email + Organizations + Last Login + SuperAdmin + + + + {filteredUsers.map((u) => ( + + + + + {u.firstName?.[0]}{u.lastName?.[0]} + + {u.firstName} {u.lastName} + + + + {u.email} + + + + {u.organizations.map((o) => ( + + {o.name} ({o.role}) + + ))} + {u.organizations.length === 0 && ( + No organizations + )} + + + + + {u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleDateString() : 'Never'} + + + + toggleSuperadmin.mutate({ + userId: u.id, + isSuperadmin: !u.isSuperadmin, + })} + size="sm" + color="red" + /> + + + ))} + +
+
+ )} +
+ + + {orgsLoading ? ( +
+ ) : ( + + + + + Organization + Schema + Status + Members + Contact + Created + + + + {filteredOrgs.map((o) => ( + + {o.name} + + {o.schema_name} + + + + {o.status} + + + + {o.member_count} + + + {o.email || 'N/A'} + + + + {new Date(o.created_at).toLocaleDateString()} + + + + ))} + +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx b/frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx new file mode 100644 index 0000000..e3a6832 --- /dev/null +++ b/frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx @@ -0,0 +1,274 @@ +import { useState } from 'react'; +import { + Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center, + ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Switch, ActionIcon, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { useDisclosure } from '@mantine/hooks'; +import { notifications } from '@mantine/notifications'; +import { + IconPlus, IconEdit, IconCategory, IconCash, IconHome, IconArchive, +} from '@tabler/icons-react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../../services/api'; + +interface AssessmentGroup { + id: string; + name: string; + description: string; + regular_assessment: string; + special_assessment: string; + unit_count: number; + actual_unit_count: string; + monthly_operating_income: string; + monthly_reserve_income: string; + total_monthly_income: string; + is_active: boolean; +} + +interface Summary { + group_count: string; + total_monthly_operating: string; + total_monthly_reserve: string; + total_monthly_income: string; + total_units: string; +} + +export function AssessmentGroupsPage() { + const [opened, { open, close }] = useDisclosure(false); + const [editing, setEditing] = useState(null); + const queryClient = useQueryClient(); + + const { data: groups = [], isLoading } = useQuery({ + queryKey: ['assessment-groups'], + queryFn: async () => { const { data } = await api.get('/assessment-groups'); return data; }, + }); + + const { data: summary } = useQuery({ + queryKey: ['assessment-groups-summary'], + queryFn: async () => { const { data } = await api.get('/assessment-groups/summary'); return data; }, + }); + + const form = useForm({ + initialValues: { + name: '', + description: '', + regularAssessment: 0, + specialAssessment: 0, + unitCount: 0, + }, + validate: { + name: (v) => (v.length > 0 ? null : 'Required'), + regularAssessment: (v) => (v >= 0 ? null : 'Must be >= 0'), + }, + }); + + const saveMutation = useMutation({ + mutationFn: (values: any) => + editing + ? api.put(`/assessment-groups/${editing.id}`, values) + : api.post('/assessment-groups', values), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['assessment-groups'] }); + queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] }); + notifications.show({ message: editing ? 'Group updated' : 'Group created', color: 'green' }); + close(); + setEditing(null); + form.reset(); + }, + onError: (err: any) => { + notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); + }, + }); + + const archiveMutation = useMutation({ + mutationFn: (group: AssessmentGroup) => + api.put(`/assessment-groups/${group.id}`, { isActive: !group.is_active }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['assessment-groups'] }); + queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] }); + notifications.show({ message: 'Group status updated', color: 'green' }); + }, + }); + + const handleEdit = (group: AssessmentGroup) => { + setEditing(group); + form.setValues({ + name: group.name, + description: group.description || '', + regularAssessment: parseFloat(group.regular_assessment || '0'), + specialAssessment: parseFloat(group.special_assessment || '0'), + unitCount: group.unit_count || 0, + }); + open(); + }; + + const handleNew = () => { + setEditing(null); + form.reset(); + open(); + }; + + const fmt = (v: string | number) => + parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); + + if (isLoading) return
; + + return ( + + +
+ Assessment Groups + Manage property types with different assessment rates +
+ +
+ + + + +
+ Groups + {summary?.group_count || 0} +
+ + + +
+
+ + +
+ Total Units + {summary?.total_units || 0} +
+ + + +
+
+ + +
+ Monthly Operating + {fmt(summary?.total_monthly_operating || '0')} +
+ + + +
+
+ + +
+ Monthly Reserve + {fmt(summary?.total_monthly_reserve || '0')} +
+ + + +
+
+
+ + + + + + Group Name + Units + Regular Assessment + Special Assessment + Monthly Operating + Monthly Reserve + Status + + + + + {groups.length === 0 && ( + + + + No assessment groups yet. Create groups like "Single Family Homes", "Condos", etc. + + + + )} + {groups.map((g) => ( + + +
+ {g.name} + {g.description && {g.description}} +
+
+ + {g.actual_unit_count || g.unit_count} + + {fmt(g.regular_assessment)} + + {parseFloat(g.special_assessment || '0') > 0 ? ( + {fmt(g.special_assessment)} + ) : '-'} + + {fmt(g.monthly_operating_income)} + {fmt(g.monthly_reserve_income)} + + + {g.is_active ? 'Active' : 'Archived'} + + + + + handleEdit(g)}> + + + archiveMutation.mutate(g)} + > + + + + +
+ ))} +
+
+
+ + + saveMutation.mutate(values))}> + + +