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

@@ -109,6 +109,7 @@ export class TenantSchemaService {
regular_assessment DECIMAL(10,2) NOT NULL DEFAULT 0.00,
special_assessment DECIMAL(10,2) DEFAULT 0.00,
unit_count INTEGER DEFAULT 0,
frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()

View File

@@ -6,12 +6,28 @@ export class AssessmentGroupsService {
constructor(private tenant: TenantService) {}
async findAll() {
// Normalize all income calculations to monthly equivalent
// monthly: amount * units (already monthly)
// quarterly: amount/3 * units (convert to monthly)
// annual: amount/12 * units (convert to monthly)
return this.tenant.query(`
SELECT ag.*,
(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id) as actual_unit_count,
ag.regular_assessment * ag.unit_count as monthly_operating_income,
ag.special_assessment * ag.unit_count as monthly_reserve_income,
(ag.regular_assessment + ag.special_assessment) * ag.unit_count as total_monthly_income
CASE ag.frequency
WHEN 'quarterly' THEN ag.regular_assessment / 3
WHEN 'annual' THEN ag.regular_assessment / 12
ELSE ag.regular_assessment
END * ag.unit_count as monthly_operating_income,
CASE ag.frequency
WHEN 'quarterly' THEN ag.special_assessment / 3
WHEN 'annual' THEN ag.special_assessment / 12
ELSE ag.special_assessment
END * ag.unit_count as monthly_reserve_income,
(CASE ag.frequency
WHEN 'quarterly' THEN (ag.regular_assessment + ag.special_assessment) / 3
WHEN 'annual' THEN (ag.regular_assessment + ag.special_assessment) / 12
ELSE ag.regular_assessment + ag.special_assessment
END) * ag.unit_count as total_monthly_income
FROM assessment_groups ag
ORDER BY ag.name
`);
@@ -25,9 +41,9 @@ export class AssessmentGroupsService {
async create(dto: any) {
const rows = await this.tenant.query(
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0, dto.unitCount || 0],
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0, dto.unitCount || 0, dto.frequency || 'monthly'],
);
return rows[0];
}
@@ -44,6 +60,7 @@ export class AssessmentGroupsService {
if (dto.specialAssessment !== undefined) { sets.push(`special_assessment = $${idx++}`); params.push(dto.specialAssessment); }
if (dto.unitCount !== undefined) { sets.push(`unit_count = $${idx++}`); params.push(dto.unitCount); }
if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); }
if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); }
if (!sets.length) return this.findOne(id);
@@ -61,9 +78,27 @@ export class AssessmentGroupsService {
const rows = await this.tenant.query(`
SELECT
COUNT(*) as group_count,
COALESCE(SUM(regular_assessment * unit_count), 0) as total_monthly_operating,
COALESCE(SUM(special_assessment * unit_count), 0) as total_monthly_reserve,
COALESCE(SUM((regular_assessment + special_assessment) * unit_count), 0) as total_monthly_income,
COALESCE(SUM(
CASE frequency
WHEN 'quarterly' THEN regular_assessment / 3
WHEN 'annual' THEN regular_assessment / 12
ELSE regular_assessment
END * unit_count
), 0) as total_monthly_operating,
COALESCE(SUM(
CASE frequency
WHEN 'quarterly' THEN special_assessment / 3
WHEN 'annual' THEN special_assessment / 12
ELSE special_assessment
END * unit_count
), 0) as total_monthly_reserve,
COALESCE(SUM(
CASE frequency
WHEN 'quarterly' THEN (regular_assessment + special_assessment) / 3
WHEN 'annual' THEN (regular_assessment + special_assessment) / 12
ELSE regular_assessment + special_assessment
END * unit_count
), 0) as total_monthly_income,
COALESCE(SUM(unit_count), 0) as total_units
FROM assessment_groups WHERE is_active = true
`);

View File

@@ -1,14 +1,19 @@
import { Controller, Get, Post, Body, Param, UseGuards, Req, ForbiddenException } from '@nestjs/common';
import { Controller, Get, Post, Put, Body, Param, UseGuards, Req, ForbiddenException, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { UsersService } from '../users/users.service';
import { OrganizationsService } from '../organizations/organizations.service';
import * as bcrypt from 'bcrypt';
@ApiTags('admin')
@Controller('admin')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class AdminController {
constructor(private usersService: UsersService) {}
constructor(
private usersService: UsersService,
private orgService: OrganizationsService,
) {}
private async requireSuperadmin(req: any) {
const user = await this.usersService.findById(req.user.userId || req.user.sub);
@@ -42,4 +47,77 @@ export class AdminController {
await this.usersService.setSuperadmin(id, body.isSuperadmin);
return { success: true };
}
@Post('tenants')
async createTenant(@Req() req: any, @Body() body: {
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;
}) {
await this.requireSuperadmin(req);
if (!body.orgName || !body.adminEmail || !body.adminPassword) {
throw new BadRequestException('Organization name, admin email and password are required');
}
// Check if admin email already exists
const existingUser = await this.usersService.findByEmail(body.adminEmail);
let userId: string;
if (existingUser) {
userId = existingUser.id;
} else {
// Create the first user for this tenant
const passwordHash = await bcrypt.hash(body.adminPassword, 12);
const newUser = await this.usersService.create({
email: body.adminEmail,
passwordHash,
firstName: body.adminFirstName,
lastName: body.adminLastName,
});
userId = newUser.id;
}
// Create the organization + tenant schema + membership
const org = await this.orgService.create({
name: body.orgName,
email: body.email,
phone: body.phone,
addressLine1: body.addressLine1,
city: body.city,
state: body.state,
zipCode: body.zipCode,
contractNumber: body.contractNumber,
planLevel: body.planLevel || 'standard',
fiscalYearStartMonth: body.fiscalYearStartMonth || 1,
}, userId);
return { success: true, organization: org };
}
@Put('organizations/:id/status')
async updateOrgStatus(
@Req() req: any,
@Param('id') id: string,
@Body() body: { status: string },
) {
await this.requireSuperadmin(req);
const validStatuses = ['active', 'suspended', 'trial', 'archived'];
if (!validStatuses.includes(body.status)) {
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
}
const org = await this.orgService.updateStatus(id, body.status);
return { success: true, organization: org };
}
}

View File

@@ -8,10 +8,12 @@ import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
import { UsersModule } from '../users/users.module';
import { OrganizationsModule } from '../organizations/organizations.module';
@Module({
imports: [
UsersModule,
OrganizationsModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],

View File

@@ -50,7 +50,22 @@ export class AuthService {
async login(user: User) {
await this.usersService.updateLastLogin(user.id);
const fullUser = await this.usersService.findByIdWithOrgs(user.id);
return this.generateTokenResponse(fullUser || user);
const u = fullUser || user;
// Check if user's organizations are all suspended/archived
const orgs = u.userOrganizations || [];
if (orgs.length > 0 && !u.isSuperadmin) {
const activeOrgs = orgs.filter(
(uo) => uo.organization && !['suspended', 'archived'].includes(uo.organization.status),
);
if (activeOrgs.length === 0) {
throw new UnauthorizedException(
'Your organization has been suspended. Please contact your administrator.',
);
}
}
return this.generateTokenResponse(u);
}
async getProfile(userId: string) {

View File

@@ -42,4 +42,14 @@ export class CreateOrganizationDto {
@Max(12)
@IsOptional()
fiscalYearStartMonth?: number;
@ApiProperty({ example: 'CON-2026-001', required: false })
@IsString()
@IsOptional()
contractNumber?: string;
@ApiProperty({ example: 'standard', required: false })
@IsString()
@IsOptional()
planLevel?: string;
}

View File

@@ -55,6 +55,12 @@ export class Organization {
@Column({ name: 'fiscal_year_start_month', default: 1 })
fiscalYearStartMonth: number;
@Column({ name: 'contract_number', nullable: true })
contractNumber: string;
@Column({ name: 'plan_level', default: 'standard' })
planLevel: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;

View File

@@ -36,6 +36,8 @@ export class OrganizationsService {
phone: dto.phone,
email: dto.email,
fiscalYearStartMonth: dto.fiscalYearStartMonth || 1,
contractNumber: dto.contractNumber,
planLevel: dto.planLevel || 'standard',
});
const savedOrg = await this.orgRepository.save(org);
@@ -52,6 +54,13 @@ export class OrganizationsService {
return savedOrg;
}
async updateStatus(id: string, status: string) {
const org = await this.orgRepository.findOne({ where: { id } });
if (!org) throw new ConflictException('Organization not found');
org.status = status;
return this.orgRepository.save(org);
}
async findByUser(userId: string) {
const memberships = await this.userOrgRepository.find({
where: { userId, isActive: true },

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UnitsService } from './units.service';
@@ -21,4 +21,7 @@ export class UnitsController {
@Put(':id')
update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); }
@Delete(':id')
delete(@Param('id') id: string) { return this.unitsService.delete(id); }
}

View File

@@ -8,12 +8,17 @@ export class UnitsService {
async findAll() {
return this.tenant.query(`
SELECT u.*,
ag.name as assessment_group_name,
ag.regular_assessment as group_regular_assessment,
ag.frequency as group_frequency,
COALESCE((
SELECT SUM(i.amount - i.amount_paid)
FROM invoices i
WHERE i.unit_id = u.id AND i.status NOT IN ('paid', 'void', 'written_off')
), 0) as balance_due
FROM units u ORDER BY u.unit_number
FROM units u
LEFT JOIN assessment_groups ag ON ag.id = u.assessment_group_id
ORDER BY u.unit_number
`);
}
@@ -28,9 +33,9 @@ export class UnitsService {
if (existing.length) throw new BadRequestException(`Unit ${dto.unit_number} already exists`);
const rows = await this.tenant.query(
`INSERT INTO units (unit_number, address_line1, city, state, zip_code, owner_name, owner_email, owner_phone, monthly_assessment)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
[dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment || 0],
`INSERT INTO units (unit_number, address_line1, city, state, zip_code, owner_name, owner_email, owner_phone, monthly_assessment, assessment_group_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
[dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment || 0, dto.assessment_group_id || null],
);
return rows[0];
}
@@ -42,10 +47,26 @@ export class UnitsService {
city = COALESCE($4, city), state = COALESCE($5, state), zip_code = COALESCE($6, zip_code),
owner_name = COALESCE($7, owner_name), owner_email = COALESCE($8, owner_email),
owner_phone = COALESCE($9, owner_phone), monthly_assessment = COALESCE($10, monthly_assessment),
status = COALESCE($11, status), updated_at = NOW()
status = COALESCE($11, status), assessment_group_id = $12, updated_at = NOW()
WHERE id = $1 RETURNING *`,
[id, dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment, dto.status],
[id, dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment, dto.status, dto.assessment_group_id !== undefined ? dto.assessment_group_id : null],
);
return rows[0];
}
async delete(id: string) {
await this.findOne(id);
// Check for outstanding invoices
const outstanding = await this.tenant.query(
`SELECT COUNT(*) as count FROM invoices WHERE unit_id = $1 AND status NOT IN ('paid', 'void', 'written_off')`,
[id],
);
if (parseInt(outstanding[0]?.count) > 0) {
throw new BadRequestException('Cannot delete unit with outstanding invoices. Please resolve all invoices first.');
}
await this.tenant.query('DELETE FROM units WHERE id = $1', [id]);
return { success: true };
}
}

View File

@@ -13,8 +13,10 @@ CREATE TABLE shared.organizations (
name VARCHAR(255) NOT NULL,
schema_name VARCHAR(63) NOT NULL UNIQUE,
subdomain VARCHAR(63) UNIQUE,
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'trial')),
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'trial', 'archived')),
settings JSONB DEFAULT '{}',
contract_number VARCHAR(100),
plan_level VARCHAR(50) DEFAULT 'standard' CHECK (plan_level IN ('standard', 'premium', 'enterprise')),
address_line1 VARCHAR(255),
address_line2 VARCHAR(255),
city VARCHAR(100),

View File

@@ -63,7 +63,7 @@ END IF;
-- Check if org exists
SELECT id INTO v_org_id FROM shared.organizations WHERE schema_name = v_schema;
IF v_org_id IS NULL THEN
INSERT INTO shared.organizations (id, name, subdomain, address_line1, city, state, zip_code, schema_name)
INSERT INTO shared.organizations (id, name, subdomain, address_line1, city, state, zip_code, schema_name, contract_number, plan_level)
VALUES (
uuid_generate_v4(),
'Sunrise Valley HOA',
@@ -72,7 +72,9 @@ IF v_org_id IS NULL THEN
'Scottsdale',
'AZ',
'85255',
v_schema
v_schema,
'CON-2026-001',
'premium'
) RETURNING id INTO v_org_id;
INSERT INTO shared.user_organizations (user_id, organization_id, role)
@@ -176,6 +178,7 @@ CREATE TABLE IF NOT EXISTS %I.assessment_groups (
regular_assessment DECIMAL(10,2) NOT NULL DEFAULT 0.00,
special_assessment DECIMAL(10,2) DEFAULT 0.00,
unit_count INTEGER DEFAULT 0,
frequency VARCHAR(20) DEFAULT ''monthly'',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -1,5 +1,4 @@
import { useState } from 'react';
import { AppShell, Burger, Group, Title, Text, Menu, UnstyledButton, Avatar } from '@mantine/core';
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconLogout,
@@ -9,6 +8,7 @@ import {
import { Outlet, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { Sidebar } from './Sidebar';
import logoSrc from '../../assets/logo.svg';
export function AppLayout() {
const [opened, { toggle }] = useDisclosure();
@@ -30,7 +30,7 @@ export function AppLayout() {
<Group h="100%" px="md" justify="space-between">
<Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<Title order={3} c="blue">HOA LedgerIQ</Title>
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 40 }} />
</Group>
<Group>
{currentOrg && (

View File

@@ -12,7 +12,6 @@ import {
IconShieldCheck,
IconPigMoney,
IconBuildingBank,
IconCalendarEvent,
IconUsers,
IconFileText,
IconSettings,
@@ -21,15 +20,46 @@ import {
} from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore';
const navItems = [
const navSections = [
{
items: [
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard' },
{ label: 'Chart of Accounts', icon: IconListDetails, path: '/accounts' },
{ label: 'Transactions', icon: IconReceipt, path: '/transactions' },
],
},
{
label: 'Financials',
items: [
{ label: 'Accounts', icon: IconListDetails, path: '/accounts' },
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' },
{ label: 'Investments', icon: IconPigMoney, path: '/investments' },
],
},
{
label: 'Assessments',
items: [
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' },
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups' },
],
},
{
label: 'Transactions',
items: [
{ label: 'Transactions', icon: IconReceipt, path: '/transactions' },
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
{ label: 'Payments', icon: IconCash, path: '/payments' },
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' },
],
},
{
label: 'Planning',
items: [
{ label: 'Capital Projects', icon: IconBuildingBank, path: '/capital-projects' },
{ label: 'Reserves', icon: IconShieldCheck, path: '/reserves' },
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
],
},
{
label: 'Reports',
items: [
{
label: 'Reports',
icon: IconChartSankey,
@@ -42,12 +72,15 @@ const navItems = [
{ label: 'Sankey Diagram', path: '/reports/sankey' },
],
},
{ label: 'Reserves', icon: IconShieldCheck, path: '/reserves' },
{ label: 'Investments', icon: IconPigMoney, path: '/investments' },
{ label: 'Capital Projects', icon: IconBuildingBank, path: '/capital-projects' },
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
],
},
{
label: 'Admin',
items: [
{ label: 'Year-End', icon: IconFileText, path: '/year-end' },
{ label: 'Settings', icon: IconSettings, path: '/settings' },
],
},
];
export function Sidebar() {
@@ -57,17 +90,27 @@ export function Sidebar() {
return (
<ScrollArea p="sm">
{navItems.map((item) =>
{navSections.map((section, sIdx) => (
<div key={sIdx}>
{section.label && (
<>
{sIdx > 0 && <Divider my={6} />}
<Text size="xs" c="dimmed" fw={700} tt="uppercase" px="sm" pb={2} pt={sIdx > 0 ? 4 : 0}>
{section.label}
</Text>
</>
)}
{section.items.map((item: any) =>
item.children ? (
<NavLink
key={item.label}
label={item.label}
leftSection={<item.icon size={18} />}
defaultOpened={item.children.some((c) =>
defaultOpened={item.children.some((c: any) =>
location.pathname.startsWith(c.path),
)}
>
{item.children.map((child) => (
{item.children.map((child: any) => (
<NavLink
key={child.path}
label={child.label}
@@ -86,6 +129,8 @@ export function Sidebar() {
/>
),
)}
</div>
))}
{user?.isSuperadmin && (
<>

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">
<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>
<Menu shadow="md" width={180} position="bottom-start">
<Menu.Target>
<Badge
size="sm"
variant="light"
color={o.status === 'active' ? 'green' : o.status === 'trial' ? 'yellow' : 'red'}
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 = () => {
// 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_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,13 +418,8 @@ 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) => {
<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
@@ -369,8 +434,7 @@ export function CapitalProjectsPage() {
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>
);
}

6
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.svg' {
const src: string;
export default src;
}