diff --git a/backend/src/database/tenant-schema.service.ts b/backend/src/database/tenant-schema.service.ts index c065415..1889f7a 100644 --- a/backend/src/database/tenant-schema.service.ts +++ b/backend/src/database/tenant-schema.service.ts @@ -109,6 +109,7 @@ export class TenantSchemaService { regular_assessment DECIMAL(10,2) NOT NULL DEFAULT 0.00, special_assessment DECIMAL(10,2) DEFAULT 0.00, unit_count INTEGER DEFAULT 0, + frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')), is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() diff --git a/backend/src/modules/assessment-groups/assessment-groups.service.ts b/backend/src/modules/assessment-groups/assessment-groups.service.ts index db62c79..69b9509 100644 --- a/backend/src/modules/assessment-groups/assessment-groups.service.ts +++ b/backend/src/modules/assessment-groups/assessment-groups.service.ts @@ -6,12 +6,28 @@ export class AssessmentGroupsService { constructor(private tenant: TenantService) {} async findAll() { + // Normalize all income calculations to monthly equivalent + // monthly: amount * units (already monthly) + // quarterly: amount/3 * units (convert to monthly) + // annual: amount/12 * units (convert to monthly) return this.tenant.query(` SELECT ag.*, (SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id) as actual_unit_count, - ag.regular_assessment * ag.unit_count as monthly_operating_income, - ag.special_assessment * ag.unit_count as monthly_reserve_income, - (ag.regular_assessment + ag.special_assessment) * ag.unit_count as total_monthly_income + CASE ag.frequency + WHEN 'quarterly' THEN ag.regular_assessment / 3 + WHEN 'annual' THEN ag.regular_assessment / 12 + ELSE ag.regular_assessment + END * ag.unit_count as monthly_operating_income, + CASE ag.frequency + WHEN 'quarterly' THEN ag.special_assessment / 3 + WHEN 'annual' THEN ag.special_assessment / 12 + ELSE ag.special_assessment + END * ag.unit_count as monthly_reserve_income, + (CASE ag.frequency + WHEN 'quarterly' THEN (ag.regular_assessment + ag.special_assessment) / 3 + WHEN 'annual' THEN (ag.regular_assessment + ag.special_assessment) / 12 + ELSE ag.regular_assessment + ag.special_assessment + END) * ag.unit_count as total_monthly_income FROM assessment_groups ag ORDER BY ag.name `); @@ -25,9 +41,9 @@ export class AssessmentGroupsService { async create(dto: any) { const rows = await this.tenant.query( - `INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count) - VALUES ($1, $2, $3, $4, $5) RETURNING *`, - [dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0, dto.unitCount || 0], + `INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0, dto.unitCount || 0, dto.frequency || 'monthly'], ); return rows[0]; } @@ -44,6 +60,7 @@ export class AssessmentGroupsService { if (dto.specialAssessment !== undefined) { sets.push(`special_assessment = $${idx++}`); params.push(dto.specialAssessment); } if (dto.unitCount !== undefined) { sets.push(`unit_count = $${idx++}`); params.push(dto.unitCount); } if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); } + if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); } if (!sets.length) return this.findOne(id); @@ -61,9 +78,27 @@ export class AssessmentGroupsService { const rows = await this.tenant.query(` SELECT COUNT(*) as group_count, - COALESCE(SUM(regular_assessment * unit_count), 0) as total_monthly_operating, - COALESCE(SUM(special_assessment * unit_count), 0) as total_monthly_reserve, - COALESCE(SUM((regular_assessment + special_assessment) * unit_count), 0) as total_monthly_income, + COALESCE(SUM( + CASE frequency + WHEN 'quarterly' THEN regular_assessment / 3 + WHEN 'annual' THEN regular_assessment / 12 + ELSE regular_assessment + END * unit_count + ), 0) as total_monthly_operating, + COALESCE(SUM( + CASE frequency + WHEN 'quarterly' THEN special_assessment / 3 + WHEN 'annual' THEN special_assessment / 12 + ELSE special_assessment + END * unit_count + ), 0) as total_monthly_reserve, + COALESCE(SUM( + CASE frequency + WHEN 'quarterly' THEN (regular_assessment + special_assessment) / 3 + WHEN 'annual' THEN (regular_assessment + special_assessment) / 12 + ELSE regular_assessment + special_assessment + END * unit_count + ), 0) as total_monthly_income, COALESCE(SUM(unit_count), 0) as total_units FROM assessment_groups WHERE is_active = true `); diff --git a/backend/src/modules/auth/admin.controller.ts b/backend/src/modules/auth/admin.controller.ts index 2f851d0..fcbca30 100644 --- a/backend/src/modules/auth/admin.controller.ts +++ b/backend/src/modules/auth/admin.controller.ts @@ -1,14 +1,19 @@ -import { Controller, Get, Post, Body, Param, UseGuards, Req, ForbiddenException } from '@nestjs/common'; +import { Controller, Get, Post, Put, Body, Param, UseGuards, Req, ForbiddenException, BadRequestException } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { UsersService } from '../users/users.service'; +import { OrganizationsService } from '../organizations/organizations.service'; +import * as bcrypt from 'bcrypt'; @ApiTags('admin') @Controller('admin') @ApiBearerAuth() @UseGuards(JwtAuthGuard) export class AdminController { - constructor(private usersService: UsersService) {} + constructor( + private usersService: UsersService, + private orgService: OrganizationsService, + ) {} private async requireSuperadmin(req: any) { const user = await this.usersService.findById(req.user.userId || req.user.sub); @@ -42,4 +47,77 @@ export class AdminController { await this.usersService.setSuperadmin(id, body.isSuperadmin); return { success: true }; } + + @Post('tenants') + async createTenant(@Req() req: any, @Body() body: { + orgName: string; + email?: string; + phone?: string; + addressLine1?: string; + city?: string; + state?: string; + zipCode?: string; + contractNumber?: string; + planLevel?: string; + fiscalYearStartMonth?: number; + adminEmail: string; + adminPassword: string; + adminFirstName: string; + adminLastName: string; + }) { + await this.requireSuperadmin(req); + + if (!body.orgName || !body.adminEmail || !body.adminPassword) { + throw new BadRequestException('Organization name, admin email and password are required'); + } + + // Check if admin email already exists + const existingUser = await this.usersService.findByEmail(body.adminEmail); + let userId: string; + + if (existingUser) { + userId = existingUser.id; + } else { + // Create the first user for this tenant + const passwordHash = await bcrypt.hash(body.adminPassword, 12); + const newUser = await this.usersService.create({ + email: body.adminEmail, + passwordHash, + firstName: body.adminFirstName, + lastName: body.adminLastName, + }); + userId = newUser.id; + } + + // Create the organization + tenant schema + membership + const org = await this.orgService.create({ + name: body.orgName, + email: body.email, + phone: body.phone, + addressLine1: body.addressLine1, + city: body.city, + state: body.state, + zipCode: body.zipCode, + contractNumber: body.contractNumber, + planLevel: body.planLevel || 'standard', + fiscalYearStartMonth: body.fiscalYearStartMonth || 1, + }, userId); + + return { success: true, organization: org }; + } + + @Put('organizations/:id/status') + async updateOrgStatus( + @Req() req: any, + @Param('id') id: string, + @Body() body: { status: string }, + ) { + await this.requireSuperadmin(req); + const validStatuses = ['active', 'suspended', 'trial', 'archived']; + if (!validStatuses.includes(body.status)) { + throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`); + } + const org = await this.orgService.updateStatus(id, body.status); + return { success: true, organization: org }; + } } diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 0d623c1..2b21b9f 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -8,10 +8,12 @@ import { AuthService } from './auth.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { LocalStrategy } from './strategies/local.strategy'; import { UsersModule } from '../users/users.module'; +import { OrganizationsModule } from '../organizations/organizations.module'; @Module({ imports: [ UsersModule, + OrganizationsModule, PassportModule, JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 282a5e9..eef0cca 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -50,7 +50,22 @@ export class AuthService { async login(user: User) { await this.usersService.updateLastLogin(user.id); const fullUser = await this.usersService.findByIdWithOrgs(user.id); - return this.generateTokenResponse(fullUser || user); + const u = fullUser || user; + + // Check if user's organizations are all suspended/archived + const orgs = u.userOrganizations || []; + if (orgs.length > 0 && !u.isSuperadmin) { + const activeOrgs = orgs.filter( + (uo) => uo.organization && !['suspended', 'archived'].includes(uo.organization.status), + ); + if (activeOrgs.length === 0) { + throw new UnauthorizedException( + 'Your organization has been suspended. Please contact your administrator.', + ); + } + } + + return this.generateTokenResponse(u); } async getProfile(userId: string) { diff --git a/backend/src/modules/organizations/dto/create-organization.dto.ts b/backend/src/modules/organizations/dto/create-organization.dto.ts index 4756bdb..9433350 100644 --- a/backend/src/modules/organizations/dto/create-organization.dto.ts +++ b/backend/src/modules/organizations/dto/create-organization.dto.ts @@ -42,4 +42,14 @@ export class CreateOrganizationDto { @Max(12) @IsOptional() fiscalYearStartMonth?: number; + + @ApiProperty({ example: 'CON-2026-001', required: false }) + @IsString() + @IsOptional() + contractNumber?: string; + + @ApiProperty({ example: 'standard', required: false }) + @IsString() + @IsOptional() + planLevel?: string; } diff --git a/backend/src/modules/organizations/entities/organization.entity.ts b/backend/src/modules/organizations/entities/organization.entity.ts index c56ea05..be83845 100644 --- a/backend/src/modules/organizations/entities/organization.entity.ts +++ b/backend/src/modules/organizations/entities/organization.entity.ts @@ -55,6 +55,12 @@ export class Organization { @Column({ name: 'fiscal_year_start_month', default: 1 }) fiscalYearStartMonth: number; + @Column({ name: 'contract_number', nullable: true }) + contractNumber: string; + + @Column({ name: 'plan_level', default: 'standard' }) + planLevel: string; + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; diff --git a/backend/src/modules/organizations/organizations.service.ts b/backend/src/modules/organizations/organizations.service.ts index 7a0465e..116b643 100644 --- a/backend/src/modules/organizations/organizations.service.ts +++ b/backend/src/modules/organizations/organizations.service.ts @@ -36,6 +36,8 @@ export class OrganizationsService { phone: dto.phone, email: dto.email, fiscalYearStartMonth: dto.fiscalYearStartMonth || 1, + contractNumber: dto.contractNumber, + planLevel: dto.planLevel || 'standard', }); const savedOrg = await this.orgRepository.save(org); @@ -52,6 +54,13 @@ export class OrganizationsService { return savedOrg; } + async updateStatus(id: string, status: string) { + const org = await this.orgRepository.findOne({ where: { id } }); + if (!org) throw new ConflictException('Organization not found'); + org.status = status; + return this.orgRepository.save(org); + } + async findByUser(userId: string) { const memberships = await this.userOrgRepository.find({ where: { userId, isActive: true }, diff --git a/backend/src/modules/units/units.controller.ts b/backend/src/modules/units/units.controller.ts index c759194..c4db83c 100644 --- a/backend/src/modules/units/units.controller.ts +++ b/backend/src/modules/units/units.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { UnitsService } from './units.service'; @@ -21,4 +21,7 @@ export class UnitsController { @Put(':id') update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); } + + @Delete(':id') + delete(@Param('id') id: string) { return this.unitsService.delete(id); } } diff --git a/backend/src/modules/units/units.service.ts b/backend/src/modules/units/units.service.ts index 5e46294..2661a43 100644 --- a/backend/src/modules/units/units.service.ts +++ b/backend/src/modules/units/units.service.ts @@ -8,12 +8,17 @@ export class UnitsService { async findAll() { return this.tenant.query(` SELECT u.*, + ag.name as assessment_group_name, + ag.regular_assessment as group_regular_assessment, + ag.frequency as group_frequency, COALESCE(( SELECT SUM(i.amount - i.amount_paid) FROM invoices i WHERE i.unit_id = u.id AND i.status NOT IN ('paid', 'void', 'written_off') ), 0) as balance_due - FROM units u ORDER BY u.unit_number + FROM units u + LEFT JOIN assessment_groups ag ON ag.id = u.assessment_group_id + ORDER BY u.unit_number `); } @@ -28,9 +33,9 @@ export class UnitsService { if (existing.length) throw new BadRequestException(`Unit ${dto.unit_number} already exists`); const rows = await this.tenant.query( - `INSERT INTO units (unit_number, address_line1, city, state, zip_code, owner_name, owner_email, owner_phone, monthly_assessment) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, - [dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment || 0], + `INSERT INTO units (unit_number, address_line1, city, state, zip_code, owner_name, owner_email, owner_phone, monthly_assessment, assessment_group_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`, + [dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment || 0, dto.assessment_group_id || null], ); return rows[0]; } @@ -42,10 +47,26 @@ export class UnitsService { city = COALESCE($4, city), state = COALESCE($5, state), zip_code = COALESCE($6, zip_code), owner_name = COALESCE($7, owner_name), owner_email = COALESCE($8, owner_email), owner_phone = COALESCE($9, owner_phone), monthly_assessment = COALESCE($10, monthly_assessment), - status = COALESCE($11, status), updated_at = NOW() + status = COALESCE($11, status), assessment_group_id = $12, updated_at = NOW() WHERE id = $1 RETURNING *`, - [id, dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment, dto.status], + [id, dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment, dto.status, dto.assessment_group_id !== undefined ? dto.assessment_group_id : null], ); return rows[0]; } + + async delete(id: string) { + await this.findOne(id); + + // Check for outstanding invoices + const outstanding = await this.tenant.query( + `SELECT COUNT(*) as count FROM invoices WHERE unit_id = $1 AND status NOT IN ('paid', 'void', 'written_off')`, + [id], + ); + if (parseInt(outstanding[0]?.count) > 0) { + throw new BadRequestException('Cannot delete unit with outstanding invoices. Please resolve all invoices first.'); + } + + await this.tenant.query('DELETE FROM units WHERE id = $1', [id]); + return { success: true }; + } } diff --git a/db/init/00-init.sql b/db/init/00-init.sql index 333b45b..9a20cd9 100644 --- a/db/init/00-init.sql +++ b/db/init/00-init.sql @@ -13,8 +13,10 @@ CREATE TABLE shared.organizations ( name VARCHAR(255) NOT NULL, schema_name VARCHAR(63) NOT NULL UNIQUE, subdomain VARCHAR(63) UNIQUE, - status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'trial')), + status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'trial', 'archived')), settings JSONB DEFAULT '{}', + contract_number VARCHAR(100), + plan_level VARCHAR(50) DEFAULT 'standard' CHECK (plan_level IN ('standard', 'premium', 'enterprise')), address_line1 VARCHAR(255), address_line2 VARCHAR(255), city VARCHAR(100), diff --git a/db/seed/seed.sql b/db/seed/seed.sql index 138a99e..67fed0a 100644 --- a/db/seed/seed.sql +++ b/db/seed/seed.sql @@ -63,7 +63,7 @@ END IF; -- Check if org exists SELECT id INTO v_org_id FROM shared.organizations WHERE schema_name = v_schema; IF v_org_id IS NULL THEN - INSERT INTO shared.organizations (id, name, subdomain, address_line1, city, state, zip_code, schema_name) + INSERT INTO shared.organizations (id, name, subdomain, address_line1, city, state, zip_code, schema_name, contract_number, plan_level) VALUES ( uuid_generate_v4(), 'Sunrise Valley HOA', @@ -72,7 +72,9 @@ IF v_org_id IS NULL THEN 'Scottsdale', 'AZ', '85255', - v_schema + v_schema, + 'CON-2026-001', + 'premium' ) RETURNING id INTO v_org_id; INSERT INTO shared.user_organizations (user_id, organization_id, role) @@ -176,6 +178,7 @@ CREATE TABLE IF NOT EXISTS %I.assessment_groups ( regular_assessment DECIMAL(10,2) NOT NULL DEFAULT 0.00, special_assessment DECIMAL(10,2) DEFAULT 0.00, unit_count INTEGER DEFAULT 0, + frequency VARCHAR(20) DEFAULT ''monthly'', is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() diff --git a/frontend/src/assets/logo.svg b/frontend/src/assets/logo.svg new file mode 100644 index 0000000..f26d80b --- /dev/null +++ b/frontend/src/assets/logo.svg @@ -0,0 +1,47 @@ + + + + diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index e707ca9..6971c92 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -1,5 +1,4 @@ -import { useState } from 'react'; -import { AppShell, Burger, Group, Title, Text, Menu, UnstyledButton, Avatar } from '@mantine/core'; +import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { IconLogout, @@ -9,6 +8,7 @@ import { import { Outlet, useNavigate } from 'react-router-dom'; import { useAuthStore } from '../../stores/authStore'; import { Sidebar } from './Sidebar'; +import logoSrc from '../../assets/logo.svg'; export function AppLayout() { const [opened, { toggle }] = useDisclosure(); @@ -30,7 +30,7 @@ export function AppLayout() { - HOA LedgerIQ + HOA LedgerIQ {currentOrg && ( diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index aed41f3..9cd6b64 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -12,7 +12,6 @@ import { IconShieldCheck, IconPigMoney, IconBuildingBank, - IconCalendarEvent, IconUsers, IconFileText, IconSettings, @@ -21,33 +20,67 @@ import { } 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' }, +const navSections = [ { - label: 'Reports', - icon: IconChartSankey, - children: [ - { label: 'Balance Sheet', path: '/reports/balance-sheet' }, - { label: 'Income Statement', path: '/reports/income-statement' }, - { label: 'Cash Flow', path: '/reports/cash-flow' }, - { label: 'Budget vs Actual', path: '/reports/budget-vs-actual' }, - { label: 'Aging Report', path: '/reports/aging' }, - { label: 'Sankey Diagram', path: '/reports/sankey' }, + items: [ + { label: 'Dashboard', icon: IconDashboard, path: '/dashboard' }, + ], + }, + { + label: 'Financials', + items: [ + { label: 'Accounts', icon: IconListDetails, path: '/accounts' }, + { label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' }, + { label: 'Investments', icon: IconPigMoney, path: '/investments' }, + ], + }, + { + label: 'Assessments', + items: [ + { label: 'Units / Homeowners', icon: IconHome, path: '/units' }, + { label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups' }, + ], + }, + { + label: 'Transactions', + items: [ + { label: 'Transactions', icon: IconReceipt, path: '/transactions' }, + { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' }, + { label: 'Payments', icon: IconCash, path: '/payments' }, + ], + }, + { + label: 'Planning', + items: [ + { label: 'Capital Projects', icon: IconBuildingBank, path: '/capital-projects' }, + { label: 'Reserves', icon: IconShieldCheck, path: '/reserves' }, + { label: 'Vendors', icon: IconUsers, path: '/vendors' }, + ], + }, + { + label: 'Reports', + items: [ + { + label: 'Reports', + icon: IconChartSankey, + children: [ + { label: 'Balance Sheet', path: '/reports/balance-sheet' }, + { label: 'Income Statement', path: '/reports/income-statement' }, + { label: 'Cash Flow', path: '/reports/cash-flow' }, + { label: 'Budget vs Actual', path: '/reports/budget-vs-actual' }, + { label: 'Aging Report', path: '/reports/aging' }, + { label: 'Sankey Diagram', path: '/reports/sankey' }, + ], + }, + ], + }, + { + label: 'Admin', + items: [ + { label: 'Year-End', icon: IconFileText, path: '/year-end' }, + { label: 'Settings', icon: IconSettings, path: '/settings' }, ], }, - { label: 'Reserves', icon: IconShieldCheck, path: '/reserves' }, - { label: 'Investments', icon: IconPigMoney, path: '/investments' }, - { label: 'Capital Projects', icon: IconBuildingBank, path: '/capital-projects' }, - { label: 'Vendors', icon: IconUsers, path: '/vendors' }, - { label: 'Year-End', icon: IconFileText, path: '/year-end' }, - { label: 'Settings', icon: IconSettings, path: '/settings' }, ]; export function Sidebar() { @@ -57,35 +90,47 @@ export function Sidebar() { return ( - {navItems.map((item) => - item.children ? ( - } - defaultOpened={item.children.some((c) => - location.pathname.startsWith(c.path), - )} - > - {item.children.map((child) => ( + {navSections.map((section, sIdx) => ( +
+ {section.label && ( + <> + {sIdx > 0 && } + 0 ? 4 : 0}> + {section.label} + + + )} + {section.items.map((item: any) => + item.children ? ( navigate(child.path)} + key={item.label} + label={item.label} + leftSection={} + defaultOpened={item.children.some((c: any) => + location.pathname.startsWith(c.path), + )} + > + {item.children.map((child: any) => ( + navigate(child.path)} + /> + ))} + + ) : ( + } + active={location.pathname === item.path} + onClick={() => navigate(item.path!)} /> - ))} - - ) : ( - } - active={location.pathname === item.path} - onClick={() => navigate(item.path!)} - /> - ), - )} + ), + )} +
+ ))} {user?.isSuperadmin && ( <> diff --git a/frontend/src/pages/admin/AdminPage.tsx b/frontend/src/pages/admin/AdminPage.tsx index 2f386ea..07e28ae 100644 --- a/frontend/src/pages/admin/AdminPage.tsx +++ b/frontend/src/pages/admin/AdminPage.tsx @@ -1,11 +1,14 @@ import { useState } from 'react'; import { Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center, - ThemeIcon, Tabs, ActionIcon, Switch, TextInput, Avatar, + ThemeIcon, Tabs, Switch, TextInput, Avatar, Modal, Button, PasswordInput, + Select, NumberInput, Menu, Divider, } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { IconUsers, IconBuilding, IconShieldLock, IconSearch, - IconCrown, IconUser, + IconCrown, IconPlus, IconArchive, IconChevronDown, + IconCircleCheck, IconBan, IconArchiveOff, } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; @@ -19,10 +22,61 @@ interface AdminUser { 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; } +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; +} + +const initialFormState: CreateTenantForm = { + 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 statusColor: Record = { + active: 'green', + trial: 'yellow', + suspended: 'red', + archived: 'gray', +}; + export function AdminPage() { const [search, setSearch] = useState(''); + 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 queryClient = useQueryClient(); const { data: users, isLoading: usersLoading } = useQuery({ @@ -42,6 +96,35 @@ export function AdminPage() { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-users'] }), }); + const createTenant = useMutation({ + mutationFn: async (payload: CreateTenantForm) => { + const { data } = await api.post('/admin/tenants', payload); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-orgs'] }); + queryClient.invalidateQueries({ queryKey: ['admin-users'] }); + setForm(initialFormState); + closeCreateModal(); + }, + }); + + const changeOrgStatus = useMutation({ + mutationFn: async ({ orgId, status }: { orgId: string; status: string }) => { + await api.put(`/admin/organizations/${orgId}/status`, { status }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-orgs'] }); + setStatusConfirm(null); + }, + }); + + const updateField = (key: K, value: CreateTenantForm[K]) => { + setForm((prev) => ({ ...prev, [key]: value })); + }; + + const canSubmitCreate = form.orgName.trim() !== '' && form.adminEmail.trim() !== '' && form.adminPassword.trim() !== ''; + const filteredUsers = (users || []).filter(u => !search || u.email.toLowerCase().includes(search.toLowerCase()) || `${u.firstName} ${u.lastName}`.toLowerCase().includes(search.toLowerCase()) @@ -52,19 +135,29 @@ export function AdminPage() { o.schema_name.toLowerCase().includes(search.toLowerCase()) ); + const archivedCount = (orgs || []).filter(o => o.status === 'archived').length; + return ( -
- Platform Administration - SuperUser Admin Panel — Manage tenants and users -
+ +
+ Platform Administration + SuperUser Admin Panel — Manage tenants and users +
+ +
}> SuperAdmin
- +
@@ -98,6 +191,17 @@ export function AdminPage() { + + +
+ Archived + {archivedCount} +
+ + + +
+
Organization Schema Status + Contract # + Plan Members Contact Created @@ -206,13 +312,61 @@ export function AdminPage() { {o.schema_name} - - {o.status} - + + + } + > + {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 + + )} + + + + + {o.contract_number || '\u2014'} + + + {o.plan_level ? ( + + {o.plan_level} + + ) : ( + {'\u2014'} + )} {o.member_count} @@ -233,6 +387,178 @@ export function AdminPage() { )} + + {/* Create Tenant Modal */} + { closeCreateModal(); setForm(initialFormState); }} + title="Create New Tenant" + size="lg" + > + + Organization Details + updateField('orgName', e.currentTarget.value)} + /> + + updateField('email', e.currentTarget.value)} + /> + updateField('phone', e.currentTarget.value)} + /> + + updateField('addressLine1', e.currentTarget.value)} + /> + + updateField('city', e.currentTarget.value)} + /> + updateField('state', e.currentTarget.value)} + /> + updateField('zipCode', e.currentTarget.value)} + /> + + + updateField('contractNumber', e.currentTarget.value)} + /> +