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

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import {
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Switch, ActionIcon,
ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
@@ -19,6 +19,7 @@ interface AssessmentGroup {
regular_assessment: string;
special_assessment: string;
unit_count: number;
frequency: string;
actual_unit_count: string;
monthly_operating_income: string;
monthly_reserve_income: string;
@@ -34,6 +35,18 @@ interface Summary {
total_units: string;
}
const frequencyLabels: Record<string, string> = {
monthly: 'Monthly',
quarterly: 'Quarterly',
annual: 'Annual',
};
const frequencyColors: Record<string, string> = {
monthly: 'blue',
quarterly: 'teal',
annual: 'violet',
};
export function AssessmentGroupsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
@@ -56,6 +69,7 @@ export function AssessmentGroupsPage() {
regularAssessment: 0,
specialAssessment: 0,
unitCount: 0,
frequency: 'monthly',
},
validate: {
name: (v) => (v.length > 0 ? null : 'Required'),
@@ -99,6 +113,7 @@ export function AssessmentGroupsPage() {
regularAssessment: parseFloat(group.regular_assessment || '0'),
specialAssessment: parseFloat(group.special_assessment || '0'),
unitCount: group.unit_count || 0,
frequency: group.frequency || 'monthly',
});
open();
};
@@ -112,6 +127,14 @@ export function AssessmentGroupsPage() {
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const freqSuffix = (freq: string) => {
switch (freq) {
case 'quarterly': return '/qtr';
case 'annual': return '/yr';
default: return '/mo';
}
};
if (isLoading) return <Center h={300}><Loader /></Center>;
return (
@@ -119,7 +142,7 @@ export function AssessmentGroupsPage() {
<Group justify="space-between">
<div>
<Title order={2}>Assessment Groups</Title>
<Text c="dimmed" size="sm">Manage property types with different assessment rates</Text>
<Text c="dimmed" size="sm">Manage property types with different assessment rates and frequencies</Text>
</div>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Group
@@ -152,7 +175,7 @@ export function AssessmentGroupsPage() {
<Card withBorder padding="lg">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Monthly Operating</Text>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Monthly Equiv. Operating</Text>
<Text fw={700} size="xl">{fmt(summary?.total_monthly_operating || '0')}</Text>
</div>
<ThemeIcon color="teal" variant="light" size={48} radius="md">
@@ -163,7 +186,7 @@ export function AssessmentGroupsPage() {
<Card withBorder padding="lg">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Monthly Reserve</Text>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Monthly Equiv. Reserve</Text>
<Text fw={700} size="xl">{fmt(summary?.total_monthly_reserve || '0')}</Text>
</div>
<ThemeIcon color="violet" variant="light" size={48} radius="md">
@@ -179,10 +202,10 @@ export function AssessmentGroupsPage() {
<Table.Tr>
<Table.Th>Group Name</Table.Th>
<Table.Th ta="center">Units</Table.Th>
<Table.Th>Frequency</Table.Th>
<Table.Th ta="right">Regular Assessment</Table.Th>
<Table.Th ta="right">Special Assessment</Table.Th>
<Table.Th ta="right">Monthly Operating</Table.Th>
<Table.Th ta="right">Monthly Reserve</Table.Th>
<Table.Th ta="right">Monthly Equiv.</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
@@ -208,14 +231,24 @@ export function AssessmentGroupsPage() {
<Table.Td ta="center">
<Badge variant="light">{g.actual_unit_count || g.unit_count}</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(g.regular_assessment)}</Table.Td>
<Table.Td>
<Badge
color={frequencyColors[g.frequency] || 'blue'}
variant="light"
size="sm"
>
{frequencyLabels[g.frequency] || 'Monthly'}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">
{fmt(g.regular_assessment)}{freqSuffix(g.frequency)}
</Table.Td>
<Table.Td ta="right" ff="monospace">
{parseFloat(g.special_assessment || '0') > 0 ? (
<Badge color="orange" variant="light">{fmt(g.special_assessment)}</Badge>
<Badge color="orange" variant="light">{fmt(g.special_assessment)}{freqSuffix(g.frequency)}</Badge>
) : '-'}
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(g.monthly_operating_income)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(g.monthly_reserve_income)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(g.total_monthly_income)}</Table.Td>
<Table.Td>
<Badge color={g.is_active ? 'green' : 'gray'} variant="light" size="sm">
{g.is_active ? 'Active' : 'Archived'}
@@ -246,16 +279,26 @@ export function AssessmentGroupsPage() {
<Stack>
<TextInput label="Group Name" placeholder="e.g. Single Family Homes" required {...form.getInputProps('name')} />
<Textarea label="Description" placeholder="Optional description" {...form.getInputProps('description')} />
<Select
label="Assessment Frequency"
description="How often assessments are collected"
data={[
{ value: 'monthly', label: 'Monthly' },
{ value: 'quarterly', label: 'Quarterly' },
{ value: 'annual', label: 'Annual' },
]}
{...form.getInputProps('frequency')}
/>
<Group grow>
<NumberInput
label="Regular Assessment (per unit)"
label={`Regular Assessment (per unit${freqSuffix(form.values.frequency)})`}
prefix="$"
decimalScale={2}
min={0}
{...form.getInputProps('regularAssessment')}
/>
<NumberInput
label="Special Assessment (per unit)"
label={`Special Assessment (per unit${freqSuffix(form.values.frequency)})`}
prefix="$"
decimalScale={2}
min={0}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, DragEvent } from 'react';
import { useState, useCallback, useEffect, useRef, DragEvent } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
@@ -24,6 +24,8 @@ interface CapitalProject {
status: string; fund_source: string; priority: number;
}
const FUTURE_YEAR = 9999;
const statusColors: Record<string, string> = {
planned: 'blue', approved: 'green', in_progress: 'yellow',
completed: 'teal', deferred: 'gray', cancelled: 'red',
@@ -34,6 +36,8 @@ const priorityColor = (p: number) => (p <= 2 ? 'red' : p <= 3 ? 'yellow' : 'gray
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const yearLabel = (year: number) => (year === FUTURE_YEAR ? 'Future' : String(year));
// ---------------------------------------------------------------------------
// Kanban card
// ---------------------------------------------------------------------------
@@ -129,7 +133,7 @@ function KanbanColumn({
onDrop={(e) => onDrop(e, year)}
>
<Group justify="space-between" mb="sm">
<Title order={5}>{year}</Title>
<Title order={5}>{yearLabel(year)}</Title>
<Badge size="sm" variant="light">{fmt(totalEst)}</Badge>
</Group>
@@ -152,6 +156,21 @@ function KanbanColumn({
);
}
// ---------------------------------------------------------------------------
// Print styles - hides kanban and shows the table view when printing
// ---------------------------------------------------------------------------
const printStyles = `
@media print {
.capital-projects-kanban-view {
display: none !important;
}
.capital-projects-table-view {
display: block !important;
}
}
`;
// ---------------------------------------------------------------------------
// Main page component
// ---------------------------------------------------------------------------
@@ -159,8 +178,10 @@ function KanbanColumn({
export function CapitalProjectsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<CapitalProject | null>(null);
const [viewMode, setViewMode] = useState<string>('table');
const [viewMode, setViewMode] = useState<string>('kanban');
const [printMode, setPrintMode] = useState(false);
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
const printModeRef = useRef(false);
const queryClient = useQueryClient();
// ---- Data fetching ----
@@ -172,6 +193,16 @@ export function CapitalProjectsPage() {
// ---- Form ----
const currentYear = new Date().getFullYear();
const targetYearOptions = [
...Array.from({ length: 6 }, (_, i) => ({
value: String(currentYear + i),
label: String(currentYear + i),
})),
{ value: String(FUTURE_YEAR), label: 'Future (Beyond 5-Year)' },
];
const form = useForm({
initialValues: {
name: '', description: '', estimated_cost: 0, actual_cost: 0,
@@ -213,6 +244,21 @@ export function CapitalProjectsPage() {
},
});
// ---- Print mode effect ----
useEffect(() => {
if (printMode) {
printModeRef.current = true;
// Wait for the table to render before printing
const timer = setTimeout(() => {
window.print();
setPrintMode(false);
printModeRef.current = false;
}, 300);
return () => clearTimeout(timer);
}
}, [printMode]);
// ---- Handlers ----
const handleEdit = (p: CapitalProject) => {
@@ -235,7 +281,13 @@ export function CapitalProjectsPage() {
};
const handlePdfExport = () => {
window.print();
// If already in table view, just print directly
if (viewMode === 'table') {
window.print();
return;
}
// Otherwise, trigger printMode which renders the table for printing
setPrintMode(true);
};
// ---- Drag & Drop ----
@@ -270,7 +322,17 @@ export function CapitalProjectsPage() {
// ---- Derived data ----
const years = [...new Set(projects.map((p) => p.target_year))].sort();
// Always show current year through current+4, plus FUTURE_YEAR if any projects have it
const baseYears = Array.from({ length: 5 }, (_, i) => currentYear + i);
const projectYears = [...new Set(projects.map((p) => p.target_year))];
const hasFutureProjects = projectYears.includes(FUTURE_YEAR);
// Merge base years with any extra years from projects (excluding FUTURE_YEAR for now)
const regularYears = [...new Set([...baseYears, ...projectYears.filter((y) => y !== FUTURE_YEAR)])].sort();
const years = hasFutureProjects ? [...regularYears, FUTURE_YEAR] : regularYears;
// Kanban columns: always current..current+4 plus Future
const kanbanYears = [...baseYears, FUTURE_YEAR];
// ---- Loading state ----
@@ -287,11 +349,12 @@ export function CapitalProjectsPage() {
) : (
years.map((year) => {
const yearProjects = projects.filter((p) => p.target_year === year);
if (yearProjects.length === 0) return null;
const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
return (
<Stack key={year} gap="xs">
<Group>
<Title order={4}>{year}</Title>
<Title order={4}>{yearLabel(year)}</Title>
<Badge size="lg" variant="light">{fmt(totalEst)} estimated</Badge>
</Group>
<Table striped highlightOnHover>
@@ -312,10 +375,17 @@ export function CapitalProjectsPage() {
<Table.Tr key={p.id}>
<Table.Td fw={500}>{p.name}</Table.Td>
<Table.Td>
{p.target_month
? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' })
: ''}{' '}
{p.target_year}
{p.target_year === FUTURE_YEAR
? 'Future'
: (
<>
{p.target_month
? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' })
: ''}{' '}
{p.target_year}
</>
)
}
</Table.Td>
<Table.Td>
<Badge size="sm" color={priorityColor(p.priority)}>P{p.priority}</Badge>
@@ -348,29 +418,23 @@ export function CapitalProjectsPage() {
const renderKanbanView = () => (
<ScrollArea type="auto" offsetScrollbars>
<Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: years.length * 300 }}>
{years.length === 0 ? (
<Text c="dimmed" ta="center" py="xl" w="100%">
No capital projects planned yet. Add your first project.
</Text>
) : (
years.map((year) => {
const yearProjects = projects.filter((p) => p.target_year === year);
return (
<KanbanColumn
key={year}
year={year}
projects={yearProjects}
onEdit={handleEdit}
onDragStart={handleDragStart}
onDrop={handleDrop}
isDragOver={dragOverYear === year}
onDragOverHandler={handleDragOver}
onDragLeave={handleDragLeave}
/>
);
})
)}
<Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: kanbanYears.length * 300 }}>
{kanbanYears.map((year) => {
const yearProjects = projects.filter((p) => p.target_year === year);
return (
<KanbanColumn
key={year}
year={year}
projects={yearProjects}
onEdit={handleEdit}
onDragStart={handleDragStart}
onDrop={handleDrop}
isDragOver={dragOverYear === year}
onDragOverHandler={handleDragOver}
onDragLeave={handleDragLeave}
/>
);
})}
</Group>
</ScrollArea>
);
@@ -379,8 +443,11 @@ export function CapitalProjectsPage() {
return (
<Stack>
{/* Print-specific styles */}
<style>{printStyles}</style>
<Group justify="space-between">
<Title order={2}>Capital Projects (5-Year Plan)</Title>
<Title order={2}>Capital Projects</Title>
<Group gap="sm">
<SegmentedControl
value={viewMode}
@@ -417,7 +484,24 @@ export function CapitalProjectsPage() {
</Group>
</Group>
{viewMode === 'table' ? renderTableView() : renderKanbanView()}
{/* Main visible view */}
{viewMode === 'table' ? (
<div className="capital-projects-table-view">
{renderTableView()}
</div>
) : (
<>
<div className="capital-projects-kanban-view">
{renderKanbanView()}
</div>
{/* Hidden table view for print mode - rendered when printMode is true */}
{printMode && (
<div className="capital-projects-table-view" style={{ display: 'none' }}>
{renderTableView()}
</div>
)}
</>
)}
<Modal opened={opened} onClose={close} title={editing ? 'Edit Project' : 'New Capital Project'} size="lg">
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
@@ -429,7 +513,13 @@ export function CapitalProjectsPage() {
<NumberInput label="Actual Cost" prefix="$" decimalScale={2} min={0} {...form.getInputProps('actual_cost')} />
</Group>
<Group grow>
<NumberInput label="Target Year" required min={2024} max={2040} {...form.getInputProps('target_year')} />
<Select
label="Target Year"
required
data={targetYearOptions}
value={String(form.values.target_year)}
onChange={(v) => form.setFieldValue('target_year', Number(v))}
/>
<Select
label="Target Month"
data={Array.from({ length: 12 }, (_, i) => ({

View File

@@ -1,12 +1,12 @@
import { useState } from 'react';
import {
Title, Table, Group, Button, Stack, TextInput, Modal,
NumberInput, Select, Badge, ActionIcon, Text, Loader, Center,
NumberInput, Select, Badge, ActionIcon, Text, Loader, Center, Tooltip,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit, IconSearch } from '@tabler/icons-react';
import { IconPlus, IconEdit, IconSearch, IconTrash } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
@@ -19,12 +19,24 @@ interface Unit {
monthly_assessment: string;
status: string;
balance_due?: string;
assessment_group_id?: string;
assessment_group_name?: string;
group_regular_assessment?: string;
group_frequency?: string;
}
interface AssessmentGroup {
id: string;
name: string;
regular_assessment: string;
frequency: string;
}
export function UnitsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Unit | null>(null);
const [search, setSearch] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
const queryClient = useQueryClient();
const { data: units = [], isLoading } = useQuery<Unit[]>({
@@ -32,16 +44,25 @@ export function UnitsPage() {
queryFn: async () => { const { data } = await api.get('/units'); return data; },
});
const { data: assessmentGroups = [] } = useQuery<AssessmentGroup[]>({
queryKey: ['assessment-groups'],
queryFn: async () => { const { data } = await api.get('/assessment-groups'); return data; },
});
const form = useForm({
initialValues: {
unit_number: '', address_line1: '', city: '', state: '', zip_code: '',
owner_name: '', owner_email: '', owner_phone: '', monthly_assessment: 0,
assessment_group_id: '' as string | null,
},
validate: { unit_number: (v) => (v.length > 0 ? null : 'Required') },
});
const saveMutation = useMutation({
mutationFn: (values: any) => editing ? api.put(`/units/${editing.id}`, values) : api.post('/units', values),
mutationFn: (values: any) => {
const payload = { ...values, assessment_group_id: values.assessment_group_id || null };
return editing ? api.put(`/units/${editing.id}`, payload) : api.post('/units', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['units'] });
notifications.show({ message: editing ? 'Unit updated' : 'Unit created', color: 'green' });
@@ -50,16 +71,40 @@ export function UnitsPage() {
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/units/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['units'] });
notifications.show({ message: 'Unit deleted', color: 'green' });
setDeleteConfirm(null);
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Cannot delete unit', color: 'red' });
setDeleteConfirm(null);
},
});
const handleEdit = (u: Unit) => {
setEditing(u);
form.setValues({
unit_number: u.unit_number, address_line1: u.address_line1 || '',
city: '', state: '', zip_code: '', owner_name: u.owner_name || '',
owner_email: u.owner_email || '', owner_phone: '', monthly_assessment: parseFloat(u.monthly_assessment || '0'),
assessment_group_id: u.assessment_group_id || '',
});
open();
};
const handleGroupChange = (groupId: string | null) => {
form.setFieldValue('assessment_group_id', groupId);
if (groupId) {
const group = assessmentGroups.find(g => g.id === groupId);
if (group) {
form.setFieldValue('monthly_assessment', parseFloat(group.regular_assessment || '0'));
}
}
};
const filtered = units.filter((u) =>
!search || u.unit_number.toLowerCase().includes(search.toLowerCase()) ||
(u.owner_name || '').toLowerCase().includes(search.toLowerCase())
@@ -77,9 +122,14 @@ export function UnitsPage() {
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Unit #</Table.Th><Table.Th>Address</Table.Th><Table.Th>Owner</Table.Th>
<Table.Th>Email</Table.Th><Table.Th ta="right">Assessment</Table.Th>
<Table.Th>Status</Table.Th><Table.Th></Table.Th>
<Table.Th>Unit #</Table.Th>
<Table.Th>Address</Table.Th>
<Table.Th>Owner</Table.Th>
<Table.Th>Email</Table.Th>
<Table.Th>Group</Table.Th>
<Table.Th ta="right">Assessment</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -89,15 +139,35 @@ export function UnitsPage() {
<Table.Td>{u.address_line1}</Table.Td>
<Table.Td>{u.owner_name}</Table.Td>
<Table.Td>{u.owner_email}</Table.Td>
<Table.Td>
{u.assessment_group_name ? (
<Badge variant="light" size="sm">{u.assessment_group_name}</Badge>
) : (
<Text size="xs" c="dimmed">-</Text>
)}
</Table.Td>
<Table.Td ta="right" ff="monospace">${parseFloat(u.monthly_assessment || '0').toFixed(2)}</Table.Td>
<Table.Td><Badge color={u.status === 'active' ? 'green' : 'gray'} size="sm">{u.status}</Badge></Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(u)}><IconEdit size={16} /></ActionIcon></Table.Td>
<Table.Td>
<Group gap={4}>
<ActionIcon variant="subtle" onClick={() => handleEdit(u)}>
<IconEdit size={16} />
</ActionIcon>
<Tooltip label="Delete unit">
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteConfirm(u)}>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))}
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={7}><Text ta="center" c="dimmed" py="lg">No units yet</Text></Table.Td></Table.Tr>}
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No units yet</Text></Table.Td></Table.Tr>}
</Table.Tbody>
</Table>
<Modal opened={opened} onClose={close} title={editing ? 'Edit Unit' : 'New Unit'}>
{/* Create/Edit Modal */}
<Modal opened={opened} onClose={close} title={editing ? 'Edit Unit' : 'New Unit'} size="md">
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
<Stack>
<TextInput label="Unit Number" required {...form.getInputProps('unit_number')} />
@@ -110,11 +180,43 @@ export function UnitsPage() {
<TextInput label="Owner Name" {...form.getInputProps('owner_name')} />
<TextInput label="Owner Email" {...form.getInputProps('owner_email')} />
<TextInput label="Owner Phone" {...form.getInputProps('owner_phone')} />
<Select
label="Assessment Group"
placeholder="Select a group (optional)"
data={assessmentGroups.map(g => ({
value: g.id,
label: `${g.name}$${parseFloat(g.regular_assessment || '0').toFixed(2)}/${g.frequency || 'mo'}`,
}))}
value={form.values.assessment_group_id}
onChange={handleGroupChange}
clearable
/>
<NumberInput label="Monthly Assessment" prefix="$" decimalScale={2} min={0} {...form.getInputProps('monthly_assessment')} />
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
</Stack>
</form>
</Modal>
{/* Delete Confirmation Modal */}
<Modal opened={!!deleteConfirm} onClose={() => setDeleteConfirm(null)} title="Delete Unit" size="sm">
<Stack>
<Text>
Are you sure you want to delete unit <strong>{deleteConfirm?.unit_number}</strong>
{deleteConfirm?.owner_name ? ` (${deleteConfirm.owner_name})` : ''}?
</Text>
<Text size="sm" c="dimmed">This action cannot be undone.</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
<Button
color="red"
loading={deleteMutation.isPending}
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
>
Delete
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}