Implement Phase 2 features: roles, assessment groups, budget import, Kanban
- Add hierarchical roles: SuperUser Admin (is_superadmin flag), Tenant Admin, Tenant User with separate /admin route and admin panel - Add Assessment Groups module for property type-based assessment rates (SFHs, Condos, Estate Lots with different regular/special rates) - Enhance Chart of Accounts: initial balance on create (with journal entry), archive/restore accounts, edit all fields including account number & fund type - Add Budget CSV import with downloadable template and account mapping - Add Capital Projects Kanban board with drag-and-drop between year columns, table/kanban view toggle, and PDF export via browser print - Update seed data with assessment groups, second test user, superadmin flag - Create repeatable reseed.sh script for clean database population - Fix AgingReportPage Mantine v7 Table prop compatibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import { ReserveComponentsModule } from './modules/reserve-components/reserve-co
|
|||||||
import { InvestmentsModule } from './modules/investments/investments.module';
|
import { InvestmentsModule } from './modules/investments/investments.module';
|
||||||
import { CapitalProjectsModule } from './modules/capital-projects/capital-projects.module';
|
import { CapitalProjectsModule } from './modules/capital-projects/capital-projects.module';
|
||||||
import { ReportsModule } from './modules/reports/reports.module';
|
import { ReportsModule } from './modules/reports/reports.module';
|
||||||
|
import { AssessmentGroupsModule } from './modules/assessment-groups/assessment-groups.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -52,6 +53,7 @@ import { ReportsModule } from './modules/reports/reports.module';
|
|||||||
InvestmentsModule,
|
InvestmentsModule,
|
||||||
CapitalProjectsModule,
|
CapitalProjectsModule,
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
|
AssessmentGroupsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -101,6 +101,19 @@ export class TenantSchemaService {
|
|||||||
CHECK (NOT (debit > 0 AND credit > 0))
|
CHECK (NOT (debit > 0 AND credit > 0))
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
|
// Assessment Groups
|
||||||
|
`CREATE TABLE "${s}".assessment_groups (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
regular_assessment DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
special_assessment DECIMAL(10,2) DEFAULT 0.00,
|
||||||
|
unit_count INTEGER DEFAULT 0,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
// Units (homeowners/lots)
|
// Units (homeowners/lots)
|
||||||
`CREATE TABLE "${s}".units (
|
`CREATE TABLE "${s}".units (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
@@ -118,6 +131,7 @@ export class TenantSchemaService {
|
|||||||
owner_phone VARCHAR(20),
|
owner_phone VARCHAR(20),
|
||||||
is_rented BOOLEAN DEFAULT FALSE,
|
is_rented BOOLEAN DEFAULT FALSE,
|
||||||
monthly_assessment DECIMAL(10,2),
|
monthly_assessment DECIMAL(10,2),
|
||||||
|
assessment_group_id UUID REFERENCES "${s}".assessment_groups(id),
|
||||||
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'sold')),
|
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'sold')),
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export class AccountsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List all accounts' })
|
@ApiOperation({ summary: 'List all accounts' })
|
||||||
findAll(@Query('fundType') fundType?: string) {
|
findAll(@Query('fundType') fundType?: string, @Query('includeArchived') includeArchived?: string) {
|
||||||
return this.accountsService.findAll(fundType);
|
return this.accountsService.findAll(fundType, includeArchived === 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('trial-balance')
|
@Get('trial-balance')
|
||||||
|
|||||||
@@ -7,12 +7,21 @@ import { UpdateAccountDto } from './dto/update-account.dto';
|
|||||||
export class AccountsService {
|
export class AccountsService {
|
||||||
constructor(private tenant: TenantService) {}
|
constructor(private tenant: TenantService) {}
|
||||||
|
|
||||||
async findAll(fundType?: string) {
|
async findAll(fundType?: string, includeArchived?: boolean) {
|
||||||
let sql = 'SELECT * FROM accounts WHERE is_active = true';
|
let sql = 'SELECT * FROM accounts';
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
|
const conditions: string[] = [];
|
||||||
|
|
||||||
|
if (!includeArchived) {
|
||||||
|
conditions.push('is_active = true');
|
||||||
|
}
|
||||||
if (fundType) {
|
if (fundType) {
|
||||||
sql += ' AND fund_type = $1';
|
|
||||||
params.push(fundType);
|
params.push(fundType);
|
||||||
|
conditions.push(`fund_type = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length) {
|
||||||
|
sql += ' WHERE ' + conditions.join(' AND ');
|
||||||
}
|
}
|
||||||
sql += ' ORDER BY account_number';
|
sql += ' ORDER BY account_number';
|
||||||
return this.tenant.query(sql, params);
|
return this.tenant.query(sql, params);
|
||||||
@@ -47,7 +56,51 @@ export class AccountsService {
|
|||||||
dto.is1099Reportable || false,
|
dto.is1099Reportable || false,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return rows[0];
|
const account = rows[0];
|
||||||
|
|
||||||
|
// Create opening balance journal entry if initialBalance is provided and non-zero
|
||||||
|
if (dto.initialBalance && dto.initialBalance !== 0) {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = now.getMonth() + 1;
|
||||||
|
|
||||||
|
// Find or use the current fiscal period
|
||||||
|
const periods = await this.tenant.query(
|
||||||
|
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
|
||||||
|
[year, month],
|
||||||
|
);
|
||||||
|
if (periods.length) {
|
||||||
|
const fiscalPeriodId = periods[0].id;
|
||||||
|
const absAmount = Math.abs(dto.initialBalance);
|
||||||
|
|
||||||
|
// Determine debit/credit based on account type
|
||||||
|
const isDebitNormal = ['asset', 'expense'].includes(dto.accountType);
|
||||||
|
const debit = isDebitNormal ? absAmount : 0;
|
||||||
|
const credit = isDebitNormal ? 0 : absAmount;
|
||||||
|
|
||||||
|
// Create the journal entry
|
||||||
|
const jeRows = await this.tenant.query(
|
||||||
|
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
||||||
|
VALUES (CURRENT_DATE, $1, 'opening_balance', $2, true, NOW(), $3)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
`Opening balance for ${dto.name}`,
|
||||||
|
fiscalPeriodId,
|
||||||
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (jeRows.length) {
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[jeRows[0].id, account.id, debit, credit, 'Opening balance'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: UpdateAccountDto) {
|
async update(id: string, dto: UpdateAccountDto) {
|
||||||
@@ -62,6 +115,9 @@ export class AccountsService {
|
|||||||
|
|
||||||
if (dto.name !== undefined) { sets.push(`name = $${idx++}`); params.push(dto.name); }
|
if (dto.name !== undefined) { sets.push(`name = $${idx++}`); params.push(dto.name); }
|
||||||
if (dto.description !== undefined) { sets.push(`description = $${idx++}`); params.push(dto.description); }
|
if (dto.description !== undefined) { sets.push(`description = $${idx++}`); params.push(dto.description); }
|
||||||
|
if (dto.accountNumber !== undefined) { sets.push(`account_number = $${idx++}`); params.push(dto.accountNumber); }
|
||||||
|
if (dto.accountType !== undefined) { sets.push(`account_type = $${idx++}`); params.push(dto.accountType); }
|
||||||
|
if (dto.fundType !== undefined) { sets.push(`fund_type = $${idx++}`); params.push(dto.fundType); }
|
||||||
if (dto.is1099Reportable !== undefined) { sets.push(`is_1099_reportable = $${idx++}`); params.push(dto.is1099Reportable); }
|
if (dto.is1099Reportable !== undefined) { sets.push(`is_1099_reportable = $${idx++}`); params.push(dto.is1099Reportable); }
|
||||||
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); }
|
||||||
|
|
||||||
|
|||||||
@@ -32,4 +32,8 @@ export class CreateAccountDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
is1099Reportable?: boolean;
|
is1099Reportable?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
initialBalance?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsString, IsOptional, IsBoolean, IsIn } from 'class-validator';
|
import { IsString, IsOptional, IsBoolean, IsIn, IsInt } from 'class-validator';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
@@ -26,4 +26,14 @@ export class UpdateAccountDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsInt()
|
||||||
|
@IsOptional()
|
||||||
|
accountNumber?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsIn(['operating', 'reserve'])
|
||||||
|
@IsOptional()
|
||||||
|
fundType?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { AssessmentGroupsService } from './assessment-groups.service';
|
||||||
|
|
||||||
|
@ApiTags('assessment-groups')
|
||||||
|
@Controller('assessment-groups')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class AssessmentGroupsController {
|
||||||
|
constructor(private service: AssessmentGroupsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
|
@Get('summary')
|
||||||
|
getSummary() { return this.service.getSummary(); }
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AssessmentGroupsService } from './assessment-groups.service';
|
||||||
|
import { AssessmentGroupsController } from './assessment-groups.controller';
|
||||||
|
import { DatabaseModule } from '../../database/database.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule],
|
||||||
|
controllers: [AssessmentGroupsController],
|
||||||
|
providers: [AssessmentGroupsService],
|
||||||
|
exports: [AssessmentGroupsService],
|
||||||
|
})
|
||||||
|
export class AssessmentGroupsModule {}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { TenantService } from '../../database/tenant.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssessmentGroupsService {
|
||||||
|
constructor(private tenant: TenantService) {}
|
||||||
|
|
||||||
|
async findAll() {
|
||||||
|
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
|
||||||
|
FROM assessment_groups ag
|
||||||
|
ORDER BY ag.name
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const rows = await this.tenant.query('SELECT * FROM assessment_groups WHERE id = $1', [id]);
|
||||||
|
if (!rows.length) throw new NotFoundException('Assessment group not found');
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: any) {
|
||||||
|
await this.findOne(id);
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) { sets.push(`name = $${idx++}`); params.push(dto.name); }
|
||||||
|
if (dto.description !== undefined) { sets.push(`description = $${idx++}`); params.push(dto.description); }
|
||||||
|
if (dto.regularAssessment !== undefined) { sets.push(`regular_assessment = $${idx++}`); params.push(dto.regularAssessment); }
|
||||||
|
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 (!sets.length) return this.findOne(id);
|
||||||
|
|
||||||
|
sets.push('updated_at = NOW()');
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`UPDATE assessment_groups SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSummary() {
|
||||||
|
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(unit_count), 0) as total_units
|
||||||
|
FROM assessment_groups WHERE is_active = true
|
||||||
|
`);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
45
backend/src/modules/auth/admin.controller.ts
Normal file
45
backend/src/modules/auth/admin.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Controller, Get, Post, Body, Param, UseGuards, Req, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
@ApiTags('admin')
|
||||||
|
@Controller('admin')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class AdminController {
|
||||||
|
constructor(private usersService: UsersService) {}
|
||||||
|
|
||||||
|
private async requireSuperadmin(req: any) {
|
||||||
|
const user = await this.usersService.findById(req.user.userId || req.user.sub);
|
||||||
|
if (!user?.isSuperadmin) {
|
||||||
|
throw new ForbiddenException('SuperUser Admin access required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('users')
|
||||||
|
async listUsers(@Req() req: any) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
const users = await this.usersService.findAllUsers();
|
||||||
|
return users.map(u => ({
|
||||||
|
id: u.id, email: u.email, firstName: u.firstName, lastName: u.lastName,
|
||||||
|
isSuperadmin: u.isSuperadmin, lastLoginAt: u.lastLoginAt, createdAt: u.createdAt,
|
||||||
|
organizations: u.userOrganizations?.map(uo => ({
|
||||||
|
id: uo.organizationId, name: uo.organization?.name, role: uo.role,
|
||||||
|
})) || [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('organizations')
|
||||||
|
async listOrganizations(@Req() req: any) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
return this.usersService.findAllOrganizations();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('users/:id/superadmin')
|
||||||
|
async toggleSuperadmin(@Req() req: any, @Param('id') id: string, @Body() body: { isSuperadmin: boolean }) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
await this.usersService.setSuperadmin(id, body.isSuperadmin);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt';
|
|||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AdminController } from './admin.controller';
|
||||||
import { AuthService } from './auth.service';
|
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';
|
||||||
@@ -21,7 +22,7 @@ import { UsersModule } from '../users/users.module';
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController, AdminController],
|
||||||
providers: [AuthService, JwtStrategy, LocalStrategy],
|
providers: [AuthService, JwtStrategy, LocalStrategy],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export class AuthService {
|
|||||||
const payload: Record<string, any> = {
|
const payload: Record<string, any> = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
isSuperadmin: user.isSuperadmin || false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (defaultOrg) {
|
if (defaultOrg) {
|
||||||
@@ -124,6 +125,7 @@ export class AuthService {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
|
isSuperadmin: user.isSuperadmin || false,
|
||||||
},
|
},
|
||||||
organizations: orgs.map((uo) => ({
|
organizations: orgs.map((uo) => ({
|
||||||
id: uo.organizationId,
|
id: uo.organizationId,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
orgId: payload.orgId,
|
orgId: payload.orgId,
|
||||||
orgSchema: payload.orgSchema,
|
orgSchema: payload.orgSchema,
|
||||||
role: payload.role,
|
role: payload.role,
|
||||||
|
isSuperadmin: payload.isSuperadmin || false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Get, Put, Body, Param, Query, UseGuards, ParseIntPipe } from '@nestjs/common';
|
import { Controller, Get, Put, Post, Body, Param, Query, Res, UseGuards, ParseIntPipe } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { BudgetsService } from './budgets.service';
|
import { BudgetsService } from './budgets.service';
|
||||||
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
||||||
@@ -11,6 +12,38 @@ import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
|||||||
export class BudgetsController {
|
export class BudgetsController {
|
||||||
constructor(private budgetsService: BudgetsService) {}
|
constructor(private budgetsService: BudgetsService) {}
|
||||||
|
|
||||||
|
@Post(':year/import')
|
||||||
|
@ApiOperation({ summary: 'Import budget data from parsed CSV/XLSX lines' })
|
||||||
|
importBudget(
|
||||||
|
@Param('year', ParseIntPipe) year: number,
|
||||||
|
@Body() lines: any[],
|
||||||
|
) {
|
||||||
|
return this.budgetsService.importBudget(year, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':year/template')
|
||||||
|
@ApiOperation({ summary: 'Download budget CSV template for a fiscal year' })
|
||||||
|
async getTemplate(
|
||||||
|
@Param('year', ParseIntPipe) year: number,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const csv = await this.budgetsService.getTemplate(year);
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'text/csv',
|
||||||
|
'Content-Disposition': `attachment; filename="budget_template_${year}.csv"`,
|
||||||
|
});
|
||||||
|
res.send(csv);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':year/vs-actual')
|
||||||
|
@ApiOperation({ summary: 'Budget vs actual comparison' })
|
||||||
|
budgetVsActual(
|
||||||
|
@Param('year', ParseIntPipe) year: number,
|
||||||
|
@Query('month') month?: string,
|
||||||
|
) {
|
||||||
|
return this.budgetsService.getBudgetVsActual(year, month ? parseInt(month) : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':year')
|
@Get(':year')
|
||||||
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
|
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
|
||||||
findByYear(@Param('year', ParseIntPipe) year: number) {
|
findByYear(@Param('year', ParseIntPipe) year: number) {
|
||||||
@@ -25,13 +58,4 @@ export class BudgetsController {
|
|||||||
) {
|
) {
|
||||||
return this.budgetsService.upsert(year, budgets);
|
return this.budgetsService.upsert(year, budgets);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':year/vs-actual')
|
|
||||||
@ApiOperation({ summary: 'Budget vs actual comparison' })
|
|
||||||
budgetVsActual(
|
|
||||||
@Param('year', ParseIntPipe) year: number,
|
|
||||||
@Query('month') month?: string,
|
|
||||||
) {
|
|
||||||
return this.budgetsService.getBudgetVsActual(year, month ? parseInt(month) : undefined);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,96 @@ import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
|||||||
export class BudgetsService {
|
export class BudgetsService {
|
||||||
constructor(private tenant: TenantService) {}
|
constructor(private tenant: TenantService) {}
|
||||||
|
|
||||||
|
async importBudget(year: number, lines: any[]) {
|
||||||
|
const results = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const accountNumber = String(line.accountNumber || line.account_number || '').trim();
|
||||||
|
if (!accountNumber) {
|
||||||
|
errors.push(`Row ${i + 1}: missing account_number`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up account by account_number
|
||||||
|
const accounts = await this.tenant.query(
|
||||||
|
`SELECT id, fund_type FROM accounts WHERE account_number = $1 AND is_active = true`,
|
||||||
|
[parseInt(accountNumber, 10)],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!accounts || accounts.length === 0) {
|
||||||
|
errors.push(`Row ${i + 1}: account_number ${accountNumber} not found`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = accounts[0];
|
||||||
|
const fundType = line.fund_type || account.fund_type || 'operating';
|
||||||
|
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`INSERT INTO budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, notes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||||
|
ON CONFLICT (fiscal_year, account_id, fund_type)
|
||||||
|
DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9, jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15, notes=$16
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
year,
|
||||||
|
account.id,
|
||||||
|
fundType,
|
||||||
|
parseFloat(line.jan) || 0,
|
||||||
|
parseFloat(line.feb) || 0,
|
||||||
|
parseFloat(line.mar) || 0,
|
||||||
|
parseFloat(line.apr) || 0,
|
||||||
|
parseFloat(line.may) || 0,
|
||||||
|
parseFloat(line.jun) || 0,
|
||||||
|
parseFloat(line.jul) || 0,
|
||||||
|
parseFloat(line.aug) || 0,
|
||||||
|
parseFloat(line.sep) || 0,
|
||||||
|
parseFloat(line.oct) || 0,
|
||||||
|
parseFloat(line.nov) || 0,
|
||||||
|
parseFloat(line.dec_amt || line.dec) || 0,
|
||||||
|
line.notes || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
results.push(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { imported: results.length, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTemplate(year: number): Promise<string> {
|
||||||
|
// Get all active income/expense accounts with existing budget data
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`SELECT a.account_number, a.name as account_name,
|
||||||
|
COALESCE(b.jan, 0) as jan, COALESCE(b.feb, 0) as feb,
|
||||||
|
COALESCE(b.mar, 0) as mar, COALESCE(b.apr, 0) as apr,
|
||||||
|
COALESCE(b.may, 0) as may, COALESCE(b.jun, 0) as jun,
|
||||||
|
COALESCE(b.jul, 0) as jul, COALESCE(b.aug, 0) as aug,
|
||||||
|
COALESCE(b.sep, 0) as sep, COALESCE(b.oct, 0) as oct,
|
||||||
|
COALESCE(b.nov, 0) as nov, COALESCE(b.dec_amt, 0) as dec
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN budgets b ON b.account_id = a.id AND b.fiscal_year = $1
|
||||||
|
WHERE a.is_active = true
|
||||||
|
AND a.account_type IN ('income', 'expense')
|
||||||
|
ORDER BY a.account_number`,
|
||||||
|
[year],
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = 'account_number,account_name,jan,feb,mar,apr,may,jun,jul,aug,sep,oct,nov,dec';
|
||||||
|
const csvLines = rows.map((r: any) => {
|
||||||
|
const name = String(r.account_name).includes(',')
|
||||||
|
? `"${r.account_name}"`
|
||||||
|
: r.account_name;
|
||||||
|
return [
|
||||||
|
r.account_number, name,
|
||||||
|
r.jan, r.feb, r.mar, r.apr, r.may, r.jun,
|
||||||
|
r.jul, r.aug, r.sep, r.oct, r.nov, r.dec,
|
||||||
|
].join(',');
|
||||||
|
});
|
||||||
|
|
||||||
|
return [header, ...csvLines].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
async findByYear(year: number) {
|
async findByYear(year: number) {
|
||||||
return this.tenant.query(
|
return this.tenant.query(
|
||||||
`SELECT b.*, a.account_number, a.name as account_name, a.account_type
|
`SELECT b.*, a.account_number, a.name as account_name, a.account_type
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export class User {
|
|||||||
@Column({ name: 'oauth_provider_id', nullable: true })
|
@Column({ name: 'oauth_provider_id', nullable: true })
|
||||||
oauthProviderId: string;
|
oauthProviderId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'is_superadmin', default: false })
|
||||||
|
isSuperadmin: boolean;
|
||||||
|
|
||||||
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
||||||
lastLoginAt: Date;
|
lastLoginAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -38,4 +38,25 @@ export class UsersService {
|
|||||||
async updateLastLogin(id: string): Promise<void> {
|
async updateLastLogin(id: string): Promise<void> {
|
||||||
await this.usersRepository.update(id, { lastLoginAt: new Date() });
|
await this.usersRepository.update(id, { lastLoginAt: new Date() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findAllUsers(): Promise<User[]> {
|
||||||
|
return this.usersRepository.find({
|
||||||
|
relations: ['userOrganizations', 'userOrganizations.organization'],
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllOrganizations(): Promise<any[]> {
|
||||||
|
const dataSource = this.usersRepository.manager.connection;
|
||||||
|
return dataSource.query(`
|
||||||
|
SELECT o.*,
|
||||||
|
(SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id) as member_count
|
||||||
|
FROM shared.organizations o
|
||||||
|
ORDER BY o.created_at DESC
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> {
|
||||||
|
await this.usersRepository.update(userId, { isSuperadmin });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ CREATE TABLE shared.users (
|
|||||||
oauth_provider VARCHAR(50),
|
oauth_provider VARCHAR(50),
|
||||||
oauth_provider_id VARCHAR(255),
|
oauth_provider_id VARCHAR(255),
|
||||||
last_login_at TIMESTAMPTZ,
|
last_login_at TIMESTAMPTZ,
|
||||||
|
is_superadmin BOOLEAN DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
@@ -51,7 +52,7 @@ CREATE TABLE shared.user_organizations (
|
|||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||||
organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
||||||
role VARCHAR(50) NOT NULL CHECK (role IN ('president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner')),
|
role VARCHAR(50) NOT NULL CHECK (role IN ('president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer')),
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
UNIQUE(user_id, organization_id)
|
UNIQUE(user_id, organization_id)
|
||||||
|
|||||||
84
db/seed/reseed.sh
Executable file
84
db/seed/reseed.sh
Executable file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# HOA LedgerIQ - Repeatable Seed Data Script
|
||||||
|
# ============================================================
|
||||||
|
# Usage: ./db/seed/reseed.sh
|
||||||
|
#
|
||||||
|
# This script will:
|
||||||
|
# 1. Drop the tenant schema (if it exists)
|
||||||
|
# 2. Remove the test users and organization
|
||||||
|
# 3. Re-run the full seed SQL to recreate everything fresh
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# - PostgreSQL container must be running (docker compose up -d postgres)
|
||||||
|
# - DATABASE_URL or PGHOST/PGUSER etc must be configured
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
DB_HOST="${DB_HOST:-localhost}"
|
||||||
|
DB_PORT="${DB_PORT:-5432}"
|
||||||
|
DB_NAME="${DB_NAME:-hoa_platform}"
|
||||||
|
DB_USER="${DB_USER:-hoa_admin}"
|
||||||
|
DB_PASS="${DB_PASS:-hoa_secure_pass}"
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SEED_FILE="$SCRIPT_DIR/seed.sql"
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo " HOA LedgerIQ - Reseed Database"
|
||||||
|
echo "============================================"
|
||||||
|
echo ""
|
||||||
|
echo "Host: $DB_HOST:$DB_PORT"
|
||||||
|
echo "Database: $DB_NAME"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if seed file exists
|
||||||
|
if [ ! -f "$SEED_FILE" ]; then
|
||||||
|
echo "ERROR: Seed file not found at $SEED_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CLEANUP_SQL=$(cat <<'EOSQL'
|
||||||
|
DROP SCHEMA IF EXISTS tenant_sunrise_valley CASCADE;
|
||||||
|
DELETE FROM shared.user_organizations WHERE user_id IN (
|
||||||
|
SELECT id FROM shared.users WHERE email IN ('admin@sunrisevalley.org', 'viewer@sunrisevalley.org')
|
||||||
|
);
|
||||||
|
DELETE FROM shared.users WHERE email IN ('admin@sunrisevalley.org', 'viewer@sunrisevalley.org');
|
||||||
|
DELETE FROM shared.organizations WHERE schema_name = 'tenant_sunrise_valley';
|
||||||
|
EOSQL
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if we can connect directly
|
||||||
|
export PGPASSWORD="$DB_PASS"
|
||||||
|
if psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1" > /dev/null 2>&1; then
|
||||||
|
echo "Step 1: Cleaning existing tenant data..."
|
||||||
|
echo "$CLEANUP_SQL" | psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME"
|
||||||
|
|
||||||
|
echo "Step 2: Running seed SQL..."
|
||||||
|
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f "$SEED_FILE"
|
||||||
|
else
|
||||||
|
# Try via docker
|
||||||
|
echo "Direct connection failed. Trying via Docker..."
|
||||||
|
DOCKER_CMD="docker compose exec -T postgres psql -U $DB_USER -d $DB_NAME"
|
||||||
|
|
||||||
|
echo "Step 1: Cleaning existing tenant data..."
|
||||||
|
echo "$CLEANUP_SQL" | $DOCKER_CMD
|
||||||
|
|
||||||
|
echo "Step 2: Running seed SQL..."
|
||||||
|
$DOCKER_CMD < "$SEED_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================"
|
||||||
|
echo " Reseed complete!"
|
||||||
|
echo "============================================"
|
||||||
|
echo ""
|
||||||
|
echo " Test Accounts:"
|
||||||
|
echo " Admin: admin@sunrisevalley.org / password123"
|
||||||
|
echo " (SuperAdmin + President role)"
|
||||||
|
echo ""
|
||||||
|
echo " Viewer: viewer@sunrisevalley.org / password123"
|
||||||
|
echo " (Homeowner role)"
|
||||||
|
echo "============================================"
|
||||||
@@ -48,14 +48,15 @@ BEGIN
|
|||||||
-- Check if user exists
|
-- Check if user exists
|
||||||
SELECT id INTO v_user_id FROM shared.users WHERE email = 'admin@sunrisevalley.org';
|
SELECT id INTO v_user_id FROM shared.users WHERE email = 'admin@sunrisevalley.org';
|
||||||
IF v_user_id IS NULL THEN
|
IF v_user_id IS NULL THEN
|
||||||
INSERT INTO shared.users (id, email, password_hash, first_name, last_name)
|
INSERT INTO shared.users (id, email, password_hash, first_name, last_name, is_superadmin)
|
||||||
VALUES (
|
VALUES (
|
||||||
uuid_generate_v4(),
|
uuid_generate_v4(),
|
||||||
'admin@sunrisevalley.org',
|
'admin@sunrisevalley.org',
|
||||||
-- bcrypt hash of 'password123'
|
-- bcrypt hash of 'password123'
|
||||||
'$2b$10$1mtM00QBNQpAsyopajk3BeFY5DdxksvRYuM1E8qB.ePjCIYkfHMHO',
|
'$2b$10$1mtM00QBNQpAsyopajk3BeFY5DdxksvRYuM1E8qB.ePjCIYkfHMHO',
|
||||||
'Sarah',
|
'Sarah',
|
||||||
'Johnson'
|
'Johnson',
|
||||||
|
true
|
||||||
) RETURNING id INTO v_user_id;
|
) RETURNING id INTO v_user_id;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
@@ -78,6 +79,26 @@ IF v_org_id IS NULL THEN
|
|||||||
VALUES (v_user_id, v_org_id, 'president');
|
VALUES (v_user_id, v_org_id, 'president');
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
-- Create a second test user (viewer/homeowner)
|
||||||
|
DECLARE v_viewer_id UUID;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO v_viewer_id FROM shared.users WHERE email = 'viewer@sunrisevalley.org';
|
||||||
|
IF v_viewer_id IS NULL THEN
|
||||||
|
INSERT INTO shared.users (id, email, password_hash, first_name, last_name, is_superadmin)
|
||||||
|
VALUES (
|
||||||
|
uuid_generate_v4(),
|
||||||
|
'viewer@sunrisevalley.org',
|
||||||
|
'$2b$10$1mtM00QBNQpAsyopajk3BeFY5DdxksvRYuM1E8qB.ePjCIYkfHMHO',
|
||||||
|
'Mike',
|
||||||
|
'Resident',
|
||||||
|
false
|
||||||
|
) RETURNING id INTO v_viewer_id;
|
||||||
|
|
||||||
|
INSERT INTO shared.user_organizations (user_id, organization_id, role)
|
||||||
|
VALUES (v_viewer_id, v_org_id, 'homeowner');
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 2. Create tenant schema (if not exists)
|
-- 2. Create tenant schema (if not exists)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@@ -147,6 +168,19 @@ CREATE TABLE IF NOT EXISTS %I.journal_entry_lines (
|
|||||||
memo TEXT
|
memo TEXT
|
||||||
)', v_schema);
|
)', v_schema);
|
||||||
|
|
||||||
|
EXECUTE format('
|
||||||
|
CREATE TABLE IF NOT EXISTS %I.assessment_groups (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
regular_assessment DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
special_assessment DECIMAL(10,2) DEFAULT 0.00,
|
||||||
|
unit_count INTEGER DEFAULT 0,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)', v_schema);
|
||||||
|
|
||||||
EXECUTE format('
|
EXECUTE format('
|
||||||
CREATE TABLE IF NOT EXISTS %I.units (
|
CREATE TABLE IF NOT EXISTS %I.units (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
@@ -164,6 +198,7 @@ CREATE TABLE IF NOT EXISTS %I.units (
|
|||||||
owner_phone VARCHAR(20),
|
owner_phone VARCHAR(20),
|
||||||
is_rented BOOLEAN DEFAULT FALSE,
|
is_rented BOOLEAN DEFAULT FALSE,
|
||||||
monthly_assessment DECIMAL(10,2),
|
monthly_assessment DECIMAL(10,2),
|
||||||
|
assessment_group_id UUID,
|
||||||
status VARCHAR(20) DEFAULT ''active'',
|
status VARCHAR(20) DEFAULT ''active'',
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
@@ -314,6 +349,7 @@ EXECUTE format('DELETE FROM %I.investment_accounts', v_schema);
|
|||||||
EXECUTE format('DELETE FROM %I.reserve_components', v_schema);
|
EXECUTE format('DELETE FROM %I.reserve_components', v_schema);
|
||||||
EXECUTE format('DELETE FROM %I.vendors', v_schema);
|
EXECUTE format('DELETE FROM %I.vendors', v_schema);
|
||||||
EXECUTE format('DELETE FROM %I.units', v_schema);
|
EXECUTE format('DELETE FROM %I.units', v_schema);
|
||||||
|
EXECUTE format('DELETE FROM %I.assessment_groups', v_schema);
|
||||||
EXECUTE format('DELETE FROM %I.fiscal_periods', v_schema);
|
EXECUTE format('DELETE FROM %I.fiscal_periods', v_schema);
|
||||||
EXECUTE format('DELETE FROM %I.accounts', v_schema);
|
EXECUTE format('DELETE FROM %I.accounts', v_schema);
|
||||||
|
|
||||||
@@ -376,6 +412,15 @@ FOR v_month IN 1..12 LOOP
|
|||||||
USING v_year - 1, v_month, 'closed';
|
USING v_year - 1, v_month, 'closed';
|
||||||
END LOOP;
|
END LOOP;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 4b. Seed Assessment Groups
|
||||||
|
-- ============================================================
|
||||||
|
EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count) VALUES
|
||||||
|
(''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20),
|
||||||
|
(''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 425.00, 0.00, 15),
|
||||||
|
(''Estate Lots'', ''Large estate lots (Units 36-50)'', 500.00, 75.00, 15)
|
||||||
|
', v_schema);
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 5. Seed 50 units
|
-- 5. Seed 50 units
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@@ -394,17 +439,26 @@ DECLARE
|
|||||||
'Mitchell','Carter','Roberts'];
|
'Mitchell','Carter','Roberts'];
|
||||||
v_unit_num INT;
|
v_unit_num INT;
|
||||||
v_assess NUMERIC;
|
v_assess NUMERIC;
|
||||||
|
v_ag_id UUID;
|
||||||
|
v_ag_name TEXT;
|
||||||
BEGIN
|
BEGIN
|
||||||
FOR v_unit_num IN 1..50 LOOP
|
FOR v_unit_num IN 1..50 LOOP
|
||||||
-- Vary assessment based on unit size
|
-- Vary assessment based on unit size and assign assessment group
|
||||||
v_assess := CASE
|
IF v_unit_num <= 20 THEN
|
||||||
WHEN v_unit_num <= 20 THEN 350.00 -- standard
|
v_assess := 350.00;
|
||||||
WHEN v_unit_num <= 35 THEN 425.00 -- medium
|
v_ag_name := 'Single Family Homes';
|
||||||
ELSE 500.00 -- large
|
ELSIF v_unit_num <= 35 THEN
|
||||||
END;
|
v_assess := 425.00;
|
||||||
|
v_ag_name := 'Patio Homes';
|
||||||
|
ELSE
|
||||||
|
v_assess := 500.00;
|
||||||
|
v_ag_name := 'Estate Lots';
|
||||||
|
END IF;
|
||||||
|
|
||||||
EXECUTE format('INSERT INTO %I.units (unit_number, address_line1, city, state, zip_code, owner_name, owner_email, owner_phone, monthly_assessment, square_footage)
|
EXECUTE format('SELECT id FROM %I.assessment_groups WHERE name = $1', v_schema) INTO v_ag_id USING v_ag_name;
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)', v_schema)
|
|
||||||
|
EXECUTE format('INSERT INTO %I.units (unit_number, address_line1, city, state, zip_code, owner_name, owner_email, owner_phone, monthly_assessment, square_footage, assessment_group_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)', v_schema)
|
||||||
USING
|
USING
|
||||||
LPAD(v_unit_num::TEXT, 3, '0'),
|
LPAD(v_unit_num::TEXT, 3, '0'),
|
||||||
(100 + v_unit_num * 2)::TEXT || ' Sunrise Valley Drive',
|
(100 + v_unit_num * 2)::TEXT || ' Sunrise Valley Drive',
|
||||||
@@ -413,7 +467,8 @@ BEGIN
|
|||||||
LOWER(v_first_names[v_unit_num]) || '.' || LOWER(v_last_names[v_unit_num]) || '@email.com',
|
LOWER(v_first_names[v_unit_num]) || '.' || LOWER(v_last_names[v_unit_num]) || '@email.com',
|
||||||
'(480) 555-' || LPAD((1000 + v_unit_num)::TEXT, 4, '0'),
|
'(480) 555-' || LPAD((1000 + v_unit_num)::TEXT, 4, '0'),
|
||||||
v_assess,
|
v_assess,
|
||||||
CASE WHEN v_unit_num <= 20 THEN 1200 WHEN v_unit_num <= 35 THEN 1600 ELSE 2000 END;
|
CASE WHEN v_unit_num <= 20 THEN 1200 WHEN v_unit_num <= 35 THEN 1600 ELSE 2000 END,
|
||||||
|
v_ag_id;
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END;
|
END;
|
||||||
|
|
||||||
@@ -779,6 +834,7 @@ EXECUTE format('INSERT INTO %I.capital_projects (name, description, estimated_co
|
|||||||
', v_schema) USING v_year;
|
', v_schema) USING v_year;
|
||||||
|
|
||||||
RAISE NOTICE 'Seed data created successfully for Sunrise Valley HOA!';
|
RAISE NOTICE 'Seed data created successfully for Sunrise Valley HOA!';
|
||||||
RAISE NOTICE 'Login: admin@sunrisevalley.org / password123';
|
RAISE NOTICE 'Admin Login: admin@sunrisevalley.org / password123 (SuperAdmin + President)';
|
||||||
|
RAISE NOTICE 'Viewer Login: viewer@sunrisevalley.org / password123 (Homeowner)';
|
||||||
|
|
||||||
END $$;
|
END $$;
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { CashFlowPage } from './pages/reports/CashFlowPage';
|
|||||||
import { AgingReportPage } from './pages/reports/AgingReportPage';
|
import { AgingReportPage } from './pages/reports/AgingReportPage';
|
||||||
import { YearEndPage } from './pages/reports/YearEndPage';
|
import { YearEndPage } from './pages/reports/YearEndPage';
|
||||||
import { SettingsPage } from './pages/settings/SettingsPage';
|
import { SettingsPage } from './pages/settings/SettingsPage';
|
||||||
|
import { AdminPage } from './pages/admin/AdminPage';
|
||||||
|
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const token = useAuthStore((s) => s.token);
|
const token = useAuthStore((s) => s.token);
|
||||||
@@ -38,6 +40,14 @@ function OrgRequiredRoute({ children }: { children: React.ReactNode }) {
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SuperAdminRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const token = useAuthStore((s) => s.token);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
if (!token) return <Navigate to="/login" replace />;
|
||||||
|
if (!user?.isSuperadmin) return <Navigate to="/dashboard" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function AuthRoute({ children }: { children: React.ReactNode }) {
|
function AuthRoute({ children }: { children: React.ReactNode }) {
|
||||||
const token = useAuthStore((s) => s.token);
|
const token = useAuthStore((s) => s.token);
|
||||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||||
@@ -73,6 +83,16 @@ export function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<SuperAdminRoute>
|
||||||
|
<AppLayout />
|
||||||
|
</SuperAdminRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<AdminPage />} />
|
||||||
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
path="/*"
|
path="/*"
|
||||||
element={
|
element={
|
||||||
@@ -93,6 +113,7 @@ export function App() {
|
|||||||
<Route path="reserves" element={<ReservesPage />} />
|
<Route path="reserves" element={<ReservesPage />} />
|
||||||
<Route path="investments" element={<InvestmentsPage />} />
|
<Route path="investments" element={<InvestmentsPage />} />
|
||||||
<Route path="capital-projects" element={<CapitalProjectsPage />} />
|
<Route path="capital-projects" element={<CapitalProjectsPage />} />
|
||||||
|
<Route path="assessment-groups" element={<AssessmentGroupsPage />} />
|
||||||
<Route path="reports/balance-sheet" element={<BalanceSheetPage />} />
|
<Route path="reports/balance-sheet" element={<BalanceSheetPage />} />
|
||||||
<Route path="reports/income-statement" element={<IncomeStatementPage />} />
|
<Route path="reports/income-statement" element={<IncomeStatementPage />} />
|
||||||
<Route path="reports/budget-vs-actual" element={<BudgetVsActualPage />} />
|
<Route path="reports/budget-vs-actual" element={<BudgetVsActualPage />} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NavLink, ScrollArea } from '@mantine/core';
|
import { NavLink, ScrollArea, Divider, Text } from '@mantine/core';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
IconDashboard,
|
IconDashboard,
|
||||||
@@ -16,13 +16,17 @@ import {
|
|||||||
IconUsers,
|
IconUsers,
|
||||||
IconFileText,
|
IconFileText,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
|
IconCrown,
|
||||||
|
IconCategory,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard' },
|
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard' },
|
||||||
{ label: 'Chart of Accounts', icon: IconListDetails, path: '/accounts' },
|
{ label: 'Chart of Accounts', icon: IconListDetails, path: '/accounts' },
|
||||||
{ label: 'Transactions', icon: IconReceipt, path: '/transactions' },
|
{ label: 'Transactions', icon: IconReceipt, path: '/transactions' },
|
||||||
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' },
|
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' },
|
||||||
|
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups' },
|
||||||
{ 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: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' },
|
||||||
@@ -49,6 +53,7 @@ const navItems = [
|
|||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea p="sm">
|
<ScrollArea p="sm">
|
||||||
@@ -81,6 +86,22 @@ export function Sidebar() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{user?.isSuperadmin && (
|
||||||
|
<>
|
||||||
|
<Divider my="sm" />
|
||||||
|
<Text size="xs" c="dimmed" fw={700} tt="uppercase" px="sm" pb={4}>
|
||||||
|
Platform Admin
|
||||||
|
</Text>
|
||||||
|
<NavLink
|
||||||
|
label="Admin Panel"
|
||||||
|
leftSection={<IconCrown size={18} />}
|
||||||
|
active={location.pathname === '/admin'}
|
||||||
|
onClick={() => navigate('/admin')}
|
||||||
|
color="red"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Loader,
|
Loader,
|
||||||
Center,
|
Center,
|
||||||
|
Tooltip,
|
||||||
|
SimpleGrid,
|
||||||
} 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, IconArchive, IconArchiveOff } 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';
|
||||||
|
|
||||||
@@ -52,36 +54,41 @@ export function AccountsPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filterType, setFilterType] = useState<string | null>(null);
|
const [filterType, setFilterType] = useState<string | null>(null);
|
||||||
const [filterFund, setFilterFund] = useState<string | null>(null);
|
const [filterFund, setFilterFund] = useState<string | null>(null);
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
||||||
queryKey: ['accounts'],
|
queryKey: ['accounts', showArchived],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/accounts');
|
const params = showArchived ? '?includeArchived=true' : '';
|
||||||
|
const { data } = await api.get(`/accounts${params}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
account_number: 0,
|
accountNumber: 0,
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
account_type: 'expense',
|
accountType: 'expense',
|
||||||
fund_type: 'operating',
|
fundType: 'operating',
|
||||||
is_1099_reportable: false,
|
is1099Reportable: false,
|
||||||
|
initialBalance: 0,
|
||||||
},
|
},
|
||||||
validate: {
|
validate: {
|
||||||
account_number: (v) => (v > 0 ? null : 'Required'),
|
accountNumber: (v) => (v > 0 ? null : 'Required'),
|
||||||
name: (v) => (v.length > 0 ? null : 'Required'),
|
name: (v) => (v.length > 0 ? null : 'Required'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (values: any) =>
|
mutationFn: (values: any) => {
|
||||||
editing
|
if (editing) {
|
||||||
? api.put(`/accounts/${editing.id}`, values)
|
return api.put(`/accounts/${editing.id}`, values);
|
||||||
: api.post('/accounts', values),
|
}
|
||||||
|
return api.post('/accounts', values);
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
notifications.show({ message: editing ? 'Account updated' : 'Account created', color: 'green' });
|
notifications.show({ message: editing ? 'Account updated' : 'Account created', color: 'green' });
|
||||||
@@ -94,15 +101,28 @@ export function AccountsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: (account: Account) =>
|
||||||
|
api.put(`/accounts/${account.id}`, { isActive: !account.is_active }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
|
notifications.show({ message: 'Account status updated', color: 'green' });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleEdit = (account: Account) => {
|
const handleEdit = (account: Account) => {
|
||||||
setEditing(account);
|
setEditing(account);
|
||||||
form.setValues({
|
form.setValues({
|
||||||
account_number: account.account_number,
|
accountNumber: account.account_number,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
description: account.description || '',
|
description: account.description || '',
|
||||||
account_type: account.account_type,
|
accountType: account.account_type,
|
||||||
fund_type: account.fund_type,
|
fundType: account.fund_type,
|
||||||
is_1099_reportable: account.is_1099_reportable,
|
is1099Reportable: account.is_1099_reportable,
|
||||||
|
initialBalance: 0,
|
||||||
});
|
});
|
||||||
open();
|
open();
|
||||||
};
|
};
|
||||||
@@ -120,11 +140,18 @@ export function AccountsPage() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const activeAccounts = filtered.filter(a => a.is_active);
|
||||||
|
const archivedAccounts = filtered.filter(a => !a.is_active);
|
||||||
|
|
||||||
const totalsByType = accounts.reduce((acc, a) => {
|
const totalsByType = accounts.reduce((acc, a) => {
|
||||||
acc[a.account_type] = (acc[a.account_type] || 0) + parseFloat(a.balance || '0');
|
if (a.is_active) {
|
||||||
|
acc[a.account_type] = (acc[a.account_type] || 0) + parseFloat(a.balance || '0');
|
||||||
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, number>);
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Center h={300}><Loader /></Center>;
|
return <Center h={300}><Loader /></Center>;
|
||||||
}
|
}
|
||||||
@@ -133,11 +160,28 @@ export function AccountsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Chart of Accounts</Title>
|
<Title order={2}>Chart of Accounts</Title>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
<Group>
|
||||||
Add Account
|
<Switch
|
||||||
</Button>
|
label="Show Archived"
|
||||||
|
checked={showArchived}
|
||||||
|
onChange={(e) => setShowArchived(e.currentTarget.checked)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||||
|
Add Account
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 2, sm: 5 }}>
|
||||||
|
{Object.entries(totalsByType).map(([type, total]) => (
|
||||||
|
<Card withBorder p="xs" key={type}>
|
||||||
|
<Text size="xs" c="dimmed" tt="capitalize">{type}</Text>
|
||||||
|
<Text fw={700} size="sm" c={accountTypeColors[type]}>{fmt(total)}</Text>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search accounts..."
|
placeholder="Search accounts..."
|
||||||
@@ -166,50 +210,69 @@ export function AccountsPage() {
|
|||||||
|
|
||||||
<Tabs defaultValue="all">
|
<Tabs defaultValue="all">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="all">All ({accounts.length})</Tabs.Tab>
|
<Tabs.Tab value="all">All ({activeAccounts.length})</Tabs.Tab>
|
||||||
<Tabs.Tab value="operating">Operating</Tabs.Tab>
|
<Tabs.Tab value="operating">Operating</Tabs.Tab>
|
||||||
<Tabs.Tab value="reserve">Reserve</Tabs.Tab>
|
<Tabs.Tab value="reserve">Reserve</Tabs.Tab>
|
||||||
|
{showArchived && archivedAccounts.length > 0 && (
|
||||||
|
<Tabs.Tab value="archived" color="gray">Archived ({archivedAccounts.length})</Tabs.Tab>
|
||||||
|
)}
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="all" pt="sm">
|
<Tabs.Panel value="all" pt="sm">
|
||||||
<AccountTable accounts={filtered} onEdit={handleEdit} />
|
<AccountTable accounts={activeAccounts} onEdit={handleEdit} onArchive={archiveMutation.mutate} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="operating" pt="sm">
|
<Tabs.Panel value="operating" pt="sm">
|
||||||
<AccountTable accounts={filtered.filter(a => a.fund_type === 'operating')} onEdit={handleEdit} />
|
<AccountTable accounts={activeAccounts.filter(a => a.fund_type === 'operating')} onEdit={handleEdit} onArchive={archiveMutation.mutate} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="reserve" pt="sm">
|
<Tabs.Panel value="reserve" pt="sm">
|
||||||
<AccountTable accounts={filtered.filter(a => a.fund_type === 'reserve')} onEdit={handleEdit} />
|
<AccountTable accounts={activeAccounts.filter(a => a.fund_type === 'reserve')} onEdit={handleEdit} onArchive={archiveMutation.mutate} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
{showArchived && (
|
||||||
|
<Tabs.Panel value="archived" pt="sm">
|
||||||
|
<AccountTable accounts={archivedAccounts} onEdit={handleEdit} onArchive={archiveMutation.mutate} isArchivedView />
|
||||||
|
</Tabs.Panel>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Modal opened={opened} onClose={close} title={editing ? 'Edit Account' : 'New Account'} size="md">
|
<Modal opened={opened} onClose={close} title={editing ? 'Edit Account' : 'New Account'} size="md">
|
||||||
<form onSubmit={form.onSubmit((values) => createMutation.mutate(values))}>
|
<form onSubmit={form.onSubmit((values) => createMutation.mutate(values))}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<NumberInput label="Account Number" required {...form.getInputProps('account_number')} />
|
<NumberInput label="Account Number" required {...form.getInputProps('accountNumber')} />
|
||||||
<TextInput label="Account Name" required {...form.getInputProps('name')} />
|
<TextInput label="Account Name" required {...form.getInputProps('name')} />
|
||||||
<TextInput label="Description" {...form.getInputProps('description')} />
|
<TextInput label="Description" {...form.getInputProps('description')} />
|
||||||
<Select
|
<Group grow>
|
||||||
label="Account Type"
|
<Select
|
||||||
required
|
label="Account Type"
|
||||||
data={[
|
required
|
||||||
{ value: 'asset', label: 'Asset' },
|
data={[
|
||||||
{ value: 'liability', label: 'Liability' },
|
{ value: 'asset', label: 'Asset' },
|
||||||
{ value: 'equity', label: 'Equity' },
|
{ value: 'liability', label: 'Liability' },
|
||||||
{ value: 'income', label: 'Income' },
|
{ value: 'equity', label: 'Equity' },
|
||||||
{ value: 'expense', label: 'Expense' },
|
{ value: 'income', label: 'Income' },
|
||||||
]}
|
{ value: 'expense', label: 'Expense' },
|
||||||
{...form.getInputProps('account_type')}
|
]}
|
||||||
/>
|
{...form.getInputProps('accountType')}
|
||||||
<Select
|
/>
|
||||||
label="Fund Type"
|
<Select
|
||||||
required
|
label="Fund Type"
|
||||||
data={[
|
required
|
||||||
{ value: 'operating', label: 'Operating' },
|
data={[
|
||||||
{ value: 'reserve', label: 'Reserve' },
|
{ value: 'operating', label: 'Operating' },
|
||||||
]}
|
{ value: 'reserve', label: 'Reserve' },
|
||||||
{...form.getInputProps('fund_type')}
|
]}
|
||||||
/>
|
{...form.getInputProps('fundType')}
|
||||||
<Switch label="1099 Reportable" {...form.getInputProps('is_1099_reportable', { type: 'checkbox' })} />
|
/>
|
||||||
|
</Group>
|
||||||
|
<Switch label="1099 Reportable" {...form.getInputProps('is1099Reportable', { type: 'checkbox' })} />
|
||||||
|
{!editing && (
|
||||||
|
<NumberInput
|
||||||
|
label="Initial Balance"
|
||||||
|
description="Opening balance (creates a journal entry)"
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
{...form.getInputProps('initialBalance')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button type="submit" loading={createMutation.isPending}>
|
<Button type="submit" loading={createMutation.isPending}>
|
||||||
{editing ? 'Update' : 'Create'}
|
{editing ? 'Update' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -220,7 +283,17 @@ export function AccountsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountTable({ accounts, onEdit }: { accounts: Account[]; onEdit: (a: Account) => void }) {
|
function AccountTable({
|
||||||
|
accounts,
|
||||||
|
onEdit,
|
||||||
|
onArchive,
|
||||||
|
isArchivedView = false,
|
||||||
|
}: {
|
||||||
|
accounts: Account[];
|
||||||
|
onEdit: (a: Account) => void;
|
||||||
|
onArchive: (a: Account) => void;
|
||||||
|
isArchivedView?: boolean;
|
||||||
|
}) {
|
||||||
const fmt = (v: string) => {
|
const fmt = (v: string) => {
|
||||||
const n = parseFloat(v || '0');
|
const n = parseFloat(v || '0');
|
||||||
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
@@ -240,10 +313,24 @@ function AccountTable({ accounts, onEdit }: { accounts: Account[]; onEdit: (a: A
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
|
{accounts.length === 0 && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={7}>
|
||||||
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
|
{isArchivedView ? 'No archived accounts' : 'No accounts found'}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
<Table.Tr key={a.id}>
|
<Table.Tr key={a.id} style={{ opacity: a.is_active ? 1 : 0.6 }}>
|
||||||
<Table.Td fw={500}>{a.account_number}</Table.Td>
|
<Table.Td fw={500}>{a.account_number}</Table.Td>
|
||||||
<Table.Td>{a.name}</Table.Td>
|
<Table.Td>
|
||||||
|
<div>
|
||||||
|
<Text size="sm">{a.name}</Text>
|
||||||
|
{a.description && <Text size="xs" c="dimmed">{a.description}</Text>}
|
||||||
|
</div>
|
||||||
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge color={accountTypeColors[a.account_type]} variant="light" size="sm">
|
<Badge color={accountTypeColors[a.account_type]} variant="light" size="sm">
|
||||||
{a.account_type}
|
{a.account_type}
|
||||||
@@ -255,13 +342,26 @@ function AccountTable({ accounts, onEdit }: { accounts: Account[]; onEdit: (a: A
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td ta="right" ff="monospace">{fmt(a.balance)}</Table.Td>
|
<Table.Td ta="right" ff="monospace">{fmt(a.balance)}</Table.Td>
|
||||||
<Table.Td>{a.is_1099_reportable ? '1099' : ''}</Table.Td>
|
<Table.Td>{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{!a.is_system && (
|
<Group gap={4}>
|
||||||
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
|
<Tooltip label="Edit account">
|
||||||
<IconEdit size={16} />
|
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
|
||||||
</ActionIcon>
|
<IconEdit size={16} />
|
||||||
)}
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
{!a.is_system && (
|
||||||
|
<Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color={a.is_active ? 'gray' : 'green'}
|
||||||
|
onClick={() => onArchive(a)}
|
||||||
|
>
|
||||||
|
{a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
238
frontend/src/pages/admin/AdminPage.tsx
Normal file
238
frontend/src/pages/admin/AdminPage.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
|
||||||
|
ThemeIcon, Tabs, ActionIcon, Switch, TextInput, Avatar,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconUsers, IconBuilding, IconShieldLock, IconSearch,
|
||||||
|
IconCrown, IconUser,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface AdminUser {
|
||||||
|
id: string; email: string; firstName: string; lastName: string;
|
||||||
|
isSuperadmin: boolean; lastLoginAt: string; createdAt: string;
|
||||||
|
organizations: { id: string; name: string; role: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminOrg {
|
||||||
|
id: string; name: string; schema_name: string; status: string;
|
||||||
|
email: string; phone: string; member_count: string; created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminPage() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: users, isLoading: usersLoading } = useQuery<AdminUser[]>({
|
||||||
|
queryKey: ['admin-users'],
|
||||||
|
queryFn: async () => { const { data } = await api.get('/admin/users'); return data; },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: orgs, isLoading: orgsLoading } = useQuery<AdminOrg[]>({
|
||||||
|
queryKey: ['admin-orgs'],
|
||||||
|
queryFn: async () => { const { data } = await api.get('/admin/organizations'); return data; },
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSuperadmin = useMutation({
|
||||||
|
mutationFn: async ({ userId, isSuperadmin }: { userId: string; isSuperadmin: boolean }) => {
|
||||||
|
await api.post(`/admin/users/${userId}/superadmin`, { isSuperadmin });
|
||||||
|
},
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-users'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredUsers = (users || []).filter(u =>
|
||||||
|
!search || u.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
`${u.firstName} ${u.lastName}`.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredOrgs = (orgs || []).filter(o =>
|
||||||
|
!search || o.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
o.schema_name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Platform Administration</Title>
|
||||||
|
<Text c="dimmed" size="sm">SuperUser Admin Panel — Manage tenants and users</Text>
|
||||||
|
</div>
|
||||||
|
<Badge color="red" variant="filled" size="lg" leftSection={<IconCrown size={14} />}>
|
||||||
|
SuperAdmin
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Users</Text>
|
||||||
|
<Text fw={700} size="xl">{users?.length || 0}</Text>
|
||||||
|
</div>
|
||||||
|
<ThemeIcon color="blue" variant="light" size={48} radius="md">
|
||||||
|
<IconUsers size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Organizations</Text>
|
||||||
|
<Text fw={700} size="xl">{orgs?.length || 0}</Text>
|
||||||
|
</div>
|
||||||
|
<ThemeIcon color="green" variant="light" size={48} radius="md">
|
||||||
|
<IconBuilding size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>SuperAdmins</Text>
|
||||||
|
<Text fw={700} size="xl">{(users || []).filter(u => u.isSuperadmin).length}</Text>
|
||||||
|
</div>
|
||||||
|
<ThemeIcon color="red" variant="light" size={48} radius="md">
|
||||||
|
<IconShieldLock size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search users or organizations..."
|
||||||
|
leftSection={<IconSearch size={16} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs defaultValue="users">
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Tab value="users" leftSection={<IconUsers size={16} />}>
|
||||||
|
Users ({filteredUsers.length})
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="orgs" leftSection={<IconBuilding size={16} />}>
|
||||||
|
Organizations ({filteredOrgs.length})
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="users" pt="md">
|
||||||
|
{usersLoading ? (
|
||||||
|
<Center h={200}><Loader /></Center>
|
||||||
|
) : (
|
||||||
|
<Card withBorder>
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>User</Table.Th>
|
||||||
|
<Table.Th>Email</Table.Th>
|
||||||
|
<Table.Th>Organizations</Table.Th>
|
||||||
|
<Table.Th>Last Login</Table.Th>
|
||||||
|
<Table.Th ta="center">SuperAdmin</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{filteredUsers.map((u) => (
|
||||||
|
<Table.Tr key={u.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Avatar size="sm" radius="xl" color={u.isSuperadmin ? 'red' : 'blue'}>
|
||||||
|
{u.firstName?.[0]}{u.lastName?.[0]}
|
||||||
|
</Avatar>
|
||||||
|
<Text size="sm" fw={500}>{u.firstName} {u.lastName}</Text>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" ff="monospace">{u.email}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={4}>
|
||||||
|
{u.organizations.map((o) => (
|
||||||
|
<Badge key={o.id} size="xs" variant="light">
|
||||||
|
{o.name} ({o.role})
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{u.organizations.length === 0 && (
|
||||||
|
<Text size="xs" c="dimmed">No organizations</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleDateString() : 'Never'}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="center">
|
||||||
|
<Switch
|
||||||
|
checked={u.isSuperadmin}
|
||||||
|
onChange={() => toggleSuperadmin.mutate({
|
||||||
|
userId: u.id,
|
||||||
|
isSuperadmin: !u.isSuperadmin,
|
||||||
|
})}
|
||||||
|
size="sm"
|
||||||
|
color="red"
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="orgs" pt="md">
|
||||||
|
{orgsLoading ? (
|
||||||
|
<Center h={200}><Loader /></Center>
|
||||||
|
) : (
|
||||||
|
<Card withBorder>
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Organization</Table.Th>
|
||||||
|
<Table.Th>Schema</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th ta="center">Members</Table.Th>
|
||||||
|
<Table.Th>Contact</Table.Th>
|
||||||
|
<Table.Th>Created</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{filteredOrgs.map((o) => (
|
||||||
|
<Table.Tr key={o.id}>
|
||||||
|
<Table.Td fw={500}>{o.name}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" ff="monospace" c="dimmed">{o.schema_name}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color={o.status === 'active' ? 'green' : o.status === 'trial' ? 'yellow' : 'red'}
|
||||||
|
>
|
||||||
|
{o.status}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="center">
|
||||||
|
<Badge variant="light" size="sm">{o.member_count}</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs">{o.email || 'N/A'}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{new Date(o.created_at).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx
Normal file
274
frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
|
||||||
|
ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Switch, ActionIcon,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconPlus, IconEdit, IconCategory, IconCash, IconHome, IconArchive,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface AssessmentGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
regular_assessment: string;
|
||||||
|
special_assessment: string;
|
||||||
|
unit_count: number;
|
||||||
|
actual_unit_count: string;
|
||||||
|
monthly_operating_income: string;
|
||||||
|
monthly_reserve_income: string;
|
||||||
|
total_monthly_income: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Summary {
|
||||||
|
group_count: string;
|
||||||
|
total_monthly_operating: string;
|
||||||
|
total_monthly_reserve: string;
|
||||||
|
total_monthly_income: string;
|
||||||
|
total_units: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssessmentGroupsPage() {
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
|
||||||
|
queryKey: ['assessment-groups'],
|
||||||
|
queryFn: async () => { const { data } = await api.get('/assessment-groups'); return data; },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: summary } = useQuery<Summary>({
|
||||||
|
queryKey: ['assessment-groups-summary'],
|
||||||
|
queryFn: async () => { const { data } = await api.get('/assessment-groups/summary'); return data; },
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
regularAssessment: 0,
|
||||||
|
specialAssessment: 0,
|
||||||
|
unitCount: 0,
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
name: (v) => (v.length > 0 ? null : 'Required'),
|
||||||
|
regularAssessment: (v) => (v >= 0 ? null : 'Must be >= 0'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (values: any) =>
|
||||||
|
editing
|
||||||
|
? api.put(`/assessment-groups/${editing.id}`, values)
|
||||||
|
: api.post('/assessment-groups', values),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['assessment-groups'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] });
|
||||||
|
notifications.show({ message: editing ? 'Group updated' : 'Group created', color: 'green' });
|
||||||
|
close();
|
||||||
|
setEditing(null);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: (group: AssessmentGroup) =>
|
||||||
|
api.put(`/assessment-groups/${group.id}`, { isActive: !group.is_active }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['assessment-groups'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] });
|
||||||
|
notifications.show({ message: 'Group status updated', color: 'green' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = (group: AssessmentGroup) => {
|
||||||
|
setEditing(group);
|
||||||
|
form.setValues({
|
||||||
|
name: group.name,
|
||||||
|
description: group.description || '',
|
||||||
|
regularAssessment: parseFloat(group.regular_assessment || '0'),
|
||||||
|
specialAssessment: parseFloat(group.special_assessment || '0'),
|
||||||
|
unitCount: group.unit_count || 0,
|
||||||
|
});
|
||||||
|
open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNew = () => {
|
||||||
|
setEditing(null);
|
||||||
|
form.reset();
|
||||||
|
open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmt = (v: string | number) =>
|
||||||
|
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Assessment Groups</Title>
|
||||||
|
<Text c="dimmed" size="sm">Manage property types with different assessment rates</Text>
|
||||||
|
</div>
|
||||||
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||||
|
Add Group
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Groups</Text>
|
||||||
|
<Text fw={700} size="xl">{summary?.group_count || 0}</Text>
|
||||||
|
</div>
|
||||||
|
<ThemeIcon color="blue" variant="light" size={48} radius="md">
|
||||||
|
<IconCategory size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Units</Text>
|
||||||
|
<Text fw={700} size="xl">{summary?.total_units || 0}</Text>
|
||||||
|
</div>
|
||||||
|
<ThemeIcon color="green" variant="light" size={48} radius="md">
|
||||||
|
<IconHome size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Monthly Operating</Text>
|
||||||
|
<Text fw={700} size="xl">{fmt(summary?.total_monthly_operating || '0')}</Text>
|
||||||
|
</div>
|
||||||
|
<ThemeIcon color="teal" variant="light" size={48} radius="md">
|
||||||
|
<IconCash size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Monthly Reserve</Text>
|
||||||
|
<Text fw={700} size="xl">{fmt(summary?.total_monthly_reserve || '0')}</Text>
|
||||||
|
</div>
|
||||||
|
<ThemeIcon color="violet" variant="light" size={48} radius="md">
|
||||||
|
<IconCash size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Group Name</Table.Th>
|
||||||
|
<Table.Th ta="center">Units</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>Status</Table.Th>
|
||||||
|
<Table.Th></Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{groups.length === 0 && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={8}>
|
||||||
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
|
No assessment groups yet. Create groups like "Single Family Homes", "Condos", etc.
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
{groups.map((g) => (
|
||||||
|
<Table.Tr key={g.id} style={{ opacity: g.is_active ? 1 : 0.5 }}>
|
||||||
|
<Table.Td>
|
||||||
|
<div>
|
||||||
|
<Text fw={500}>{g.name}</Text>
|
||||||
|
{g.description && <Text size="xs" c="dimmed">{g.description}</Text>}
|
||||||
|
</div>
|
||||||
|
</Table.Td>
|
||||||
|
<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 ta="right" ff="monospace">
|
||||||
|
{parseFloat(g.special_assessment || '0') > 0 ? (
|
||||||
|
<Badge color="orange" variant="light">{fmt(g.special_assessment)}</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>
|
||||||
|
<Badge color={g.is_active ? 'green' : 'gray'} variant="light" size="sm">
|
||||||
|
{g.is_active ? 'Active' : 'Archived'}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={4}>
|
||||||
|
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color={g.is_active ? 'gray' : 'green'}
|
||||||
|
onClick={() => archiveMutation.mutate(g)}
|
||||||
|
>
|
||||||
|
<IconArchive size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal opened={opened} onClose={close} title={editing ? 'Edit Assessment Group' : 'New Assessment Group'} size="md">
|
||||||
|
<form onSubmit={form.onSubmit((values) => saveMutation.mutate(values))}>
|
||||||
|
<Stack>
|
||||||
|
<TextInput label="Group Name" placeholder="e.g. Single Family Homes" required {...form.getInputProps('name')} />
|
||||||
|
<Textarea label="Description" placeholder="Optional description" {...form.getInputProps('description')} />
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput
|
||||||
|
label="Regular Assessment (per unit)"
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
min={0}
|
||||||
|
{...form.getInputProps('regularAssessment')}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Special Assessment (per unit)"
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
min={0}
|
||||||
|
{...form.getInputProps('specialAssessment')}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<NumberInput label="Expected Unit Count" min={0} {...form.getInputProps('unitCount')} />
|
||||||
|
<Button type="submit" loading={saveMutation.isPending}>
|
||||||
|
{editing ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, Text, NumberInput,
|
Title, Table, Group, Button, Stack, Text, NumberInput,
|
||||||
Select, Loader, Center, Badge, Card,
|
Select, Loader, Center, Badge, Card,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
import { IconDeviceFloppy, IconUpload, IconDownload } 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';
|
||||||
|
|
||||||
@@ -23,10 +23,49 @@ interface BudgetLine {
|
|||||||
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
|
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
|
||||||
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
|
function parseCSV(text: string): Record<string, string>[] {
|
||||||
|
const lines = text.trim().split('\n');
|
||||||
|
if (lines.length < 2) return [];
|
||||||
|
|
||||||
|
const headers = lines[0].split(',').map((h) => h.trim().toLowerCase());
|
||||||
|
const rows: Record<string, string>[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
// Handle quoted fields containing commas
|
||||||
|
const values: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let j = 0; j < line.length; j++) {
|
||||||
|
const ch = line[j];
|
||||||
|
if (ch === '"') {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
} else if (ch === ',' && !inQuotes) {
|
||||||
|
values.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values.push(current.trim());
|
||||||
|
|
||||||
|
const row: Record<string, string> = {};
|
||||||
|
headers.forEach((h, idx) => {
|
||||||
|
row[h] = values[idx] || '';
|
||||||
|
});
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
export function BudgetsPage() {
|
export function BudgetsPage() {
|
||||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||||
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { isLoading } = useQuery<BudgetLine[]>({
|
const { isLoading } = useQuery<BudgetLine[]>({
|
||||||
queryKey: ['budgets', year],
|
queryKey: ['budgets', year],
|
||||||
@@ -59,6 +98,88 @@ export function BudgetsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const importMutation = useMutation({
|
||||||
|
mutationFn: async (lines: Record<string, string>[]) => {
|
||||||
|
const parsed = lines.map((row) => ({
|
||||||
|
account_number: row.account_number || row.accountnumber || '',
|
||||||
|
jan: parseFloat(row.jan) || 0,
|
||||||
|
feb: parseFloat(row.feb) || 0,
|
||||||
|
mar: parseFloat(row.mar) || 0,
|
||||||
|
apr: parseFloat(row.apr) || 0,
|
||||||
|
may: parseFloat(row.may) || 0,
|
||||||
|
jun: parseFloat(row.jun) || 0,
|
||||||
|
jul: parseFloat(row.jul) || 0,
|
||||||
|
aug: parseFloat(row.aug) || 0,
|
||||||
|
sep: parseFloat(row.sep) || 0,
|
||||||
|
oct: parseFloat(row.oct) || 0,
|
||||||
|
nov: parseFloat(row.nov) || 0,
|
||||||
|
dec_amt: parseFloat(row.dec_amt || row.dec) || 0,
|
||||||
|
}));
|
||||||
|
const { data } = await api.post(`/budgets/${year}/import`, parsed);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
||||||
|
const msg = `Imported ${data.imported} budget line(s)` +
|
||||||
|
(data.errors?.length ? `. ${data.errors.length} error(s): ${data.errors.join('; ')}` : '');
|
||||||
|
notifications.show({
|
||||||
|
message: msg,
|
||||||
|
color: data.errors?.length ? 'yellow' : 'green',
|
||||||
|
autoClose: 8000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDownloadTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/budgets/${year}/template`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
const blob = new Blob([response.data], { type: 'text/csv' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `budget_template_${year}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (err: any) {
|
||||||
|
notifications.show({ message: 'Failed to download template', color: 'red' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportCSV = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const text = e.target?.result as string;
|
||||||
|
if (!text) {
|
||||||
|
notifications.show({ message: 'Could not read file', color: 'red' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = parseCSV(text);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
notifications.show({ message: 'No data rows found in CSV', color: 'red' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
importMutation.mutate(rows);
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
|
||||||
|
// Reset input so the same file can be re-selected
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
const updateCell = (idx: number, month: string, value: number) => {
|
const updateCell = (idx: number, month: string, value: number) => {
|
||||||
const updated = [...budgetData];
|
const updated = [...budgetData];
|
||||||
(updated[idx] as any)[month] = value || 0;
|
(updated[idx] as any)[month] = value || 0;
|
||||||
@@ -86,6 +207,28 @@ export function BudgetsPage() {
|
|||||||
<Title order={2}>Budget Manager</Title>
|
<Title order={2}>Budget Manager</Title>
|
||||||
<Group>
|
<Group>
|
||||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
|
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
leftSection={<IconDownload size={16} />}
|
||||||
|
onClick={handleDownloadTemplate}
|
||||||
|
>
|
||||||
|
Download Template
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
leftSection={<IconUpload size={16} />}
|
||||||
|
onClick={handleImportCSV}
|
||||||
|
loading={importMutation.isPending}
|
||||||
|
>
|
||||||
|
Import CSV
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
accept=".csv,.txt"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
||||||
Save Budget
|
Save Budget
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useCallback, 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,
|
||||||
|
SegmentedControl, Card, Paper, ScrollArea, Box, 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 } from '@tabler/icons-react';
|
import {
|
||||||
|
IconPlus, IconEdit, IconTable, IconLayoutKanban, IconFileTypePdf,
|
||||||
|
IconGripVertical,
|
||||||
|
} 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';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types & constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface CapitalProject {
|
interface CapitalProject {
|
||||||
id: string; name: string; description: string; estimated_cost: string;
|
id: string; name: string; description: string; estimated_cost: string;
|
||||||
actual_cost: string; target_year: number; target_month: number;
|
actual_cost: string; target_year: number; target_month: number;
|
||||||
@@ -21,95 +29,395 @@ const statusColors: Record<string, string> = {
|
|||||||
completed: 'teal', deferred: 'gray', cancelled: 'red',
|
completed: 'teal', deferred: 'gray', cancelled: 'red',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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' });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Kanban card
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface KanbanCardProps {
|
||||||
|
project: CapitalProject;
|
||||||
|
onEdit: (p: CapitalProject) => void;
|
||||||
|
onDragStart: (e: DragEvent<HTMLDivElement>, project: CapitalProject) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
shadow="sm"
|
||||||
|
padding="sm"
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => onDragStart(e, project)}
|
||||||
|
style={{ cursor: 'grab', userSelect: 'none' }}
|
||||||
|
mb="xs"
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap" mb={4}>
|
||||||
|
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
|
||||||
|
<IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />
|
||||||
|
<Text fw={600} size="sm" truncate>
|
||||||
|
{project.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
|
||||||
|
<IconEdit size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap={6} mb={6}>
|
||||||
|
<Badge size="xs" color={statusColors[project.status] || 'gray'}>
|
||||||
|
{project.status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
<Badge size="xs" color={priorityColor(project.priority)} variant="outline">
|
||||||
|
P{project.priority}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text size="xs" ff="monospace" fw={500} mb={4}>
|
||||||
|
{fmt(project.estimated_cost)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Badge size="xs" variant="light" color="violet">
|
||||||
|
{project.fund_source?.replace('_', ' ') || 'reserve'}
|
||||||
|
</Badge>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Kanban column (year)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface KanbanColumnProps {
|
||||||
|
year: number;
|
||||||
|
projects: CapitalProject[];
|
||||||
|
onEdit: (p: CapitalProject) => void;
|
||||||
|
onDragStart: (e: DragEvent<HTMLDivElement>, project: CapitalProject) => void;
|
||||||
|
onDrop: (e: DragEvent<HTMLDivElement>, targetYear: number) => void;
|
||||||
|
isDragOver: boolean;
|
||||||
|
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
|
||||||
|
onDragLeave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KanbanColumn({
|
||||||
|
year, projects, onEdit, onDragStart, onDrop,
|
||||||
|
isDragOver, onDragOverHandler, onDragLeave,
|
||||||
|
}: KanbanColumnProps) {
|
||||||
|
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
p="sm"
|
||||||
|
miw={280}
|
||||||
|
maw={320}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
backgroundColor: isDragOver ? 'var(--mantine-color-blue-0)' : undefined,
|
||||||
|
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
|
||||||
|
transition: 'background-color 150ms ease, border 150ms ease',
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => onDragOverHandler(e, year)}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={(e) => onDrop(e, year)}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" mb="sm">
|
||||||
|
<Title order={5}>{year}</Title>
|
||||||
|
<Badge size="sm" variant="light">{fmt(totalEst)}</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed" mb="xs">
|
||||||
|
{projects.length} project{projects.length !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box style={{ flex: 1, minHeight: 60 }}>
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<Text size="xs" c="dimmed" ta="center" py="lg">
|
||||||
|
Drop projects here
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
projects.map((p) => (
|
||||||
|
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
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 [dragOverYear, setDragOverYear] = useState<number | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// ---- Data fetching ----
|
||||||
|
|
||||||
const { data: projects = [], isLoading } = useQuery<CapitalProject[]>({
|
const { data: projects = [], isLoading } = useQuery<CapitalProject[]>({
|
||||||
queryKey: ['capital-projects'],
|
queryKey: ['capital-projects'],
|
||||||
queryFn: async () => { const { data } = await api.get('/capital-projects'); return data; },
|
queryFn: async () => { const { data } = await api.get('/capital-projects'); return data; },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Form ----
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: '', description: '', estimated_cost: 0, actual_cost: 0,
|
name: '', description: '', estimated_cost: 0, actual_cost: 0,
|
||||||
target_year: new Date().getFullYear(), target_month: 6,
|
target_year: new Date().getFullYear(), target_month: 6,
|
||||||
status: 'planned', fund_source: 'reserve', priority: 3,
|
status: 'planned', fund_source: 'reserve', priority: 3,
|
||||||
},
|
},
|
||||||
validate: { name: (v) => (v.length > 0 ? null : 'Required'), estimated_cost: (v) => (v > 0 ? null : 'Required') },
|
validate: {
|
||||||
|
name: (v) => (v.length > 0 ? null : 'Required'),
|
||||||
|
estimated_cost: (v) => (v > 0 ? null : 'Required'),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Mutations ----
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (values: any) => editing ? api.put(`/capital-projects/${editing.id}`, values) : api.post('/capital-projects', values),
|
mutationFn: (values: any) =>
|
||||||
|
editing
|
||||||
|
? api.put(`/capital-projects/${editing.id}`, values)
|
||||||
|
: api.post('/capital-projects', values),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['capital-projects'] });
|
queryClient.invalidateQueries({ queryKey: ['capital-projects'] });
|
||||||
notifications.show({ message: editing ? 'Project updated' : 'Project created', color: 'green' });
|
notifications.show({ message: editing ? 'Project updated' : 'Project created', color: 'green' });
|
||||||
close(); setEditing(null); form.reset();
|
close(); setEditing(null); form.reset();
|
||||||
},
|
},
|
||||||
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 moveProjectMutation = useMutation({
|
||||||
|
mutationFn: ({ id, target_year }: { id: string; target_year: number }) =>
|
||||||
|
api.put(`/capital-projects/${id}`, { target_year }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['capital-projects'] });
|
||||||
|
notifications.show({ message: 'Project moved successfully', color: 'green' });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({ message: err.response?.data?.message || 'Failed to move project', color: 'red' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Handlers ----
|
||||||
|
|
||||||
const handleEdit = (p: CapitalProject) => {
|
const handleEdit = (p: CapitalProject) => {
|
||||||
setEditing(p);
|
setEditing(p);
|
||||||
form.setValues({
|
form.setValues({
|
||||||
name: p.name, description: p.description || '',
|
name: p.name, description: p.description || '',
|
||||||
estimated_cost: parseFloat(p.estimated_cost || '0'), actual_cost: parseFloat(p.actual_cost || '0'),
|
estimated_cost: parseFloat(p.estimated_cost || '0'),
|
||||||
|
actual_cost: parseFloat(p.actual_cost || '0'),
|
||||||
target_year: p.target_year, target_month: p.target_month || 6,
|
target_year: p.target_year, target_month: p.target_month || 6,
|
||||||
status: p.status, fund_source: p.fund_source || 'reserve', priority: p.priority || 3,
|
status: p.status, fund_source: p.fund_source || 'reserve',
|
||||||
|
priority: p.priority || 3,
|
||||||
});
|
});
|
||||||
open();
|
open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
const handleNewProject = () => {
|
||||||
const years = [...new Set(projects.map(p => p.target_year))].sort();
|
setEditing(null);
|
||||||
|
form.reset();
|
||||||
|
open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePdfExport = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Drag & Drop ----
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: CapitalProject) => {
|
||||||
|
e.dataTransfer.setData('application/json', JSON.stringify({ id: project.id, source_year: project.target_year }));
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>, year: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
setDragOverYear(year);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback(() => {
|
||||||
|
setDragOverYear(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: DragEvent<HTMLDivElement>, targetYear: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOverYear(null);
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||||
|
if (payload.source_year !== targetYear) {
|
||||||
|
moveProjectMutation.mutate({ id: payload.id, target_year: targetYear });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed drag data
|
||||||
|
}
|
||||||
|
}, [moveProjectMutation]);
|
||||||
|
|
||||||
|
// ---- Derived data ----
|
||||||
|
|
||||||
|
const years = [...new Set(projects.map((p) => p.target_year))].sort();
|
||||||
|
|
||||||
|
// ---- Loading state ----
|
||||||
|
|
||||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
|
// ---- Render: Table view ----
|
||||||
|
|
||||||
|
const renderTableView = () => (
|
||||||
|
<>
|
||||||
|
{years.length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
|
No capital projects planned yet. Add your first project.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
years.map((year) => {
|
||||||
|
const yearProjects = projects.filter((p) => p.target_year === year);
|
||||||
|
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>
|
||||||
|
<Badge size="lg" variant="light">{fmt(totalEst)} estimated</Badge>
|
||||||
|
</Group>
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Project</Table.Th>
|
||||||
|
<Table.Th>Target</Table.Th>
|
||||||
|
<Table.Th>Priority</Table.Th>
|
||||||
|
<Table.Th ta="right">Estimated</Table.Th>
|
||||||
|
<Table.Th ta="right">Actual</Table.Th>
|
||||||
|
<Table.Th>Source</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th></Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{yearProjects.map((p) => (
|
||||||
|
<Table.Tr key={p.id}>
|
||||||
|
<Table.Td fw={500}>{p.name}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{p.target_month
|
||||||
|
? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' })
|
||||||
|
: ''}{' '}
|
||||||
|
{p.target_year}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" color={priorityColor(p.priority)}>P{p.priority}</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">{fmt(p.estimated_cost)}</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
{parseFloat(p.actual_cost || '0') > 0 ? fmt(p.actual_cost) : '-'}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td><Badge size="sm" variant="light">{p.fund_source}</Badge></Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" color={statusColors[p.status] || 'gray'}>{p.status}</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Render: Kanban view ----
|
||||||
|
|
||||||
|
const renderKanbanView = () => (
|
||||||
|
<ScrollArea type="auto" offsetScrollbars>
|
||||||
|
<Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: years.length * 300 }}>
|
||||||
|
{years.length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="xl" w="100%">
|
||||||
|
No capital projects planned yet. Add your first project.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
years.map((year) => {
|
||||||
|
const yearProjects = projects.filter((p) => p.target_year === year);
|
||||||
|
return (
|
||||||
|
<KanbanColumn
|
||||||
|
key={year}
|
||||||
|
year={year}
|
||||||
|
projects={yearProjects}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
isDragOver={dragOverYear === year}
|
||||||
|
onDragOverHandler={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Render ----
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Capital Projects (5-Year Plan)</Title>
|
<Title order={2}>Capital Projects (5-Year Plan)</Title>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Project</Button>
|
<Group gap="sm">
|
||||||
|
<SegmentedControl
|
||||||
|
value={viewMode}
|
||||||
|
onChange={setViewMode}
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
value: 'table',
|
||||||
|
label: (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<IconTable size={16} />
|
||||||
|
<Text size="sm">Table</Text>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'kanban',
|
||||||
|
label: (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<IconLayoutKanban size={16} />
|
||||||
|
<Text size="sm">Kanban</Text>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Tooltip label="Export as PDF (browser print)">
|
||||||
|
<Button variant="light" leftSection={<IconFileTypePdf size={16} />} onClick={handlePdfExport}>
|
||||||
|
PDF
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNewProject}>
|
||||||
|
Add Project
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{years.length === 0 ? (
|
{viewMode === 'table' ? renderTableView() : renderKanbanView()}
|
||||||
<Text c="dimmed" ta="center" py="xl">No capital projects planned yet. Add your first project.</Text>
|
|
||||||
) : years.map(year => {
|
|
||||||
const yearProjects = projects.filter(p => p.target_year === year);
|
|
||||||
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>
|
|
||||||
<Badge size="lg" variant="light">{fmt(totalEst)} estimated</Badge>
|
|
||||||
</Group>
|
|
||||||
<Table striped highlightOnHover>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Project</Table.Th><Table.Th>Target</Table.Th><Table.Th>Priority</Table.Th>
|
|
||||||
<Table.Th ta="right">Estimated</Table.Th><Table.Th ta="right">Actual</Table.Th>
|
|
||||||
<Table.Th>Source</Table.Th><Table.Th>Status</Table.Th><Table.Th></Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{yearProjects.map((p) => (
|
|
||||||
<Table.Tr key={p.id}>
|
|
||||||
<Table.Td fw={500}>{p.name}</Table.Td>
|
|
||||||
<Table.Td>{p.target_month ? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' }) : ''} {p.target_year}</Table.Td>
|
|
||||||
<Table.Td><Badge size="sm" color={p.priority <= 2 ? 'red' : p.priority <= 3 ? 'yellow' : 'gray'}>P{p.priority}</Badge></Table.Td>
|
|
||||||
<Table.Td ta="right" ff="monospace">{fmt(p.estimated_cost)}</Table.Td>
|
|
||||||
<Table.Td ta="right" ff="monospace">{parseFloat(p.actual_cost || '0') > 0 ? fmt(p.actual_cost) : '-'}</Table.Td>
|
|
||||||
<Table.Td><Badge size="sm" variant="light">{p.fund_source}</Badge></Table.Td>
|
|
||||||
<Table.Td><Badge size="sm" color={statusColors[p.status] || 'gray'}>{p.status}</Badge></Table.Td>
|
|
||||||
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(p)}><IconEdit size={16} /></ActionIcon></Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<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))}>
|
||||||
@@ -122,12 +430,31 @@ export function CapitalProjectsPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput label="Target Year" required min={2024} max={2040} {...form.getInputProps('target_year')} />
|
<NumberInput label="Target Year" required min={2024} max={2040} {...form.getInputProps('target_year')} />
|
||||||
<Select label="Target Month" data={Array.from({length:12},(_,i)=>({value:String(i+1),label:new Date(2026,i).toLocaleString('default',{month:'long'})}))}
|
<Select
|
||||||
value={String(form.values.target_month)} onChange={(v) => form.setFieldValue('target_month', Number(v))} />
|
label="Target Month"
|
||||||
|
data={Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
value: String(i + 1),
|
||||||
|
label: new Date(2026, i).toLocaleString('default', { month: 'long' }),
|
||||||
|
}))}
|
||||||
|
value={String(form.values.target_month)}
|
||||||
|
onChange={(v) => form.setFieldValue('target_month', Number(v))}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<Select label="Status" data={Object.keys(statusColors).map(s => ({ value: s, label: s.replace('_', ' ') }))} {...form.getInputProps('status')} />
|
<Select
|
||||||
<Select label="Fund Source" data={[{value:'reserve',label:'Reserve'},{value:'operating',label:'Operating'},{value:'special_assessment',label:'Special Assessment'}]} {...form.getInputProps('fund_source')} />
|
label="Status"
|
||||||
|
data={Object.keys(statusColors).map((s) => ({ value: s, label: s.replace('_', ' ') }))}
|
||||||
|
{...form.getInputProps('status')}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Fund Source"
|
||||||
|
data={[
|
||||||
|
{ value: 'reserve', label: 'Reserve' },
|
||||||
|
{ value: 'operating', label: 'Operating' },
|
||||||
|
{ value: 'special_assessment', label: 'Special Assessment' },
|
||||||
|
]}
|
||||||
|
{...form.getInputProps('fund_source')}
|
||||||
|
/>
|
||||||
<NumberInput label="Priority (1=High, 5=Low)" min={1} max={5} {...form.getInputProps('priority')} />
|
<NumberInput label="Priority (1=High, 5=Low)" min={1} max={5} {...form.getInputProps('priority')} />
|
||||||
</Group>
|
</Group>
|
||||||
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
|
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ function InvoiceRows({ invoices }: { invoices: Invoice[] }) {
|
|||||||
return (
|
return (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={9} p={0} style={{ background: 'var(--mantine-color-gray-0)' }}>
|
<Table.Td colSpan={9} p={0} style={{ background: 'var(--mantine-color-gray-0)' }}>
|
||||||
<Table fontSize="xs" horizontalSpacing="sm" verticalSpacing={4}>
|
<Table horizontalSpacing="sm" fz="xs">
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th w={40} />
|
<Table.Th w={40} />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface User {
|
|||||||
email: string;
|
email: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
isSuperadmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
|
|||||||
Reference in New Issue
Block a user