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, regular_assessment DECIMAL(10,2) NOT NULL DEFAULT 0.00,
special_assessment DECIMAL(10,2) DEFAULT 0.00, special_assessment DECIMAL(10,2) DEFAULT 0.00,
unit_count INTEGER DEFAULT 0, unit_count INTEGER DEFAULT 0,
frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')),
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()

View File

@@ -6,12 +6,28 @@ export class AssessmentGroupsService {
constructor(private tenant: TenantService) {} constructor(private tenant: TenantService) {}
async findAll() { 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(` return this.tenant.query(`
SELECT ag.*, SELECT ag.*,
(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id) as actual_unit_count, (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, CASE ag.frequency
ag.special_assessment * ag.unit_count as monthly_reserve_income, WHEN 'quarterly' THEN ag.regular_assessment / 3
(ag.regular_assessment + ag.special_assessment) * ag.unit_count as total_monthly_income 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 FROM assessment_groups ag
ORDER BY ag.name ORDER BY ag.name
`); `);
@@ -25,9 +41,9 @@ export class AssessmentGroupsService {
async create(dto: any) { async create(dto: any) {
const rows = await this.tenant.query( const rows = await this.tenant.query(
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count) `INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency)
VALUES ($1, $2, $3, $4, $5) RETURNING *`, VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0, dto.unitCount || 0], [dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0, dto.unitCount || 0, dto.frequency || 'monthly'],
); );
return rows[0]; 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.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.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.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); if (!sets.length) return this.findOne(id);
@@ -61,9 +78,27 @@ export class AssessmentGroupsService {
const rows = await this.tenant.query(` const rows = await this.tenant.query(`
SELECT SELECT
COUNT(*) as group_count, COUNT(*) as group_count,
COALESCE(SUM(regular_assessment * unit_count), 0) as total_monthly_operating, COALESCE(SUM(
COALESCE(SUM(special_assessment * unit_count), 0) as total_monthly_reserve, CASE frequency
COALESCE(SUM((regular_assessment + special_assessment) * unit_count), 0) as total_monthly_income, 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 COALESCE(SUM(unit_count), 0) as total_units
FROM assessment_groups WHERE is_active = true 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 { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { OrganizationsService } from '../organizations/organizations.service';
import * as bcrypt from 'bcrypt';
@ApiTags('admin') @ApiTags('admin')
@Controller('admin') @Controller('admin')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class AdminController { export class AdminController {
constructor(private usersService: UsersService) {} constructor(
private usersService: UsersService,
private orgService: OrganizationsService,
) {}
private async requireSuperadmin(req: any) { private async requireSuperadmin(req: any) {
const user = await this.usersService.findById(req.user.userId || req.user.sub); 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); await this.usersService.setSuperadmin(id, body.isSuperadmin);
return { success: true }; 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 { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy'; import { LocalStrategy } from './strategies/local.strategy';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { OrganizationsModule } from '../organizations/organizations.module';
@Module({ @Module({
imports: [ imports: [
UsersModule, UsersModule,
OrganizationsModule,
PassportModule, PassportModule,
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],

View File

@@ -50,7 +50,22 @@ export class AuthService {
async login(user: User) { async login(user: User) {
await this.usersService.updateLastLogin(user.id); await this.usersService.updateLastLogin(user.id);
const fullUser = await this.usersService.findByIdWithOrgs(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) { async getProfile(userId: string) {

View File

@@ -42,4 +42,14 @@ export class CreateOrganizationDto {
@Max(12) @Max(12)
@IsOptional() @IsOptional()
fiscalYearStartMonth?: number; 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 }) @Column({ name: 'fiscal_year_start_month', default: 1 })
fiscalYearStartMonth: number; fiscalYearStartMonth: number;
@Column({ name: 'contract_number', nullable: true })
contractNumber: string;
@Column({ name: 'plan_level', default: 'standard' })
planLevel: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;

View File

@@ -36,6 +36,8 @@ export class OrganizationsService {
phone: dto.phone, phone: dto.phone,
email: dto.email, email: dto.email,
fiscalYearStartMonth: dto.fiscalYearStartMonth || 1, fiscalYearStartMonth: dto.fiscalYearStartMonth || 1,
contractNumber: dto.contractNumber,
planLevel: dto.planLevel || 'standard',
}); });
const savedOrg = await this.orgRepository.save(org); const savedOrg = await this.orgRepository.save(org);
@@ -52,6 +54,13 @@ export class OrganizationsService {
return savedOrg; 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) { async findByUser(userId: string) {
const memberships = await this.userOrgRepository.find({ const memberships = await this.userOrgRepository.find({
where: { userId, isActive: true }, 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 { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UnitsService } from './units.service'; import { UnitsService } from './units.service';
@@ -21,4 +21,7 @@ export class UnitsController {
@Put(':id') @Put(':id')
update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); } 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() { async findAll() {
return this.tenant.query(` return this.tenant.query(`
SELECT u.*, SELECT u.*,
ag.name as assessment_group_name,
ag.regular_assessment as group_regular_assessment,
ag.frequency as group_frequency,
COALESCE(( COALESCE((
SELECT SUM(i.amount - i.amount_paid) SELECT SUM(i.amount - i.amount_paid)
FROM invoices i FROM invoices i
WHERE i.unit_id = u.id AND i.status NOT IN ('paid', 'void', 'written_off') WHERE i.unit_id = u.id AND i.status NOT IN ('paid', 'void', 'written_off')
), 0) as balance_due ), 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`); if (existing.length) throw new BadRequestException(`Unit ${dto.unit_number} already exists`);
const rows = await this.tenant.query( 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) `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) RETURNING *`, 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.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]; return rows[0];
} }
@@ -42,10 +47,26 @@ export class UnitsService {
city = COALESCE($4, city), state = COALESCE($5, state), zip_code = COALESCE($6, zip_code), 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_name = COALESCE($7, owner_name), owner_email = COALESCE($8, owner_email),
owner_phone = COALESCE($9, owner_phone), monthly_assessment = COALESCE($10, monthly_assessment), 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 *`, 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]; 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, name VARCHAR(255) NOT NULL,
schema_name VARCHAR(63) NOT NULL UNIQUE, schema_name VARCHAR(63) NOT NULL UNIQUE,
subdomain VARCHAR(63) 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 '{}', 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_line1 VARCHAR(255),
address_line2 VARCHAR(255), address_line2 VARCHAR(255),
city VARCHAR(100), city VARCHAR(100),

View File

@@ -63,7 +63,7 @@ END IF;
-- Check if org exists -- Check if org exists
SELECT id INTO v_org_id FROM shared.organizations WHERE schema_name = v_schema; SELECT id INTO v_org_id FROM shared.organizations WHERE schema_name = v_schema;
IF v_org_id IS NULL THEN 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 ( VALUES (
uuid_generate_v4(), uuid_generate_v4(),
'Sunrise Valley HOA', 'Sunrise Valley HOA',
@@ -72,7 +72,9 @@ IF v_org_id IS NULL THEN
'Scottsdale', 'Scottsdale',
'AZ', 'AZ',
'85255', '85255',
v_schema v_schema,
'CON-2026-001',
'premium'
) RETURNING id INTO v_org_id; ) RETURNING id INTO v_org_id;
INSERT INTO shared.user_organizations (user_id, organization_id, role) 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, regular_assessment DECIMAL(10,2) NOT NULL DEFAULT 0.00,
special_assessment DECIMAL(10,2) DEFAULT 0.00, special_assessment DECIMAL(10,2) DEFAULT 0.00,
unit_count INTEGER DEFAULT 0, unit_count INTEGER DEFAULT 0,
frequency VARCHAR(20) DEFAULT ''monthly'',
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_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, Text, Menu, UnstyledButton, Avatar } from '@mantine/core';
import { AppShell, Burger, Group, Title, Text, Menu, UnstyledButton, Avatar } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { import {
IconLogout, IconLogout,
@@ -9,6 +8,7 @@ import {
import { Outlet, useNavigate } from 'react-router-dom'; import { Outlet, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import logoSrc from '../../assets/logo.svg';
export function AppLayout() { export function AppLayout() {
const [opened, { toggle }] = useDisclosure(); const [opened, { toggle }] = useDisclosure();
@@ -30,7 +30,7 @@ export function AppLayout() {
<Group h="100%" px="md" justify="space-between"> <Group h="100%" px="md" justify="space-between">
<Group> <Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" /> <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>
<Group> <Group>
{currentOrg && ( {currentOrg && (

View File

@@ -12,7 +12,6 @@ import {
IconShieldCheck, IconShieldCheck,
IconPigMoney, IconPigMoney,
IconBuildingBank, IconBuildingBank,
IconCalendarEvent,
IconUsers, IconUsers,
IconFileText, IconFileText,
IconSettings, IconSettings,
@@ -21,15 +20,46 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
const navItems = [ const navSections = [
{
items: [
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard' }, { 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: 'Units / Homeowners', icon: IconHome, path: '/units' },
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups' }, { label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups' },
],
},
{
label: 'Transactions',
items: [
{ label: 'Transactions', icon: IconReceipt, path: '/transactions' },
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' }, { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
{ label: 'Payments', icon: IconCash, path: '/payments' }, { 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', label: 'Reports',
icon: IconChartSankey, icon: IconChartSankey,
@@ -42,12 +72,15 @@ const navItems = [
{ label: 'Sankey Diagram', path: '/reports/sankey' }, { 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: 'Year-End', icon: IconFileText, path: '/year-end' },
{ label: 'Settings', icon: IconSettings, path: '/settings' }, { label: 'Settings', icon: IconSettings, path: '/settings' },
],
},
]; ];
export function Sidebar() { export function Sidebar() {
@@ -57,17 +90,27 @@ export function Sidebar() {
return ( return (
<ScrollArea p="sm"> <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 ? ( item.children ? (
<NavLink <NavLink
key={item.label} key={item.label}
label={item.label} label={item.label}
leftSection={<item.icon size={18} />} leftSection={<item.icon size={18} />}
defaultOpened={item.children.some((c) => defaultOpened={item.children.some((c: any) =>
location.pathname.startsWith(c.path), location.pathname.startsWith(c.path),
)} )}
> >
{item.children.map((child) => ( {item.children.map((child: any) => (
<NavLink <NavLink
key={child.path} key={child.path}
label={child.label} label={child.label}
@@ -86,6 +129,8 @@ export function Sidebar() {
/> />
), ),
)} )}
</div>
))}
{user?.isSuperadmin && ( {user?.isSuperadmin && (
<> <>

View File

@@ -1,11 +1,14 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center, 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'; } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { import {
IconUsers, IconBuilding, IconShieldLock, IconSearch, IconUsers, IconBuilding, IconShieldLock, IconSearch,
IconCrown, IconUser, IconCrown, IconPlus, IconArchive, IconChevronDown,
IconCircleCheck, IconBan, IconArchiveOff,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
@@ -19,10 +22,61 @@ interface AdminUser {
interface AdminOrg { interface AdminOrg {
id: string; name: string; schema_name: string; status: string; id: string; name: string; schema_name: string; status: string;
email: string; phone: string; member_count: string; created_at: 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() { export function AdminPage() {
const [search, setSearch] = useState(''); 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 queryClient = useQueryClient();
const { data: users, isLoading: usersLoading } = useQuery<AdminUser[]>({ const { data: users, isLoading: usersLoading } = useQuery<AdminUser[]>({
@@ -42,6 +96,35 @@ export function AdminPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-users'] }), 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 => const filteredUsers = (users || []).filter(u =>
!search || u.email.toLowerCase().includes(search.toLowerCase()) || !search || u.email.toLowerCase().includes(search.toLowerCase()) ||
`${u.firstName} ${u.lastName}`.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()) o.schema_name.toLowerCase().includes(search.toLowerCase())
); );
const archivedCount = (orgs || []).filter(o => o.status === 'archived').length;
return ( return (
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Group gap="md">
<div> <div>
<Title order={2}>Platform Administration</Title> <Title order={2}>Platform Administration</Title>
<Text c="dimmed" size="sm">SuperUser Admin Panel Manage tenants and users</Text> <Text c="dimmed" size="sm">SuperUser Admin Panel Manage tenants and users</Text>
</div> </div>
<Button
leftSection={<IconPlus size={16} />}
onClick={openCreateModal}
>
Create Tenant
</Button>
</Group>
<Badge color="red" variant="filled" size="lg" leftSection={<IconCrown size={14} />}> <Badge color="red" variant="filled" size="lg" leftSection={<IconCrown size={14} />}>
SuperAdmin SuperAdmin
</Badge> </Badge>
</Group> </Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}> <SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
<Card withBorder padding="lg"> <Card withBorder padding="lg">
<Group justify="space-between"> <Group justify="space-between">
<div> <div>
@@ -98,6 +191,17 @@ export function AdminPage() {
</ThemeIcon> </ThemeIcon>
</Group> </Group>
</Card> </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> </SimpleGrid>
<TextInput <TextInput
@@ -193,6 +297,8 @@ export function AdminPage() {
<Table.Th>Organization</Table.Th> <Table.Th>Organization</Table.Th>
<Table.Th>Schema</Table.Th> <Table.Th>Schema</Table.Th>
<Table.Th>Status</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 ta="center">Members</Table.Th>
<Table.Th>Contact</Table.Th> <Table.Th>Contact</Table.Th>
<Table.Th>Created</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> <Text size="xs" ff="monospace" c="dimmed">{o.schema_name}</Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Menu shadow="md" width={180} position="bottom-start">
<Menu.Target>
<Badge <Badge
size="sm" size="sm"
variant="light" 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} {o.status}
</Badge> </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>
<Table.Td ta="center"> <Table.Td ta="center">
<Badge variant="light" size="sm">{o.member_count}</Badge> <Badge variant="light" size="sm">{o.member_count}</Badge>
@@ -233,6 +387,178 @@ export function AdminPage() {
)} )}
</Tabs.Panel> </Tabs.Panel>
</Tabs> </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> </Stack>
); );
} }

View File

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

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, DragEvent } from 'react'; import { useState, useCallback, useEffect, useRef, DragEvent } from 'react';
import { import {
Title, Table, Group, Button, Stack, Text, Modal, TextInput, Title, Table, Group, Button, Stack, Text, Modal, TextInput,
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center, NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
@@ -24,6 +24,8 @@ interface CapitalProject {
status: string; fund_source: string; priority: number; status: string; fund_source: string; priority: number;
} }
const FUTURE_YEAR = 9999;
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
planned: 'blue', approved: 'green', in_progress: 'yellow', planned: 'blue', approved: 'green', in_progress: 'yellow',
completed: 'teal', deferred: 'gray', cancelled: 'red', 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) => const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const yearLabel = (year: number) => (year === FUTURE_YEAR ? 'Future' : String(year));
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Kanban card // Kanban card
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -129,7 +133,7 @@ function KanbanColumn({
onDrop={(e) => onDrop(e, year)} onDrop={(e) => onDrop(e, year)}
> >
<Group justify="space-between" mb="sm"> <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> <Badge size="sm" variant="light">{fmt(totalEst)}</Badge>
</Group> </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 // Main page component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -159,8 +178,10 @@ function KanbanColumn({
export function CapitalProjectsPage() { export function CapitalProjectsPage() {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<CapitalProject | null>(null); 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 [dragOverYear, setDragOverYear] = useState<number | null>(null);
const printModeRef = useRef(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// ---- Data fetching ---- // ---- Data fetching ----
@@ -172,6 +193,16 @@ export function CapitalProjectsPage() {
// ---- Form ---- // ---- 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({ const form = useForm({
initialValues: { initialValues: {
name: '', description: '', estimated_cost: 0, actual_cost: 0, 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 ---- // ---- Handlers ----
const handleEdit = (p: CapitalProject) => { const handleEdit = (p: CapitalProject) => {
@@ -235,7 +281,13 @@ export function CapitalProjectsPage() {
}; };
const handlePdfExport = () => { const handlePdfExport = () => {
// If already in table view, just print directly
if (viewMode === 'table') {
window.print(); window.print();
return;
}
// Otherwise, trigger printMode which renders the table for printing
setPrintMode(true);
}; };
// ---- Drag & Drop ---- // ---- Drag & Drop ----
@@ -270,7 +322,17 @@ export function CapitalProjectsPage() {
// ---- Derived data ---- // ---- 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 ---- // ---- Loading state ----
@@ -287,11 +349,12 @@ export function CapitalProjectsPage() {
) : ( ) : (
years.map((year) => { years.map((year) => {
const yearProjects = projects.filter((p) => p.target_year === 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); const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
return ( return (
<Stack key={year} gap="xs"> <Stack key={year} gap="xs">
<Group> <Group>
<Title order={4}>{year}</Title> <Title order={4}>{yearLabel(year)}</Title>
<Badge size="lg" variant="light">{fmt(totalEst)} estimated</Badge> <Badge size="lg" variant="light">{fmt(totalEst)} estimated</Badge>
</Group> </Group>
<Table striped highlightOnHover> <Table striped highlightOnHover>
@@ -312,10 +375,17 @@ export function CapitalProjectsPage() {
<Table.Tr key={p.id}> <Table.Tr key={p.id}>
<Table.Td fw={500}>{p.name}</Table.Td> <Table.Td fw={500}>{p.name}</Table.Td>
<Table.Td> <Table.Td>
{p.target_year === FUTURE_YEAR
? 'Future'
: (
<>
{p.target_month {p.target_month
? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' }) ? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' })
: ''}{' '} : ''}{' '}
{p.target_year} {p.target_year}
</>
)
}
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge size="sm" color={priorityColor(p.priority)}>P{p.priority}</Badge> <Badge size="sm" color={priorityColor(p.priority)}>P{p.priority}</Badge>
@@ -348,13 +418,8 @@ export function CapitalProjectsPage() {
const renderKanbanView = () => ( const renderKanbanView = () => (
<ScrollArea type="auto" offsetScrollbars> <ScrollArea type="auto" offsetScrollbars>
<Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: years.length * 300 }}> <Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: kanbanYears.length * 300 }}>
{years.length === 0 ? ( {kanbanYears.map((year) => {
<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); const yearProjects = projects.filter((p) => p.target_year === year);
return ( return (
<KanbanColumn <KanbanColumn
@@ -369,8 +434,7 @@ export function CapitalProjectsPage() {
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
/> />
); );
}) })}
)}
</Group> </Group>
</ScrollArea> </ScrollArea>
); );
@@ -379,8 +443,11 @@ export function CapitalProjectsPage() {
return ( return (
<Stack> <Stack>
{/* Print-specific styles */}
<style>{printStyles}</style>
<Group justify="space-between"> <Group justify="space-between">
<Title order={2}>Capital Projects (5-Year Plan)</Title> <Title order={2}>Capital Projects</Title>
<Group gap="sm"> <Group gap="sm">
<SegmentedControl <SegmentedControl
value={viewMode} value={viewMode}
@@ -417,7 +484,24 @@ export function CapitalProjectsPage() {
</Group> </Group>
</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"> <Modal opened={opened} onClose={close} title={editing ? 'Edit Project' : 'New Capital Project'} size="lg">
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}> <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')} /> <NumberInput label="Actual Cost" prefix="$" decimalScale={2} min={0} {...form.getInputProps('actual_cost')} />
</Group> </Group>
<Group grow> <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 <Select
label="Target Month" label="Target Month"
data={Array.from({ length: 12 }, (_, i) => ({ data={Array.from({ length: 12 }, (_, i) => ({

View File

@@ -1,12 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Title, Table, Group, Button, Stack, TextInput, Modal, 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'; } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications'; 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
@@ -19,12 +19,24 @@ interface Unit {
monthly_assessment: string; monthly_assessment: string;
status: string; status: string;
balance_due?: 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() { export function UnitsPage() {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Unit | null>(null); const [editing, setEditing] = useState<Unit | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: units = [], isLoading } = useQuery<Unit[]>({ const { data: units = [], isLoading } = useQuery<Unit[]>({
@@ -32,16 +44,25 @@ export function UnitsPage() {
queryFn: async () => { const { data } = await api.get('/units'); return data; }, 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({ const form = useForm({
initialValues: { initialValues: {
unit_number: '', address_line1: '', city: '', state: '', zip_code: '', unit_number: '', address_line1: '', city: '', state: '', zip_code: '',
owner_name: '', owner_email: '', owner_phone: '', monthly_assessment: 0, owner_name: '', owner_email: '', owner_phone: '', monthly_assessment: 0,
assessment_group_id: '' as string | null,
}, },
validate: { unit_number: (v) => (v.length > 0 ? null : 'Required') }, validate: { unit_number: (v) => (v.length > 0 ? null : 'Required') },
}); });
const saveMutation = useMutation({ 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['units'] }); queryClient.invalidateQueries({ queryKey: ['units'] });
notifications.show({ message: editing ? 'Unit updated' : 'Unit created', color: 'green' }); 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' }); }, 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) => { const handleEdit = (u: Unit) => {
setEditing(u); setEditing(u);
form.setValues({ form.setValues({
unit_number: u.unit_number, address_line1: u.address_line1 || '', unit_number: u.unit_number, address_line1: u.address_line1 || '',
city: '', state: '', zip_code: '', owner_name: u.owner_name || '', city: '', state: '', zip_code: '', owner_name: u.owner_name || '',
owner_email: u.owner_email || '', owner_phone: '', monthly_assessment: parseFloat(u.monthly_assessment || '0'), owner_email: u.owner_email || '', owner_phone: '', monthly_assessment: parseFloat(u.monthly_assessment || '0'),
assessment_group_id: u.assessment_group_id || '',
}); });
open(); 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) => const filtered = units.filter((u) =>
!search || u.unit_number.toLowerCase().includes(search.toLowerCase()) || !search || u.unit_number.toLowerCase().includes(search.toLowerCase()) ||
(u.owner_name || '').toLowerCase().includes(search.toLowerCase()) (u.owner_name || '').toLowerCase().includes(search.toLowerCase())
@@ -77,9 +122,14 @@ export function UnitsPage() {
<Table striped highlightOnHover> <Table striped highlightOnHover>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Unit #</Table.Th><Table.Th>Address</Table.Th><Table.Th>Owner</Table.Th> <Table.Th>Unit #</Table.Th>
<Table.Th>Email</Table.Th><Table.Th ta="right">Assessment</Table.Th> <Table.Th>Address</Table.Th>
<Table.Th>Status</Table.Th><Table.Th></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.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
@@ -89,15 +139,35 @@ export function UnitsPage() {
<Table.Td>{u.address_line1}</Table.Td> <Table.Td>{u.address_line1}</Table.Td>
<Table.Td>{u.owner_name}</Table.Td> <Table.Td>{u.owner_name}</Table.Td>
<Table.Td>{u.owner_email}</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 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><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> </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.Tbody>
</Table> </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))}> <form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
<Stack> <Stack>
<TextInput label="Unit Number" required {...form.getInputProps('unit_number')} /> <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 Name" {...form.getInputProps('owner_name')} />
<TextInput label="Owner Email" {...form.getInputProps('owner_email')} /> <TextInput label="Owner Email" {...form.getInputProps('owner_email')} />
<TextInput label="Owner Phone" {...form.getInputProps('owner_phone')} /> <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')} /> <NumberInput label="Monthly Assessment" prefix="$" decimalScale={2} min={0} {...form.getInputProps('monthly_assessment')} />
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button> <Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
</Stack> </Stack>
</form> </form>
</Modal> </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> </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;
}