Phase 2 tweaks: admin tenant creation, unit delete, frequency, UI overhaul

- Admin panel: create tenants with org + first user, manage org status
  (active/suspended/archived), contract number and plan level fields
- Units: delete with invoice check, assessment group dropdown binding
- Assessment groups: frequency field (monthly/quarterly/annual) with
  income calculations normalized to monthly equivalents
- Sidebar: grouped nav sections (Financials, Assessments, Transactions,
  Planning, Reports, Admin), renamed Chart of Accounts to Accounts
- Header: replaced text with SVG logo
- Capital projects: Kanban as default view, table-only PDF export,
  Future category (beyond 5-year plan)
- Auth: block login for suspended/archived organizations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 20:00:16 -05:00
parent 01502e07bc
commit 17fdacc0f2
20 changed files with 992 additions and 148 deletions

View File

@@ -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<string, string> = {
standard: 'blue',
premium: 'violet',
enterprise: 'orange',
};
const statusColor: Record<string, string> = {
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<CreateTenantForm>(initialFormState);
const [statusConfirm, setStatusConfirm] = useState<{ orgId: string; orgName: string; newStatus: string } | null>(null);
const queryClient = useQueryClient();
const { data: users, isLoading: usersLoading } = useQuery<AdminUser[]>({
@@ -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 = <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())
@@ -52,19 +135,29 @@ export function AdminPage() {
o.schema_name.toLowerCase().includes(search.toLowerCase())
);
const archivedCount = (orgs || []).filter(o => o.status === 'archived').length;
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>
<Group gap="md">
<div>
<Title order={2}>Platform Administration</Title>
<Text c="dimmed" size="sm">SuperUser Admin Panel Manage tenants and users</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>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
<Card withBorder padding="lg">
<Group justify="space-between">
<div>
@@ -98,6 +191,17 @@ export function AdminPage() {
</ThemeIcon>
</Group>
</Card>
<Card withBorder padding="lg">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Archived</Text>
<Text fw={700} size="xl">{archivedCount}</Text>
</div>
<ThemeIcon color="gray" variant="light" size={48} radius="md">
<IconArchive size={28} />
</ThemeIcon>
</Group>
</Card>
</SimpleGrid>
<TextInput
@@ -193,6 +297,8 @@ export function AdminPage() {
<Table.Th>Organization</Table.Th>
<Table.Th>Schema</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Contract #</Table.Th>
<Table.Th>Plan</Table.Th>
<Table.Th ta="center">Members</Table.Th>
<Table.Th>Contact</Table.Th>
<Table.Th>Created</Table.Th>
@@ -206,13 +312,61 @@ export function AdminPage() {
<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>
<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} />}
>
{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={() => 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={() => setStatusConfirm({ orgId: o.id, orgName: o.name, newStatus: 'suspended' })}
>
Suspend
</Menu.Item>
)}
{o.status !== 'archived' && (
<Menu.Item
leftSection={<IconArchiveOff size={14} />}
color="gray"
onClick={() => setStatusConfirm({ orgId: o.id, orgName: o.name, newStatus: 'archived' })}
>
Archive
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
<Table.Td>
<Text size="xs" ff="monospace">{o.contract_number || '\u2014'}</Text>
</Table.Td>
<Table.Td>
{o.plan_level ? (
<Badge size="sm" variant="light" color={planBadgeColor[o.plan_level] || 'gray'}>
{o.plan_level}
</Badge>
) : (
<Text size="xs" c="dimmed">{'\u2014'}</Text>
)}
</Table.Td>
<Table.Td ta="center">
<Badge variant="light" size="sm">{o.member_count}</Badge>
@@ -233,6 +387,178 @@ export function AdminPage() {
)}
</Tabs.Panel>
</Tabs>
{/* 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>
);
}