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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user