diff --git a/backend/src/modules/auth/admin-analytics.service.ts b/backend/src/modules/auth/admin-analytics.service.ts new file mode 100644 index 0000000..2ccd2c7 --- /dev/null +++ b/backend/src/modules/auth/admin-analytics.service.ts @@ -0,0 +1,325 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +@Injectable() +export class AdminAnalyticsService { + private readonly logger = new Logger(AdminAnalyticsService.name); + + constructor(private dataSource: DataSource) {} + + /** + * Platform-wide metrics for the admin dashboard. + */ + async getPlatformMetrics() { + const [ + userStats, + orgStats, + planBreakdown, + statusBreakdown, + newTenantsPerMonth, + newUsersPerMonth, + aiStats, + activeUsers30d, + ] = await Promise.all([ + this.dataSource.query(` + SELECT + COUNT(*) as total_users, + COUNT(*) FILTER (WHERE is_superadmin = true) as superadmin_count, + COUNT(*) FILTER (WHERE is_platform_owner = true) as platform_owner_count + FROM shared.users + `), + this.dataSource.query(` + SELECT + COUNT(*) as total_organizations, + COUNT(*) FILTER (WHERE status = 'active') as active_count, + COUNT(*) FILTER (WHERE status = 'archived') as archived_count, + COUNT(*) FILTER (WHERE status = 'suspended') as suspended_count, + COUNT(*) FILTER (WHERE status = 'trial') as trial_count + FROM shared.organizations + `), + this.dataSource.query(` + SELECT plan_level, COUNT(*) as count + FROM shared.organizations + WHERE status != 'archived' + GROUP BY plan_level + ORDER BY count DESC + `), + this.dataSource.query(` + SELECT status, COUNT(*) as count + FROM shared.organizations + GROUP BY status + ORDER BY count DESC + `), + this.dataSource.query(` + SELECT + DATE_TRUNC('month', created_at) as month, + COUNT(*) as count + FROM shared.organizations + WHERE created_at > NOW() - INTERVAL '6 months' + GROUP BY DATE_TRUNC('month', created_at) + ORDER BY month DESC + `), + this.dataSource.query(` + SELECT + DATE_TRUNC('month', created_at) as month, + COUNT(*) as count + FROM shared.users + WHERE created_at > NOW() - INTERVAL '6 months' + GROUP BY DATE_TRUNC('month', created_at) + ORDER BY month DESC + `), + this.dataSource.query(` + SELECT + COUNT(*) as total_requests, + COUNT(*) FILTER (WHERE status = 'success') as successful, + ROUND(AVG(response_time_ms)) as avg_response_ms + FROM shared.ai_recommendation_log + WHERE requested_at > NOW() - INTERVAL '30 days' + `), + this.dataSource.query(` + SELECT COUNT(DISTINCT user_id) as count + FROM shared.login_history + WHERE logged_in_at > NOW() - INTERVAL '30 days' + `), + ]); + + return { + totalUsers: parseInt(userStats[0]?.total_users || '0'), + superadminCount: parseInt(userStats[0]?.superadmin_count || '0'), + platformOwnerCount: parseInt(userStats[0]?.platform_owner_count || '0'), + activeUsers30d: parseInt(activeUsers30d[0]?.count || '0'), + totalOrganizations: parseInt(orgStats[0]?.total_organizations || '0'), + activeOrganizations: parseInt(orgStats[0]?.active_count || '0'), + archivedOrganizations: parseInt(orgStats[0]?.archived_count || '0'), + suspendedOrganizations: parseInt(orgStats[0]?.suspended_count || '0'), + trialOrganizations: parseInt(orgStats[0]?.trial_count || '0'), + planBreakdown: planBreakdown.map((r: any) => ({ + plan: r.plan_level, + count: parseInt(r.count), + })), + statusBreakdown: statusBreakdown.map((r: any) => ({ + status: r.status, + count: parseInt(r.count), + })), + newTenantsPerMonth: newTenantsPerMonth.map((r: any) => ({ + month: r.month, + count: parseInt(r.count), + })), + newUsersPerMonth: newUsersPerMonth.map((r: any) => ({ + month: r.month, + count: parseInt(r.count), + })), + aiRequestsLast30d: parseInt(aiStats[0]?.total_requests || '0'), + aiSuccessfulLast30d: parseInt(aiStats[0]?.successful || '0'), + aiAvgResponseMs: parseInt(aiStats[0]?.avg_response_ms || '0'), + }; + } + + /** + * Detailed analytics for a specific tenant/organization. + */ + async getTenantDetail(orgId: string) { + const [orgInfo, loginStats, weeklyLogins, monthlyLogins, aiCount, memberCount] = await Promise.all([ + this.dataSource.query( + `SELECT o.*, (SELECT MAX(logged_in_at) FROM shared.login_history WHERE organization_id = o.id) as last_login + FROM shared.organizations o WHERE o.id = $1`, + [orgId], + ), + this.dataSource.query( + `SELECT + COUNT(*) FILTER (WHERE logged_in_at > NOW() - INTERVAL '7 days') as logins_this_week, + COUNT(*) FILTER (WHERE logged_in_at > NOW() - INTERVAL '30 days') as logins_this_month, + COUNT(DISTINCT user_id) FILTER (WHERE logged_in_at > NOW() - INTERVAL '30 days') as active_users_30d + FROM shared.login_history WHERE organization_id = $1`, + [orgId], + ), + this.dataSource.query( + `SELECT + DATE_TRUNC('week', logged_in_at) as week, + COUNT(*) as count + FROM shared.login_history + WHERE organization_id = $1 AND logged_in_at > NOW() - INTERVAL '4 weeks' + GROUP BY DATE_TRUNC('week', logged_in_at) + ORDER BY week DESC`, + [orgId], + ), + this.dataSource.query( + `SELECT + DATE_TRUNC('month', logged_in_at) as month, + COUNT(*) as count + FROM shared.login_history + WHERE organization_id = $1 AND logged_in_at > NOW() - INTERVAL '6 months' + GROUP BY DATE_TRUNC('month', logged_in_at) + ORDER BY month DESC`, + [orgId], + ), + this.dataSource.query( + `SELECT COUNT(*) as count + FROM shared.ai_recommendation_log + WHERE organization_id = $1 AND requested_at > NOW() - INTERVAL '30 days'`, + [orgId], + ), + this.dataSource.query( + `SELECT COUNT(*) as count FROM shared.user_organizations WHERE organization_id = $1 AND is_active = true`, + [orgId], + ), + ]); + + const org = orgInfo[0]; + if (!org) return null; + + // Cross-schema queries for tenant financial data + let cashOnHand = 0; + let hasBudget = false; + let recentTransactions = 0; + + try { + const cashResult = await this.dataSource.query(` + SELECT COALESCE(SUM(sub.bal), 0) as total FROM ( + SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal + FROM "${org.schema_name}".accounts a + JOIN "${org.schema_name}".journal_entry_lines jel ON jel.account_id = a.id + JOIN "${org.schema_name}".journal_entries je ON je.id = jel.journal_entry_id + AND je.is_posted = true AND je.is_void = false + WHERE a.account_type = 'asset' AND a.is_active = true + GROUP BY a.id + ) sub + `); + cashOnHand = parseFloat(cashResult[0]?.total || '0'); + + const budgetResult = await this.dataSource.query( + `SELECT COUNT(*) as count FROM "${org.schema_name}".budgets WHERE fiscal_year = $1`, + [new Date().getFullYear()], + ); + hasBudget = parseInt(budgetResult[0]?.count || '0') > 0; + + const txnResult = await this.dataSource.query(` + SELECT COUNT(*) as count + FROM "${org.schema_name}".journal_entries + WHERE is_posted = true AND entry_date > NOW() - INTERVAL '30 days' + `); + recentTransactions = parseInt(txnResult[0]?.count || '0'); + } catch (err) { + this.logger.warn(`Failed to query tenant schema ${org.schema_name}: ${err.message}`); + } + + return { + organization: org, + lastLogin: org.last_login, + loginsThisWeek: parseInt(loginStats[0]?.logins_this_week || '0'), + loginsThisMonth: parseInt(loginStats[0]?.logins_this_month || '0'), + activeUsers30d: parseInt(loginStats[0]?.active_users_30d || '0'), + weeklyLogins: weeklyLogins.map((r: any) => ({ + week: r.week, + count: parseInt(r.count), + })), + monthlyLogins: monthlyLogins.map((r: any) => ({ + month: r.month, + count: parseInt(r.count), + })), + aiRecommendations30d: parseInt(aiCount[0]?.count || '0'), + memberCount: parseInt(memberCount[0]?.count || '0'), + cashOnHand, + hasBudget, + recentTransactions, + }; + } + + /** + * All tenants with health scores for the Health & Adoption tab. + * + * Health Score (0-100): + * Active users 30d ≥ 1 → 25pts + * Has current-year budget → 25pts + * Journal entries 30d ≥ 1 → 25pts + * 2+ active members → 15pts + * AI usage 30d ≥ 1 → 10pts + */ + async getAllTenantsHealth() { + const orgs = await this.dataSource.query(` + SELECT + o.id, o.name, o.schema_name, o.status, o.plan_level, o.created_at, + o.payment_date, o.renewal_date, + (SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id AND is_active = true) as member_count, + (SELECT MAX(lh.logged_in_at) FROM shared.login_history lh WHERE lh.organization_id = o.id) as last_login, + (SELECT COUNT(DISTINCT lh.user_id) FROM shared.login_history lh WHERE lh.organization_id = o.id AND lh.logged_in_at > NOW() - INTERVAL '30 days') as active_users_30d, + (SELECT COUNT(*) FROM shared.ai_recommendation_log ar WHERE ar.organization_id = o.id AND ar.requested_at > NOW() - INTERVAL '30 days') as ai_usage_30d + FROM shared.organizations o + WHERE o.status != 'archived' + ORDER BY o.name + `); + + const currentYear = new Date().getFullYear(); + const results = []; + + for (const org of orgs) { + let cashOnHand = 0; + let hasBudget = false; + let journalEntries30d = 0; + + try { + const cashResult = await this.dataSource.query(` + SELECT COALESCE(SUM(sub.bal), 0) as total FROM ( + SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal + FROM "${org.schema_name}".accounts a + JOIN "${org.schema_name}".journal_entry_lines jel ON jel.account_id = a.id + JOIN "${org.schema_name}".journal_entries je ON je.id = jel.journal_entry_id + AND je.is_posted = true AND je.is_void = false + WHERE a.account_type = 'asset' AND a.is_active = true + GROUP BY a.id + ) sub + `); + cashOnHand = parseFloat(cashResult[0]?.total || '0'); + + const budgetResult = await this.dataSource.query( + `SELECT COUNT(*) as count FROM "${org.schema_name}".budgets WHERE fiscal_year = $1`, + [currentYear], + ); + hasBudget = parseInt(budgetResult[0]?.count || '0') > 0; + + const jeResult = await this.dataSource.query(` + SELECT COUNT(*) as count + FROM "${org.schema_name}".journal_entries + WHERE is_posted = true AND entry_date > NOW() - INTERVAL '30 days' + `); + journalEntries30d = parseInt(jeResult[0]?.count || '0'); + } catch (err) { + // Schema may not exist yet (new tenant) + this.logger.warn(`Health check skip for ${org.schema_name}: ${err.message}`); + } + + // Calculate health score + const activeUsers = parseInt(org.active_users_30d) || 0; + const memberCount = parseInt(org.member_count) || 0; + const aiUsage = parseInt(org.ai_usage_30d) || 0; + + let healthScore = 0; + if (activeUsers >= 1) healthScore += 25; + if (hasBudget) healthScore += 25; + if (journalEntries30d >= 1) healthScore += 25; + if (memberCount >= 2) healthScore += 15; + if (aiUsage >= 1) healthScore += 10; + + results.push({ + id: org.id, + name: org.name, + schemaName: org.schema_name, + status: org.status, + planLevel: org.plan_level, + createdAt: org.created_at, + paymentDate: org.payment_date, + renewalDate: org.renewal_date, + memberCount, + lastLogin: org.last_login, + activeUsers30d: activeUsers, + aiUsage30d: aiUsage, + cashOnHand, + hasBudget, + journalEntries30d, + healthScore, + }); + } + + return results; + } +} diff --git a/backend/src/modules/auth/admin.controller.ts b/backend/src/modules/auth/admin.controller.ts index 6812ffe..5012b37 100644 --- a/backend/src/modules/auth/admin.controller.ts +++ b/backend/src/modules/auth/admin.controller.ts @@ -3,6 +3,7 @@ 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 { AdminAnalyticsService } from './admin-analytics.service'; import * as bcrypt from 'bcryptjs'; @ApiTags('admin') @@ -13,6 +14,7 @@ export class AdminController { constructor( private usersService: UsersService, private orgService: OrganizationsService, + private analyticsService: AdminAnalyticsService, ) {} private async requireSuperadmin(req: any) { @@ -22,25 +24,76 @@ export class AdminController { } } + // ── Platform Metrics ── + + @Get('metrics') + async getPlatformMetrics(@Req() req: any) { + await this.requireSuperadmin(req); + return this.analyticsService.getPlatformMetrics(); + } + + // ── Users ── + @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, + isSuperadmin: u.isSuperadmin, isPlatformOwner: u.isPlatformOwner || false, + lastLoginAt: u.lastLoginAt, createdAt: u.createdAt, organizations: u.userOrganizations?.map(uo => ({ id: uo.organizationId, name: uo.organization?.name, role: uo.role, })) || [], })); } + // ── Organizations ── + @Get('organizations') async listOrganizations(@Req() req: any) { await this.requireSuperadmin(req); return this.usersService.findAllOrganizations(); } + @Get('organizations/:id/detail') + async getTenantDetail(@Req() req: any, @Param('id') id: string) { + await this.requireSuperadmin(req); + const detail = await this.analyticsService.getTenantDetail(id); + if (!detail) { + throw new BadRequestException('Organization not found'); + } + return detail; + } + + @Put('organizations/:id/subscription') + async updateSubscription( + @Req() req: any, + @Param('id') id: string, + @Body() body: { paymentDate?: string; confirmationNumber?: string; renewalDate?: string }, + ) { + await this.requireSuperadmin(req); + const org = await this.orgService.updateSubscription(id, body); + 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 }; + } + + // ── Superadmin Toggle ── + @Post('users/:id/superadmin') async toggleSuperadmin(@Req() req: any, @Param('id') id: string, @Body() body: { isSuperadmin: boolean }) { await this.requireSuperadmin(req); @@ -48,6 +101,16 @@ export class AdminController { return { success: true }; } + // ── Tenant Health ── + + @Get('tenants-health') + async getTenantsHealth(@Req() req: any) { + await this.requireSuperadmin(req); + return this.analyticsService.getAllTenantsHealth(); + } + + // ── Create Tenant ── + @Post('tenants') async createTenant(@Req() req: any, @Body() body: { orgName: string; @@ -105,19 +168,4 @@ export class AdminController { 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 }; - } } diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 6ae3caf..971f3a0 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -29,7 +29,9 @@ export class AuthController { @ApiOperation({ summary: 'Login with email and password' }) @UseGuards(AuthGuard('local')) async login(@Request() req: any, @Body() _dto: LoginDto) { - return this.authService.login(req.user); + const ip = req.headers['x-forwarded-for'] || req.ip; + const ua = req.headers['user-agent']; + return this.authService.login(req.user, ip, ua); } @Get('profile') @@ -45,6 +47,8 @@ export class AuthController { @ApiBearerAuth() @UseGuards(JwtAuthGuard) async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) { - return this.authService.switchOrganization(req.user.sub, dto.organizationId); + const ip = req.headers['x-forwarded-for'] || req.ip; + const ua = req.headers['user-agent']; + return this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua); } } diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 2b21b9f..44d9096 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -5,6 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { AuthController } from './auth.controller'; import { AdminController } from './admin.controller'; import { AuthService } from './auth.service'; +import { AdminAnalyticsService } from './admin-analytics.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { LocalStrategy } from './strategies/local.strategy'; import { UsersModule } from '../users/users.module'; @@ -25,7 +26,7 @@ import { OrganizationsModule } from '../organizations/organizations.module'; }), ], controllers: [AuthController, AdminController], - providers: [AuthService, JwtStrategy, LocalStrategy], + providers: [AuthService, AdminAnalyticsService, JwtStrategy, LocalStrategy], exports: [AuthService], }) export class AuthModule {} diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 8ffeded..17fbd2b 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -4,6 +4,7 @@ import { ConflictException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { DataSource } from 'typeorm'; import * as bcrypt from 'bcryptjs'; import { UsersService } from '../users/users.service'; import { RegisterDto } from './dto/register.dto'; @@ -14,6 +15,7 @@ export class AuthService { constructor( private usersService: UsersService, private jwtService: JwtService, + private dataSource: DataSource, ) {} async register(dto: RegisterDto) { @@ -47,7 +49,7 @@ export class AuthService { return user; } - async login(user: User) { + async login(user: User, ipAddress?: string, userAgent?: string) { await this.usersService.updateLastLogin(user.id); const fullUser = await this.usersService.findByIdWithOrgs(user.id); const u = fullUser || user; @@ -65,6 +67,9 @@ export class AuthService { } } + // Record login in history (org_id is null at initial login) + this.recordLoginHistory(user.id, null, ipAddress, userAgent).catch(() => {}); + return this.generateTokenResponse(u); } @@ -86,7 +91,7 @@ export class AuthService { }; } - async switchOrganization(userId: string, organizationId: string) { + async switchOrganization(userId: string, organizationId: string, ipAddress?: string, userAgent?: string) { const user = await this.usersService.findByIdWithOrgs(userId); if (!user) { throw new UnauthorizedException('User not found'); @@ -107,6 +112,9 @@ export class AuthService { role: membership.role, }; + // Record org switch in login history + this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {}); + return { accessToken: this.jwtService.sign(payload), organization: { @@ -117,6 +125,23 @@ export class AuthService { }; } + private async recordLoginHistory( + userId: string, + organizationId: string | null, + ipAddress?: string, + userAgent?: string, + ) { + try { + await this.dataSource.query( + `INSERT INTO shared.login_history (user_id, organization_id, ip_address, user_agent) + VALUES ($1, $2, $3, $4)`, + [userId, organizationId, ipAddress || null, userAgent || null], + ); + } catch (err) { + // Non-critical — don't let login history failure block auth + } + } + private generateTokenResponse(user: User) { const orgs = user.userOrganizations || []; const defaultOrg = orgs[0]; @@ -141,6 +166,7 @@ export class AuthService { firstName: user.firstName, lastName: user.lastName, isSuperadmin: user.isSuperadmin || false, + isPlatformOwner: user.isPlatformOwner || false, }, organizations: orgs.map((uo) => ({ id: uo.organizationId, diff --git a/backend/src/modules/investment-planning/investment-planning.controller.ts b/backend/src/modules/investment-planning/investment-planning.controller.ts index 58c57c0..71f28c2 100644 --- a/backend/src/modules/investment-planning/investment-planning.controller.ts +++ b/backend/src/modules/investment-planning/investment-planning.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { InvestmentPlanningService } from './investment-planning.service'; @@ -24,7 +24,7 @@ export class InvestmentPlanningController { @Post('recommendations') @ApiOperation({ summary: 'Get AI-powered investment recommendations' }) - getRecommendations() { - return this.service.getAIRecommendations(); + getRecommendations(@Req() req: any) { + return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId); } } diff --git a/backend/src/modules/investment-planning/investment-planning.service.ts b/backend/src/modules/investment-planning/investment-planning.service.ts index 9f7d5ab..4f90a8d 100644 --- a/backend/src/modules/investment-planning/investment-planning.service.ts +++ b/backend/src/modules/investment-planning/investment-planning.service.ts @@ -166,8 +166,9 @@ export class InvestmentPlanningService { * 4. Call the AI API * 5. Parse and return structured recommendations */ - async getAIRecommendations(): Promise { + async getAIRecommendations(userId?: string, orgId?: string): Promise { this.debug('getAIRecommendations', 'Starting AI recommendation flow'); + const startTime = Date.now(); const [snapshot, cdRates, monthlyForecast] = await Promise.all([ this.getFinancialSnapshot(), @@ -188,6 +189,7 @@ export class InvestmentPlanningService { const messages = this.buildPromptMessages(snapshot, cdRates, monthlyForecast); const aiResponse = await this.callAI(messages); + const elapsed = Date.now() - startTime; this.debug('final_response', { recommendation_count: aiResponse.recommendations.length, @@ -195,9 +197,33 @@ export class InvestmentPlanningService { risk_notes_count: aiResponse.risk_notes?.length || 0, }); + // Log AI usage to shared.ai_recommendation_log (fire-and-forget) + this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {}); + return aiResponse; } + private async logAIUsage(userId: string | undefined, orgId: string | undefined, response: AIResponse, elapsed: number) { + try { + const schema = this.tenant.getSchema(); + await this.dataSource.query( + `INSERT INTO shared.ai_recommendation_log + (tenant_schema, organization_id, user_id, recommendation_count, response_time_ms, status) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + schema || null, + orgId || null, + userId || null, + response.recommendations?.length || 0, + elapsed, + response.recommendations?.length > 0 ? 'success' : 'empty', + ], + ); + } catch (err) { + // Non-critical — don't let logging failure break recommendations + } + } + // ── Private: Tenant-Scoped Data Queries ── private async getAccountBalances(): Promise { diff --git a/backend/src/modules/organizations/entities/organization.entity.ts b/backend/src/modules/organizations/entities/organization.entity.ts index be83845..f9cdfa9 100644 --- a/backend/src/modules/organizations/entities/organization.entity.ts +++ b/backend/src/modules/organizations/entities/organization.entity.ts @@ -61,6 +61,15 @@ export class Organization { @Column({ name: 'plan_level', default: 'standard' }) planLevel: string; + @Column({ name: 'payment_date', type: 'date', nullable: true }) + paymentDate: Date; + + @Column({ name: 'confirmation_number', nullable: true }) + confirmationNumber: string; + + @Column({ name: 'renewal_date', type: 'date', nullable: true }) + renewalDate: Date; + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; diff --git a/backend/src/modules/organizations/organizations.service.ts b/backend/src/modules/organizations/organizations.service.ts index 7135646..b386dd9 100644 --- a/backend/src/modules/organizations/organizations.service.ts +++ b/backend/src/modules/organizations/organizations.service.ts @@ -62,6 +62,15 @@ export class OrganizationsService { return this.orgRepository.save(org); } + async updateSubscription(id: string, data: { paymentDate?: string; confirmationNumber?: string; renewalDate?: string }) { + const org = await this.orgRepository.findOne({ where: { id } }); + if (!org) throw new NotFoundException('Organization not found'); + if (data.paymentDate !== undefined) org.paymentDate = data.paymentDate ? new Date(data.paymentDate) : null; + if (data.confirmationNumber !== undefined) org.confirmationNumber = data.confirmationNumber || null; + if (data.renewalDate !== undefined) org.renewalDate = data.renewalDate ? new Date(data.renewalDate) : null; + return this.orgRepository.save(org); + } + async findByUser(userId: string) { const memberships = await this.userOrgRepository.find({ where: { userId, isActive: true }, diff --git a/backend/src/modules/users/entities/user.entity.ts b/backend/src/modules/users/entities/user.entity.ts index dad9a8e..4ecfef3 100644 --- a/backend/src/modules/users/entities/user.entity.ts +++ b/backend/src/modules/users/entities/user.entity.ts @@ -46,6 +46,9 @@ export class User { @Column({ name: 'is_superadmin', default: false }) isSuperadmin: boolean; + @Column({ name: 'is_platform_owner', default: false }) + isPlatformOwner: 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 20701d8..110612a 100644 --- a/backend/src/modules/users/users.service.ts +++ b/backend/src/modules/users/users.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, ForbiddenException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './entities/user.entity'; @@ -50,13 +50,19 @@ export class UsersService { 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 + (SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id) as member_count, + (SELECT MAX(lh.logged_in_at) FROM shared.login_history lh WHERE lh.organization_id = o.id) as last_activity FROM shared.organizations o ORDER BY o.created_at DESC `); } async setSuperadmin(userId: string, isSuperadmin: boolean): Promise { + // Protect platform owner from having superadmin removed + const user = await this.usersRepository.findOne({ where: { id: userId } }); + if (user?.isPlatformOwner) { + throw new ForbiddenException('Cannot modify platform owner superadmin status'); + } await this.usersRepository.update(userId, { isSuperadmin }); } } diff --git a/db/init/00-init.sql b/db/init/00-init.sql index 894266f..24c7170 100644 --- a/db/init/00-init.sql +++ b/db/init/00-init.sql @@ -26,6 +26,9 @@ CREATE TABLE shared.organizations ( email VARCHAR(255), tax_id VARCHAR(20), fiscal_year_start_month INTEGER DEFAULT 1 CHECK (fiscal_year_start_month BETWEEN 1 AND 12), + payment_date DATE, + confirmation_number VARCHAR(100), + renewal_date DATE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); @@ -45,6 +48,7 @@ CREATE TABLE shared.users ( oauth_provider_id VARCHAR(255), last_login_at TIMESTAMPTZ, is_superadmin BOOLEAN DEFAULT FALSE, + is_platform_owner BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); @@ -86,6 +90,28 @@ CREATE TABLE shared.cd_rates ( created_at TIMESTAMPTZ DEFAULT NOW() ); +-- Login history (track logins/org-switches for platform analytics) +CREATE TABLE shared.login_history ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE, + organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL, + logged_in_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ip_address VARCHAR(45), + user_agent TEXT +); + +-- AI recommendation log (track AI usage per tenant) +CREATE TABLE shared.ai_recommendation_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_schema VARCHAR(63), + organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL, + user_id UUID REFERENCES shared.users(id) ON DELETE SET NULL, + recommendation_count INTEGER, + response_time_ms INTEGER, + status VARCHAR(20), + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + -- Indexes CREATE INDEX idx_user_orgs_user ON shared.user_organizations(user_id); CREATE INDEX idx_user_orgs_org ON shared.user_organizations(organization_id); @@ -95,3 +121,8 @@ CREATE INDEX idx_invitations_token ON shared.invitations(token); CREATE INDEX idx_invitations_email ON shared.invitations(email); CREATE INDEX idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC); CREATE INDEX idx_cd_rates_apy ON shared.cd_rates(apy DESC); +CREATE INDEX idx_login_history_org_time ON shared.login_history(organization_id, logged_in_at DESC); +CREATE INDEX idx_login_history_user ON shared.login_history(user_id); +CREATE INDEX idx_login_history_time ON shared.login_history(logged_in_at DESC); +CREATE INDEX idx_ai_rec_log_org ON shared.ai_recommendation_log(organization_id); +CREATE INDEX idx_ai_rec_log_time ON shared.ai_recommendation_log(requested_at DESC); diff --git a/db/migrations/006-admin-platform.sql b/db/migrations/006-admin-platform.sql new file mode 100644 index 0000000..f1e4dec --- /dev/null +++ b/db/migrations/006-admin-platform.sql @@ -0,0 +1,52 @@ +-- ============================================================ +-- Migration 006: Platform Administration Features +-- Adds: is_platform_owner, subscription fields, login_history, ai_recommendation_log +-- ============================================================ + +BEGIN; + +-- 1. Add is_platform_owner to users +ALTER TABLE shared.users + ADD COLUMN IF NOT EXISTS is_platform_owner BOOLEAN DEFAULT FALSE; + +-- 2. Add subscription fields to organizations +ALTER TABLE shared.organizations + ADD COLUMN IF NOT EXISTS payment_date DATE, + ADD COLUMN IF NOT EXISTS confirmation_number VARCHAR(100), + ADD COLUMN IF NOT EXISTS renewal_date DATE; + +-- 3. Create login_history table +CREATE TABLE IF NOT EXISTS shared.login_history ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE, + organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL, + logged_in_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ip_address VARCHAR(45), + user_agent TEXT +); + +CREATE INDEX IF NOT EXISTS idx_login_history_org_time + ON shared.login_history(organization_id, logged_in_at DESC); +CREATE INDEX IF NOT EXISTS idx_login_history_user + ON shared.login_history(user_id); +CREATE INDEX IF NOT EXISTS idx_login_history_time + ON shared.login_history(logged_in_at DESC); + +-- 4. Create ai_recommendation_log table +CREATE TABLE IF NOT EXISTS shared.ai_recommendation_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_schema VARCHAR(63), + organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL, + user_id UUID REFERENCES shared.users(id) ON DELETE SET NULL, + recommendation_count INTEGER, + response_time_ms INTEGER, + status VARCHAR(20), + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ai_rec_log_org + ON shared.ai_recommendation_log(organization_id); +CREATE INDEX IF NOT EXISTS idx_ai_rec_log_time + ON shared.ai_recommendation_log(requested_at DESC); + +COMMIT; diff --git a/db/seed/seed.sql b/db/seed/seed.sql index 67fed0a..cdc4be8 100644 --- a/db/seed/seed.sql +++ b/db/seed/seed.sql @@ -16,6 +16,31 @@ -- Enable UUID generation CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +-- ============================================================ +-- 0. Create platform owner account (admin@hoaledgeriq.com) +-- ============================================================ +DO $$ +DECLARE + v_platform_owner_id UUID; +BEGIN + SELECT id INTO v_platform_owner_id FROM shared.users WHERE email = 'admin@hoaledgeriq.com'; + IF v_platform_owner_id IS NULL THEN + INSERT INTO shared.users (id, email, password_hash, first_name, last_name, is_superadmin, is_platform_owner) + VALUES ( + uuid_generate_v4(), + 'admin@hoaledgeriq.com', + -- bcrypt hash of platform owner password (cost 12) + '$2b$12$QRJEJYsjy.24Va.57h13Te7UX7nMTN9hWhW19bwuCAkr1Dm0FWqrm', + 'Platform', + 'Admin', + true, + true + ) RETURNING id INTO v_platform_owner_id; + END IF; + -- Platform owner has NO org memberships — admin-only account + RAISE NOTICE 'Platform Owner: admin@hoaledgeriq.com (id: %)', v_platform_owner_id; +END $$; + -- ============================================================ -- 1. Create test user and organization -- ============================================================ @@ -836,7 +861,42 @@ EXECUTE format('INSERT INTO %I.capital_projects (name, description, estimated_co (''Perimeter Fence Repair'', ''Replace damaged fence sections and repaint'', 8000, $1 + 4, 8, ''planned'', ''reserve'', 4) ', v_schema) USING v_year; +-- Add subscription data to the organization +UPDATE shared.organizations +SET payment_date = (CURRENT_DATE - INTERVAL '15 days')::DATE, + confirmation_number = 'PAY-2026-SVH-001', + renewal_date = (CURRENT_DATE + INTERVAL '350 days')::DATE +WHERE schema_name = v_schema; + +-- ============================================================ +-- 13. Seed login_history for demo analytics +-- ============================================================ +-- Admin user: regular logins over the past 30 days +FOR v_month IN 0..29 LOOP + INSERT INTO shared.login_history (user_id, organization_id, logged_in_at, ip_address, user_agent) + VALUES ( + v_user_id, + v_org_id, + NOW() - (v_month || ' days')::INTERVAL - (random() * 8 || ' hours')::INTERVAL, + '192.168.1.' || (10 + (random() * 50)::INT), + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' + ); +END LOOP; + +-- Viewer user: occasional logins (every 3-5 days) +FOR v_month IN 0..9 LOOP + INSERT INTO shared.login_history (user_id, organization_id, logged_in_at, ip_address, user_agent) + VALUES ( + (SELECT id FROM shared.users WHERE email = 'viewer@sunrisevalley.org'), + v_org_id, + NOW() - ((v_month * 3) || ' days')::INTERVAL - (random() * 12 || ' hours')::INTERVAL, + '10.0.0.' || (100 + (random() * 50)::INT), + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' + ); +END LOOP; + RAISE NOTICE 'Seed data created successfully for Sunrise Valley HOA!'; +RAISE NOTICE 'Platform Owner: admin@hoaledgeriq.com (SuperAdmin + Platform Owner)'; RAISE NOTICE 'Admin Login: admin@sunrisevalley.org / password123 (SuperAdmin + President)'; RAISE NOTICE 'Viewer Login: viewer@sunrisevalley.org / password123 (Homeowner)'; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3eec4f1..52d275b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -55,8 +55,14 @@ function SuperAdminRoute({ children }: { children: React.ReactNode }) { function AuthRoute({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token); + const user = useAuthStore((s) => s.user); const currentOrg = useAuthStore((s) => s.currentOrg); + const organizations = useAuthStore((s) => s.organizations); if (token && currentOrg) return ; + // Platform owner / superadmin with no org memberships → admin panel + if (token && user?.isSuperadmin && (!organizations || organizations.length === 0)) { + return ; + } if (token && !currentOrg) return ; return <>{children}; } diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 0f44879..4f56d64 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -17,6 +17,7 @@ import { IconChartAreaLine, IconClipboardCheck, IconSparkles, + IconHeartRateMonitor, } from '@tabler/icons-react'; import { useAuthStore } from '../../stores/authStore'; @@ -87,12 +88,44 @@ export function Sidebar({ onNavigate }: SidebarProps) { const navigate = useNavigate(); const location = useLocation(); const user = useAuthStore((s) => s.user); + const currentOrg = useAuthStore((s) => s.currentOrg); + const organizations = useAuthStore((s) => s.organizations); + const isAdminOnly = location.pathname.startsWith('/admin') && !currentOrg; const go = (path: string) => { navigate(path); onNavigate?.(); }; + // When on admin route with no org selected, show admin-only sidebar + if (isAdminOnly && user?.isSuperadmin) { + return ( + + + Platform Administration + + } + active={location.pathname === '/admin'} + onClick={() => go('/admin')} + color="red" + /> + {organizations && organizations.length > 0 && ( + <> + + } + onClick={() => go('/select-org')} + variant="subtle" + /> + + )} + + ); + } + return ( {navSections.map((section, sIdx) => ( diff --git a/frontend/src/pages/admin/AdminPage.tsx b/frontend/src/pages/admin/AdminPage.tsx index 07e28ae..c2b7633 100644 --- a/frontend/src/pages/admin/AdminPage.tsx +++ b/frontend/src/pages/admin/AdminPage.tsx @@ -2,20 +2,23 @@ import { useState } from 'react'; import { Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center, ThemeIcon, Tabs, Switch, TextInput, Avatar, Modal, Button, PasswordInput, - Select, NumberInput, Menu, Divider, + Select, NumberInput, Menu, Divider, Progress, Tooltip, Drawer, RingProgress, + Paper, } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { IconUsers, IconBuilding, IconShieldLock, IconSearch, IconCrown, IconPlus, IconArchive, IconChevronDown, - IconCircleCheck, IconBan, IconArchiveOff, + IconCircleCheck, IconBan, IconArchiveOff, IconDashboard, + IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity, + IconCurrencyDollar, IconClipboardCheck, IconLogin, } 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; + isSuperadmin: boolean; isPlatformOwner?: boolean; lastLoginAt: string; createdAt: string; organizations: { id: string; name: string; role: string }[]; } @@ -23,62 +26,107 @@ interface AdminOrg { id: string; name: string; schema_name: string; status: string; email: string; phone: string; member_count: string; created_at: string; contract_number: string; plan_level: string; + payment_date: string; confirmation_number: string; renewal_date: string; + last_activity: string; +} + +interface PlatformMetrics { + totalUsers: number; + superadminCount: number; + platformOwnerCount: number; + activeUsers30d: number; + totalOrganizations: number; + activeOrganizations: number; + archivedOrganizations: number; + suspendedOrganizations: number; + trialOrganizations: number; + planBreakdown: { plan: string; count: number }[]; + statusBreakdown: { status: string; count: number }[]; + newTenantsPerMonth: { month: string; count: number }[]; + newUsersPerMonth: { month: string; count: number }[]; + aiRequestsLast30d: number; + aiSuccessfulLast30d: number; + aiAvgResponseMs: number; +} + +interface TenantHealth { + id: string; name: string; schemaName: string; status: string; + planLevel: string; createdAt: string; paymentDate: string; renewalDate: string; + memberCount: number; lastLogin: string; activeUsers30d: number; + aiUsage30d: number; cashOnHand: number; hasBudget: boolean; + journalEntries30d: number; healthScore: number; +} + +interface TenantDetail { + organization: any; + lastLogin: string; + loginsThisWeek: number; + loginsThisMonth: number; + activeUsers30d: number; + weeklyLogins: { week: string; count: number }[]; + monthlyLogins: { month: string; count: number }[]; + aiRecommendations30d: number; + memberCount: number; + cashOnHand: number; + hasBudget: boolean; + recentTransactions: number; } interface CreateTenantForm { - 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; + 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; } const initialFormState: CreateTenantForm = { - orgName: '', - email: '', - phone: '', - addressLine1: '', - city: '', - state: '', - zipCode: '', - contractNumber: '', - planLevel: 'standard', - fiscalYearStartMonth: 1, - adminEmail: '', - adminPassword: '', - adminFirstName: '', - adminLastName: '', + orgName: '', email: '', phone: '', addressLine1: '', city: '', state: '', zipCode: '', + contractNumber: '', planLevel: 'standard', fiscalYearStartMonth: 1, + adminEmail: '', adminPassword: '', adminFirstName: '', adminLastName: '', }; -const planBadgeColor: Record = { - standard: 'blue', - premium: 'violet', - enterprise: 'orange', -}; +const planBadgeColor: Record = { standard: 'blue', premium: 'violet', enterprise: 'orange' }; +const statusColor: Record = { active: 'green', trial: 'yellow', suspended: 'red', archived: 'gray' }; -const statusColor: Record = { - active: 'green', - trial: 'yellow', - suspended: 'red', - archived: 'gray', -}; +function healthScoreColor(score: number): string { + if (score >= 75) return 'green'; + if (score >= 50) return 'yellow'; + if (score >= 25) return 'orange'; + return 'red'; +} + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(amount); +} + +function formatDate(dateStr: string | null | undefined): string { + if (!dateStr) return '\u2014'; + return new Date(dateStr).toLocaleDateString(); +} + +function formatDateTime(dateStr: string | null | undefined): string { + if (!dateStr) return 'Never'; + return new Date(dateStr).toLocaleString(); +} export function AdminPage() { const [search, setSearch] = useState(''); + const [activeTab, setActiveTab] = useState('dashboard'); const [createModalOpened, { open: openCreateModal, close: closeCreateModal }] = useDisclosure(false); const [form, setForm] = useState(initialFormState); const [statusConfirm, setStatusConfirm] = useState<{ orgId: string; orgName: string; newStatus: string } | null>(null); + const [selectedOrgId, setSelectedOrgId] = useState(null); + const [drawerOpened, { open: openDrawer, close: closeDrawer }] = useDisclosure(false); + const [subForm, setSubForm] = useState({ paymentDate: '', confirmationNumber: '', renewalDate: '' }); const queryClient = useQueryClient(); + // ── Queries ── + + const { data: metrics, isLoading: metricsLoading } = useQuery({ + queryKey: ['admin-metrics'], + queryFn: async () => { const { data } = await api.get('/admin/metrics'); return data; }, + }); + const { data: users, isLoading: usersLoading } = useQuery({ queryKey: ['admin-users'], queryFn: async () => { const { data } = await api.get('/admin/users'); return data; }, @@ -89,6 +137,20 @@ export function AdminPage() { queryFn: async () => { const { data } = await api.get('/admin/organizations'); return data; }, }); + const { data: tenantsHealth, isLoading: healthLoading } = useQuery({ + queryKey: ['admin-tenants-health'], + queryFn: async () => { const { data } = await api.get('/admin/tenants-health'); return data; }, + enabled: activeTab === 'health', + }); + + const { data: tenantDetail, isLoading: detailLoading } = useQuery({ + queryKey: ['admin-tenant-detail', selectedOrgId], + queryFn: async () => { const { data } = await api.get(`/admin/organizations/${selectedOrgId}/detail`); return data; }, + enabled: !!selectedOrgId && drawerOpened, + }); + + // ── Mutations ── + const toggleSuperadmin = useMutation({ mutationFn: async ({ userId, isSuperadmin }: { userId: string; isSuperadmin: boolean }) => { await api.post(`/admin/users/${userId}/superadmin`, { isSuperadmin }); @@ -104,6 +166,7 @@ export function AdminPage() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-orgs'] }); queryClient.invalidateQueries({ queryKey: ['admin-users'] }); + queryClient.invalidateQueries({ queryKey: ['admin-metrics'] }); setForm(initialFormState); closeCreateModal(); }, @@ -115,10 +178,23 @@ export function AdminPage() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-orgs'] }); + queryClient.invalidateQueries({ queryKey: ['admin-metrics'] }); setStatusConfirm(null); }, }); + const updateSubscription = useMutation({ + mutationFn: async ({ orgId, data: subData }: { orgId: string; data: any }) => { + await api.put(`/admin/organizations/${orgId}/subscription`, subData); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-orgs'] }); + queryClient.invalidateQueries({ queryKey: ['admin-tenant-detail', selectedOrgId] }); + }, + }); + + // ── Helpers ── + const updateField = (key: K, value: CreateTenantForm[K]) => { setForm((prev) => ({ ...prev, [key]: value })); }; @@ -135,7 +211,18 @@ export function AdminPage() { o.schema_name.toLowerCase().includes(search.toLowerCase()) ); - const archivedCount = (orgs || []).filter(o => o.status === 'archived').length; + const openOrgDetail = (orgId: string) => { + setSelectedOrgId(orgId); + const org = (orgs || []).find(o => o.id === orgId); + if (org) { + setSubForm({ + paymentDate: org.payment_date ? org.payment_date.split('T')[0] : '', + confirmationNumber: org.confirmation_number || '', + renewalDate: org.renewal_date ? org.renewal_date.split('T')[0] : '', + }); + } + openDrawer(); + }; return ( @@ -143,12 +230,9 @@ export function AdminPage() {
Platform Administration - SuperUser Admin Panel — Manage tenants and users + HOA LedgerIQ SaaS Management Console
-
@@ -157,70 +241,235 @@ export function AdminPage() { - - - -
- Total Users - {users?.length || 0} -
- - - -
-
- - -
- Organizations - {orgs?.length || 0} -
- - - -
-
- - -
- SuperAdmins - {(users || []).filter(u => u.isSuperadmin).length} -
- - - -
-
- - -
- Archived - {archivedCount} -
- - - -
-
-
- } value={search} onChange={(e) => setSearch(e.currentTarget.value)} /> - + - }> - Users ({filteredUsers.length}) + }> + Dashboard }> Organizations ({filteredOrgs.length}) + }> + Users ({filteredUsers.length}) + + }> + Tenant Health + + {/* ── TAB 1: Dashboard ── */} + + {metricsLoading ? ( +
+ ) : metrics ? ( + + + + +
+ Total Users + {metrics.totalUsers} + {metrics.activeUsers30d} active (30d) +
+ + + +
+
+ + +
+ Organizations + {metrics.totalOrganizations} + {metrics.activeOrganizations} active +
+ + + +
+
+ + +
+ AI Requests (30d) + {metrics.aiRequestsLast30d} + + {metrics.aiAvgResponseMs ? `Avg ${(metrics.aiAvgResponseMs / 1000).toFixed(1)}s` : 'No data'} + +
+ + + +
+
+ + +
+ SuperAdmins + {metrics.superadminCount} + {metrics.suspendedOrganizations} suspended, {metrics.archivedOrganizations} archived +
+ + + +
+
+
+ + + + Plan Distribution + + {metrics.planBreakdown.map((p) => ( + + + {p.plan} + {p.count} tenant{p.count !== 1 ? 's' : ''} + + 0 ? (p.count / metrics.totalOrganizations) * 100 : 0} + size="lg" + radius="xl" + color={planBadgeColor[p.plan] || 'gray'} + style={{ width: '50%' }} + /> + + ))} + + + + Status Breakdown + + {metrics.statusBreakdown.map((s) => ( + + + {s.status} + {s.count} org{s.count !== 1 ? 's' : ''} + + 0 ? (s.count / metrics.totalOrganizations) * 100 : 0} + size="lg" + radius="xl" + color={statusColor[s.status] || 'gray'} + style={{ width: '50%' }} + /> + + ))} + + + +
+ ) : null} +
+ + {/* ── TAB 2: Organizations ── */} + + {orgsLoading ? ( +
+ ) : ( + + + + + Organization + Status + Plan + Members + Last Activity + Subscription + Created + + + + + {filteredOrgs.map((o) => ( + openOrgDetail(o.id)}> + +
+ {o.name} + {o.schema_name} +
+
+ + + + } + onClick={(e) => e.stopPropagation()} + > + {o.status} + + + + Change status + {o.status !== 'active' && ( + } color="green" + onClick={(e) => { e.stopPropagation(); setStatusConfirm({ orgId: o.id, orgName: o.name, newStatus: 'active' }); }}> + Set Active + + )} + {o.status !== 'suspended' && ( + } color="red" + onClick={(e) => { e.stopPropagation(); setStatusConfirm({ orgId: o.id, orgName: o.name, newStatus: 'suspended' }); }}> + Suspend + + )} + {o.status !== 'archived' && ( + } color="gray" + onClick={(e) => { e.stopPropagation(); setStatusConfirm({ orgId: o.id, orgName: o.name, newStatus: 'archived' }); }}> + Archive + + )} + + + + + + {o.plan_level} + + + + {o.member_count} + + + {formatDateTime(o.last_activity)} + + + {o.renewal_date ? ( + + Renews {formatDate(o.renewal_date)} + + ) : ( + Not set + )} + + + {formatDate(o.created_at)} + + + + +
+ ))} +
+
+
+ )} +
+ + {/* ── TAB 3: Users ── */} {usersLoading ? (
@@ -241,10 +490,15 @@ export function AdminPage() { - + {u.firstName?.[0]}{u.lastName?.[0]} - {u.firstName} {u.lastName} +
+ {u.firstName} {u.lastName} + {u.isPlatformOwner && ( + Platform Owner + )} +
@@ -268,15 +522,21 @@ export function AdminPage() { - toggleSuperadmin.mutate({ - userId: u.id, - isSuperadmin: !u.isSuperadmin, - })} - size="sm" - color="red" - /> + {u.isPlatformOwner ? ( + + + + ) : ( + toggleSuperadmin.mutate({ + userId: u.id, + isSuperadmin: !u.isSuperadmin, + })} + size="sm" + color="red" + /> + )}
))} @@ -286,99 +546,88 @@ export function AdminPage() { )}
- - {orgsLoading ? ( + {/* ── TAB 4: Tenant Health ── */} + + {healthLoading ? (
) : ( - Organization - Schema - Status - Contract # - Plan - Members - Contact - Created + Tenant + Health Score + Active Users + Last Login + Budget + Transactions (30d) + Cash on Hand + AI Usage (30d) + Renewal - {filteredOrgs.map((o) => ( - - {o.name} + {(tenantsHealth || []) + .filter(t => !search || t.name.toLowerCase().includes(search.toLowerCase())) + .map((t) => ( + openOrgDetail(t.id)}> - {o.schema_name} +
+ {t.name} + + {t.status} + {t.planLevel} + +
- - - } - > - {o.status} - - - - Change status - {o.status !== 'active' && ( - } - color="green" - onClick={() => setStatusConfirm({ orgId: o.id, orgName: o.name, newStatus: 'active' })} - > - Set Active - - )} - {o.status !== 'suspended' && ( - } - color="red" - onClick={() => setStatusConfirm({ orgId: o.id, orgName: o.name, newStatus: 'suspended' })} - > - Suspend - - )} - {o.status !== 'archived' && ( - } - color="gray" - onClick={() => setStatusConfirm({ orgId: o.id, orgName: o.name, newStatus: 'archived' })} - > - Archive - - )} - - + + {t.healthScore} + } + /> + + {t.healthScore >= 75 ? 'Healthy' : t.healthScore >= 50 ? 'Fair' : t.healthScore >= 25 ? 'At Risk' : 'Critical'} + + + + + {t.activeUsers30d} / {t.memberCount} - {o.contract_number || '\u2014'} + {formatDateTime(t.lastLogin)} - - {o.plan_level ? ( - - {o.plan_level} - + + {t.hasBudget ? ( + + + ) : ( - {'\u2014'} + + + )} - {o.member_count} + {t.journalEntries30d} - {o.email || 'N/A'} - - - - {new Date(o.created_at).toLocaleDateString()} + 0 ? undefined : 'red'}> + {formatCurrency(t.cashOnHand)} + + 0 ? 'violet' : 'gray'}> + {t.aiUsage30d} + + + + {formatDate(t.renewalDate)} +
))}
@@ -388,7 +637,109 @@ export function AdminPage() { - {/* Create Tenant Modal */} + {/* ── Tenant Detail Drawer ── */} + Tenant Details} + position="right" + size="lg" + > + {detailLoading ? ( +
+ ) : tenantDetail ? ( + + + {tenantDetail.organization.name} + + Schema + {tenantDetail.organization.schema_name} + Status + {tenantDetail.organization.status} + Plan + {tenantDetail.organization.plan_level} + Contract # + {tenantDetail.organization.contract_number || '\u2014'} + Members + {tenantDetail.memberCount} + + + + + Activity + + Last Login + {formatDateTime(tenantDetail.lastLogin)} + Logins This Week + {tenantDetail.loginsThisWeek} + Logins This Month + {tenantDetail.loginsThisMonth} + Active Users (30d) + {tenantDetail.activeUsers30d} + AI Recommendations (30d) + {tenantDetail.aiRecommendations30d} + + + + + Setup Health + + Cash on Hand + {formatCurrency(tenantDetail.cashOnHand)} + Has Budget + + {tenantDetail.hasBudget ? 'Yes' : 'No'} + + Recent Transactions (30d) + {tenantDetail.recentTransactions} + + + + + Subscription + + setSubForm(p => ({ ...p, paymentDate: e.currentTarget.value }))} + /> + setSubForm(p => ({ ...p, confirmationNumber: e.currentTarget.value }))} + /> + setSubForm(p => ({ ...p, renewalDate: e.currentTarget.value }))} + /> + + + + + ) : ( + No data available + )} +
+ + {/* ── Create Tenant Modal ── */} { closeCreateModal(); setForm(initialFormState); }} @@ -397,130 +748,63 @@ export function AdminPage() { > Organization Details - updateField('orgName', e.currentTarget.value)} - /> + updateField('orgName', e.currentTarget.value)} /> - updateField('email', e.currentTarget.value)} - /> - updateField('phone', e.currentTarget.value)} - /> + updateField('email', e.currentTarget.value)} /> + updateField('phone', e.currentTarget.value)} /> - updateField('addressLine1', e.currentTarget.value)} - /> + updateField('addressLine1', e.currentTarget.value)} /> - updateField('city', e.currentTarget.value)} - /> - updateField('state', e.currentTarget.value)} - /> - updateField('zipCode', e.currentTarget.value)} - /> + updateField('city', e.currentTarget.value)} /> + updateField('state', e.currentTarget.value)} /> + updateField('zipCode', e.currentTarget.value)} /> - updateField('contractNumber', e.currentTarget.value)} - /> - updateField('planLevel', val || 'standard')} - /> - updateField('planLevel', val || 'standard')} /> + updateField('fiscalYearStartMonth', val as number | '')} - /> + onChange={(val) => updateField('fiscalYearStartMonth', val as number | '')} /> - - Admin User - updateField('adminEmail', e.currentTarget.value)} - /> - updateField('adminPassword', e.currentTarget.value)} - /> + updateField('adminEmail', e.currentTarget.value)} /> + updateField('adminPassword', e.currentTarget.value)} /> - updateField('adminFirstName', e.currentTarget.value)} - /> - updateField('adminLastName', e.currentTarget.value)} - /> + updateField('adminFirstName', e.currentTarget.value)} /> + updateField('adminLastName', e.currentTarget.value)} /> - - - + - {/* Status Change Confirmation Modal */} + {/* ── Status Change Confirmation Modal ── */} setStatusConfirm(null)} @@ -535,19 +819,13 @@ export function AdminPage() { to {statusConfirm.newStatus}? {statusConfirm.newStatus === 'archived' && ( - - Archiving an organization will disable access for all its members. - + Archiving an organization will disable access for all its members. )} {statusConfirm.newStatus === 'suspended' && ( - - Suspending an organization will temporarily disable access for all its members. - + Suspending an organization will temporarily disable access for all its members. )} - +