diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 212e0f9..1dce6d9 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -20,6 +20,7 @@ import { InvestmentsModule } from './modules/investments/investments.module'; import { CapitalProjectsModule } from './modules/capital-projects/capital-projects.module'; import { ReportsModule } from './modules/reports/reports.module'; import { AssessmentGroupsModule } from './modules/assessment-groups/assessment-groups.module'; +import { ProjectsModule } from './modules/projects/projects.module'; @Module({ imports: [ @@ -54,6 +55,7 @@ import { AssessmentGroupsModule } from './modules/assessment-groups/assessment-g CapitalProjectsModule, ReportsModule, AssessmentGroupsModule, + ProjectsModule, ], controllers: [AppController], }) diff --git a/backend/src/database/tenant-schema.service.ts b/backend/src/database/tenant-schema.service.ts index 1889f7a..adef55e 100644 --- a/backend/src/database/tenant-schema.service.ts +++ b/backend/src/database/tenant-schema.service.ts @@ -19,7 +19,6 @@ export class TenantSchemaService { await queryRunner.query(statement); } - await this.seedDefaultChartOfAccounts(queryRunner, schemaName); await this.seedDefaultFiscalPeriods(queryRunner, schemaName); await queryRunner.commitTransaction(); @@ -45,6 +44,7 @@ export class TenantSchemaService { is_1099_reportable BOOLEAN DEFAULT FALSE, is_active BOOLEAN DEFAULT TRUE, is_system BOOLEAN DEFAULT FALSE, + is_primary BOOLEAN DEFAULT FALSE, balance DECIMAL(15,2) DEFAULT 0.00, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), @@ -110,6 +110,7 @@ export class TenantSchemaService { special_assessment DECIMAL(10,2) DEFAULT 0.00, unit_count INTEGER DEFAULT 0, frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')), + is_default BOOLEAN DEFAULT FALSE, is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() @@ -281,6 +282,37 @@ export class TenantSchemaService { 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 `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)`, diff --git a/backend/src/modules/accounts/accounts.controller.ts b/backend/src/modules/accounts/accounts.controller.ts index 6702176..6366553 100644 --- a/backend/src/modules/accounts/accounts.controller.ts +++ b/backend/src/modules/accounts/accounts.controller.ts @@ -26,6 +26,21 @@ export class AccountsController { 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') @ApiOperation({ summary: 'Get account by ID' }) findOne(@Param('id') id: string) { diff --git a/backend/src/modules/accounts/accounts.service.ts b/backend/src/modules/accounts/accounts.service.ts index 55752b1..3eca89f 100644 --- a/backend/src/modules/accounts/accounts.service.ts +++ b/backend/src/modules/accounts/accounts.service.ts @@ -109,6 +109,16 @@ export class AccountsService { 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 params: any[] = []; 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.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.isPrimary !== undefined) { sets.push(`is_primary = $${idx++}`); params.push(dto.isPrimary); } if (!sets.length) return account; @@ -133,6 +144,136 @@ export class AccountsService { 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) { const dateFilter = asOfDate ? `AND je.entry_date <= $1` diff --git a/backend/src/modules/accounts/dto/update-account.dto.ts b/backend/src/modules/accounts/dto/update-account.dto.ts index 2bc209f..d8b2999 100644 --- a/backend/src/modules/accounts/dto/update-account.dto.ts +++ b/backend/src/modules/accounts/dto/update-account.dto.ts @@ -36,4 +36,9 @@ export class UpdateAccountDto { @IsIn(['operating', 'reserve']) @IsOptional() fundType?: string; + + @ApiProperty({ required: false }) + @IsBoolean() + @IsOptional() + isPrimary?: boolean; } diff --git a/backend/src/modules/assessment-groups/assessment-groups.controller.ts b/backend/src/modules/assessment-groups/assessment-groups.controller.ts index 41042f7..333a852 100644 --- a/backend/src/modules/assessment-groups/assessment-groups.controller.ts +++ b/backend/src/modules/assessment-groups/assessment-groups.controller.ts @@ -16,6 +16,9 @@ export class AssessmentGroupsController { @Get('summary') getSummary() { return this.service.getSummary(); } + @Get('default') + getDefault() { return this.service.getDefault(); } + @Get(':id') findOne(@Param('id') id: string) { return this.service.findOne(id); } @@ -24,4 +27,7 @@ export class AssessmentGroupsController { @Put(':id') 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); } } diff --git a/backend/src/modules/assessment-groups/assessment-groups.service.ts b/backend/src/modules/assessment-groups/assessment-groups.service.ts index 69b9509..6d2cb35 100644 --- a/backend/src/modules/assessment-groups/assessment-groups.service.ts +++ b/backend/src/modules/assessment-groups/assessment-groups.service.ts @@ -6,10 +6,6 @@ export class AssessmentGroupsService { constructor(private tenant: TenantService) {} async findAll() { - // Normalize all income calculations to monthly equivalent - // monthly: amount * units (already monthly) - // quarterly: amount/3 * units (convert to monthly) - // annual: amount/12 * units (convert to monthly) return this.tenant.query(` SELECT ag.*, (SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id) as actual_unit_count, @@ -39,17 +35,38 @@ export class AssessmentGroupsService { return rows[0]; } - async create(dto: any) { + async getDefault() { const rows = await this.tenant.query( - `INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency) - VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, - [dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0, dto.unitCount || 0, dto.frequency || 'monthly'], + 'SELECT * FROM assessment_groups WHERE is_default = true AND is_active = true LIMIT 1', + ); + 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]; } async update(id: string, dto: any) { 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 params: any[] = []; 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.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.isDefault !== undefined) { sets.push(`is_default = $${idx++}`); params.push(dto.isDefault); } if (!sets.length) return this.findOne(id); @@ -74,6 +92,16 @@ export class AssessmentGroupsService { 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() { const rows = await this.tenant.query(` SELECT diff --git a/backend/src/modules/investments/investments.service.ts b/backend/src/modules/investments/investments.service.ts index 66381ee..9379d75 100644 --- a/backend/src/modules/investments/investments.service.ts +++ b/backend/src/modules/investments/investments.service.ts @@ -6,7 +6,30 @@ export class InvestmentsService { constructor(private tenant: TenantService) {} 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) { diff --git a/backend/src/modules/projects/projects.controller.ts b/backend/src/modules/projects/projects.controller.ts new file mode 100644 index 0000000..29c1e85 --- /dev/null +++ b/backend/src/modules/projects/projects.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/projects/projects.module.ts b/backend/src/modules/projects/projects.module.ts new file mode 100644 index 0000000..0e6091e --- /dev/null +++ b/backend/src/modules/projects/projects.module.ts @@ -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 {} diff --git a/backend/src/modules/projects/projects.service.ts b/backend/src/modules/projects/projects.service.ts new file mode 100644 index 0000000..1abb763 --- /dev/null +++ b/backend/src/modules/projects/projects.service.ts @@ -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]; + } +} diff --git a/backend/src/modules/units/units.service.ts b/backend/src/modules/units/units.service.ts index 2661a43..9074e9f 100644 --- a/backend/src/modules/units/units.service.ts +++ b/backend/src/modules/units/units.service.ts @@ -32,10 +32,29 @@ export class UnitsService { 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`); + // 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( `INSERT INTO units (unit_number, address_line1, city, state, zip_code, owner_name, owner_email, owner_phone, monthly_assessment, assessment_group_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`, - [dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment || 0, dto.assessment_group_id || null], + [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]; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6f24427..414cc7b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,7 +12,7 @@ import { UnitsPage } from './pages/units/UnitsPage'; import { InvoicesPage } from './pages/invoices/InvoicesPage'; import { PaymentsPage } from './pages/payments/PaymentsPage'; 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 { CapitalProjectsPage } from './pages/capital-projects/CapitalProjectsPage'; import { BalanceSheetPage } from './pages/reports/BalanceSheetPage'; @@ -110,7 +110,7 @@ export function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> @@ -120,7 +120,7 @@ export function App() { } /> } /> } /> - } /> + } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 9cd6b64..d62df7f 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -10,10 +10,8 @@ import { IconReportAnalytics, IconChartSankey, IconShieldCheck, - IconPigMoney, IconBuildingBank, IconUsers, - IconFileText, IconSettings, IconCrown, IconCategory, @@ -31,7 +29,6 @@ const navSections = [ items: [ { label: 'Accounts', icon: IconListDetails, path: '/accounts' }, { label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' }, - { label: 'Investments', icon: IconPigMoney, path: '/investments' }, ], }, { @@ -52,8 +49,8 @@ const navSections = [ { label: 'Planning', items: [ - { label: 'Capital Projects', icon: IconBuildingBank, path: '/capital-projects' }, - { label: 'Reserves', icon: IconShieldCheck, path: '/reserves' }, + { label: 'Projects', icon: IconShieldCheck, path: '/projects' }, + { label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' }, { label: 'Vendors', icon: IconUsers, path: '/vendors' }, ], }, @@ -70,6 +67,7 @@ const navSections = [ { label: 'Budget vs Actual', path: '/reports/budget-vs-actual' }, { label: 'Aging Report', path: '/reports/aging' }, { label: 'Sankey Diagram', path: '/reports/sankey' }, + { label: 'Year-End', path: '/reports/year-end' }, ], }, ], @@ -77,7 +75,6 @@ const navSections = [ { label: 'Admin', items: [ - { label: 'Year-End', icon: IconFileText, path: '/year-end' }, { label: 'Settings', icon: IconSettings, path: '/settings' }, ], }, diff --git a/frontend/src/pages/accounts/AccountsPage.tsx b/frontend/src/pages/accounts/AccountsPage.tsx index 4b65d1b..989f1e7 100644 --- a/frontend/src/pages/accounts/AccountsPage.tsx +++ b/frontend/src/pages/accounts/AccountsPage.tsx @@ -19,11 +19,23 @@ import { Center, Tooltip, SimpleGrid, + Alert, } 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, 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 api from '../../services/api'; @@ -37,6 +49,36 @@ interface Account { is_1099_reportable: boolean; is_active: 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; } @@ -48,15 +90,21 @@ const accountTypeColors: Record = { expense: 'orange', }; +const fmt = (v: string | number) => + parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); + export function AccountsPage() { const [opened, { open, close }] = useDisclosure(false); + const [adjustOpened, { open: openAdjust, close: closeAdjust }] = useDisclosure(false); const [editing, setEditing] = useState(null); + const [adjustingAccount, setAdjustingAccount] = useState(null); const [search, setSearch] = useState(''); const [filterType, setFilterType] = useState(null); const [filterFund, setFilterFund] = useState(null); const [showArchived, setShowArchived] = useState(false); const queryClient = useQueryClient(); + // ── Accounts query ── const { data: accounts = [], isLoading } = useQuery({ queryKey: ['accounts', showArchived], queryFn: async () => { @@ -66,6 +114,25 @@ export function AccountsPage() { }, }); + // ── Investments query ── + const { data: investments = [], isLoading: investmentsLoading } = useQuery({ + queryKey: ['investments'], + queryFn: async () => { + const { data } = await api.get('/investment-accounts'); + return data; + }, + }); + + // ── Trial balance query (for balance adjustment) ── + const { data: trialBalance = [] } = useQuery({ + queryKey: ['trial-balance'], + queryFn: async () => { + const { data } = await api.get('/accounts/trial-balance'); + return data; + }, + }); + + // ── Create / Edit form ── const form = useForm({ initialValues: { 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({ mutationFn: (values: any) => { 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) => { setEditing(account); form.setValues({ @@ -133,6 +246,28 @@ export function AccountsPage() { 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) => { if (search && !a.name.toLowerCase().includes(search.toLowerCase()) && !String(a.account_number).includes(search)) return false; if (filterType && a.account_type !== filterType) return false; @@ -140,26 +275,42 @@ export function AccountsPage() { return true; }); - const activeAccounts = filtered.filter(a => a.is_active); - const archivedAccounts = filtered.filter(a => !a.is_active); + const activeAccounts = filtered.filter((a) => a.is_active); + const archivedAccounts = filtered.filter((a) => !a.is_active); - const totalsByType = accounts.reduce((acc, a) => { - if (a.is_active) { - acc[a.account_type] = (acc[a.account_type] || 0) + parseFloat(a.balance || '0'); - } - return acc; - }, {} as Record); + // ── Summary cards ── + const totalsByType = accounts.reduce( + (acc, a) => { + if (a.is_active) { + acc[a.account_type] = (acc[a.account_type] || 0) + parseFloat(a.balance || '0'); + } + return acc; + }, + {} as Record, + ); - 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) { - return
; + return ( +
+ +
+ ); } return ( - Chart of Accounts + Accounts {Object.entries(totalsByType).map(([type, total]) => ( - {type} - {fmt(total)} + + {type} + + + {fmt(total)} + ))} @@ -213,27 +368,59 @@ export function AccountsPage() { All ({activeAccounts.length}) Operating Reserve + Investments {showArchived && archivedAccounts.length > 0 && ( - Archived ({archivedAccounts.length}) + + Archived ({archivedAccounts.length}) + )} - + setPrimaryMutation.mutate(id)} + onAdjustBalance={handleAdjustBalance} + /> - a.fund_type === 'operating')} onEdit={handleEdit} onArchive={archiveMutation.mutate} /> + a.fund_type === 'operating')} + onEdit={handleEdit} + onArchive={archiveMutation.mutate} + onSetPrimary={(id) => setPrimaryMutation.mutate(id)} + onAdjustBalance={handleAdjustBalance} + /> - a.fund_type === 'reserve')} onEdit={handleEdit} onArchive={archiveMutation.mutate} /> + a.fund_type === 'reserve')} + onEdit={handleEdit} + onArchive={archiveMutation.mutate} + onSetPrimary={(id) => setPrimaryMutation.mutate(id)} + onAdjustBalance={handleAdjustBalance} + /> + + + {showArchived && ( - + setPrimaryMutation.mutate(id)} + onAdjustBalance={handleAdjustBalance} + isArchivedView + /> )} + {/* Create / Edit Account Modal */}
createMutation.mutate(values))}> @@ -279,30 +466,87 @@ export function AccountsPage() {
+ + {/* Balance Adjustment Modal */} + + {adjustingAccount && ( +
+ + + Account: {adjustingAccount.account_number} - {adjustingAccount.name} + + + + + + + + + + + } color={adjustmentAmount >= 0 ? 'blue' : 'orange'} variant="light"> + + Adjustment amount: {fmt(adjustmentAmount)} + {adjustmentAmount > 0 && ' (increase)'} + {adjustmentAmount < 0 && ' (decrease)'} + {adjustmentAmount === 0 && ' (no change)'} + + + + + +
+ )} +
); } +// ── Account Table Component ── + function AccountTable({ accounts, onEdit, onArchive, + onSetPrimary, + onAdjustBalance, isArchivedView = false, }: { accounts: Account[]; onEdit: (a: Account) => void; onArchive: (a: Account) => void; + onSetPrimary: (id: string) => void; + onAdjustBalance: (a: Account) => void; isArchivedView?: boolean; }) { - const fmt = (v: string) => { - const n = parseFloat(v || '0'); - return n.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); - }; - return ( + Acct # Name Type @@ -315,7 +559,7 @@ function AccountTable({ {accounts.length === 0 && ( - + {isArchivedView ? 'No archived accounts' : 'No accounts found'} @@ -324,11 +568,22 @@ function AccountTable({ )} {accounts.map((a) => ( + + {a.is_primary && ( + + + + )} + {a.account_number}
{a.name} - {a.description && {a.description}} + {a.description && ( + + {a.description} + + )}
@@ -341,10 +596,32 @@ function AccountTable({ {a.fund_type} - {fmt(a.balance)} - {a.is_1099_reportable ? 1099 : ''} + + {fmt(a.balance)} + + + {a.is_1099_reportable ? 1099 : ''} + + {a.account_type === 'asset' && ( + + onSetPrimary(a.id)} + > + {a.is_primary ? : } + + + )} + {a.account_type === 'asset' && ( + + onAdjustBalance(a)}> + + + + )} onEdit(a)}> @@ -369,3 +646,134 @@ function AccountTable({
); } + +// ── 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 ( +
+ +
+ ); + } + + return ( + + + + + Total Principal + + + {fmt(totalPrincipal)} + + + + + Total Current Value + + + {fmt(totalValue)} + + + + + Avg Interest Rate + + + {avgRate.toFixed(2)}% + + + + + + + + Name + Institution + Type + Fund + Principal + Rate + Interest Earned + Maturity Value + Days Remaining + Maturity Date + + + + {investments.length === 0 && ( + + + + No investments yet + + + + )} + {investments.map((inv) => ( + + {inv.name} + {inv.institution} + + + {inv.investment_type} + + + + + {inv.fund_type} + + + + {fmt(inv.principal)} + + + {parseFloat(inv.interest_rate || '0').toFixed(2)}% + + + {inv.interest_earned !== null ? fmt(inv.interest_earned) : '-'} + + + {inv.maturity_value !== null ? fmt(inv.maturity_value) : '-'} + + + {inv.days_remaining !== null ? ( + + {inv.days_remaining} days + + ) : ( + '-' + )} + + + {inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'} + + + ))} + +
+
+ ); +} diff --git a/frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx b/frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx index 138f42d..f83d542 100644 --- a/frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx +++ b/frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx @@ -1,13 +1,13 @@ import { useState } from 'react'; import { 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'; import { useForm } from '@mantine/form'; import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { - IconPlus, IconEdit, IconCategory, IconCash, IconHome, IconArchive, + IconPlus, IconEdit, IconCategory, IconCash, IconHome, IconArchive, IconStarFilled, IconStar, } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; @@ -24,6 +24,7 @@ interface AssessmentGroup { monthly_operating_income: string; monthly_reserve_income: string; total_monthly_income: string; + is_default: 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) => { setEditing(group); form.setValues({ @@ -223,10 +235,17 @@ export function AssessmentGroupsPage() { {groups.map((g) => ( -
- {g.name} - {g.description && {g.description}} -
+ +
+ + {g.name} + {g.is_default && ( + Default + )} + + {g.description && {g.description}} +
+
{g.actual_unit_count || g.unit_count} @@ -256,6 +275,16 @@ export function AssessmentGroupsPage() { + + !g.is_default && setDefaultMutation.mutate(g.id)} + disabled={g.is_default} + > + {g.is_default ? : } + + handleEdit(g)}> diff --git a/frontend/src/pages/capital-projects/CapitalProjectsPage.tsx b/frontend/src/pages/capital-projects/CapitalProjectsPage.tsx index df9755e..e7f67e1 100644 --- a/frontend/src/pages/capital-projects/CapitalProjectsPage.tsx +++ b/frontend/src/pages/capital-projects/CapitalProjectsPage.tsx @@ -8,9 +8,10 @@ import { useForm } from '@mantine/form'; import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { - IconPlus, IconEdit, IconTable, IconLayoutKanban, IconFileTypePdf, - IconGripVertical, + IconEdit, IconTable, IconLayoutKanban, IconFileTypePdf, + IconGripVertical, IconCalendar, IconClipboardList, } from '@tabler/icons-react'; +import { useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; @@ -18,10 +19,21 @@ import api from '../../services/api'; // Types & constants // --------------------------------------------------------------------------- -interface CapitalProject { - id: string; name: string; description: string; estimated_cost: string; - actual_cost: string; target_year: number; target_month: number; - status: string; fund_source: string; priority: number; +interface Project { + id: string; + name: string; + 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; @@ -38,17 +50,30 @@ const fmt = (v: string | number) => 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 // --------------------------------------------------------------------------- interface KanbanCardProps { - project: CapitalProject; - onEdit: (p: CapitalProject) => void; - onDragStart: (e: DragEvent, project: CapitalProject) => void; + project: Project; + onEdit: (p: Project) => void; + onDragStart: (e: DragEvent, project: Project) => void; } function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) { + const plannedLabel = formatPlannedDate(project.planned_date); + return ( - - {project.fund_source?.replace('_', ' ') || 'reserve'} - + + + {project.fund_source?.replace('_', ' ') || 'reserve'} + + {plannedLabel && ( + }> + {plannedLabel} + + )} + ); } @@ -98,9 +130,9 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) { interface KanbanColumnProps { year: number; - projects: CapitalProject[]; - onEdit: (p: CapitalProject) => void; - onDragStart: (e: DragEvent, project: CapitalProject) => void; + projects: Project[]; + onEdit: (p: Project) => void; + onDragStart: (e: DragEvent, project: Project) => void; onDrop: (e: DragEvent, targetYear: number) => void; isDragOver: boolean; onDragOverHandler: (e: DragEvent, year: number) => void; @@ -177,7 +209,7 @@ const printStyles = ` export function CapitalProjectsPage() { const [opened, { open, close }] = useDisclosure(false); - const [editing, setEditing] = useState(null); + const [editing, setEditing] = useState(null); const [viewMode, setViewMode] = useState('kanban'); const [printMode, setPrintMode] = useState(false); const [dragOverYear, setDragOverYear] = useState(null); @@ -186,12 +218,12 @@ export function CapitalProjectsPage() { // ---- Data fetching ---- - const { data: projects = [], isLoading } = useQuery({ - queryKey: ['capital-projects'], - queryFn: async () => { const { data } = await api.get('/capital-projects'); return data; }, + const { data: projects = [], isLoading } = useQuery({ + queryKey: ['projects-planning'], + queryFn: async () => { const { data } = await api.get('/projects/planning'); return data; }, }); - // ---- Form ---- + // ---- Form (simplified edit modal) ---- const currentYear = new Date().getFullYear(); @@ -205,26 +237,48 @@ export function CapitalProjectsPage() { const form = useForm({ initialValues: { - name: '', description: '', estimated_cost: 0, actual_cost: 0, - target_year: new Date().getFullYear(), target_month: 6, - status: 'planned', fund_source: 'reserve', priority: 3, - }, - validate: { - name: (v) => (v.length > 0 ? null : 'Required'), - estimated_cost: (v) => (v > 0 ? null : 'Required'), + status: 'planned', + priority: 3, + target_year: currentYear, + target_month: 6, + planned_date: '', + notes: '', }, }); // ---- Mutations ---- const saveMutation = useMutation({ - mutationFn: (values: any) => - editing - ? api.put(`/capital-projects/${editing.id}`, values) - : api.post('/capital-projects', values), + mutationFn: (values: { + status: string; + priority: number; + target_year: number; + target_month: number; + planned_date: string; + notes: string; + }) => { + if (!editing) return Promise.reject(new Error('No project selected')); + const payload: Record = { + 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: () => { - queryClient.invalidateQueries({ queryKey: ['capital-projects'] }); - notifications.show({ message: editing ? 'Project updated' : 'Project created', color: 'green' }); + queryClient.invalidateQueries({ queryKey: ['projects-planning'] }); + queryClient.invalidateQueries({ queryKey: ['projects'] }); + notifications.show({ message: 'Project updated', color: 'green' }); close(); setEditing(null); form.reset(); }, onError: (err: any) => { @@ -233,10 +287,19 @@ export function CapitalProjectsPage() { }); const moveProjectMutation = useMutation({ - mutationFn: ({ id, target_year }: { id: string; target_year: number }) => - api.put(`/capital-projects/${id}`, { target_year }), + mutationFn: ({ id, target_year, target_month }: { id: string; target_year: number; target_month: number }) => { + const payload: Record = { 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: () => { - queryClient.invalidateQueries({ queryKey: ['capital-projects'] }); + queryClient.invalidateQueries({ queryKey: ['projects-planning'] }); + queryClient.invalidateQueries({ queryKey: ['projects'] }); notifications.show({ message: 'Project moved successfully', color: 'green' }); }, onError: (err: any) => { @@ -261,25 +324,19 @@ export function CapitalProjectsPage() { // ---- Handlers ---- - const handleEdit = (p: CapitalProject) => { + const handleEdit = (p: Project) => { setEditing(p); form.setValues({ - name: p.name, description: p.description || '', - 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', + status: p.status || 'planned', priority: p.priority || 3, + target_year: p.target_year, + target_month: p.target_month || 6, + planned_date: p.planned_date || '', + notes: p.notes || '', }); open(); }; - const handleNewProject = () => { - setEditing(null); - form.reset(); - open(); - }; - const handlePdfExport = () => { // If already in table view, just print directly if (viewMode === 'table') { @@ -292,8 +349,12 @@ export function CapitalProjectsPage() { // ---- Drag & Drop ---- - const handleDragStart = useCallback((e: DragEvent, project: CapitalProject) => { - e.dataTransfer.setData('application/json', JSON.stringify({ id: project.id, source_year: project.target_year })); + const handleDragStart = useCallback((e: DragEvent, project: Project) => { + e.dataTransfer.setData('application/json', JSON.stringify({ + id: project.id, + source_year: project.target_year, + target_month: project.target_month, + })); e.dataTransfer.effectAllowed = 'move'; }, []); @@ -313,7 +374,11 @@ export function CapitalProjectsPage() { try { const payload = JSON.parse(e.dataTransfer.getData('application/json')); 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 { // ignore malformed drag data @@ -336,15 +401,50 @@ export function CapitalProjectsPage() { // ---- Loading state ---- + const navigate = useNavigate(); + if (isLoading) return
; + // ---- Empty state when no planning projects exist ---- + + if (projects.length === 0) { + return ( + + + Capital Planning + +
+ + + + No projects in the capital plan + + + 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. + + + +
+
+ ); + } + // ---- Render: Table view ---- const renderTableView = () => ( <> {years.length === 0 ? ( - 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. ) : ( years.map((year) => { @@ -361,12 +461,15 @@ export function CapitalProjectsPage() { Project + Category Target Priority Estimated Actual Source + Funded Status + Planned Date @@ -374,6 +477,7 @@ export function CapitalProjectsPage() { {yearProjects.map((p) => ( {p.name} + {p.category || '-'} {p.target_year === FUTURE_YEAR ? 'Future' @@ -394,10 +498,18 @@ export function CapitalProjectsPage() { {parseFloat(p.actual_cost || '0') > 0 ? fmt(p.actual_cost) : '-'} - {p.fund_source} - {p.status} + {p.fund_source?.replace('_', ' ') || 'reserve'} + + {parseFloat(p.funded_percentage || '0') > 0 + ? `${parseFloat(p.funded_percentage).toFixed(0)}%` + : '-'} + + + {p.status?.replace('_', ' ')} + + {formatPlannedDate(p.planned_date) || '-'} handleEdit(p)}> @@ -447,7 +559,7 @@ export function CapitalProjectsPage() { - Capital Projects + Capital Planning - @@ -503,14 +612,22 @@ export function CapitalProjectsPage() { )} - + {/* Simplified edit modal - full project editing is done in ProjectsPage */} +
saveMutation.mutate(v))}> - -