Phase 3: Optimize & clean up — unified projects, account enhancements, new tenant fix
- Unify reserve_components + capital_projects into single projects model with full CRUD backend and new Projects page frontend - Rewrite Capital Planning to read from unified projects/planning endpoint; add empty state directing users to Projects page when no planning items exist - Add default designation to assessment groups with auto-set on first creation; units now require an assessment group (pre-populated with default) - Add primary account designation (one per fund type) and balance adjustment via journal entries against equity offset accounts (3000/3100) - Add computed investment fields (interest earned, maturity value, days remaining) with PostgreSQL date arithmetic fix for DATE - DATE integer result - Restructure sidebar: investments in Accounts tab, Year-End under Reports, Planning section with Projects and Capital Planning - Fix new tenant creation seeding unwanted default chart of accounts — new tenants now start with a blank slate Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ 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';
|
import { AssessmentGroupsModule } from './modules/assessment-groups/assessment-groups.module';
|
||||||
|
import { ProjectsModule } from './modules/projects/projects.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -54,6 +55,7 @@ import { AssessmentGroupsModule } from './modules/assessment-groups/assessment-g
|
|||||||
CapitalProjectsModule,
|
CapitalProjectsModule,
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
AssessmentGroupsModule,
|
AssessmentGroupsModule,
|
||||||
|
ProjectsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export class TenantSchemaService {
|
|||||||
await queryRunner.query(statement);
|
await queryRunner.query(statement);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.seedDefaultChartOfAccounts(queryRunner, schemaName);
|
|
||||||
await this.seedDefaultFiscalPeriods(queryRunner, schemaName);
|
await this.seedDefaultFiscalPeriods(queryRunner, schemaName);
|
||||||
|
|
||||||
await queryRunner.commitTransaction();
|
await queryRunner.commitTransaction();
|
||||||
@@ -45,6 +44,7 @@ export class TenantSchemaService {
|
|||||||
is_1099_reportable BOOLEAN DEFAULT FALSE,
|
is_1099_reportable BOOLEAN DEFAULT FALSE,
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
is_system BOOLEAN DEFAULT FALSE,
|
is_system BOOLEAN DEFAULT FALSE,
|
||||||
|
is_primary BOOLEAN DEFAULT FALSE,
|
||||||
balance DECIMAL(15,2) DEFAULT 0.00,
|
balance DECIMAL(15,2) DEFAULT 0.00,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
@@ -110,6 +110,7 @@ export class TenantSchemaService {
|
|||||||
special_assessment DECIMAL(10,2) DEFAULT 0.00,
|
special_assessment DECIMAL(10,2) DEFAULT 0.00,
|
||||||
unit_count INTEGER DEFAULT 0,
|
unit_count INTEGER DEFAULT 0,
|
||||||
frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')),
|
frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')),
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
@@ -281,6 +282,37 @@ export class TenantSchemaService {
|
|||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
|
// Unified Projects (replaces reserve_components + capital_projects for new features)
|
||||||
|
`CREATE TABLE "${s}".projects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(100),
|
||||||
|
estimated_cost DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
actual_cost DECIMAL(15,2),
|
||||||
|
current_fund_balance DECIMAL(15,2) DEFAULT 0.00,
|
||||||
|
annual_contribution DECIMAL(12,2) DEFAULT 0.00,
|
||||||
|
fund_source VARCHAR(20) CHECK (fund_source IN ('operating', 'reserve', 'special_assessment')),
|
||||||
|
funded_percentage DECIMAL(5,2) DEFAULT 0,
|
||||||
|
useful_life_years INTEGER,
|
||||||
|
remaining_life_years DECIMAL(5,1),
|
||||||
|
condition_rating INTEGER CHECK (condition_rating BETWEEN 1 AND 10),
|
||||||
|
last_replacement_date DATE,
|
||||||
|
next_replacement_date DATE,
|
||||||
|
planned_date DATE,
|
||||||
|
target_year INTEGER,
|
||||||
|
target_month INTEGER CHECK (target_month BETWEEN 1 AND 12),
|
||||||
|
status VARCHAR(20) DEFAULT 'planned' CHECK (status IN (
|
||||||
|
'planned', 'approved', 'in_progress', 'completed', 'deferred', 'cancelled'
|
||||||
|
)),
|
||||||
|
priority INTEGER DEFAULT 3 CHECK (priority BETWEEN 1 AND 5),
|
||||||
|
account_id UUID REFERENCES "${s}".accounts(id),
|
||||||
|
notes TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
// Indexes
|
// Indexes
|
||||||
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
|
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
|
||||||
`CREATE INDEX "idx_${s}_je_fiscal" ON "${s}".journal_entries(fiscal_period_id)`,
|
`CREATE INDEX "idx_${s}_je_fiscal" ON "${s}".journal_entries(fiscal_period_id)`,
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ export class AccountsController {
|
|||||||
return this.accountsService.getTrialBalance(asOfDate);
|
return this.accountsService.getTrialBalance(asOfDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put(':id/set-primary')
|
||||||
|
@ApiOperation({ summary: 'Set account as primary for its fund type' })
|
||||||
|
setPrimary(@Param('id') id: string) {
|
||||||
|
return this.accountsService.setPrimary(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/adjust-balance')
|
||||||
|
@ApiOperation({ summary: 'Adjust account balance to a target amount' })
|
||||||
|
adjustBalance(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
||||||
|
) {
|
||||||
|
return this.accountsService.adjustBalance(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get account by ID' })
|
@ApiOperation({ summary: 'Get account by ID' })
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -109,6 +109,16 @@ export class AccountsService {
|
|||||||
throw new BadRequestException('Cannot change type of system account');
|
throw new BadRequestException('Cannot change type of system account');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle isPrimary: clear other primary accounts in the same fund_type first
|
||||||
|
if (dto.isPrimary === true) {
|
||||||
|
await this.tenant.query(
|
||||||
|
`UPDATE accounts SET is_primary = false
|
||||||
|
WHERE fund_type = (SELECT fund_type FROM accounts WHERE id = $1)
|
||||||
|
AND is_primary = true`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const sets: string[] = [];
|
const sets: string[] = [];
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
let idx = 1;
|
let idx = 1;
|
||||||
@@ -120,6 +130,7 @@ export class AccountsService {
|
|||||||
if (dto.fundType !== undefined) { sets.push(`fund_type = $${idx++}`); params.push(dto.fundType); }
|
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); }
|
||||||
|
if (dto.isPrimary !== undefined) { sets.push(`is_primary = $${idx++}`); params.push(dto.isPrimary); }
|
||||||
|
|
||||||
if (!sets.length) return account;
|
if (!sets.length) return account;
|
||||||
|
|
||||||
@@ -133,6 +144,136 @@ export class AccountsService {
|
|||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setPrimary(id: string) {
|
||||||
|
const account = await this.findOne(id);
|
||||||
|
|
||||||
|
// Clear other primary accounts in the same fund_type
|
||||||
|
await this.tenant.query(
|
||||||
|
`UPDATE accounts SET is_primary = false
|
||||||
|
WHERE fund_type = $1 AND is_primary = true`,
|
||||||
|
[account.fund_type],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set this account as primary
|
||||||
|
await this.tenant.query(
|
||||||
|
`UPDATE accounts SET is_primary = true, updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return this.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async adjustBalance(id: string, dto: { targetBalance: number; asOfDate: string; memo?: string }) {
|
||||||
|
const account = await this.findOne(id);
|
||||||
|
|
||||||
|
// Get current balance for this account using trial balance logic
|
||||||
|
const balanceRows = await this.tenant.query(
|
||||||
|
`SELECT
|
||||||
|
CASE
|
||||||
|
WHEN a.account_type IN ('asset', 'expense')
|
||||||
|
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||||
|
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||||
|
END as balance
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND je.entry_date <= $1
|
||||||
|
WHERE a.id = $2
|
||||||
|
GROUP BY a.id, a.account_type`,
|
||||||
|
[dto.asOfDate, id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentBalance = balanceRows.length ? parseFloat(balanceRows[0].balance) : 0;
|
||||||
|
const difference = dto.targetBalance - currentBalance;
|
||||||
|
|
||||||
|
if (difference === 0) {
|
||||||
|
return { message: 'No adjustment needed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find fiscal period for the asOfDate
|
||||||
|
const asOf = new Date(dto.asOfDate);
|
||||||
|
const year = asOf.getFullYear();
|
||||||
|
const month = asOf.getMonth() + 1;
|
||||||
|
|
||||||
|
const periods = await this.tenant.query(
|
||||||
|
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
|
||||||
|
[year, month],
|
||||||
|
);
|
||||||
|
if (!periods.length) {
|
||||||
|
throw new BadRequestException(`No fiscal period found for ${year}-${String(month).padStart(2, '0')}`);
|
||||||
|
}
|
||||||
|
const fiscalPeriodId = periods[0].id;
|
||||||
|
|
||||||
|
// Determine the equity offset account based on fund_type
|
||||||
|
const equityAccountNumber = account.fund_type === 'reserve' ? 3100 : 3000;
|
||||||
|
const equityRows = await this.tenant.query(
|
||||||
|
'SELECT id, account_type FROM accounts WHERE account_number = $1',
|
||||||
|
[equityAccountNumber],
|
||||||
|
);
|
||||||
|
if (!equityRows.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Equity offset account ${equityAccountNumber} not found`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const equityAccount = equityRows[0];
|
||||||
|
|
||||||
|
// Calculate debit/credit for the target account line
|
||||||
|
// For debit-normal accounts (asset, expense): increase = debit, decrease = credit
|
||||||
|
// For credit-normal accounts (liability, equity, income): increase = credit, decrease = debit
|
||||||
|
const isDebitNormal = ['asset', 'expense'].includes(account.account_type);
|
||||||
|
const absDifference = Math.abs(difference);
|
||||||
|
|
||||||
|
let targetDebit: number;
|
||||||
|
let targetCredit: number;
|
||||||
|
|
||||||
|
if (isDebitNormal) {
|
||||||
|
// Debit-normal: positive difference means we need more debit
|
||||||
|
targetDebit = difference > 0 ? absDifference : 0;
|
||||||
|
targetCredit = difference > 0 ? 0 : absDifference;
|
||||||
|
} else {
|
||||||
|
// Credit-normal: positive difference means we need more credit
|
||||||
|
targetDebit = difference > 0 ? 0 : absDifference;
|
||||||
|
targetCredit = difference > 0 ? absDifference : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balancing line to equity account is the opposite
|
||||||
|
const equityDebit = targetCredit > 0 ? targetCredit : 0;
|
||||||
|
const equityCredit = targetDebit > 0 ? targetDebit : 0;
|
||||||
|
|
||||||
|
const memo = dto.memo || `Balance adjustment to ${dto.targetBalance}`;
|
||||||
|
|
||||||
|
// Create 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 ($1, $2, 'adjustment', $3, true, NOW(), $4)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
dto.asOfDate,
|
||||||
|
memo,
|
||||||
|
fiscalPeriodId,
|
||||||
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const journalEntry = jeRows[0];
|
||||||
|
|
||||||
|
// Create the two journal entry lines
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[journalEntry.id, id, targetDebit, targetCredit, memo],
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[journalEntry.id, equityAccount.id, equityDebit, equityCredit, memo],
|
||||||
|
);
|
||||||
|
|
||||||
|
return journalEntry;
|
||||||
|
}
|
||||||
|
|
||||||
async getTrialBalance(asOfDate?: string) {
|
async getTrialBalance(asOfDate?: string) {
|
||||||
const dateFilter = asOfDate
|
const dateFilter = asOfDate
|
||||||
? `AND je.entry_date <= $1`
|
? `AND je.entry_date <= $1`
|
||||||
|
|||||||
@@ -36,4 +36,9 @@ export class UpdateAccountDto {
|
|||||||
@IsIn(['operating', 'reserve'])
|
@IsIn(['operating', 'reserve'])
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
fundType?: string;
|
fundType?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isPrimary?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export class AssessmentGroupsController {
|
|||||||
@Get('summary')
|
@Get('summary')
|
||||||
getSummary() { return this.service.getSummary(); }
|
getSummary() { return this.service.getSummary(); }
|
||||||
|
|
||||||
|
@Get('default')
|
||||||
|
getDefault() { return this.service.getDefault(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@@ -24,4 +27,7 @@ export class AssessmentGroupsController {
|
|||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
|
|
||||||
|
@Put(':id/set-default')
|
||||||
|
setDefault(@Param('id') id: string) { return this.service.setDefault(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ export class AssessmentGroupsService {
|
|||||||
constructor(private tenant: TenantService) {}
|
constructor(private tenant: TenantService) {}
|
||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
// Normalize all income calculations to monthly equivalent
|
|
||||||
// monthly: amount * units (already monthly)
|
|
||||||
// quarterly: amount/3 * units (convert to monthly)
|
|
||||||
// annual: amount/12 * units (convert to monthly)
|
|
||||||
return this.tenant.query(`
|
return this.tenant.query(`
|
||||||
SELECT ag.*,
|
SELECT ag.*,
|
||||||
(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id) as actual_unit_count,
|
(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id) as actual_unit_count,
|
||||||
@@ -39,17 +35,38 @@ export class AssessmentGroupsService {
|
|||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: any) {
|
async getDefault() {
|
||||||
const rows = await this.tenant.query(
|
const rows = await this.tenant.query(
|
||||||
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency)
|
'SELECT * FROM assessment_groups WHERE is_default = true AND is_active = true LIMIT 1',
|
||||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
);
|
||||||
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0, dto.unitCount || 0, dto.frequency || 'monthly'],
|
return rows.length ? rows[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: any) {
|
||||||
|
const existingGroups = await this.tenant.query('SELECT COUNT(*) as cnt FROM assessment_groups');
|
||||||
|
const isFirstGroup = parseInt(existingGroups[0].cnt) === 0;
|
||||||
|
const shouldBeDefault = dto.isDefault || isFirstGroup;
|
||||||
|
|
||||||
|
if (shouldBeDefault) {
|
||||||
|
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, is_default)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||||
|
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0,
|
||||||
|
dto.unitCount || 0, dto.frequency || 'monthly', shouldBeDefault],
|
||||||
);
|
);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: any) {
|
async update(id: string, dto: any) {
|
||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
|
|
||||||
|
if (dto.isDefault === true) {
|
||||||
|
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
|
||||||
|
}
|
||||||
|
|
||||||
const sets: string[] = [];
|
const sets: string[] = [];
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
let idx = 1;
|
let idx = 1;
|
||||||
@@ -61,6 +78,7 @@ export class AssessmentGroupsService {
|
|||||||
if (dto.unitCount !== undefined) { sets.push(`unit_count = $${idx++}`); params.push(dto.unitCount); }
|
if (dto.unitCount !== undefined) { sets.push(`unit_count = $${idx++}`); params.push(dto.unitCount); }
|
||||||
if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); }
|
if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); }
|
||||||
if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); }
|
if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); }
|
||||||
|
if (dto.isDefault !== undefined) { sets.push(`is_default = $${idx++}`); params.push(dto.isDefault); }
|
||||||
|
|
||||||
if (!sets.length) return this.findOne(id);
|
if (!sets.length) return this.findOne(id);
|
||||||
|
|
||||||
@@ -74,6 +92,16 @@ export class AssessmentGroupsService {
|
|||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setDefault(id: string) {
|
||||||
|
await this.findOne(id);
|
||||||
|
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
|
||||||
|
await this.tenant.query(
|
||||||
|
'UPDATE assessment_groups SET is_default = true, updated_at = NOW() WHERE id = $1',
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return this.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
async getSummary() {
|
async getSummary() {
|
||||||
const rows = await this.tenant.query(`
|
const rows = await this.tenant.query(`
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@@ -6,7 +6,30 @@ export class InvestmentsService {
|
|||||||
constructor(private tenant: TenantService) {}
|
constructor(private tenant: TenantService) {}
|
||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
return this.tenant.query('SELECT * FROM investment_accounts WHERE is_active = true ORDER BY name');
|
return this.tenant.query(`
|
||||||
|
SELECT ia.*,
|
||||||
|
CASE
|
||||||
|
WHEN ia.purchase_date IS NOT NULL AND ia.interest_rate IS NOT NULL AND ia.interest_rate > 0 THEN
|
||||||
|
ROUND(ia.principal * (ia.interest_rate / 100.0) *
|
||||||
|
(LEAST(COALESCE(ia.maturity_date, CURRENT_DATE), CURRENT_DATE) - ia.purchase_date)::numeric
|
||||||
|
/ 365.0, 2)
|
||||||
|
ELSE NULL
|
||||||
|
END as interest_earned,
|
||||||
|
CASE
|
||||||
|
WHEN ia.purchase_date IS NOT NULL AND ia.maturity_date IS NOT NULL AND ia.interest_rate IS NOT NULL THEN
|
||||||
|
ROUND(ia.principal * (1 + ia.interest_rate / 100.0 *
|
||||||
|
(ia.maturity_date - ia.purchase_date)::numeric / 365.0), 2)
|
||||||
|
ELSE NULL
|
||||||
|
END as maturity_value,
|
||||||
|
CASE
|
||||||
|
WHEN ia.maturity_date IS NOT NULL THEN
|
||||||
|
GREATEST(ia.maturity_date - CURRENT_DATE, 0)
|
||||||
|
ELSE NULL
|
||||||
|
END as days_remaining
|
||||||
|
FROM investment_accounts ia
|
||||||
|
WHERE ia.is_active = true
|
||||||
|
ORDER BY ia.name
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string) {
|
async findOne(id: string) {
|
||||||
|
|||||||
32
backend/src/modules/projects/projects.controller.ts
Normal file
32
backend/src/modules/projects/projects.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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 { ProjectsService } from './projects.service';
|
||||||
|
|
||||||
|
@ApiTags('projects')
|
||||||
|
@Controller('projects')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ProjectsController {
|
||||||
|
constructor(private service: ProjectsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
|
@Get('planning')
|
||||||
|
findForPlanning() { return this.service.findForPlanning(); }
|
||||||
|
|
||||||
|
@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); }
|
||||||
|
|
||||||
|
@Put(':id/planned-date')
|
||||||
|
updatePlannedDate(@Param('id') id: string, @Body() dto: { planned_date: string }) {
|
||||||
|
return this.service.updatePlannedDate(id, dto.planned_date);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/modules/projects/projects.module.ts
Normal file
12
backend/src/modules/projects/projects.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DatabaseModule } from '../../database/database.module';
|
||||||
|
import { ProjectsService } from './projects.service';
|
||||||
|
import { ProjectsController } from './projects.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule],
|
||||||
|
controllers: [ProjectsController],
|
||||||
|
providers: [ProjectsService],
|
||||||
|
exports: [ProjectsService],
|
||||||
|
})
|
||||||
|
export class ProjectsModule {}
|
||||||
108
backend/src/modules/projects/projects.service.ts
Normal file
108
backend/src/modules/projects/projects.service.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { TenantService } from '../../database/tenant.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProjectsService {
|
||||||
|
constructor(private tenant: TenantService) {}
|
||||||
|
|
||||||
|
async findAll() {
|
||||||
|
// Return all active projects ordered by name
|
||||||
|
return this.tenant.query('SELECT * FROM projects WHERE is_active = true ORDER BY name');
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const rows = await this.tenant.query('SELECT * FROM projects WHERE id = $1', [id]);
|
||||||
|
if (!rows.length) throw new NotFoundException('Project not found');
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async findForPlanning() {
|
||||||
|
// Only return projects that have target_year set (for the Capital Planning kanban)
|
||||||
|
return this.tenant.query(`
|
||||||
|
SELECT * FROM projects
|
||||||
|
WHERE is_active = true AND target_year IS NOT NULL
|
||||||
|
ORDER BY target_year, target_month NULLS LAST, priority
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: any) {
|
||||||
|
// Default planned_date to next_replacement_date if not provided
|
||||||
|
const plannedDate = dto.planned_date || dto.next_replacement_date || null;
|
||||||
|
// If fund_source is not 'reserve', funded_percentage stays 0
|
||||||
|
const fundedPct = dto.fund_source === 'reserve' ? (dto.funded_percentage || 0) : 0;
|
||||||
|
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`INSERT INTO projects (
|
||||||
|
name, description, category, estimated_cost, actual_cost,
|
||||||
|
current_fund_balance, annual_contribution, fund_source, funded_percentage,
|
||||||
|
useful_life_years, remaining_life_years, condition_rating,
|
||||||
|
last_replacement_date, next_replacement_date, planned_date,
|
||||||
|
target_year, target_month, status, priority, account_id, notes
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
dto.name, dto.description || null, dto.category || null,
|
||||||
|
dto.estimated_cost || 0, dto.actual_cost || null,
|
||||||
|
dto.current_fund_balance || 0, dto.annual_contribution || 0,
|
||||||
|
dto.fund_source || 'reserve', fundedPct,
|
||||||
|
dto.useful_life_years || null, dto.remaining_life_years || null,
|
||||||
|
dto.condition_rating || null,
|
||||||
|
dto.last_replacement_date || null, dto.next_replacement_date || null,
|
||||||
|
plannedDate,
|
||||||
|
dto.target_year || null, dto.target_month || null,
|
||||||
|
dto.status || 'planned', dto.priority || 3,
|
||||||
|
dto.account_id || null, dto.notes || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: any) {
|
||||||
|
await this.findOne(id);
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
// Build dynamic SET clause
|
||||||
|
const fields: [string, string][] = [
|
||||||
|
['name', 'name'], ['description', 'description'], ['category', 'category'],
|
||||||
|
['estimated_cost', 'estimated_cost'], ['actual_cost', 'actual_cost'],
|
||||||
|
['current_fund_balance', 'current_fund_balance'], ['annual_contribution', 'annual_contribution'],
|
||||||
|
['fund_source', 'fund_source'], ['funded_percentage', 'funded_percentage'],
|
||||||
|
['useful_life_years', 'useful_life_years'], ['remaining_life_years', 'remaining_life_years'],
|
||||||
|
['condition_rating', 'condition_rating'],
|
||||||
|
['last_replacement_date', 'last_replacement_date'], ['next_replacement_date', 'next_replacement_date'],
|
||||||
|
['planned_date', 'planned_date'],
|
||||||
|
['target_year', 'target_year'], ['target_month', 'target_month'],
|
||||||
|
['status', 'status'], ['priority', 'priority'],
|
||||||
|
['account_id', 'account_id'], ['notes', 'notes'], ['is_active', 'is_active'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [dtoKey, dbCol] of fields) {
|
||||||
|
if (dto[dtoKey] !== undefined) {
|
||||||
|
sets.push(`${dbCol} = $${idx++}`);
|
||||||
|
params.push(dto[dtoKey]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sets.length) return this.findOne(id);
|
||||||
|
|
||||||
|
sets.push('updated_at = NOW()');
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`UPDATE projects SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePlannedDate(id: string, planned_date: string) {
|
||||||
|
await this.findOne(id);
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
'UPDATE projects SET planned_date = $2, updated_at = NOW() WHERE id = $1 RETURNING *',
|
||||||
|
[id, planned_date],
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,10 +32,29 @@ export class UnitsService {
|
|||||||
const existing = await this.tenant.query('SELECT id FROM units WHERE unit_number = $1', [dto.unit_number]);
|
const existing = await this.tenant.query('SELECT id FROM units WHERE unit_number = $1', [dto.unit_number]);
|
||||||
if (existing.length) throw new BadRequestException(`Unit ${dto.unit_number} already exists`);
|
if (existing.length) throw new BadRequestException(`Unit ${dto.unit_number} already exists`);
|
||||||
|
|
||||||
|
// Resolve assessment group: use provided, fall back to default, or error
|
||||||
|
let groupId = dto.assessment_group_id || null;
|
||||||
|
if (!groupId) {
|
||||||
|
const defaultGroup = await this.tenant.query(
|
||||||
|
'SELECT id FROM assessment_groups WHERE is_default = true AND is_active = true LIMIT 1',
|
||||||
|
);
|
||||||
|
if (defaultGroup.length) {
|
||||||
|
groupId = defaultGroup[0].id;
|
||||||
|
} else {
|
||||||
|
// Check if any groups exist at all
|
||||||
|
const anyGroup = await this.tenant.query('SELECT id FROM assessment_groups WHERE is_active = true LIMIT 1');
|
||||||
|
if (!anyGroup.length) {
|
||||||
|
throw new BadRequestException('An assessment group must exist before creating units. Please create an assessment group first.');
|
||||||
|
}
|
||||||
|
// Use the first available group
|
||||||
|
groupId = anyGroup[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const rows = await this.tenant.query(
|
const rows = await this.tenant.query(
|
||||||
`INSERT INTO units (unit_number, address_line1, city, state, zip_code, owner_name, owner_email, owner_phone, monthly_assessment, assessment_group_id)
|
`INSERT INTO units (unit_number, address_line1, city, state, zip_code, owner_name, owner_email, owner_phone, monthly_assessment, assessment_group_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
|
||||||
[dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment || 0, dto.assessment_group_id || null],
|
[dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment || 0, groupId],
|
||||||
);
|
);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { UnitsPage } from './pages/units/UnitsPage';
|
|||||||
import { InvoicesPage } from './pages/invoices/InvoicesPage';
|
import { InvoicesPage } from './pages/invoices/InvoicesPage';
|
||||||
import { PaymentsPage } from './pages/payments/PaymentsPage';
|
import { PaymentsPage } from './pages/payments/PaymentsPage';
|
||||||
import { VendorsPage } from './pages/vendors/VendorsPage';
|
import { VendorsPage } from './pages/vendors/VendorsPage';
|
||||||
import { ReservesPage } from './pages/reserves/ReservesPage';
|
import { ProjectsPage } from './pages/projects/ProjectsPage';
|
||||||
import { InvestmentsPage } from './pages/investments/InvestmentsPage';
|
import { InvestmentsPage } from './pages/investments/InvestmentsPage';
|
||||||
import { CapitalProjectsPage } from './pages/capital-projects/CapitalProjectsPage';
|
import { CapitalProjectsPage } from './pages/capital-projects/CapitalProjectsPage';
|
||||||
import { BalanceSheetPage } from './pages/reports/BalanceSheetPage';
|
import { BalanceSheetPage } from './pages/reports/BalanceSheetPage';
|
||||||
@@ -110,7 +110,7 @@ export function App() {
|
|||||||
<Route path="invoices" element={<InvoicesPage />} />
|
<Route path="invoices" element={<InvoicesPage />} />
|
||||||
<Route path="payments" element={<PaymentsPage />} />
|
<Route path="payments" element={<PaymentsPage />} />
|
||||||
<Route path="vendors" element={<VendorsPage />} />
|
<Route path="vendors" element={<VendorsPage />} />
|
||||||
<Route path="reserves" element={<ReservesPage />} />
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
<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="assessment-groups" element={<AssessmentGroupsPage />} />
|
||||||
@@ -120,7 +120,7 @@ export function App() {
|
|||||||
<Route path="reports/cash-flow" element={<CashFlowPage />} />
|
<Route path="reports/cash-flow" element={<CashFlowPage />} />
|
||||||
<Route path="reports/aging" element={<AgingReportPage />} />
|
<Route path="reports/aging" element={<AgingReportPage />} />
|
||||||
<Route path="reports/sankey" element={<SankeyPage />} />
|
<Route path="reports/sankey" element={<SankeyPage />} />
|
||||||
<Route path="year-end" element={<YearEndPage />} />
|
<Route path="reports/year-end" element={<YearEndPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ import {
|
|||||||
IconReportAnalytics,
|
IconReportAnalytics,
|
||||||
IconChartSankey,
|
IconChartSankey,
|
||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
IconPigMoney,
|
|
||||||
IconBuildingBank,
|
IconBuildingBank,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
IconFileText,
|
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconCrown,
|
IconCrown,
|
||||||
IconCategory,
|
IconCategory,
|
||||||
@@ -31,7 +29,6 @@ const navSections = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'Accounts', icon: IconListDetails, path: '/accounts' },
|
{ label: 'Accounts', icon: IconListDetails, path: '/accounts' },
|
||||||
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' },
|
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' },
|
||||||
{ label: 'Investments', icon: IconPigMoney, path: '/investments' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -52,8 +49,8 @@ const navSections = [
|
|||||||
{
|
{
|
||||||
label: 'Planning',
|
label: 'Planning',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Capital Projects', icon: IconBuildingBank, path: '/capital-projects' },
|
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
|
||||||
{ label: 'Reserves', icon: IconShieldCheck, path: '/reserves' },
|
{ label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' },
|
||||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -70,6 +67,7 @@ const navSections = [
|
|||||||
{ label: 'Budget vs Actual', path: '/reports/budget-vs-actual' },
|
{ label: 'Budget vs Actual', path: '/reports/budget-vs-actual' },
|
||||||
{ label: 'Aging Report', path: '/reports/aging' },
|
{ label: 'Aging Report', path: '/reports/aging' },
|
||||||
{ label: 'Sankey Diagram', path: '/reports/sankey' },
|
{ label: 'Sankey Diagram', path: '/reports/sankey' },
|
||||||
|
{ label: 'Year-End', path: '/reports/year-end' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -77,7 +75,6 @@ const navSections = [
|
|||||||
{
|
{
|
||||||
label: 'Admin',
|
label: 'Admin',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Year-End', icon: IconFileText, path: '/year-end' },
|
|
||||||
{ label: 'Settings', icon: IconSettings, path: '/settings' },
|
{ label: 'Settings', icon: IconSettings, path: '/settings' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,11 +19,23 @@ import {
|
|||||||
Center,
|
Center,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
|
Alert,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
|
import { DateInput } from '@mantine/dates';
|
||||||
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, IconArchive, IconArchiveOff } from '@tabler/icons-react';
|
import {
|
||||||
|
IconPlus,
|
||||||
|
IconEdit,
|
||||||
|
IconSearch,
|
||||||
|
IconArchive,
|
||||||
|
IconArchiveOff,
|
||||||
|
IconStar,
|
||||||
|
IconStarFilled,
|
||||||
|
IconAdjustments,
|
||||||
|
IconInfoCircle,
|
||||||
|
} 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';
|
||||||
|
|
||||||
@@ -37,6 +49,36 @@ interface Account {
|
|||||||
is_1099_reportable: boolean;
|
is_1099_reportable: boolean;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
is_system: boolean;
|
is_system: boolean;
|
||||||
|
is_primary: boolean;
|
||||||
|
balance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Investment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
institution: string;
|
||||||
|
account_number_last4: string;
|
||||||
|
investment_type: string;
|
||||||
|
fund_type: string;
|
||||||
|
principal: string;
|
||||||
|
interest_rate: string;
|
||||||
|
maturity_date: string;
|
||||||
|
purchase_date: string;
|
||||||
|
current_value: string;
|
||||||
|
is_active: boolean;
|
||||||
|
interest_earned: string | null;
|
||||||
|
maturity_value: string | null;
|
||||||
|
days_remaining: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrialBalanceEntry {
|
||||||
|
id: string;
|
||||||
|
account_number: number;
|
||||||
|
name: string;
|
||||||
|
account_type: string;
|
||||||
|
fund_type: string;
|
||||||
|
total_debits: string;
|
||||||
|
total_credits: string;
|
||||||
balance: string;
|
balance: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,15 +90,21 @@ const accountTypeColors: Record<string, string> = {
|
|||||||
expense: 'orange',
|
expense: 'orange',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fmt = (v: string | number) =>
|
||||||
|
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
export function AccountsPage() {
|
export function AccountsPage() {
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const [adjustOpened, { open: openAdjust, close: closeAdjust }] = useDisclosure(false);
|
||||||
const [editing, setEditing] = useState<Account | null>(null);
|
const [editing, setEditing] = useState<Account | null>(null);
|
||||||
|
const [adjustingAccount, setAdjustingAccount] = useState<Account | null>(null);
|
||||||
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 [showArchived, setShowArchived] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// ── Accounts query ──
|
||||||
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
||||||
queryKey: ['accounts', showArchived],
|
queryKey: ['accounts', showArchived],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -66,6 +114,25 @@ export function AccountsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Investments query ──
|
||||||
|
const { data: investments = [], isLoading: investmentsLoading } = useQuery<Investment[]>({
|
||||||
|
queryKey: ['investments'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/investment-accounts');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Trial balance query (for balance adjustment) ──
|
||||||
|
const { data: trialBalance = [] } = useQuery<TrialBalanceEntry[]>({
|
||||||
|
queryKey: ['trial-balance'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/accounts/trial-balance');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create / Edit form ──
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
accountNumber: 0,
|
accountNumber: 0,
|
||||||
@@ -82,6 +149,20 @@ export function AccountsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Balance adjustment form ──
|
||||||
|
const adjustForm = useForm({
|
||||||
|
initialValues: {
|
||||||
|
targetBalance: 0,
|
||||||
|
asOfDate: new Date() as Date | null,
|
||||||
|
memo: '',
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
targetBalance: (v) => (v !== undefined && v !== null ? null : 'Required'),
|
||||||
|
asOfDate: (v) => (v ? null : 'Required'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mutations ──
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (values: any) => {
|
mutationFn: (values: any) => {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
@@ -113,6 +194,38 @@ export function AccountsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const setPrimaryMutation = useMutation({
|
||||||
|
mutationFn: (accountId: string) => api.put(`/accounts/${accountId}/set-primary`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
|
notifications.show({ message: 'Primary account updated', color: 'green' });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const adjustBalanceMutation = useMutation({
|
||||||
|
mutationFn: (values: { accountId: string; targetBalance: number; asOfDate: string; memo: string }) =>
|
||||||
|
api.post(`/accounts/${values.accountId}/adjust-balance`, {
|
||||||
|
targetBalance: values.targetBalance,
|
||||||
|
asOfDate: values.asOfDate,
|
||||||
|
memo: values.memo,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['trial-balance'] });
|
||||||
|
notifications.show({ message: 'Balance adjusted successfully', color: 'green' });
|
||||||
|
closeAdjust();
|
||||||
|
setAdjustingAccount(null);
|
||||||
|
adjustForm.reset();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({ message: err.response?.data?.message || 'Error adjusting balance', color: 'red' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Handlers ──
|
||||||
const handleEdit = (account: Account) => {
|
const handleEdit = (account: Account) => {
|
||||||
setEditing(account);
|
setEditing(account);
|
||||||
form.setValues({
|
form.setValues({
|
||||||
@@ -133,6 +246,28 @@ export function AccountsPage() {
|
|||||||
open();
|
open();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAdjustBalance = (account: Account) => {
|
||||||
|
setAdjustingAccount(account);
|
||||||
|
const tbEntry = trialBalance.find((tb) => tb.id === account.id);
|
||||||
|
adjustForm.setValues({
|
||||||
|
targetBalance: parseFloat(tbEntry?.balance || account.balance || '0'),
|
||||||
|
asOfDate: new Date(),
|
||||||
|
memo: '',
|
||||||
|
});
|
||||||
|
openAdjust();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdjustSubmit = (values: { targetBalance: number; asOfDate: Date | null; memo: string }) => {
|
||||||
|
if (!adjustingAccount || !values.asOfDate) return;
|
||||||
|
adjustBalanceMutation.mutate({
|
||||||
|
accountId: adjustingAccount.id,
|
||||||
|
targetBalance: values.targetBalance,
|
||||||
|
asOfDate: values.asOfDate.toISOString().split('T')[0],
|
||||||
|
memo: values.memo,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Filtering ──
|
||||||
const filtered = accounts.filter((a) => {
|
const filtered = accounts.filter((a) => {
|
||||||
if (search && !a.name.toLowerCase().includes(search.toLowerCase()) && !String(a.account_number).includes(search)) return false;
|
if (search && !a.name.toLowerCase().includes(search.toLowerCase()) && !String(a.account_number).includes(search)) return false;
|
||||||
if (filterType && a.account_type !== filterType) return false;
|
if (filterType && a.account_type !== filterType) return false;
|
||||||
@@ -140,26 +275,42 @@ export function AccountsPage() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeAccounts = filtered.filter(a => a.is_active);
|
const activeAccounts = filtered.filter((a) => a.is_active);
|
||||||
const archivedAccounts = filtered.filter(a => !a.is_active);
|
const archivedAccounts = filtered.filter((a) => !a.is_active);
|
||||||
|
|
||||||
const totalsByType = accounts.reduce((acc, a) => {
|
// ── Summary cards ──
|
||||||
|
const totalsByType = accounts.reduce(
|
||||||
|
(acc, a) => {
|
||||||
if (a.is_active) {
|
if (a.is_active) {
|
||||||
acc[a.account_type] = (acc[a.account_type] || 0) + parseFloat(a.balance || '0');
|
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' });
|
// ── Adjust modal: current balance from trial balance ──
|
||||||
|
const adjustCurrentBalance = adjustingAccount
|
||||||
|
? parseFloat(
|
||||||
|
trialBalance.find((tb) => tb.id === adjustingAccount.id)?.balance ||
|
||||||
|
adjustingAccount.balance ||
|
||||||
|
'0',
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
const adjustmentAmount = (adjustForm.values.targetBalance || 0) - adjustCurrentBalance;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Center h={300}><Loader /></Center>;
|
return (
|
||||||
|
<Center h={300}>
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Chart of Accounts</Title>
|
<Title order={2}>Accounts</Title>
|
||||||
<Group>
|
<Group>
|
||||||
<Switch
|
<Switch
|
||||||
label="Show Archived"
|
label="Show Archived"
|
||||||
@@ -176,8 +327,12 @@ export function AccountsPage() {
|
|||||||
<SimpleGrid cols={{ base: 2, sm: 5 }}>
|
<SimpleGrid cols={{ base: 2, sm: 5 }}>
|
||||||
{Object.entries(totalsByType).map(([type, total]) => (
|
{Object.entries(totalsByType).map(([type, total]) => (
|
||||||
<Card withBorder p="xs" key={type}>
|
<Card withBorder p="xs" key={type}>
|
||||||
<Text size="xs" c="dimmed" tt="capitalize">{type}</Text>
|
<Text size="xs" c="dimmed" tt="capitalize">
|
||||||
<Text fw={700} size="sm" c={accountTypeColors[type]}>{fmt(total)}</Text>
|
{type}
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="sm" c={accountTypeColors[type]}>
|
||||||
|
{fmt(total)}
|
||||||
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
@@ -213,27 +368,59 @@ export function AccountsPage() {
|
|||||||
<Tabs.Tab value="all">All ({activeAccounts.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>
|
||||||
|
<Tabs.Tab value="investments">Investments</Tabs.Tab>
|
||||||
{showArchived && archivedAccounts.length > 0 && (
|
{showArchived && archivedAccounts.length > 0 && (
|
||||||
<Tabs.Tab value="archived" color="gray">Archived ({archivedAccounts.length})</Tabs.Tab>
|
<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={activeAccounts} onEdit={handleEdit} onArchive={archiveMutation.mutate} />
|
<AccountTable
|
||||||
|
accounts={activeAccounts}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onArchive={archiveMutation.mutate}
|
||||||
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
|
onAdjustBalance={handleAdjustBalance}
|
||||||
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="operating" pt="sm">
|
<Tabs.Panel value="operating" pt="sm">
|
||||||
<AccountTable accounts={activeAccounts.filter(a => a.fund_type === 'operating')} onEdit={handleEdit} onArchive={archiveMutation.mutate} />
|
<AccountTable
|
||||||
|
accounts={activeAccounts.filter((a) => a.fund_type === 'operating')}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onArchive={archiveMutation.mutate}
|
||||||
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
|
onAdjustBalance={handleAdjustBalance}
|
||||||
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="reserve" pt="sm">
|
<Tabs.Panel value="reserve" pt="sm">
|
||||||
<AccountTable accounts={activeAccounts.filter(a => a.fund_type === 'reserve')} onEdit={handleEdit} onArchive={archiveMutation.mutate} />
|
<AccountTable
|
||||||
|
accounts={activeAccounts.filter((a) => a.fund_type === 'reserve')}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onArchive={archiveMutation.mutate}
|
||||||
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
|
onAdjustBalance={handleAdjustBalance}
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
<Tabs.Panel value="investments" pt="sm">
|
||||||
|
<InvestmentsTab investments={investments} isLoading={investmentsLoading} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
{showArchived && (
|
{showArchived && (
|
||||||
<Tabs.Panel value="archived" pt="sm">
|
<Tabs.Panel value="archived" pt="sm">
|
||||||
<AccountTable accounts={archivedAccounts} onEdit={handleEdit} onArchive={archiveMutation.mutate} isArchivedView />
|
<AccountTable
|
||||||
|
accounts={archivedAccounts}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onArchive={archiveMutation.mutate}
|
||||||
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
|
onAdjustBalance={handleAdjustBalance}
|
||||||
|
isArchivedView
|
||||||
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
)}
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Create / Edit Account Modal */}
|
||||||
<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>
|
||||||
@@ -279,30 +466,87 @@ export function AccountsPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Balance Adjustment Modal */}
|
||||||
|
<Modal opened={adjustOpened} onClose={closeAdjust} title="Adjust Balance" size="md">
|
||||||
|
{adjustingAccount && (
|
||||||
|
<form onSubmit={adjustForm.onSubmit(handleAdjustSubmit)}>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Account: <strong>{adjustingAccount.account_number} - {adjustingAccount.name}</strong>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Current Balance"
|
||||||
|
value={fmt(adjustCurrentBalance)}
|
||||||
|
readOnly
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Target Balance"
|
||||||
|
required
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
thousandSeparator=","
|
||||||
|
{...adjustForm.getInputProps('targetBalance')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DateInput
|
||||||
|
label="As-of Date"
|
||||||
|
required
|
||||||
|
clearable
|
||||||
|
{...adjustForm.getInputProps('asOfDate')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Memo"
|
||||||
|
placeholder="Optional memo for this adjustment"
|
||||||
|
{...adjustForm.getInputProps('memo')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert icon={<IconInfoCircle size={16} />} color={adjustmentAmount >= 0 ? 'blue' : 'orange'} variant="light">
|
||||||
|
<Text size="sm">
|
||||||
|
Adjustment amount: <strong>{fmt(adjustmentAmount)}</strong>
|
||||||
|
{adjustmentAmount > 0 && ' (increase)'}
|
||||||
|
{adjustmentAmount < 0 && ' (decrease)'}
|
||||||
|
{adjustmentAmount === 0 && ' (no change)'}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Button type="submit" loading={adjustBalanceMutation.isPending}>
|
||||||
|
Apply Adjustment
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Account Table Component ──
|
||||||
|
|
||||||
function AccountTable({
|
function AccountTable({
|
||||||
accounts,
|
accounts,
|
||||||
onEdit,
|
onEdit,
|
||||||
onArchive,
|
onArchive,
|
||||||
|
onSetPrimary,
|
||||||
|
onAdjustBalance,
|
||||||
isArchivedView = false,
|
isArchivedView = false,
|
||||||
}: {
|
}: {
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
onEdit: (a: Account) => void;
|
onEdit: (a: Account) => void;
|
||||||
onArchive: (a: Account) => void;
|
onArchive: (a: Account) => void;
|
||||||
|
onSetPrimary: (id: string) => void;
|
||||||
|
onAdjustBalance: (a: Account) => void;
|
||||||
isArchivedView?: boolean;
|
isArchivedView?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const fmt = (v: string) => {
|
|
||||||
const n = parseFloat(v || '0');
|
|
||||||
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
|
<Table.Th w={40}></Table.Th>
|
||||||
<Table.Th>Acct #</Table.Th>
|
<Table.Th>Acct #</Table.Th>
|
||||||
<Table.Th>Name</Table.Th>
|
<Table.Th>Name</Table.Th>
|
||||||
<Table.Th>Type</Table.Th>
|
<Table.Th>Type</Table.Th>
|
||||||
@@ -315,7 +559,7 @@ function AccountTable({
|
|||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{accounts.length === 0 && (
|
{accounts.length === 0 && (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={7}>
|
<Table.Td colSpan={8}>
|
||||||
<Text ta="center" c="dimmed" py="lg">
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
{isArchivedView ? 'No archived accounts' : 'No accounts found'}
|
{isArchivedView ? 'No archived accounts' : 'No accounts found'}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -324,11 +568,22 @@ function AccountTable({
|
|||||||
)}
|
)}
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
<Table.Tr key={a.id} style={{ opacity: a.is_active ? 1 : 0.6 }}>
|
<Table.Tr key={a.id} style={{ opacity: a.is_active ? 1 : 0.6 }}>
|
||||||
|
<Table.Td>
|
||||||
|
{a.is_primary && (
|
||||||
|
<Tooltip label="Primary account">
|
||||||
|
<IconStarFilled size={16} style={{ color: 'var(--mantine-color-yellow-5)' }} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
<Table.Td fw={500}>{a.account_number}</Table.Td>
|
<Table.Td fw={500}>{a.account_number}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm">{a.name}</Text>
|
<Text size="sm">{a.name}</Text>
|
||||||
{a.description && <Text size="xs" c="dimmed">{a.description}</Text>}
|
{a.description && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{a.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
@@ -341,10 +596,32 @@ function AccountTable({
|
|||||||
{a.fund_type}
|
{a.fund_type}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td ta="right" ff="monospace">{fmt(a.balance)}</Table.Td>
|
<Table.Td ta="right" ff="monospace">
|
||||||
<Table.Td>{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}</Table.Td>
|
{fmt(a.balance)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
|
||||||
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
|
{a.account_type === 'asset' && (
|
||||||
|
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="yellow"
|
||||||
|
onClick={() => onSetPrimary(a.id)}
|
||||||
|
>
|
||||||
|
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{a.account_type === 'asset' && (
|
||||||
|
<Tooltip label="Adjust Balance">
|
||||||
|
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
|
||||||
|
<IconAdjustments size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip label="Edit account">
|
<Tooltip label="Edit account">
|
||||||
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
|
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
|
||||||
<IconEdit size={16} />
|
<IconEdit size={16} />
|
||||||
@@ -369,3 +646,134 @@ function AccountTable({
|
|||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Investments Tab Component ──
|
||||||
|
|
||||||
|
function InvestmentsTab({
|
||||||
|
investments,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
investments: Investment[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}) {
|
||||||
|
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
|
||||||
|
const totalValue = investments.reduce(
|
||||||
|
(s, i) => s + parseFloat(i.current_value || i.principal || '0'),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const avgRate =
|
||||||
|
investments.length > 0
|
||||||
|
? investments.reduce((s, i) => s + parseFloat(i.interest_rate || '0'), 0) / investments.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Center h={200}>
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Total Principal
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="xl">
|
||||||
|
{fmt(totalPrincipal)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Total Current Value
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="xl" c="green">
|
||||||
|
{fmt(totalValue)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Avg Interest Rate
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="xl">
|
||||||
|
{avgRate.toFixed(2)}%
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Institution</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Fund</Table.Th>
|
||||||
|
<Table.Th ta="right">Principal</Table.Th>
|
||||||
|
<Table.Th ta="right">Rate</Table.Th>
|
||||||
|
<Table.Th ta="right">Interest Earned</Table.Th>
|
||||||
|
<Table.Th ta="right">Maturity Value</Table.Th>
|
||||||
|
<Table.Th ta="right">Days Remaining</Table.Th>
|
||||||
|
<Table.Th>Maturity Date</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{investments.length === 0 && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={10}>
|
||||||
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
|
No investments yet
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
{investments.map((inv) => (
|
||||||
|
<Table.Tr key={inv.id}>
|
||||||
|
<Table.Td fw={500}>{inv.name}</Table.Td>
|
||||||
|
<Table.Td>{inv.institution}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" variant="light">
|
||||||
|
{inv.investment_type}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" color={inv.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light">
|
||||||
|
{inv.fund_type}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
{fmt(inv.principal)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
{parseFloat(inv.interest_rate || '0').toFixed(2)}%
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
{inv.interest_earned !== null ? fmt(inv.interest_earned) : '-'}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
{inv.maturity_value !== null ? fmt(inv.maturity_value) : '-'}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
{inv.days_remaining !== null ? (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
color={inv.days_remaining <= 30 ? 'red' : inv.days_remaining <= 90 ? 'yellow' : 'gray'}
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
{inv.days_remaining} days
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
|
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
|
||||||
ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon,
|
ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon, 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 {
|
import {
|
||||||
IconPlus, IconEdit, IconCategory, IconCash, IconHome, IconArchive,
|
IconPlus, IconEdit, IconCategory, IconCash, IconHome, IconArchive, IconStarFilled, IconStar,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
@@ -24,6 +24,7 @@ interface AssessmentGroup {
|
|||||||
monthly_operating_income: string;
|
monthly_operating_income: string;
|
||||||
monthly_reserve_income: string;
|
monthly_reserve_income: string;
|
||||||
total_monthly_income: string;
|
total_monthly_income: string;
|
||||||
|
is_default: boolean;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +106,17 @@ export function AssessmentGroupsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const setDefaultMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => api.put(`/assessment-groups/${id}/set-default`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['assessment-groups'] });
|
||||||
|
notifications.show({ message: 'Default group updated', color: 'green' });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleEdit = (group: AssessmentGroup) => {
|
const handleEdit = (group: AssessmentGroup) => {
|
||||||
setEditing(group);
|
setEditing(group);
|
||||||
form.setValues({
|
form.setValues({
|
||||||
@@ -223,10 +235,17 @@ export function AssessmentGroupsPage() {
|
|||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<Table.Tr key={g.id} style={{ opacity: g.is_active ? 1 : 0.5 }}>
|
<Table.Tr key={g.id} style={{ opacity: g.is_active ? 1 : 0.5 }}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
|
<Group gap={8}>
|
||||||
<div>
|
<div>
|
||||||
|
<Group gap={6}>
|
||||||
<Text fw={500}>{g.name}</Text>
|
<Text fw={500}>{g.name}</Text>
|
||||||
|
{g.is_default && (
|
||||||
|
<Badge color="yellow" variant="light" size="xs">Default</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
{g.description && <Text size="xs" c="dimmed">{g.description}</Text>}
|
{g.description && <Text size="xs" c="dimmed">{g.description}</Text>}
|
||||||
</div>
|
</div>
|
||||||
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td ta="center">
|
<Table.Td ta="center">
|
||||||
<Badge variant="light">{g.actual_unit_count || g.unit_count}</Badge>
|
<Badge variant="light">{g.actual_unit_count || g.unit_count}</Badge>
|
||||||
@@ -256,6 +275,16 @@ export function AssessmentGroupsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
|
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color={g.is_default ? 'yellow' : 'gray'}
|
||||||
|
onClick={() => !g.is_default && setDefaultMutation.mutate(g.id)}
|
||||||
|
disabled={g.is_default}
|
||||||
|
>
|
||||||
|
{g.is_default ? <IconStarFilled size={16} /> : <IconStar size={16} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}>
|
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}>
|
||||||
<IconEdit size={16} />
|
<IconEdit size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ 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 {
|
import {
|
||||||
IconPlus, IconEdit, IconTable, IconLayoutKanban, IconFileTypePdf,
|
IconEdit, IconTable, IconLayoutKanban, IconFileTypePdf,
|
||||||
IconGripVertical,
|
IconGripVertical, IconCalendar, IconClipboardList,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
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';
|
||||||
|
|
||||||
@@ -18,10 +19,21 @@ import api from '../../services/api';
|
|||||||
// Types & constants
|
// Types & constants
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface CapitalProject {
|
interface Project {
|
||||||
id: string; name: string; description: string; estimated_cost: string;
|
id: string;
|
||||||
actual_cost: string; target_year: number; target_month: number;
|
name: string;
|
||||||
status: string; fund_source: string; priority: number;
|
description: string;
|
||||||
|
category: string;
|
||||||
|
estimated_cost: string;
|
||||||
|
actual_cost: string;
|
||||||
|
fund_source: string;
|
||||||
|
funded_percentage: string;
|
||||||
|
planned_date: string;
|
||||||
|
target_year: number;
|
||||||
|
target_month: number;
|
||||||
|
status: string;
|
||||||
|
priority: number;
|
||||||
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FUTURE_YEAR = 9999;
|
const FUTURE_YEAR = 9999;
|
||||||
@@ -38,17 +50,30 @@ const fmt = (v: string | number) =>
|
|||||||
|
|
||||||
const yearLabel = (year: number) => (year === FUTURE_YEAR ? 'Future' : String(year));
|
const yearLabel = (year: number) => (year === FUTURE_YEAR ? 'Future' : String(year));
|
||||||
|
|
||||||
|
const formatPlannedDate = (d: string | null | undefined) => {
|
||||||
|
if (!d) return null;
|
||||||
|
try {
|
||||||
|
const date = new Date(d);
|
||||||
|
if (isNaN(date.getTime())) return null;
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Kanban card
|
// Kanban card
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface KanbanCardProps {
|
interface KanbanCardProps {
|
||||||
project: CapitalProject;
|
project: Project;
|
||||||
onEdit: (p: CapitalProject) => void;
|
onEdit: (p: Project) => void;
|
||||||
onDragStart: (e: DragEvent<HTMLDivElement>, project: CapitalProject) => void;
|
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
||||||
|
const plannedLabel = formatPlannedDate(project.planned_date);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
@@ -85,9 +110,16 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
|||||||
{fmt(project.estimated_cost)}
|
{fmt(project.estimated_cost)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
<Group gap={6} wrap="wrap">
|
||||||
<Badge size="xs" variant="light" color="violet">
|
<Badge size="xs" variant="light" color="violet">
|
||||||
{project.fund_source?.replace('_', ' ') || 'reserve'}
|
{project.fund_source?.replace('_', ' ') || 'reserve'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{plannedLabel && (
|
||||||
|
<Badge size="xs" variant="light" color="cyan" leftSection={<IconCalendar size={10} />}>
|
||||||
|
{plannedLabel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -98,9 +130,9 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
|||||||
|
|
||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
year: number;
|
year: number;
|
||||||
projects: CapitalProject[];
|
projects: Project[];
|
||||||
onEdit: (p: CapitalProject) => void;
|
onEdit: (p: Project) => void;
|
||||||
onDragStart: (e: DragEvent<HTMLDivElement>, project: CapitalProject) => void;
|
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
|
||||||
onDrop: (e: DragEvent<HTMLDivElement>, targetYear: number) => void;
|
onDrop: (e: DragEvent<HTMLDivElement>, targetYear: number) => void;
|
||||||
isDragOver: boolean;
|
isDragOver: boolean;
|
||||||
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
|
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
|
||||||
@@ -177,7 +209,7 @@ const printStyles = `
|
|||||||
|
|
||||||
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<Project | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<string>('kanban');
|
const [viewMode, setViewMode] = useState<string>('kanban');
|
||||||
const [printMode, setPrintMode] = useState(false);
|
const [printMode, setPrintMode] = useState(false);
|
||||||
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
|
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
|
||||||
@@ -186,12 +218,12 @@ export function CapitalProjectsPage() {
|
|||||||
|
|
||||||
// ---- Data fetching ----
|
// ---- Data fetching ----
|
||||||
|
|
||||||
const { data: projects = [], isLoading } = useQuery<CapitalProject[]>({
|
const { data: projects = [], isLoading } = useQuery<Project[]>({
|
||||||
queryKey: ['capital-projects'],
|
queryKey: ['projects-planning'],
|
||||||
queryFn: async () => { const { data } = await api.get('/capital-projects'); return data; },
|
queryFn: async () => { const { data } = await api.get('/projects/planning'); return data; },
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Form ----
|
// ---- Form (simplified edit modal) ----
|
||||||
|
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
@@ -205,26 +237,48 @@ export function CapitalProjectsPage() {
|
|||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: '', description: '', estimated_cost: 0, actual_cost: 0,
|
status: 'planned',
|
||||||
target_year: new Date().getFullYear(), target_month: 6,
|
priority: 3,
|
||||||
status: 'planned', fund_source: 'reserve', priority: 3,
|
target_year: currentYear,
|
||||||
},
|
target_month: 6,
|
||||||
validate: {
|
planned_date: '',
|
||||||
name: (v) => (v.length > 0 ? null : 'Required'),
|
notes: '',
|
||||||
estimated_cost: (v) => (v > 0 ? null : 'Required'),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Mutations ----
|
// ---- Mutations ----
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (values: any) =>
|
mutationFn: (values: {
|
||||||
editing
|
status: string;
|
||||||
? api.put(`/capital-projects/${editing.id}`, values)
|
priority: number;
|
||||||
: api.post('/capital-projects', values),
|
target_year: number;
|
||||||
|
target_month: number;
|
||||||
|
planned_date: string;
|
||||||
|
notes: string;
|
||||||
|
}) => {
|
||||||
|
if (!editing) return Promise.reject(new Error('No project selected'));
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
status: values.status,
|
||||||
|
priority: values.priority,
|
||||||
|
target_year: values.target_year,
|
||||||
|
target_month: values.target_month,
|
||||||
|
notes: values.notes,
|
||||||
|
};
|
||||||
|
// Derive planned_date from target_year/target_month if not explicitly set
|
||||||
|
if (values.planned_date) {
|
||||||
|
payload.planned_date = values.planned_date;
|
||||||
|
} else if (values.target_year !== FUTURE_YEAR) {
|
||||||
|
payload.planned_date = `${values.target_year}-${String(values.target_month || 6).padStart(2, '0')}-01`;
|
||||||
|
} else {
|
||||||
|
payload.planned_date = null;
|
||||||
|
}
|
||||||
|
return api.put(`/projects/${editing.id}`, payload);
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['capital-projects'] });
|
queryClient.invalidateQueries({ queryKey: ['projects-planning'] });
|
||||||
notifications.show({ message: editing ? 'Project updated' : 'Project created', color: 'green' });
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
notifications.show({ message: 'Project updated', color: 'green' });
|
||||||
close(); setEditing(null); form.reset();
|
close(); setEditing(null); form.reset();
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
@@ -233,10 +287,19 @@ export function CapitalProjectsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const moveProjectMutation = useMutation({
|
const moveProjectMutation = useMutation({
|
||||||
mutationFn: ({ id, target_year }: { id: string; target_year: number }) =>
|
mutationFn: ({ id, target_year, target_month }: { id: string; target_year: number; target_month: number }) => {
|
||||||
api.put(`/capital-projects/${id}`, { target_year }),
|
const payload: Record<string, unknown> = { target_year };
|
||||||
|
// Derive planned_date based on the new year
|
||||||
|
if (target_year === FUTURE_YEAR) {
|
||||||
|
payload.planned_date = null;
|
||||||
|
} else {
|
||||||
|
payload.planned_date = `${target_year}-${String(target_month || 6).padStart(2, '0')}-01`;
|
||||||
|
}
|
||||||
|
return api.put(`/projects/${id}`, payload);
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['capital-projects'] });
|
queryClient.invalidateQueries({ queryKey: ['projects-planning'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
notifications.show({ message: 'Project moved successfully', color: 'green' });
|
notifications.show({ message: 'Project moved successfully', color: 'green' });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
@@ -261,25 +324,19 @@ export function CapitalProjectsPage() {
|
|||||||
|
|
||||||
// ---- Handlers ----
|
// ---- Handlers ----
|
||||||
|
|
||||||
const handleEdit = (p: CapitalProject) => {
|
const handleEdit = (p: Project) => {
|
||||||
setEditing(p);
|
setEditing(p);
|
||||||
form.setValues({
|
form.setValues({
|
||||||
name: p.name, description: p.description || '',
|
status: p.status || 'planned',
|
||||||
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,
|
|
||||||
status: p.status, fund_source: p.fund_source || 'reserve',
|
|
||||||
priority: p.priority || 3,
|
priority: p.priority || 3,
|
||||||
|
target_year: p.target_year,
|
||||||
|
target_month: p.target_month || 6,
|
||||||
|
planned_date: p.planned_date || '',
|
||||||
|
notes: p.notes || '',
|
||||||
});
|
});
|
||||||
open();
|
open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNewProject = () => {
|
|
||||||
setEditing(null);
|
|
||||||
form.reset();
|
|
||||||
open();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePdfExport = () => {
|
const handlePdfExport = () => {
|
||||||
// If already in table view, just print directly
|
// If already in table view, just print directly
|
||||||
if (viewMode === 'table') {
|
if (viewMode === 'table') {
|
||||||
@@ -292,8 +349,12 @@ export function CapitalProjectsPage() {
|
|||||||
|
|
||||||
// ---- Drag & Drop ----
|
// ---- Drag & Drop ----
|
||||||
|
|
||||||
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: CapitalProject) => {
|
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: Project) => {
|
||||||
e.dataTransfer.setData('application/json', JSON.stringify({ id: project.id, source_year: project.target_year }));
|
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||||
|
id: project.id,
|
||||||
|
source_year: project.target_year,
|
||||||
|
target_month: project.target_month,
|
||||||
|
}));
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -313,7 +374,11 @@ export function CapitalProjectsPage() {
|
|||||||
try {
|
try {
|
||||||
const payload = JSON.parse(e.dataTransfer.getData('application/json'));
|
const payload = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||||
if (payload.source_year !== targetYear) {
|
if (payload.source_year !== targetYear) {
|
||||||
moveProjectMutation.mutate({ id: payload.id, target_year: targetYear });
|
moveProjectMutation.mutate({
|
||||||
|
id: payload.id,
|
||||||
|
target_year: targetYear,
|
||||||
|
target_month: payload.target_month || 6,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed drag data
|
// ignore malformed drag data
|
||||||
@@ -336,15 +401,50 @@ export function CapitalProjectsPage() {
|
|||||||
|
|
||||||
// ---- Loading state ----
|
// ---- Loading state ----
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
|
// ---- Empty state when no planning projects exist ----
|
||||||
|
|
||||||
|
if (projects.length === 0) {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Title order={2}>Capital Planning</Title>
|
||||||
|
</Group>
|
||||||
|
<Center py={80}>
|
||||||
|
<Stack align="center" gap="md" maw={420}>
|
||||||
|
<IconClipboardList size={64} color="var(--mantine-color-dimmed)" stroke={1.2} />
|
||||||
|
<Title order={3} c="dimmed" ta="center">
|
||||||
|
No projects in the capital plan
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" ta="center" size="sm">
|
||||||
|
Capital Planning displays projects that have a target year assigned.
|
||||||
|
Head over to the Projects page to define your reserve and operating
|
||||||
|
projects, then assign target years to see them here.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="md"
|
||||||
|
leftSection={<IconClipboardList size={18} />}
|
||||||
|
onClick={() => navigate('/projects')}
|
||||||
|
>
|
||||||
|
Go to Projects
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Render: Table view ----
|
// ---- Render: Table view ----
|
||||||
|
|
||||||
const renderTableView = () => (
|
const renderTableView = () => (
|
||||||
<>
|
<>
|
||||||
{years.length === 0 ? (
|
{years.length === 0 ? (
|
||||||
<Text c="dimmed" ta="center" py="xl">
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
No capital projects planned yet. Add your first project.
|
No projects in the capital plan. Assign a target year to projects in the Projects page.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
years.map((year) => {
|
years.map((year) => {
|
||||||
@@ -361,12 +461,15 @@ export function CapitalProjectsPage() {
|
|||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Project</Table.Th>
|
<Table.Th>Project</Table.Th>
|
||||||
|
<Table.Th>Category</Table.Th>
|
||||||
<Table.Th>Target</Table.Th>
|
<Table.Th>Target</Table.Th>
|
||||||
<Table.Th>Priority</Table.Th>
|
<Table.Th>Priority</Table.Th>
|
||||||
<Table.Th ta="right">Estimated</Table.Th>
|
<Table.Th ta="right">Estimated</Table.Th>
|
||||||
<Table.Th ta="right">Actual</Table.Th>
|
<Table.Th ta="right">Actual</Table.Th>
|
||||||
<Table.Th>Source</Table.Th>
|
<Table.Th>Source</Table.Th>
|
||||||
|
<Table.Th>Funded</Table.Th>
|
||||||
<Table.Th>Status</Table.Th>
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Planned Date</Table.Th>
|
||||||
<Table.Th></Table.Th>
|
<Table.Th></Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
@@ -374,6 +477,7 @@ export function CapitalProjectsPage() {
|
|||||||
{yearProjects.map((p) => (
|
{yearProjects.map((p) => (
|
||||||
<Table.Tr key={p.id}>
|
<Table.Tr key={p.id}>
|
||||||
<Table.Td fw={500}>{p.name}</Table.Td>
|
<Table.Td fw={500}>{p.name}</Table.Td>
|
||||||
|
<Table.Td>{p.category || '-'}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{p.target_year === FUTURE_YEAR
|
{p.target_year === FUTURE_YEAR
|
||||||
? 'Future'
|
? 'Future'
|
||||||
@@ -394,10 +498,18 @@ export function CapitalProjectsPage() {
|
|||||||
<Table.Td ta="right" ff="monospace">
|
<Table.Td ta="right" ff="monospace">
|
||||||
{parseFloat(p.actual_cost || '0') > 0 ? fmt(p.actual_cost) : '-'}
|
{parseFloat(p.actual_cost || '0') > 0 ? fmt(p.actual_cost) : '-'}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td><Badge size="sm" variant="light">{p.fund_source}</Badge></Table.Td>
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge size="sm" color={statusColors[p.status] || 'gray'}>{p.status}</Badge>
|
<Badge size="sm" variant="light">{p.fund_source?.replace('_', ' ') || 'reserve'}</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{parseFloat(p.funded_percentage || '0') > 0
|
||||||
|
? `${parseFloat(p.funded_percentage).toFixed(0)}%`
|
||||||
|
: '-'}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" color={statusColors[p.status] || 'gray'}>{p.status?.replace('_', ' ')}</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{formatPlannedDate(p.planned_date) || '-'}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
||||||
<IconEdit size={16} />
|
<IconEdit size={16} />
|
||||||
@@ -447,7 +559,7 @@ export function CapitalProjectsPage() {
|
|||||||
<style>{printStyles}</style>
|
<style>{printStyles}</style>
|
||||||
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Capital Projects</Title>
|
<Title order={2}>Capital Planning</Title>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={viewMode}
|
value={viewMode}
|
||||||
@@ -478,9 +590,6 @@ export function CapitalProjectsPage() {
|
|||||||
PDF
|
PDF
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNewProject}>
|
|
||||||
Add Project
|
|
||||||
</Button>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -503,14 +612,22 @@ export function CapitalProjectsPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal opened={opened} onClose={close} title={editing ? 'Edit Project' : 'New Capital Project'} size="lg">
|
{/* Simplified edit modal - full project editing is done in ProjectsPage */}
|
||||||
|
<Modal opened={opened} onClose={close} title="Edit Capital Plan Details" size="md">
|
||||||
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
|
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label="Project Name" required {...form.getInputProps('name')} />
|
{editing && (
|
||||||
<Textarea label="Description" {...form.getInputProps('description')} />
|
<Text size="sm" fw={600} c="dimmed">
|
||||||
|
{editing.name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput label="Estimated Cost" required prefix="$" decimalScale={2} min={0} {...form.getInputProps('estimated_cost')} />
|
<Select
|
||||||
<NumberInput label="Actual Cost" prefix="$" decimalScale={2} min={0} {...form.getInputProps('actual_cost')} />
|
label="Status"
|
||||||
|
data={Object.keys(statusColors).map((s) => ({ value: s, label: s.replace('_', ' ') }))}
|
||||||
|
{...form.getInputProps('status')}
|
||||||
|
/>
|
||||||
|
<NumberInput label="Priority (1=High, 5=Low)" min={1} max={5} {...form.getInputProps('priority')} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<Select
|
<Select
|
||||||
@@ -530,24 +647,14 @@ export function CapitalProjectsPage() {
|
|||||||
onChange={(v) => form.setFieldValue('target_month', Number(v))}
|
onChange={(v) => form.setFieldValue('target_month', Number(v))}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<TextInput
|
||||||
<Select
|
label="Planned Date"
|
||||||
label="Status"
|
placeholder="YYYY-MM-DD"
|
||||||
data={Object.keys(statusColors).map((s) => ({ value: s, label: s.replace('_', ' ') }))}
|
description="Leave blank to auto-derive from target year/month"
|
||||||
{...form.getInputProps('status')}
|
{...form.getInputProps('planned_date')}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Textarea label="Notes" autosize minRows={2} maxRows={6} {...form.getInputProps('notes')} />
|
||||||
label="Fund Source"
|
<Button type="submit" loading={saveMutation.isPending}>Update</Button>
|
||||||
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')} />
|
|
||||||
</Group>
|
|
||||||
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ interface Investment {
|
|||||||
investment_type: string; fund_type: string; principal: string;
|
investment_type: string; fund_type: string; principal: string;
|
||||||
interest_rate: string; maturity_date: string; purchase_date: string;
|
interest_rate: string; maturity_date: string; purchase_date: string;
|
||||||
current_value: string; is_active: boolean;
|
current_value: string; is_active: boolean;
|
||||||
|
interest_earned: string | null;
|
||||||
|
maturity_value: string | null;
|
||||||
|
days_remaining: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvestmentsPage() {
|
export function InvestmentsPage() {
|
||||||
@@ -71,8 +74,16 @@ export function InvestmentsPage() {
|
|||||||
const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
|
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
|
||||||
const totalValue = investments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
|
const totalValue = investments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||||
|
const totalInterestEarned = investments.reduce((s, i) => s + parseFloat(i.interest_earned || '0'), 0);
|
||||||
const avgRate = investments.length > 0 ? investments.reduce((s, i) => s + parseFloat(i.interest_rate || '0'), 0) / investments.length : 0;
|
const avgRate = investments.length > 0 ? investments.reduce((s, i) => s + parseFloat(i.interest_rate || '0'), 0) / investments.length : 0;
|
||||||
|
|
||||||
|
const daysRemainingColor = (days: number | null) => {
|
||||||
|
if (days === null) return 'gray';
|
||||||
|
if (days <= 30) return 'red';
|
||||||
|
if (days <= 90) return 'yellow';
|
||||||
|
return 'gray';
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -81,9 +92,10 @@ export function InvestmentsPage() {
|
|||||||
<Title order={2}>Investment Accounts</Title>
|
<Title order={2}>Investment Accounts</Title>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button>
|
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button>
|
||||||
</Group>
|
</Group>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
<SimpleGrid cols={{ base: 1, sm: 4 }}>
|
||||||
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Principal</Text><Text fw={700} size="xl">{fmt(totalPrincipal)}</Text></Card>
|
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Principal</Text><Text fw={700} size="xl">{fmt(totalPrincipal)}</Text></Card>
|
||||||
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Current Value</Text><Text fw={700} size="xl" c="green">{fmt(totalValue)}</Text></Card>
|
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Current Value</Text><Text fw={700} size="xl" c="green">{fmt(totalValue)}</Text></Card>
|
||||||
|
<Card withBorder p="md"><Text size="xs" c="dimmed">Interest Earned</Text><Text fw={700} size="xl" c="teal">{fmt(totalInterestEarned)}</Text></Card>
|
||||||
<Card withBorder p="md"><Text size="xs" c="dimmed">Avg Interest Rate</Text><Text fw={700} size="xl">{avgRate.toFixed(2)}%</Text></Card>
|
<Card withBorder p="md"><Text size="xs" c="dimmed">Avg Interest Rate</Text><Text fw={700} size="xl">{avgRate.toFixed(2)}%</Text></Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
@@ -91,7 +103,11 @@ export function InvestmentsPage() {
|
|||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Name</Table.Th><Table.Th>Institution</Table.Th><Table.Th>Type</Table.Th>
|
<Table.Th>Name</Table.Th><Table.Th>Institution</Table.Th><Table.Th>Type</Table.Th>
|
||||||
<Table.Th>Fund</Table.Th><Table.Th ta="right">Principal</Table.Th>
|
<Table.Th>Fund</Table.Th><Table.Th ta="right">Principal</Table.Th>
|
||||||
<Table.Th ta="right">Rate</Table.Th><Table.Th>Maturity</Table.Th><Table.Th></Table.Th>
|
<Table.Th ta="right">Rate</Table.Th>
|
||||||
|
<Table.Th ta="right">Interest Earned</Table.Th>
|
||||||
|
<Table.Th ta="right">Maturity Value</Table.Th>
|
||||||
|
<Table.Th ta="center">Days Left</Table.Th>
|
||||||
|
<Table.Th>Maturity</Table.Th><Table.Th></Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
@@ -103,11 +119,24 @@ export function InvestmentsPage() {
|
|||||||
<Table.Td><Badge size="sm" color={inv.fund_type === 'reserve' ? 'violet' : 'gray'}>{inv.fund_type}</Badge></Table.Td>
|
<Table.Td><Badge size="sm" color={inv.fund_type === 'reserve' ? 'violet' : 'gray'}>{inv.fund_type}</Badge></Table.Td>
|
||||||
<Table.Td ta="right" ff="monospace">{fmt(inv.principal)}</Table.Td>
|
<Table.Td ta="right" ff="monospace">{fmt(inv.principal)}</Table.Td>
|
||||||
<Table.Td ta="right">{parseFloat(inv.interest_rate || '0').toFixed(2)}%</Table.Td>
|
<Table.Td ta="right">{parseFloat(inv.interest_rate || '0').toFixed(2)}%</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace" c="teal">
|
||||||
|
{inv.interest_earned !== null ? fmt(inv.interest_earned) : '-'}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
{inv.maturity_value !== null ? fmt(inv.maturity_value) : '-'}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="center">
|
||||||
|
{inv.days_remaining !== null ? (
|
||||||
|
<Badge size="sm" color={daysRemainingColor(inv.days_remaining)} variant="light">
|
||||||
|
{inv.days_remaining}d
|
||||||
|
</Badge>
|
||||||
|
) : '-'}
|
||||||
|
</Table.Td>
|
||||||
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}</Table.Td>
|
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}</Table.Td>
|
||||||
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(inv)}><IconEdit size={16} /></ActionIcon></Table.Td>
|
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(inv)}><IconEdit size={16} /></ActionIcon></Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
{investments.length === 0 && <Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No investments yet</Text></Table.Td></Table.Tr>}
|
{investments.length === 0 && <Table.Tr><Table.Td colSpan={11}><Text ta="center" c="dimmed" py="lg">No investments yet</Text></Table.Td></Table.Tr>}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
<Modal opened={opened} onClose={close} title={editing ? 'Edit Investment' : 'New Investment'} size="lg">
|
<Modal opened={opened} onClose={close} title={editing ? 'Edit Investment' : 'New Investment'} size="lg">
|
||||||
|
|||||||
590
frontend/src/pages/projects/ProjectsPage.tsx
Normal file
590
frontend/src/pages/projects/ProjectsPage.tsx
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
|
||||||
|
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
|
||||||
|
Card, SimpleGrid, Progress,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { DateInput } from '@mantine/dates';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types & constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
estimated_cost: string;
|
||||||
|
actual_cost: string;
|
||||||
|
current_fund_balance: string;
|
||||||
|
annual_contribution: string;
|
||||||
|
fund_source: string;
|
||||||
|
funded_percentage: string;
|
||||||
|
useful_life_years: number;
|
||||||
|
remaining_life_years: number;
|
||||||
|
condition_rating: number;
|
||||||
|
last_replacement_date: string;
|
||||||
|
next_replacement_date: string;
|
||||||
|
planned_date: string;
|
||||||
|
target_year: number;
|
||||||
|
target_month: number;
|
||||||
|
status: string;
|
||||||
|
priority: number;
|
||||||
|
account_id: string;
|
||||||
|
notes: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FUTURE_YEAR = 9999;
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
'roof', 'pool', 'hvac', 'paving', 'painting',
|
||||||
|
'fencing', 'elevator', 'irrigation', 'clubhouse', 'other',
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
planned: 'blue',
|
||||||
|
approved: 'green',
|
||||||
|
in_progress: 'yellow',
|
||||||
|
completed: 'teal',
|
||||||
|
deferred: 'gray',
|
||||||
|
cancelled: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fundSourceColors: Record<string, string> = {
|
||||||
|
operating: 'gray',
|
||||||
|
reserve: 'violet',
|
||||||
|
special_assessment: 'orange',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmt = (v: string | number) =>
|
||||||
|
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ProjectsPage() {
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const [editing, setEditing] = useState<Project | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// ---- Data fetching ----
|
||||||
|
|
||||||
|
const { data: projects = [], isLoading } = useQuery<Project[]>({
|
||||||
|
queryKey: ['projects'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/projects');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Derived summary values ----
|
||||||
|
|
||||||
|
const totalEstimatedCost = projects.reduce(
|
||||||
|
(sum, p) => sum + parseFloat(p.estimated_cost || '0'),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const reserveProjects = projects.filter((p) => p.fund_source === 'reserve');
|
||||||
|
|
||||||
|
const totalFundedReserve = reserveProjects.reduce(
|
||||||
|
(sum, p) => sum + parseFloat(p.current_fund_balance || '0'),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalReserveReplacementCost = reserveProjects.reduce(
|
||||||
|
(sum, p) => sum + parseFloat(p.estimated_cost || '0'),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pctFundedReserve =
|
||||||
|
totalReserveReplacementCost > 0
|
||||||
|
? (totalFundedReserve / totalReserveReplacementCost) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// ---- Form setup ----
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
const targetYearOptions = [
|
||||||
|
...Array.from({ length: 6 }, (_, i) => ({
|
||||||
|
value: String(currentYear + i),
|
||||||
|
label: String(currentYear + i),
|
||||||
|
})),
|
||||||
|
{ value: String(FUTURE_YEAR), label: 'Future (Beyond 5-Year)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const monthOptions = Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
value: String(i + 1),
|
||||||
|
label: new Date(2000, i).toLocaleString('default', { month: 'long' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
name: '',
|
||||||
|
category: 'other',
|
||||||
|
description: '',
|
||||||
|
fund_source: 'reserve',
|
||||||
|
status: 'planned',
|
||||||
|
priority: 3,
|
||||||
|
estimated_cost: 0,
|
||||||
|
actual_cost: 0,
|
||||||
|
current_fund_balance: 0,
|
||||||
|
annual_contribution: 0,
|
||||||
|
funded_percentage: 0,
|
||||||
|
useful_life_years: 20,
|
||||||
|
remaining_life_years: 10,
|
||||||
|
condition_rating: 5,
|
||||||
|
last_replacement_date: null as Date | null,
|
||||||
|
next_replacement_date: null as Date | null,
|
||||||
|
planned_date: null as Date | null,
|
||||||
|
target_year: currentYear,
|
||||||
|
target_month: 6,
|
||||||
|
notes: '',
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
name: (v) => (v.length > 0 ? null : 'Required'),
|
||||||
|
estimated_cost: (v) => (v > 0 ? null : 'Required'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Mutations ----
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (values: any) => {
|
||||||
|
const payload = {
|
||||||
|
...values,
|
||||||
|
last_replacement_date:
|
||||||
|
values.last_replacement_date?.toISOString?.()?.split('T')[0] || null,
|
||||||
|
next_replacement_date:
|
||||||
|
values.next_replacement_date?.toISOString?.()?.split('T')[0] || null,
|
||||||
|
planned_date:
|
||||||
|
values.planned_date?.toISOString?.()?.split('T')[0] || null,
|
||||||
|
};
|
||||||
|
return editing
|
||||||
|
? api.put(`/projects/${editing.id}`, payload)
|
||||||
|
: api.post('/projects', payload);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
notifications.show({
|
||||||
|
message: editing ? 'Project updated' : 'Project created',
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
close();
|
||||||
|
setEditing(null);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({
|
||||||
|
message: err.response?.data?.message || 'Error',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Handlers ----
|
||||||
|
|
||||||
|
const handleEdit = (p: Project) => {
|
||||||
|
setEditing(p);
|
||||||
|
form.setValues({
|
||||||
|
name: p.name,
|
||||||
|
category: p.category || 'other',
|
||||||
|
description: p.description || '',
|
||||||
|
fund_source: p.fund_source || 'reserve',
|
||||||
|
status: p.status || 'planned',
|
||||||
|
priority: p.priority || 3,
|
||||||
|
estimated_cost: parseFloat(p.estimated_cost || '0'),
|
||||||
|
actual_cost: parseFloat(p.actual_cost || '0'),
|
||||||
|
current_fund_balance: parseFloat(p.current_fund_balance || '0'),
|
||||||
|
annual_contribution: parseFloat(p.annual_contribution || '0'),
|
||||||
|
funded_percentage: parseFloat(p.funded_percentage || '0'),
|
||||||
|
useful_life_years: p.useful_life_years || 0,
|
||||||
|
remaining_life_years: p.remaining_life_years || 0,
|
||||||
|
condition_rating: p.condition_rating || 5,
|
||||||
|
last_replacement_date: p.last_replacement_date
|
||||||
|
? new Date(p.last_replacement_date)
|
||||||
|
: null,
|
||||||
|
next_replacement_date: p.next_replacement_date
|
||||||
|
? new Date(p.next_replacement_date)
|
||||||
|
: null,
|
||||||
|
planned_date: p.planned_date ? new Date(p.planned_date) : null,
|
||||||
|
target_year: p.target_year || currentYear,
|
||||||
|
target_month: p.target_month || 6,
|
||||||
|
notes: p.notes || '',
|
||||||
|
});
|
||||||
|
open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNew = () => {
|
||||||
|
setEditing(null);
|
||||||
|
form.reset();
|
||||||
|
open();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Helpers for table rendering ----
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string | null | undefined) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (isNaN(d.getTime())) return '-';
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const conditionBadge = (rating: number | null | undefined) => {
|
||||||
|
if (rating == null) return <Text c="dimmed">-</Text>;
|
||||||
|
const color = rating >= 7 ? 'green' : rating >= 4 ? 'yellow' : 'red';
|
||||||
|
return (
|
||||||
|
<Badge size="sm" color={color}>
|
||||||
|
{rating}/10
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fundedPercentageCell = (project: Project) => {
|
||||||
|
if (project.fund_source !== 'reserve') {
|
||||||
|
return <Text c="dimmed">-</Text>;
|
||||||
|
}
|
||||||
|
const cost = parseFloat(project.estimated_cost || '0');
|
||||||
|
const funded = parseFloat(project.current_fund_balance || '0');
|
||||||
|
const pct = cost > 0 ? (funded / cost) * 100 : 0;
|
||||||
|
const color = pct >= 70 ? 'green' : pct >= 40 ? 'yellow' : 'red';
|
||||||
|
return (
|
||||||
|
<Text span c={color} ff="monospace">
|
||||||
|
{pct.toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Loading state ----
|
||||||
|
|
||||||
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
|
// ---- Render ----
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
{/* Header */}
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Title order={2}>Projects</Title>
|
||||||
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||||
|
+ Add Project
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||||
|
Total Estimated Cost
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="xl">
|
||||||
|
{fmt(totalEstimatedCost)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||||
|
Total Funded - Reserve Only
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="xl" c="green">
|
||||||
|
{fmt(totalFundedReserve)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||||
|
Percent Funded - Reserve Only
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<Text
|
||||||
|
fw={700}
|
||||||
|
size="xl"
|
||||||
|
c={
|
||||||
|
pctFundedReserve >= 70
|
||||||
|
? 'green'
|
||||||
|
: pctFundedReserve >= 40
|
||||||
|
? 'yellow'
|
||||||
|
: 'red'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pctFundedReserve.toFixed(1)}%
|
||||||
|
</Text>
|
||||||
|
<Progress
|
||||||
|
value={pctFundedReserve}
|
||||||
|
size="lg"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
color={
|
||||||
|
pctFundedReserve >= 70
|
||||||
|
? 'green'
|
||||||
|
: pctFundedReserve >= 40
|
||||||
|
? 'yellow'
|
||||||
|
: 'red'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Projects Table */}
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Project Name</Table.Th>
|
||||||
|
<Table.Th>Category</Table.Th>
|
||||||
|
<Table.Th>Fund Source</Table.Th>
|
||||||
|
<Table.Th ta="right">Estimated Cost</Table.Th>
|
||||||
|
<Table.Th ta="right">Funded %</Table.Th>
|
||||||
|
<Table.Th>Condition</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Planned Date</Table.Th>
|
||||||
|
<Table.Th></Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<Table.Tr key={p.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={500}>{p.name}</Text>
|
||||||
|
{p.description && (
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||||
|
{p.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" variant="light">
|
||||||
|
{p.category
|
||||||
|
? p.category.charAt(0).toUpperCase() + p.category.slice(1)
|
||||||
|
: '-'}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color={fundSourceColors[p.fund_source] || 'gray'}
|
||||||
|
>
|
||||||
|
{p.fund_source?.replace('_', ' ') || '-'}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
{fmt(p.estimated_cost)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">{fundedPercentageCell(p)}</Table.Td>
|
||||||
|
<Table.Td>{conditionBadge(p.condition_rating)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
color={statusColors[p.status] || 'gray'}
|
||||||
|
>
|
||||||
|
{p.status?.replace('_', ' ') || '-'}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{formatDate(p.planned_date)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
{projects.length === 0 && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={9}>
|
||||||
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
|
No projects yet
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{/* Create / Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title={editing ? 'Edit Project' : 'New Project'}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
|
||||||
|
<Stack>
|
||||||
|
{/* Row 1: Name + Category */}
|
||||||
|
<Group grow>
|
||||||
|
<TextInput
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
{...form.getInputProps('name')}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Category"
|
||||||
|
data={categories.map((c) => ({
|
||||||
|
value: c,
|
||||||
|
label: c.charAt(0).toUpperCase() + c.slice(1),
|
||||||
|
}))}
|
||||||
|
{...form.getInputProps('category')}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Row 2: Description */}
|
||||||
|
<Textarea
|
||||||
|
label="Description"
|
||||||
|
{...form.getInputProps('description')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Row 3: Fund Source, Status, Priority */}
|
||||||
|
<Group grow>
|
||||||
|
<Select
|
||||||
|
label="Fund Source"
|
||||||
|
data={[
|
||||||
|
{ value: 'operating', label: 'Operating' },
|
||||||
|
{ value: 'reserve', label: 'Reserve' },
|
||||||
|
{ value: 'special_assessment', label: 'Special Assessment' },
|
||||||
|
]}
|
||||||
|
{...form.getInputProps('fund_source')}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
data={Object.keys(statusColors).map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: s.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()),
|
||||||
|
}))}
|
||||||
|
{...form.getInputProps('status')}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Priority (1-5)"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
{...form.getInputProps('priority')}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Row 4: Estimated Cost, Actual Cost */}
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput
|
||||||
|
label="Estimated Cost"
|
||||||
|
required
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
min={0}
|
||||||
|
{...form.getInputProps('estimated_cost')}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Actual Cost"
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
min={0}
|
||||||
|
{...form.getInputProps('actual_cost')}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Row 5: Conditional reserve fields */}
|
||||||
|
{form.values.fund_source === 'reserve' && (
|
||||||
|
<>
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput
|
||||||
|
label="Current Fund Balance"
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
min={0}
|
||||||
|
{...form.getInputProps('current_fund_balance')}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Annual Contribution"
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
min={0}
|
||||||
|
{...form.getInputProps('annual_contribution')}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Funded Percentage"
|
||||||
|
suffix="%"
|
||||||
|
decimalScale={1}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
{...form.getInputProps('funded_percentage')}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput
|
||||||
|
label="Useful Life (years)"
|
||||||
|
min={0}
|
||||||
|
{...form.getInputProps('useful_life_years')}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Remaining Life (years)"
|
||||||
|
min={0}
|
||||||
|
decimalScale={1}
|
||||||
|
{...form.getInputProps('remaining_life_years')}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Condition Rating (1-10)"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
{...form.getInputProps('condition_rating')}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 6: Last / Next Replacement Date */}
|
||||||
|
<Group grow>
|
||||||
|
<DateInput
|
||||||
|
label="Last Replacement Date"
|
||||||
|
clearable
|
||||||
|
{...form.getInputProps('last_replacement_date')}
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
label="Next Replacement Date"
|
||||||
|
clearable
|
||||||
|
{...form.getInputProps('next_replacement_date')}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Row 7: Planned Date */}
|
||||||
|
<DateInput
|
||||||
|
label="Planned Date"
|
||||||
|
description="Defaults to Next Replacement Date if not set"
|
||||||
|
clearable
|
||||||
|
{...form.getInputProps('planned_date')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Row 8: Target Year + Target Month */}
|
||||||
|
<Group grow>
|
||||||
|
<Select
|
||||||
|
label="Target Year"
|
||||||
|
data={targetYearOptions}
|
||||||
|
value={String(form.values.target_year)}
|
||||||
|
onChange={(v) => form.setFieldValue('target_year', Number(v))}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Target Month"
|
||||||
|
data={monthOptions}
|
||||||
|
value={String(form.values.target_month)}
|
||||||
|
onChange={(v) => form.setFieldValue('target_month', Number(v))}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Row 9: Notes */}
|
||||||
|
<Textarea label="Notes" {...form.getInputProps('notes')} />
|
||||||
|
|
||||||
|
<Button type="submit" loading={saveMutation.isPending}>
|
||||||
|
{editing ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, TextInput, Modal,
|
Title, Table, Group, Button, Stack, TextInput, Modal,
|
||||||
NumberInput, Select, Badge, ActionIcon, Text, Loader, Center, Tooltip,
|
NumberInput, Select, Badge, ActionIcon, Text, Loader, Center, Tooltip, Alert,
|
||||||
} 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, IconTrash } from '@tabler/icons-react';
|
import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle } 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';
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ interface AssessmentGroup {
|
|||||||
name: string;
|
name: string;
|
||||||
regular_assessment: string;
|
regular_assessment: string;
|
||||||
frequency: string;
|
frequency: string;
|
||||||
|
is_default: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UnitsPage() {
|
export function UnitsPage() {
|
||||||
@@ -49,13 +50,19 @@ export function UnitsPage() {
|
|||||||
queryFn: async () => { const { data } = await api.get('/assessment-groups'); return data; },
|
queryFn: async () => { const { data } = await api.get('/assessment-groups'); return data; },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const defaultGroup = assessmentGroups.find(g => g.is_default);
|
||||||
|
const hasGroups = assessmentGroups.length > 0;
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
unit_number: '', address_line1: '', city: '', state: '', zip_code: '',
|
unit_number: '', address_line1: '', city: '', state: '', zip_code: '',
|
||||||
owner_name: '', owner_email: '', owner_phone: '', monthly_assessment: 0,
|
owner_name: '', owner_email: '', owner_phone: '', monthly_assessment: 0,
|
||||||
assessment_group_id: '' as string | null,
|
assessment_group_id: '' as string | null,
|
||||||
},
|
},
|
||||||
validate: { unit_number: (v) => (v.length > 0 ? null : 'Required') },
|
validate: {
|
||||||
|
unit_number: (v) => (v.length > 0 ? null : 'Required'),
|
||||||
|
assessment_group_id: (v) => (v && v.length > 0 ? null : 'Assessment group is required'),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
@@ -95,6 +102,17 @@ export function UnitsPage() {
|
|||||||
open();
|
open();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNew = () => {
|
||||||
|
setEditing(null);
|
||||||
|
form.reset();
|
||||||
|
// Pre-populate with default group
|
||||||
|
if (defaultGroup) {
|
||||||
|
form.setFieldValue('assessment_group_id', defaultGroup.id);
|
||||||
|
form.setFieldValue('monthly_assessment', parseFloat(defaultGroup.regular_assessment || '0'));
|
||||||
|
}
|
||||||
|
open();
|
||||||
|
};
|
||||||
|
|
||||||
const handleGroupChange = (groupId: string | null) => {
|
const handleGroupChange = (groupId: string | null) => {
|
||||||
form.setFieldValue('assessment_group_id', groupId);
|
form.setFieldValue('assessment_group_id', groupId);
|
||||||
if (groupId) {
|
if (groupId) {
|
||||||
@@ -116,8 +134,21 @@ export function UnitsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Units / Homeowners</Title>
|
<Title order={2}>Units / Homeowners</Title>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Unit</Button>
|
{hasGroups ? (
|
||||||
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Add Unit</Button>
|
||||||
|
) : (
|
||||||
|
<Tooltip label="Create an assessment group first">
|
||||||
|
<Button leftSection={<IconPlus size={16} />} disabled>Add Unit</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{!hasGroups && (
|
||||||
|
<Alert icon={<IconInfoCircle size={16} />} color="yellow" variant="light">
|
||||||
|
You must create at least one assessment group before adding units. Go to Assessment Groups to create one.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<TextInput placeholder="Search units..." leftSection={<IconSearch size={16} />} value={search} onChange={(e) => setSearch(e.currentTarget.value)} />
|
<TextInput placeholder="Search units..." leftSection={<IconSearch size={16} />} value={search} onChange={(e) => setSearch(e.currentTarget.value)} />
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
@@ -182,14 +213,15 @@ export function UnitsPage() {
|
|||||||
<TextInput label="Owner Phone" {...form.getInputProps('owner_phone')} />
|
<TextInput label="Owner Phone" {...form.getInputProps('owner_phone')} />
|
||||||
<Select
|
<Select
|
||||||
label="Assessment Group"
|
label="Assessment Group"
|
||||||
placeholder="Select a group (optional)"
|
description="Required — all units must belong to an assessment group"
|
||||||
|
required
|
||||||
data={assessmentGroups.map(g => ({
|
data={assessmentGroups.map(g => ({
|
||||||
value: g.id,
|
value: g.id,
|
||||||
label: `${g.name} — $${parseFloat(g.regular_assessment || '0').toFixed(2)}/${g.frequency || 'mo'}`,
|
label: `${g.name}${g.is_default ? ' (Default)' : ''} — $${parseFloat(g.regular_assessment || '0').toFixed(2)}/${g.frequency || 'mo'}`,
|
||||||
}))}
|
}))}
|
||||||
value={form.values.assessment_group_id}
|
value={form.values.assessment_group_id}
|
||||||
onChange={handleGroupChange}
|
onChange={handleGroupChange}
|
||||||
clearable
|
error={form.errors.assessment_group_id}
|
||||||
/>
|
/>
|
||||||
<NumberInput label="Monthly Assessment" prefix="$" decimalScale={2} min={0} {...form.getInputProps('monthly_assessment')} />
|
<NumberInput label="Monthly Assessment" prefix="$" decimalScale={2} min={0} {...form.getInputProps('monthly_assessment')} />
|
||||||
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
|
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user