Files
HOA_Financial_Platform/frontend/src/pages/admin/AdminPage.tsx
olsch01 d9bb9363dd Add admin enhancements: impersonation, plan management, org status enforcement
Enhancement 1 - Block suspended/archived org access:
- Add org status check in switchOrganization() (auth.service.ts)
- Filter suspended/archived orgs from login response (generateTokenResponse)
- Add org status guard with 60s cache in TenantMiddleware
- Frontend: filter orgs in SelectOrgPage, add 403 handler in api.ts

Enhancement 2 - Change tenant plan level:
- Add updatePlanLevel() to organizations.service.ts
- Add PUT /admin/organizations/:id/plan endpoint
- Frontend: clickable plan dropdown in Organizations table + confirmation modal
- Plan level Select in tenant detail drawer

Enhancement 3 - User impersonation:
- Add impersonateUser() to auth.service.ts with impersonatedBy JWT claim
- Add POST /admin/impersonate/:userId endpoint
- Frontend: Impersonate button in Users tab (disabled for admins)
- Impersonation state management in authStore (start/stop/persist)
- Orange impersonation banner in AppLayout header with stop button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:21:59 -05:00

960 lines
42 KiB
TypeScript

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, 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, IconDashboard,
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
interface AdminUser {
id: string; email: string; firstName: string; lastName: string;
isSuperadmin: boolean; isPlatformOwner?: 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;
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;
}
const initialFormState: CreateTenantForm = {
orgName: '', email: '', phone: '', addressLine1: '', city: '', state: '', zipCode: '',
contractNumber: '', planLevel: 'standard', fiscalYearStartMonth: 1,
adminEmail: '', adminPassword: '', adminFirstName: '', adminLastName: '',
};
const planBadgeColor: Record<string, string> = { standard: 'blue', premium: 'violet', enterprise: 'orange' };
const statusColor: Record<string, string> = { 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<string | null>('dashboard');
const [createModalOpened, { open: openCreateModal, close: closeCreateModal }] = useDisclosure(false);
const [form, setForm] = useState<CreateTenantForm>(initialFormState);
const [statusConfirm, setStatusConfirm] = useState<{ orgId: string; orgName: string; newStatus: string } | null>(null);
const [planConfirm, setPlanConfirm] = useState<{ orgId: string; orgName: string; newPlan: string } | null>(null);
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const [drawerOpened, { open: openDrawer, close: closeDrawer }] = useDisclosure(false);
const [subForm, setSubForm] = useState({ paymentDate: '', confirmationNumber: '', renewalDate: '' });
const queryClient = useQueryClient();
const navigate = useNavigate();
const { startImpersonation } = useAuthStore();
// ── Queries ──
const { data: metrics, isLoading: metricsLoading } = useQuery<PlatformMetrics>({
queryKey: ['admin-metrics'],
queryFn: async () => { const { data } = await api.get('/admin/metrics'); return data; },
});
const { data: users, isLoading: usersLoading } = useQuery<AdminUser[]>({
queryKey: ['admin-users'],
queryFn: async () => { const { data } = await api.get('/admin/users'); return data; },
});
const { data: orgs, isLoading: orgsLoading } = useQuery<AdminOrg[]>({
queryKey: ['admin-orgs'],
queryFn: async () => { const { data } = await api.get('/admin/organizations'); return data; },
});
const { data: tenantsHealth, isLoading: healthLoading } = useQuery<TenantHealth[]>({
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<TenantDetail>({
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 });
},
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'] });
queryClient.invalidateQueries({ queryKey: ['admin-metrics'] });
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'] });
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] });
},
});
const changeOrgPlan = useMutation({
mutationFn: async ({ orgId, planLevel }: { orgId: string; planLevel: string }) => {
await api.put(`/admin/organizations/${orgId}/plan`, { planLevel });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-orgs'] });
queryClient.invalidateQueries({ queryKey: ['admin-metrics'] });
queryClient.invalidateQueries({ queryKey: ['admin-tenants-health'] });
queryClient.invalidateQueries({ queryKey: ['admin-tenant-detail', selectedOrgId] });
setPlanConfirm(null);
},
});
const impersonateUser = useMutation({
mutationFn: async (userId: string) => {
const { data } = await api.post(`/admin/impersonate/${userId}`);
return data;
},
onSuccess: (data) => {
startImpersonation(data.accessToken, data.user, data.organizations);
if (data.organizations.length > 0) {
navigate('/select-org');
} else {
navigate('/');
}
},
});
// ── Helpers ──
const updateField = <K extends keyof CreateTenantForm>(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())
);
const filteredOrgs = (orgs || []).filter(o =>
!search || o.name.toLowerCase().includes(search.toLowerCase()) ||
o.schema_name.toLowerCase().includes(search.toLowerCase())
);
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 (
<Stack>
<Group justify="space-between">
<Group gap="md">
<div>
<Title order={2}>Platform Administration</Title>
<Text c="dimmed" size="sm">HOA LedgerIQ SaaS Management Console</Text>
</div>
<Button leftSection={<IconPlus size={16} />} onClick={openCreateModal}>
Create Tenant
</Button>
</Group>
<Badge color="red" variant="filled" size="lg" leftSection={<IconCrown size={14} />}>
SuperAdmin
</Badge>
</Group>
<TextInput
placeholder="Search users, organizations, or tenants..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
<Tabs.Tab value="dashboard" leftSection={<IconDashboard size={16} />}>
Dashboard
</Tabs.Tab>
<Tabs.Tab value="orgs" leftSection={<IconBuilding size={16} />}>
Organizations ({filteredOrgs.length})
</Tabs.Tab>
<Tabs.Tab value="users" leftSection={<IconUsers size={16} />}>
Users ({filteredUsers.length})
</Tabs.Tab>
<Tabs.Tab value="health" leftSection={<IconHeartRateMonitor size={16} />}>
Tenant Health
</Tabs.Tab>
</Tabs.List>
{/* ── TAB 1: Dashboard ── */}
<Tabs.Panel value="dashboard" pt="md">
{metricsLoading ? (
<Center h={300}><Loader /></Center>
) : metrics ? (
<Stack>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
<Card withBorder padding="lg">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Users</Text>
<Text fw={700} size="xl">{metrics.totalUsers}</Text>
<Text size="xs" c="dimmed">{metrics.activeUsers30d} active (30d)</Text>
</div>
<ThemeIcon color="blue" variant="light" size={48} radius="md">
<IconUsers size={28} />
</ThemeIcon>
</Group>
</Card>
<Card withBorder padding="lg">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Organizations</Text>
<Text fw={700} size="xl">{metrics.totalOrganizations}</Text>
<Text size="xs" c="dimmed">{metrics.activeOrganizations} active</Text>
</div>
<ThemeIcon color="green" variant="light" size={48} radius="md">
<IconBuilding size={28} />
</ThemeIcon>
</Group>
</Card>
<Card withBorder padding="lg">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>AI Requests (30d)</Text>
<Text fw={700} size="xl">{metrics.aiRequestsLast30d}</Text>
<Text size="xs" c="dimmed">
{metrics.aiAvgResponseMs ? `Avg ${(metrics.aiAvgResponseMs / 1000).toFixed(1)}s` : 'No data'}
</Text>
</div>
<ThemeIcon color="violet" variant="light" size={48} radius="md">
<IconSparkles size={28} />
</ThemeIcon>
</Group>
</Card>
<Card withBorder padding="lg">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>SuperAdmins</Text>
<Text fw={700} size="xl">{metrics.superadminCount}</Text>
<Text size="xs" c="dimmed">{metrics.suspendedOrganizations} suspended, {metrics.archivedOrganizations} archived</Text>
</div>
<ThemeIcon color="red" variant="light" size={48} radius="md">
<IconShieldLock size={28} />
</ThemeIcon>
</Group>
</Card>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, md: 2 }}>
<Card withBorder>
<Text fw={600} mb="sm">Plan Distribution</Text>
<Stack gap="xs">
{metrics.planBreakdown.map((p) => (
<Group key={p.plan} justify="space-between">
<Group gap="xs">
<Badge size="sm" variant="light" color={planBadgeColor[p.plan] || 'gray'}>{p.plan}</Badge>
<Text size="sm">{p.count} tenant{p.count !== 1 ? 's' : ''}</Text>
</Group>
<Progress
value={metrics.totalOrganizations > 0 ? (p.count / metrics.totalOrganizations) * 100 : 0}
size="lg"
radius="xl"
color={planBadgeColor[p.plan] || 'gray'}
style={{ width: '50%' }}
/>
</Group>
))}
</Stack>
</Card>
<Card withBorder>
<Text fw={600} mb="sm">Status Breakdown</Text>
<Stack gap="xs">
{metrics.statusBreakdown.map((s) => (
<Group key={s.status} justify="space-between">
<Group gap="xs">
<Badge size="sm" variant="light" color={statusColor[s.status] || 'gray'}>{s.status}</Badge>
<Text size="sm">{s.count} org{s.count !== 1 ? 's' : ''}</Text>
</Group>
<Progress
value={metrics.totalOrganizations > 0 ? (s.count / metrics.totalOrganizations) * 100 : 0}
size="lg"
radius="xl"
color={statusColor[s.status] || 'gray'}
style={{ width: '50%' }}
/>
</Group>
))}
</Stack>
</Card>
</SimpleGrid>
</Stack>
) : null}
</Tabs.Panel>
{/* ── TAB 2: Organizations ── */}
<Tabs.Panel value="orgs" pt="md">
{orgsLoading ? (
<Center h={200}><Loader /></Center>
) : (
<Card withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Organization</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Plan</Table.Th>
<Table.Th ta="center">Members</Table.Th>
<Table.Th>Last Activity</Table.Th>
<Table.Th>Subscription</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredOrgs.map((o) => (
<Table.Tr key={o.id} style={{ cursor: 'pointer' }} onClick={() => openOrgDetail(o.id)}>
<Table.Td>
<div>
<Text size="sm" fw={500}>{o.name}</Text>
<Text size="xs" ff="monospace" c="dimmed">{o.schema_name}</Text>
</div>
</Table.Td>
<Table.Td>
<Menu shadow="md" width={180} position="bottom-start">
<Menu.Target>
<Badge
size="sm" variant="light"
color={statusColor[o.status] || 'gray'}
style={{ cursor: 'pointer' }}
rightSection={<IconChevronDown size={10} />}
onClick={(e) => e.stopPropagation()}
>
{o.status}
</Badge>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Change status</Menu.Label>
{o.status !== 'active' && (
<Menu.Item leftSection={<IconCircleCheck size={14} />} color="green"
onClick={(e) => { e.stopPropagation(); setStatusConfirm({ orgId: o.id, orgName: o.name, newStatus: 'active' }); }}>
Set Active
</Menu.Item>
)}
{o.status !== 'suspended' && (
<Menu.Item leftSection={<IconBan size={14} />} color="red"
onClick={(e) => { e.stopPropagation(); setStatusConfirm({ orgId: o.id, orgName: o.name, newStatus: 'suspended' }); }}>
Suspend
</Menu.Item>
)}
{o.status !== 'archived' && (
<Menu.Item leftSection={<IconArchiveOff size={14} />} color="gray"
onClick={(e) => { e.stopPropagation(); setStatusConfirm({ orgId: o.id, orgName: o.name, newStatus: 'archived' }); }}>
Archive
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
<Table.Td>
<Menu shadow="md" width={180} position="bottom-start">
<Menu.Target>
<Badge
size="sm" variant="light"
color={planBadgeColor[o.plan_level] || 'gray'}
style={{ cursor: 'pointer' }}
rightSection={<IconChevronDown size={10} />}
onClick={(e) => e.stopPropagation()}
>
{o.plan_level}
</Badge>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Change plan</Menu.Label>
{['standard', 'premium', 'enterprise']
.filter(p => p !== o.plan_level)
.map(p => (
<Menu.Item key={p}
color={planBadgeColor[p]}
onClick={(e) => {
e.stopPropagation();
setPlanConfirm({ orgId: o.id, orgName: o.name, newPlan: p });
}}>
{p.charAt(0).toUpperCase() + p.slice(1)}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
</Table.Td>
<Table.Td ta="center">
<Badge variant="light" size="sm">{o.member_count}</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{formatDateTime(o.last_activity)}</Text>
</Table.Td>
<Table.Td>
{o.renewal_date ? (
<Tooltip label={`Paid: ${formatDate(o.payment_date)} | Conf: ${o.confirmation_number || 'N/A'}`}>
<Text size="xs">Renews {formatDate(o.renewal_date)}</Text>
</Tooltip>
) : (
<Text size="xs" c="dimmed">Not set</Text>
)}
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{formatDate(o.created_at)}</Text>
</Table.Td>
<Table.Td>
<Button variant="subtle" size="xs" onClick={(e) => { e.stopPropagation(); openOrgDetail(o.id); }}>
Details
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
</Tabs.Panel>
{/* ── TAB 3: Users ── */}
<Tabs.Panel value="users" pt="md">
{usersLoading ? (
<Center h={200}><Loader /></Center>
) : (
<Card withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Email</Table.Th>
<Table.Th>Organizations</Table.Th>
<Table.Th>Last Login</Table.Th>
<Table.Th ta="center">SuperAdmin</Table.Th>
<Table.Th ta="center">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredUsers.map((u) => (
<Table.Tr key={u.id}>
<Table.Td>
<Group gap="xs">
<Avatar size="sm" radius="xl" color={u.isPlatformOwner ? 'orange' : u.isSuperadmin ? 'red' : 'blue'}>
{u.firstName?.[0]}{u.lastName?.[0]}
</Avatar>
<div>
<Text size="sm" fw={500}>{u.firstName} {u.lastName}</Text>
{u.isPlatformOwner && (
<Badge size="xs" variant="filled" color="orange">Platform Owner</Badge>
)}
</div>
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" ff="monospace">{u.email}</Text>
</Table.Td>
<Table.Td>
<Group gap={4}>
{u.organizations.map((o) => (
<Badge key={o.id} size="xs" variant="light">
{o.name} ({o.role})
</Badge>
))}
{u.organizations.length === 0 && (
<Text size="xs" c="dimmed">No organizations</Text>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleDateString() : 'Never'}
</Text>
</Table.Td>
<Table.Td ta="center">
{u.isPlatformOwner ? (
<Tooltip label="Platform owner - cannot modify">
<Switch checked={true} disabled size="sm" color="orange" />
</Tooltip>
) : (
<Switch
checked={u.isSuperadmin}
onChange={() => toggleSuperadmin.mutate({
userId: u.id,
isSuperadmin: !u.isSuperadmin,
})}
size="sm"
color="red"
/>
)}
</Table.Td>
<Table.Td ta="center">
<Tooltip label={u.isPlatformOwner || u.isSuperadmin ? 'Cannot impersonate admins' : `View app as ${u.firstName}`}>
<Button
variant="subtle"
size="xs"
leftSection={<IconEye size={14} />}
disabled={u.isPlatformOwner || u.isSuperadmin}
loading={impersonateUser.isPending}
onClick={() => impersonateUser.mutate(u.id)}
>
Impersonate
</Button>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
</Tabs.Panel>
{/* ── TAB 4: Tenant Health ── */}
<Tabs.Panel value="health" pt="md">
{healthLoading ? (
<Center h={200}><Loader /></Center>
) : (
<Card withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Tenant</Table.Th>
<Table.Th>Health Score</Table.Th>
<Table.Th ta="center">Active Users</Table.Th>
<Table.Th>Last Login</Table.Th>
<Table.Th ta="center">Budget</Table.Th>
<Table.Th ta="center">Transactions (30d)</Table.Th>
<Table.Th>Cash on Hand</Table.Th>
<Table.Th ta="center">AI Usage (30d)</Table.Th>
<Table.Th>Renewal</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(tenantsHealth || [])
.filter(t => !search || t.name.toLowerCase().includes(search.toLowerCase()))
.map((t) => (
<Table.Tr key={t.id} style={{ cursor: 'pointer' }} onClick={() => openOrgDetail(t.id)}>
<Table.Td>
<div>
<Text size="sm" fw={500}>{t.name}</Text>
<Group gap={4}>
<Badge size="xs" variant="light" color={statusColor[t.status]}>{t.status}</Badge>
<Badge size="xs" variant="light" color={planBadgeColor[t.planLevel]}>{t.planLevel}</Badge>
</Group>
</div>
</Table.Td>
<Table.Td>
<Group gap="xs">
<RingProgress
size={36}
thickness={4}
sections={[{ value: t.healthScore, color: healthScoreColor(t.healthScore) }]}
label={
<Text size="xs" ta="center" fw={700}>{t.healthScore}</Text>
}
/>
<Text size="xs" c={healthScoreColor(t.healthScore)} fw={600}>
{t.healthScore >= 75 ? 'Healthy' : t.healthScore >= 50 ? 'Fair' : t.healthScore >= 25 ? 'At Risk' : 'Critical'}
</Text>
</Group>
</Table.Td>
<Table.Td ta="center">
<Text size="sm">{t.activeUsers30d} / {t.memberCount}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{formatDateTime(t.lastLogin)}</Text>
</Table.Td>
<Table.Td ta="center">
{t.hasBudget ? (
<ThemeIcon color="green" variant="light" size="sm" radius="xl">
<IconClipboardCheck size={14} />
</ThemeIcon>
) : (
<ThemeIcon color="red" variant="light" size="sm" radius="xl">
<IconBan size={14} />
</ThemeIcon>
)}
</Table.Td>
<Table.Td ta="center">
<Text size="sm">{t.journalEntries30d}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500} c={t.cashOnHand > 0 ? undefined : 'red'}>
{formatCurrency(t.cashOnHand)}
</Text>
</Table.Td>
<Table.Td ta="center">
<Badge size="sm" variant="light" color={t.aiUsage30d > 0 ? 'violet' : 'gray'}>
{t.aiUsage30d}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{formatDate(t.renewalDate)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
</Tabs.Panel>
</Tabs>
{/* ── Tenant Detail Drawer ── */}
<Drawer
opened={drawerOpened}
onClose={closeDrawer}
title={<Text fw={600}>Tenant Details</Text>}
position="right"
size="lg"
>
{detailLoading ? (
<Center h={300}><Loader /></Center>
) : tenantDetail ? (
<Stack>
<Card withBorder>
<Text fw={600} mb="xs">{tenantDetail.organization.name}</Text>
<SimpleGrid cols={2} spacing="xs">
<Text size="xs" c="dimmed">Schema</Text>
<Text size="xs" ff="monospace">{tenantDetail.organization.schema_name}</Text>
<Text size="xs" c="dimmed">Status</Text>
<Badge size="xs" variant="light" color={statusColor[tenantDetail.organization.status]}>{tenantDetail.organization.status}</Badge>
<Text size="xs" c="dimmed">Plan</Text>
<Select
size="xs"
data={[
{ value: 'standard', label: 'Standard' },
{ value: 'premium', label: 'Premium' },
{ value: 'enterprise', label: 'Enterprise' },
]}
value={tenantDetail.organization.plan_level}
onChange={(val) => {
if (val && selectedOrgId && val !== tenantDetail.organization.plan_level) {
changeOrgPlan.mutate({ orgId: selectedOrgId, planLevel: val });
}
}}
styles={{ root: { maxWidth: 140 } }}
/>
<Text size="xs" c="dimmed">Contract #</Text>
<Text size="xs">{tenantDetail.organization.contract_number || '\u2014'}</Text>
<Text size="xs" c="dimmed">Members</Text>
<Text size="xs">{tenantDetail.memberCount}</Text>
</SimpleGrid>
</Card>
<Card withBorder>
<Text fw={600} mb="xs">Activity</Text>
<SimpleGrid cols={2} spacing="xs">
<Text size="xs" c="dimmed">Last Login</Text>
<Text size="xs">{formatDateTime(tenantDetail.lastLogin)}</Text>
<Text size="xs" c="dimmed">Logins This Week</Text>
<Text size="xs" fw={500}>{tenantDetail.loginsThisWeek}</Text>
<Text size="xs" c="dimmed">Logins This Month</Text>
<Text size="xs" fw={500}>{tenantDetail.loginsThisMonth}</Text>
<Text size="xs" c="dimmed">Active Users (30d)</Text>
<Text size="xs" fw={500}>{tenantDetail.activeUsers30d}</Text>
<Text size="xs" c="dimmed">AI Recommendations (30d)</Text>
<Badge size="xs" variant="light" color="violet">{tenantDetail.aiRecommendations30d}</Badge>
</SimpleGrid>
</Card>
<Card withBorder>
<Text fw={600} mb="xs">Setup Health</Text>
<SimpleGrid cols={2} spacing="xs">
<Text size="xs" c="dimmed">Cash on Hand</Text>
<Text size="xs" fw={500}>{formatCurrency(tenantDetail.cashOnHand)}</Text>
<Text size="xs" c="dimmed">Has Budget</Text>
<Badge size="xs" color={tenantDetail.hasBudget ? 'green' : 'red'}>
{tenantDetail.hasBudget ? 'Yes' : 'No'}
</Badge>
<Text size="xs" c="dimmed">Recent Transactions (30d)</Text>
<Text size="xs">{tenantDetail.recentTransactions}</Text>
</SimpleGrid>
</Card>
<Card withBorder>
<Text fw={600} mb="xs">Subscription</Text>
<Stack gap="xs">
<TextInput
label="Payment Date"
type="date"
size="xs"
value={subForm.paymentDate}
onChange={(e) => setSubForm(p => ({ ...p, paymentDate: e.currentTarget.value }))}
/>
<TextInput
label="Confirmation Number"
size="xs"
placeholder="PAY-2026-..."
value={subForm.confirmationNumber}
onChange={(e) => setSubForm(p => ({ ...p, confirmationNumber: e.currentTarget.value }))}
/>
<TextInput
label="Renewal Date"
type="date"
size="xs"
value={subForm.renewalDate}
onChange={(e) => setSubForm(p => ({ ...p, renewalDate: e.currentTarget.value }))}
/>
<Button
size="xs"
variant="light"
loading={updateSubscription.isPending}
onClick={() => {
if (selectedOrgId) {
updateSubscription.mutate({ orgId: selectedOrgId, data: subForm });
}
}}
>
Save Subscription
</Button>
</Stack>
</Card>
</Stack>
) : (
<Text c="dimmed">No data available</Text>
)}
</Drawer>
{/* ── Create Tenant Modal ── */}
<Modal
opened={createModalOpened}
onClose={() => { closeCreateModal(); setForm(initialFormState); }}
title="Create New Tenant"
size="lg"
>
<Stack>
<Text fw={600} size="sm" c="dimmed" tt="uppercase">Organization Details</Text>
<TextInput label="Organization Name" placeholder="Sunset Ridge HOA" required
value={form.orgName} onChange={(e) => updateField('orgName', e.currentTarget.value)} />
<Group grow>
<TextInput label="Email" placeholder="contact@sunsetridge.org"
value={form.email} onChange={(e) => updateField('email', e.currentTarget.value)} />
<TextInput label="Phone" placeholder="(555) 123-4567"
value={form.phone} onChange={(e) => updateField('phone', e.currentTarget.value)} />
</Group>
<TextInput label="Address Line 1" placeholder="123 Main Street"
value={form.addressLine1} onChange={(e) => updateField('addressLine1', e.currentTarget.value)} />
<Group grow>
<TextInput label="City" placeholder="Springfield"
value={form.city} onChange={(e) => updateField('city', e.currentTarget.value)} />
<TextInput label="State" placeholder="CA"
value={form.state} onChange={(e) => updateField('state', e.currentTarget.value)} />
<TextInput label="Zip Code" placeholder="90210"
value={form.zipCode} onChange={(e) => updateField('zipCode', e.currentTarget.value)} />
</Group>
<Group grow>
<TextInput label="Contract Number" placeholder="HOA-2026-001"
value={form.contractNumber} onChange={(e) => updateField('contractNumber', e.currentTarget.value)} />
<Select label="Plan Level"
data={[
{ value: 'standard', label: 'Standard' },
{ value: 'premium', label: 'Premium' },
{ value: 'enterprise', label: 'Enterprise' },
]}
value={form.planLevel}
onChange={(val) => updateField('planLevel', val || 'standard')} />
<NumberInput label="Fiscal Year Start Month" placeholder="1" min={1} max={12}
value={form.fiscalYearStartMonth}
onChange={(val) => updateField('fiscalYearStartMonth', val as number | '')} />
</Group>
<Divider my="xs" />
<Text fw={600} size="sm" c="dimmed" tt="uppercase">Admin User</Text>
<Group grow>
<TextInput label="Email" placeholder="admin@sunsetridge.org" required
value={form.adminEmail} onChange={(e) => updateField('adminEmail', e.currentTarget.value)} />
<PasswordInput label="Password" placeholder="Strong password" required
value={form.adminPassword} onChange={(e) => updateField('adminPassword', e.currentTarget.value)} />
</Group>
<Group grow>
<TextInput label="First Name" placeholder="Jane"
value={form.adminFirstName} onChange={(e) => updateField('adminFirstName', e.currentTarget.value)} />
<TextInput label="Last Name" placeholder="Doe"
value={form.adminLastName} onChange={(e) => updateField('adminLastName', e.currentTarget.value)} />
</Group>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={() => { closeCreateModal(); setForm(initialFormState); }}>Cancel</Button>
<Button onClick={() => createTenant.mutate(form)} loading={createTenant.isPending} disabled={!canSubmitCreate}>
Create Tenant
</Button>
</Group>
</Stack>
</Modal>
{/* ── Status Change Confirmation Modal ── */}
<Modal
opened={statusConfirm !== null}
onClose={() => setStatusConfirm(null)}
title="Confirm Status Change"
size="sm"
centered
>
{statusConfirm && (
<Stack>
<Text size="sm">
Are you sure you want to change <Text span fw={700}>{statusConfirm.orgName}</Text> status
to <Badge size="sm" variant="light" color={statusColor[statusConfirm.newStatus] || 'gray'}>{statusConfirm.newStatus}</Badge>?
</Text>
{statusConfirm.newStatus === 'archived' && (
<Text size="xs" c="red">Archiving an organization will disable access for all its members.</Text>
)}
{statusConfirm.newStatus === 'suspended' && (
<Text size="xs" c="red">Suspending an organization will temporarily disable access for all its members.</Text>
)}
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={() => setStatusConfirm(null)}>Cancel</Button>
<Button
color={statusColor[statusConfirm.newStatus] || 'gray'}
onClick={() => changeOrgStatus.mutate({ orgId: statusConfirm.orgId, status: statusConfirm.newStatus })}
loading={changeOrgStatus.isPending}
>
Confirm
</Button>
</Group>
</Stack>
)}
</Modal>
{/* ── Plan Change Confirmation Modal ── */}
<Modal
opened={planConfirm !== null}
onClose={() => setPlanConfirm(null)}
title="Confirm Plan Change"
size="sm"
centered
>
{planConfirm && (
<Stack>
<Text size="sm">
Change <Text span fw={700}>{planConfirm.orgName}</Text> plan to{' '}
<Badge size="sm" variant="light" color={planBadgeColor[planConfirm.newPlan] || 'gray'}>
{planConfirm.newPlan}
</Badge>?
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={() => setPlanConfirm(null)}>Cancel</Button>
<Button
color={planBadgeColor[planConfirm.newPlan] || 'blue'}
onClick={() => changeOrgPlan.mutate({ orgId: planConfirm.orgId, planLevel: planConfirm.newPlan })}
loading={changeOrgPlan.isPending}
>
Confirm
</Button>
</Group>
</Stack>
)}
</Modal>
</Stack>
);
}