- Database: Add login_history, ai_recommendation_log tables; is_platform_owner column on users; subscription fields on organizations (payment_date, confirmation_number, renewal_date) - Backend: New AdminAnalyticsService with platform metrics, tenant detail, and health score calculations (0-100 based on activity, budget, transactions, members, AI usage) - Backend: Login/org-switch now records to login_history; AI recommendations logged to ai_recommendation_log; platform owner protected from superadmin toggle - Frontend: 4-tab admin panel (Dashboard, Organizations, Users, Tenant Health) with tenant detail drawer, subscription management, health scoring visualization - Platform owner account (admin@hoaledgeriq.com) auto-redirects to admin panel - Seed data includes platform owner account and sample login history Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
843 lines
37 KiB
TypeScript
843 lines
37 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,
|
|
} 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; 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 [selectedOrgId, setSelectedOrgId] = useState<string | null>(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<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] });
|
|
},
|
|
});
|
|
|
|
// ── 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>
|
|
<Badge size="sm" variant="light" color={planBadgeColor[o.plan_level] || 'gray'}>
|
|
{o.plan_level}
|
|
</Badge>
|
|
</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.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.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>
|
|
<Badge size="xs" variant="light" color={planBadgeColor[tenantDetail.organization.plan_level]}>{tenantDetail.organization.plan_level}</Badge>
|
|
<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>
|
|
</Stack>
|
|
);
|
|
}
|