Implement Phase 2 features: roles, assessment groups, budget import, Kanban
- Add hierarchical roles: SuperUser Admin (is_superadmin flag), Tenant Admin, Tenant User with separate /admin route and admin panel - Add Assessment Groups module for property type-based assessment rates (SFHs, Condos, Estate Lots with different regular/special rates) - Enhance Chart of Accounts: initial balance on create (with journal entry), archive/restore accounts, edit all fields including account number & fund type - Add Budget CSV import with downloadable template and account mapping - Add Capital Projects Kanban board with drag-and-drop between year columns, table/kanban view toggle, and PDF export via browser print - Update seed data with assessment groups, second test user, superadmin flag - Create repeatable reseed.sh script for clean database population - Fix AgingReportPage Mantine v7 Table prop compatibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
238
frontend/src/pages/admin/AdminPage.tsx
Normal file
238
frontend/src/pages/admin/AdminPage.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
|
||||
ThemeIcon, Tabs, ActionIcon, Switch, TextInput, Avatar,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconUsers, IconBuilding, IconShieldLock, IconSearch,
|
||||
IconCrown, IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface AdminUser {
|
||||
id: string; email: string; firstName: string; lastName: string;
|
||||
isSuperadmin: boolean; lastLoginAt: string; createdAt: string;
|
||||
organizations: { id: string; name: string; role: string }[];
|
||||
}
|
||||
|
||||
interface AdminOrg {
|
||||
id: string; name: string; schema_name: string; status: string;
|
||||
email: string; phone: string; member_count: string; created_at: string;
|
||||
}
|
||||
|
||||
export function AdminPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: users, isLoading: usersLoading } = useQuery<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 toggleSuperadmin = useMutation({
|
||||
mutationFn: async ({ userId, isSuperadmin }: { userId: string; isSuperadmin: boolean }) => {
|
||||
await api.post(`/admin/users/${userId}/superadmin`, { isSuperadmin });
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-users'] }),
|
||||
});
|
||||
|
||||
const filteredUsers = (users || []).filter(u =>
|
||||
!search || u.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||
`${u.firstName} ${u.lastName}`.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const filteredOrgs = (orgs || []).filter(o =>
|
||||
!search || o.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
o.schema_name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Title order={2}>Platform Administration</Title>
|
||||
<Text c="dimmed" size="sm">SuperUser Admin Panel — Manage tenants and users</Text>
|
||||
</div>
|
||||
<Badge color="red" variant="filled" size="lg" leftSection={<IconCrown size={14} />}>
|
||||
SuperAdmin
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<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">{users?.length || 0}</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">{orgs?.length || 0}</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}>SuperAdmins</Text>
|
||||
<Text fw={700} size="xl">{(users || []).filter(u => u.isSuperadmin).length}</Text>
|
||||
</div>
|
||||
<ThemeIcon color="red" variant="light" size={48} radius="md">
|
||||
<IconShieldLock size={28} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
placeholder="Search users or organizations..."
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="users">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="users" leftSection={<IconUsers size={16} />}>
|
||||
Users ({filteredUsers.length})
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="orgs" leftSection={<IconBuilding size={16} />}>
|
||||
Organizations ({filteredOrgs.length})
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<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.isSuperadmin ? 'red' : 'blue'}>
|
||||
{u.firstName?.[0]}{u.lastName?.[0]}
|
||||
</Avatar>
|
||||
<Text size="sm" fw={500}>{u.firstName} {u.lastName}</Text>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
<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>Schema</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="center">Members</Table.Th>
|
||||
<Table.Th>Contact</Table.Th>
|
||||
<Table.Th>Created</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filteredOrgs.map((o) => (
|
||||
<Table.Tr key={o.id}>
|
||||
<Table.Td fw={500}>{o.name}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" ff="monospace" c="dimmed">{o.schema_name}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={o.status === 'active' ? 'green' : o.status === 'trial' ? 'yellow' : 'red'}
|
||||
>
|
||||
{o.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="center">
|
||||
<Badge variant="light" size="sm">{o.member_count}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{o.email || 'N/A'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(o.created_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user