diff --git a/backend/src/modules/board-planning/board-planning-projection.service.ts b/backend/src/modules/board-planning/board-planning-projection.service.ts index bddba51..521e884 100644 --- a/backend/src/modules/board-planning/board-planning-projection.service.ts +++ b/backend/src/modules/board-planning/board-planning-projection.service.ts @@ -287,7 +287,7 @@ export class BoardPlanningProjectionService { else maturityIndex[key].reserve += maturityTotal; } - // Capital project expenses + // Capital project expenses (from unified projects table) const projectExpenses = await this.tenant.query(` SELECT estimated_cost, target_year, target_month, fund_source FROM projects WHERE is_active = true AND status IN ('planned', 'in_progress') AND target_year IS NOT NULL AND estimated_cost > 0 @@ -303,6 +303,25 @@ export class BoardPlanningProjectionService { else projectIndex[key].reserve += cost; } + // Also include capital_projects table (Capital Planning page) + try { + const capitalProjectExpenses = await this.tenant.query(` + SELECT estimated_cost, target_year, target_month, fund_source + FROM capital_projects WHERE status IN ('planned', 'approved', 'in_progress') AND target_year IS NOT NULL AND estimated_cost > 0 + `); + for (const p of capitalProjectExpenses) { + const yr = parseInt(p.target_year); + const mo = parseInt(p.target_month) || 6; + const key = `${yr}-${mo}`; + if (!projectIndex[key]) projectIndex[key] = { operating: 0, reserve: 0 }; + const cost = parseFloat(p.estimated_cost) || 0; + if (p.fund_source === 'operating') projectIndex[key].operating += cost; + else projectIndex[key].reserve += cost; + } + } catch { + // capital_projects table may not exist in all tenants + } + return { openingBalances: { opCash: parseFloat(openingOp[0]?.total || '0'), @@ -464,15 +483,18 @@ export class BoardPlanningProjectionService { const minLiquidity = Math.min(...allLiquidity); const endLiquidity = allLiquidity[allLiquidity.length - 1]; - // Monthly reserve expense from budgets (approximate average) - let totalResExpense = 0; - let budgetMonths = 0; - for (const key of Object.keys(baseline.budgetsByYearMonth)) { - const b = baseline.budgetsByYearMonth[key]; - if (b.resExpense > 0) { totalResExpense += b.resExpense; budgetMonths++; } + // Reserve coverage: reserve balance / avg monthly reserve expenditure from planned capital projects + let totalReserveProjectCost = 0; + const projectionYears = Math.max(1, Math.ceil(datapoints.length / 12)); + for (const key of Object.keys(baseline.projectIndex)) { + totalReserveProjectCost += baseline.projectIndex[key].reserve || 0; } - const avgMonthlyResExpense = budgetMonths > 0 ? totalResExpense / budgetMonths : 1; - const reserveCoverageMonths = (last.reserve_cash + last.reserve_investments) / Math.max(avgMonthlyResExpense, 1); + const avgMonthlyReserveExpenditure = totalReserveProjectCost > 0 + ? totalReserveProjectCost / (projectionYears * 12) + : 0; + const reserveCoverageMonths = avgMonthlyReserveExpenditure > 0 + ? (last.reserve_cash + last.reserve_investments) / avgMonthlyReserveExpenditure + : 0; // No planned projects = show 0 (N/A) // Estimate total investment income from scenario investments const totalInterestEarned = datapoints.reduce((sum, d, i) => { diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index a17c425..6903d8d 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -67,8 +67,8 @@ const navSections = [ label: 'Board Planning', items: [ { label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets' }, - { label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' }, { label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments' }, + { label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' }, { label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' }, ], }, diff --git a/frontend/src/pages/board-planning/AssessmentScenarioDetailPage.tsx b/frontend/src/pages/board-planning/AssessmentScenarioDetailPage.tsx index 0a08dd7..d7837ca 100644 --- a/frontend/src/pages/board-planning/AssessmentScenarioDetailPage.tsx +++ b/frontend/src/pages/board-planning/AssessmentScenarioDetailPage.tsx @@ -1,10 +1,10 @@ import { useState } from 'react'; import { Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon, - Loader, Center, Menu, Select, SimpleGrid, + Loader, Center, Select, SimpleGrid, Tooltip, } from '@mantine/core'; import { - IconPlus, IconArrowLeft, IconDots, IconTrash, IconEdit, IconRefresh, + IconPlus, IconArrowLeft, IconTrash, IconEdit, } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; @@ -92,14 +92,6 @@ export function AssessmentScenarioDetailPage() { }, }); - const refreshProjection = useMutation({ - mutationFn: () => api.post(`/board-planning/scenarios/${id}/projection/refresh`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] }); - notifications.show({ message: 'Projection refreshed', color: 'green' }); - }, - }); - if (isLoading) return
; if (!scenario) return
Scenario not found
; @@ -132,9 +124,6 @@ export function AssessmentScenarioDetailPage() { { value: 'approved', label: 'Approved' }, ]} /> - @@ -163,8 +152,13 @@ export function AssessmentScenarioDetailPage() { Reserve Coverage - {(projection.summary.reserve_coverage_months || 0).toFixed(1)} mo + {projection.summary.reserve_coverage_months > 0 + ? `${projection.summary.reserve_coverage_months.toFixed(1)} mo` + : 'N/A'} + {projection.summary.reserve_coverage_months <= 0 && ( + No planned capital projects + )} )} @@ -201,7 +195,13 @@ export function AssessmentScenarioDetailPage() { {a.change_type === 'special_assessment' - ? `${fmt(parseFloat(a.special_per_unit) || 0)}/unit x ${a.special_installments || 1} mo` + ? `${fmt(parseFloat(a.special_per_unit) || 0)}/unit${(() => { + const inst = parseInt(a.special_installments) || 1; + if (inst === 1) return ', one-time'; + if (inst === 3) return ', quarterly'; + if (inst === 12) return ', annual'; + return `, ${inst} mo`; + })()}` : a.percentage_change ? `${parseFloat(a.percentage_change).toFixed(1)}%` : a.flat_amount_change @@ -211,15 +211,18 @@ export function AssessmentScenarioDetailPage() { {a.effective_date ? new Date(a.effective_date).toLocaleDateString() : '-'} {a.end_date ? new Date(a.end_date).toLocaleDateString() : 'Ongoing'} - - - - - - } onClick={() => setEditAsmt(a)}>Edit - } color="red" onClick={() => removeMutation.mutate(a.id)}>Remove - - + + + setEditAsmt(a)}> + + + + + removeMutation.mutate(a.id)}> + + + + ))} diff --git a/frontend/src/pages/board-planning/components/AssessmentChangeForm.tsx b/frontend/src/pages/board-planning/components/AssessmentChangeForm.tsx index 34c6fdc..8925a1b 100644 --- a/frontend/src/pages/board-planning/components/AssessmentChangeForm.tsx +++ b/frontend/src/pages/board-planning/components/AssessmentChangeForm.tsx @@ -117,32 +117,27 @@ export function AssessmentChangeForm({ opened, onClose, onSubmit, initialData, l {isSpecial && ( <> - - setForm({ ...form, specialTotal: Number(v) || 0 })} - min={0} - decimalScale={2} - thousandSeparator="," - prefix="$" - /> - setForm({ ...form, specialPerUnit: Number(v) || 0 })} - min={0} - decimalScale={2} - prefix="$" - /> - setForm({ ...form, specialPerUnit: Number(v) || 0 })} + min={0} + decimalScale={2} + thousandSeparator="," + prefix="$" + /> +