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:
2026-02-18 14:28:46 -05:00
parent e0272f9d8a
commit 01502e07bc
29 changed files with 1792 additions and 142 deletions

View File

@@ -16,8 +16,8 @@ export class AccountsController {
@Get()
@ApiOperation({ summary: 'List all accounts' })
findAll(@Query('fundType') fundType?: string) {
return this.accountsService.findAll(fundType);
findAll(@Query('fundType') fundType?: string, @Query('includeArchived') includeArchived?: string) {
return this.accountsService.findAll(fundType, includeArchived === 'true');
}
@Get('trial-balance')

View File

@@ -7,12 +7,21 @@ import { UpdateAccountDto } from './dto/update-account.dto';
export class AccountsService {
constructor(private tenant: TenantService) {}
async findAll(fundType?: string) {
let sql = 'SELECT * FROM accounts WHERE is_active = true';
async findAll(fundType?: string, includeArchived?: boolean) {
let sql = 'SELECT * FROM accounts';
const params: any[] = [];
const conditions: string[] = [];
if (!includeArchived) {
conditions.push('is_active = true');
}
if (fundType) {
sql += ' AND fund_type = $1';
params.push(fundType);
conditions.push(`fund_type = $${params.length}`);
}
if (conditions.length) {
sql += ' WHERE ' + conditions.join(' AND ');
}
sql += ' ORDER BY account_number';
return this.tenant.query(sql, params);
@@ -47,7 +56,51 @@ export class AccountsService {
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) {
@@ -62,6 +115,9 @@ export class AccountsService {
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.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.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); }

View File

@@ -32,4 +32,8 @@ export class CreateAccountDto {
@IsBoolean()
@IsOptional()
is1099Reportable?: boolean;
@ApiProperty({ required: false, default: 0 })
@IsOptional()
initialBalance?: number;
}

View File

@@ -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';
export class UpdateAccountDto {
@@ -26,4 +26,14 @@ export class UpdateAccountDto {
@IsBoolean()
@IsOptional()
isActive?: boolean;
@ApiProperty({ required: false })
@IsInt()
@IsOptional()
accountNumber?: number;
@ApiProperty({ required: false })
@IsIn(['operating', 'reserve'])
@IsOptional()
fundType?: string;
}