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:
2026-02-18 14:28:46 -05:00
parent e0272f9d8a
commit 01502e07bc
29 changed files with 1792 additions and 142 deletions

View 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>
);
}