From 560f2d3c7ba9a223cbbb7f4c5d76ac4f1a4fc229 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Tue, 24 Mar 2026 14:41:02 -0400 Subject: [PATCH] feat: investment chart alignment, auto-renew records, fund transfers, capital planning report, and upcoming activities (v2026.3.24) - Lock InvestmentTimeline and ProjectionChart to shared X axis range - Auto-create renewal scenario_investments records when auto_renew is true - Add fund transfer mechanism between asset accounts with journal entries - Add Capital Planning Report (5-year forecast grouped by category) - Add Upcoming Investment Activities dashboard card (maturities + planned purchases) - Bump version to 2026.3.24 Co-Authored-By: Claude Opus 4.6 --- backend/package.json | 2 +- .../modules/accounts/accounts.controller.ts | 8 + .../src/modules/accounts/accounts.service.ts | 56 +++++ .../board-planning-projection.service.ts | 60 +++++- .../src/modules/reports/reports.controller.ts | 12 ++ .../src/modules/reports/reports.service.ts | 188 +++++++++++++++++ frontend/package.json | 2 +- frontend/src/App.tsx | 2 + frontend/src/components/layout/Sidebar.tsx | 1 + frontend/src/pages/accounts/AccountsPage.tsx | 112 +++++++++- .../InvestmentScenarioDetailPage.tsx | 40 +++- .../components/InvestmentTimeline.tsx | 21 +- .../components/ProjectionChart.tsx | 19 +- .../src/pages/dashboard/DashboardPage.tsx | 103 +++++++++ .../src/pages/reports/CapitalPlanningPage.tsx | 196 ++++++++++++++++++ 15 files changed, 801 insertions(+), 21 deletions(-) create mode 100644 frontend/src/pages/reports/CapitalPlanningPage.tsx diff --git a/backend/package.json b/backend/package.json index b91551f..47d0896 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "hoa-ledgeriq-backend", - "version": "2026.3.19", + "version": "2026.3.24", "description": "HOA LedgerIQ - Backend API", "private": true, "scripts": { diff --git a/backend/src/modules/accounts/accounts.controller.ts b/backend/src/modules/accounts/accounts.controller.ts index 038ce4f..2c07733 100644 --- a/backend/src/modules/accounts/accounts.controller.ts +++ b/backend/src/modules/accounts/accounts.controller.ts @@ -58,6 +58,14 @@ export class AccountsController { return this.accountsService.adjustBalance(id, dto); } + @Post('transfer') + @ApiOperation({ summary: 'Transfer funds between asset accounts' }) + transferFunds( + @Body() dto: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo?: string }, + ) { + return this.accountsService.transferFunds(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 911b457..b99c77b 100644 --- a/backend/src/modules/accounts/accounts.service.ts +++ b/backend/src/modules/accounts/accounts.service.ts @@ -360,6 +360,62 @@ export class AccountsService { return journalEntry; } + async transferFunds(dto: { + fromAccountId: string; + toAccountId: string; + amount: number; + transferDate: string; + memo?: string; + }) { + if (dto.amount <= 0) throw new BadRequestException('Transfer amount must be positive'); + if (dto.fromAccountId === dto.toAccountId) throw new BadRequestException('Cannot transfer to the same account'); + + const fromAccount = await this.findOne(dto.fromAccountId); + const toAccount = await this.findOne(dto.toAccountId); + + if (fromAccount.account_type !== 'asset') throw new BadRequestException('Source account must be an asset account'); + if (toAccount.account_type !== 'asset') throw new BadRequestException('Destination account must be an asset account'); + + // Find fiscal period + const asOf = new Date(dto.transferDate); + 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 memo = dto.memo || `Transfer from ${fromAccount.name} to ${toAccount.name}`; + + // Create journal entry: debit destination (increase), credit source (decrease) + 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, 'transfer', $3, true, NOW(), $4) + RETURNING *`, + [dto.transferDate, memo, periods[0].id, '00000000-0000-0000-0000-000000000000'], + ); + const je = jeRows[0]; + + // Credit source account (reduces asset balance) + await this.tenant.query( + `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo) + VALUES ($1, $2, 0, $3, $4)`, + [je.id, dto.fromAccountId, dto.amount, memo], + ); + + // Debit destination account (increases asset balance) + await this.tenant.query( + `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo) + VALUES ($1, $2, $3, 0, $4)`, + [je.id, dto.toAccountId, dto.amount, memo], + ); + + return je; + } + async getTrialBalance(asOfDate?: string) { const dateFilter = asOfDate ? `AND je.entry_date <= $1` 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 ebd63ac..3a8172b 100644 --- a/backend/src/modules/board-planning/board-planning-projection.service.ts +++ b/backend/src/modules/board-planning/board-planning-projection.service.ts @@ -25,12 +25,15 @@ export class BoardPlanningProjectionService { return this.computeProjection(scenarioId); } - /** Compute full projection for a scenario. */ + /** Compute full projection for a scenario. Also auto-creates renewal records for auto_renew investments. */ async computeProjection(scenarioId: string) { const scenarioRows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]); if (!scenarioRows.length) throw new NotFoundException('Scenario not found'); const scenario = scenarioRows[0]; + // Auto-create renewal investment records for auto_renew investments that have maturity dates + await this.ensureRenewalRecords(scenarioId); + const investments = await this.tenant.query( 'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY purchase_date', [scenarioId], ); @@ -152,6 +155,53 @@ export class BoardPlanningProjectionService { // ── Private Helpers ── + /** + * For each auto_renew investment with a maturity_date, ensure a corresponding + * renewal investment record exists (starting at maturity_date, same term). + * The renewal record has auto_renew=false so it won't create infinite chains. + */ + private async ensureRenewalRecords(scenarioId: string) { + const autoRenewInvestments = await this.tenant.query( + `SELECT * FROM scenario_investments + WHERE scenario_id = $1 AND auto_renew = true AND maturity_date IS NOT NULL AND executed_investment_id IS NULL`, + [scenarioId], + ); + + for (const inv of autoRenewInvestments) { + // Check if a renewal record already exists (linked by notes convention or same label pattern) + const renewalLabel = `${inv.label} (Renewal)`; + const existing = await this.tenant.query( + `SELECT id FROM scenario_investments WHERE scenario_id = $1 AND label = $2 AND purchase_date = $3`, + [scenarioId, renewalLabel, inv.maturity_date], + ); + + if (existing.length > 0) continue; // Already created + + // Compute new maturity date from original term + let newMaturityDate: string | null = null; + const termMonths = parseInt(inv.term_months) || 0; + if (termMonths > 0 && inv.maturity_date) { + const d = new Date(inv.maturity_date); + d.setMonth(d.getMonth() + termMonths); + newMaturityDate = d.toISOString().split('T')[0]; + } + + await this.tenant.query( + `INSERT INTO scenario_investments + (scenario_id, label, investment_type, fund_type, principal, interest_rate, + term_months, institution, purchase_date, maturity_date, auto_renew, notes, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, false, $11, $12)`, + [ + scenarioId, renewalLabel, inv.investment_type, inv.fund_type, + inv.principal, inv.interest_rate, inv.term_months || null, + inv.institution, inv.maturity_date, newMaturityDate, + `Auto-created renewal of "${inv.label}". Modify as needed.`, + (parseInt(inv.sort_order) || 0) + 1, + ], + ); + } + } + private async getBaselineState(startYear: number, months: number) { // Current balances from asset accounts const opCashRows = await this.tenant.query(` @@ -403,11 +453,9 @@ export class BoardPlanningProjectionService { if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; } else { resCashFlow += maturityTotal; resInvChange -= principal; } - // Auto-renew: immediately reinvest - if (inv.auto_renew) { - if (isOp) { opCashFlow -= principal; opInvChange += principal; } - else { resCashFlow -= principal; resInvChange += principal; } - } + // Note: auto_renew investments now create separate renewal records + // (via ensureRenewalRecords), so the renewal purchase is handled by + // that record's purchase_date logic above — no inline reinvest needed. } } } diff --git a/backend/src/modules/reports/reports.controller.ts b/backend/src/modules/reports/reports.controller.ts index 8f9e270..9fc2294 100644 --- a/backend/src/modules/reports/reports.controller.ts +++ b/backend/src/modules/reports/reports.controller.ts @@ -65,6 +65,11 @@ export class ReportsController { return this.reportsService.getDashboardKPIs(); } + @Get('upcoming-investment-activities') + getUpcomingInvestmentActivities() { + return this.reportsService.getUpcomingInvestmentActivities(); + } + @Get('cash-flow-forecast') getCashFlowForecast( @Query('startYear') startYear?: string, @@ -75,6 +80,13 @@ export class ReportsController { return this.reportsService.getCashFlowForecast(yr, mo); } + @Get('capital-planning') + getCapitalPlanningReport(@Query('startYear') startYear?: string) { + return this.reportsService.getCapitalPlanningReport( + parseInt(startYear || '') || undefined, + ); + } + @Get('quarterly') getQuarterlyFinancial( @Query('year') year?: string, diff --git a/backend/src/modules/reports/reports.service.ts b/backend/src/modules/reports/reports.service.ts index 8df2e58..a1e9554 100644 --- a/backend/src/modules/reports/reports.service.ts +++ b/backend/src/modules/reports/reports.service.ts @@ -780,6 +780,78 @@ export class ReportsService { }; } + async getUpcomingInvestmentActivities() { + const now = new Date(); + const in45Days = new Date(now); + in45Days.setDate(in45Days.getDate() + 45); + const in60Days = new Date(now); + in60Days.setDate(in60Days.getDate() + 60); + + // 1. Investments maturing within 45 days + const maturingInvestments = await this.tenant.query(` + SELECT id, name, institution, investment_type, fund_type, current_value, principal, + interest_rate, maturity_date, purchase_date + FROM investment_accounts + WHERE is_active = true + AND maturity_date IS NOT NULL + AND maturity_date BETWEEN CURRENT_DATE AND $1::date + ORDER BY maturity_date ASC + `, [in45Days.toISOString().split('T')[0]]); + + // Compute interest earned and days remaining for each + const maturing = maturingInvestments.map((inv: any) => { + const principal = parseFloat(inv.principal) || parseFloat(inv.current_value) || 0; + const rate = parseFloat(inv.interest_rate) || 0; + const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : now; + const maturityDate = new Date(inv.maturity_date); + const daysHeld = Math.max((maturityDate.getTime() - purchaseDate.getTime()) / 86400000, 1); + const interestEarned = principal * (rate / 100) * (daysHeld / 365); + const daysRemaining = Math.max(Math.ceil((maturityDate.getTime() - now.getTime()) / 86400000), 0); + return { + ...inv, + interest_earned: interestEarned.toFixed(2), + maturity_value: (principal + interestEarned).toFixed(2), + days_remaining: daysRemaining, + activity_type: 'maturity', + }; + }); + + // 2. Approved scenario investments due to execute within 60 days + let scenarioItems: any[] = []; + try { + scenarioItems = await this.tenant.query(` + SELECT si.id, si.label, si.investment_type, si.fund_type, si.principal, + si.interest_rate, si.purchase_date, si.maturity_date, si.institution, + bs.name as scenario_name, bs.status as scenario_status + FROM scenario_investments si + JOIN board_scenarios bs ON bs.id = si.scenario_id + WHERE bs.status = 'approved' + AND si.executed_investment_id IS NULL + AND si.purchase_date IS NOT NULL + AND si.purchase_date BETWEEN CURRENT_DATE AND $1::date + ORDER BY si.purchase_date ASC + `, [in60Days.toISOString().split('T')[0]]); + } catch { + // scenario tables may not exist + } + + const upcoming = scenarioItems.map((si: any) => { + const purchaseDate = new Date(si.purchase_date); + const daysUntil = Math.max(Math.ceil((purchaseDate.getTime() - now.getTime()) / 86400000), 0); + return { + ...si, + days_until: daysUntil, + activity_type: 'planned_purchase', + }; + }); + + return { + maturing_investments: maturing, + upcoming_scenario_investments: upcoming, + total_activities: maturing.length + upcoming.length, + }; + } + /** * Cash Flow Forecast: monthly datapoints with actuals (historical) and projections (future). * Each month has: operating_cash, operating_investments, reserve_cash, reserve_investments. @@ -1264,4 +1336,120 @@ export class ReportsService { over_budget_items: overBudgetItems, }; } + + async getCapitalPlanningReport(startYear?: number) { + const baseYear = startYear || new Date().getFullYear(); + const years = [baseYear, baseYear + 1, baseYear + 2, baseYear + 3, baseYear + 4]; + + // Get all active projects + const projects = await this.tenant.query( + `SELECT id, name, description, category, estimated_cost, target_year, target_month, + useful_life_years, last_replacement_date, next_replacement_date, fund_source, + status, priority, condition_rating + FROM projects + WHERE is_active = true + ORDER BY category NULLS LAST, priority, name`, + ); + + // Also try capital_projects table + let capitalProjects: any[] = []; + try { + capitalProjects = await this.tenant.query( + `SELECT id, name, description, estimated_cost, target_year, target_month, + fund_source, status, priority, notes + FROM capital_projects + WHERE status NOT IN ('cancelled') + ORDER BY priority, name`, + ); + } catch { + // Table may not exist + } + + // Merge and group by category + const allProjects = [ + ...projects.map((p: any) => ({ + id: p.id, + name: p.name, + description: p.description, + category: p.category || 'Uncategorized', + estimated_cost: parseFloat(p.estimated_cost) || 0, + target_year: parseInt(p.target_year) || null, + useful_life_years: parseInt(p.useful_life_years) || null, + last_replacement_date: p.last_replacement_date, + fund_source: p.fund_source || 'reserve', + status: p.status, + priority: parseInt(p.priority) || 3, + condition_rating: parseInt(p.condition_rating) || null, + })), + ...capitalProjects + .filter((cp: any) => !projects.some((p: any) => p.name === cp.name && p.target_year === cp.target_year)) + .map((cp: any) => ({ + id: cp.id, + name: cp.name, + description: cp.description, + category: 'Capital Projects', + estimated_cost: parseFloat(cp.estimated_cost) || 0, + target_year: parseInt(cp.target_year) || null, + useful_life_years: null, + last_replacement_date: null, + fund_source: cp.fund_source || 'reserve', + status: cp.status, + priority: parseInt(cp.priority) || 3, + condition_rating: null, + })), + ]; + + // Group by category + const categories: Record = {}; + for (const project of allProjects) { + const cat = project.category; + if (!categories[cat]) categories[cat] = []; + categories[cat].push(project); + } + + // Build year columns for each project + const categoryData = Object.entries(categories).map(([category, items]) => ({ + category, + projects: items.map((p) => { + const yearAmounts: Record = {}; + let beyond = 0; + if (p.target_year) { + if (p.target_year >= years[0] && p.target_year <= years[4]) { + yearAmounts[p.target_year] = p.estimated_cost; + } else if (p.target_year > years[4]) { + beyond = p.estimated_cost; + } + } + return { + ...p, + year_amounts: yearAmounts, + beyond, + }; + }), + })); + + // Compute totals per year + const yearTotals: Record = {}; + let beyondTotal = 0; + for (const y of years) yearTotals[y] = 0; + for (const cat of categoryData) { + for (const p of cat.projects) { + for (const y of years) { + yearTotals[y] += p.year_amounts[y] || 0; + } + beyondTotal += p.beyond; + } + } + + return { + title: `${years[4] - years[0] + 1}-YEAR CAPITAL PROJECT FORECAST`, + start_year: years[0], + years, + categories: categoryData, + year_totals: yearTotals, + beyond_total: beyondTotal, + grand_total: Object.values(yearTotals).reduce((a, b) => a + b, 0) + beyondTotal, + generated_at: new Date().toISOString(), + }; + } } diff --git a/frontend/package.json b/frontend/package.json index 1fb3b5f..3e9e175 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hoa-ledgeriq-frontend", - "version": "2026.3.19", + "version": "2026.3.24", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dc7ae07..d21e25b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ import { CashFlowPage } from './pages/reports/CashFlowPage'; import { AgingReportPage } from './pages/reports/AgingReportPage'; import { YearEndPage } from './pages/reports/YearEndPage'; import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage'; +import { CapitalPlanningPage } from './pages/reports/CapitalPlanningPage'; import { SettingsPage } from './pages/settings/SettingsPage'; import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage'; import { OrgMembersPage } from './pages/org-members/OrgMembersPage'; @@ -167,6 +168,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 7785502..d07d371 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -94,6 +94,7 @@ const navSections = [ { label: 'Sankey Diagram', path: '/reports/sankey' }, { label: 'Year-End', path: '/reports/year-end' }, { label: 'Quarterly Financial', path: '/reports/quarterly' }, + { label: 'Capital Planning', path: '/reports/capital-planning' }, ], }, ], diff --git a/frontend/src/pages/accounts/AccountsPage.tsx b/frontend/src/pages/accounts/AccountsPage.tsx index dd614e4..162811c 100644 --- a/frontend/src/pages/accounts/AccountsPage.tsx +++ b/frontend/src/pages/accounts/AccountsPage.tsx @@ -37,6 +37,7 @@ import { IconStarFilled, IconAdjustments, IconInfoCircle, + IconArrowsTransferDown, } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; @@ -126,6 +127,7 @@ export function AccountsPage() { const [search, setSearch] = useState(''); const [filterType, setFilterType] = useState(null); const [showArchived, setShowArchived] = useState(false); + const [transferOpened, { open: openTransfer, close: closeTransfer }] = useDisclosure(false); const queryClient = useQueryClient(); const isReadOnly = useIsReadOnly(); @@ -283,6 +285,39 @@ export function AccountsPage() { }, }); + // ── Transfer form ── + const transferForm = useForm({ + initialValues: { + fromAccountId: '', + toAccountId: '', + amount: 0, + transferDate: new Date() as Date | null, + memo: '', + }, + validate: { + fromAccountId: (v) => (v ? null : 'Required'), + toAccountId: (v, values) => !v ? 'Required' : v === values.fromAccountId ? 'Must be different from source' : null, + amount: (v) => (v > 0 ? null : 'Must be greater than 0'), + transferDate: (v) => (v ? null : 'Required'), + }, + }); + + const transferMutation = useMutation({ + mutationFn: (values: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo: string }) => + api.post('/accounts/transfer', values), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + queryClient.invalidateQueries({ queryKey: ['trial-balance'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + notifications.show({ message: 'Transfer completed successfully', color: 'green' }); + closeTransfer(); + transferForm.reset(); + }, + onError: (err: any) => { + notifications.show({ message: err.response?.data?.message || 'Transfer failed', color: 'red' }); + }, + }); + // ── Investment edit form ── const invForm = useForm({ initialValues: { @@ -408,6 +443,9 @@ export function AccountsPage() { const activeAccounts = filtered.filter((a) => a.is_active); const archivedAccounts = filtered.filter((a) => !a.is_active); + // Asset accounts for transfer modal (all active asset accounts, not just filtered by search) + const assetAccounts = accounts.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset'); + // ── Investments split by fund type ── const operatingInvestments = investments.filter((i) => i.fund_type === 'operating' && i.is_active); const reserveInvestments = investments.filter((i) => i.fund_type === 'reserve' && i.is_active); @@ -505,9 +543,14 @@ export function AccountsPage() { size="sm" /> {!isReadOnly && ( - + <> + + + )} @@ -854,6 +897,69 @@ export function AccountsPage() { )} + {/* Transfer Funds Modal */} + +
{ + transferMutation.mutate({ + ...values, + transferDate: values.transferDate ? values.transferDate.toISOString().split('T')[0] : new Date().toISOString().split('T')[0], + }); + })}> + + } color="blue" variant="light"> + This creates a journal entry transferring funds between asset accounts. + Both accounts will be updated in the general ledger. + + a.id !== transferForm.values.fromAccountId) + .map((a) => ({ + value: a.id, + label: `${a.name} (${a.fund_type}) — ${fmt(a.balance)}`, + }))} + searchable + {...transferForm.getInputProps('toAccountId')} + /> + + + + + +
+
+ {/* Investment Edit Modal */} {editingInvestment && ( diff --git a/frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx b/frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx index 83e487f..4e89659 100644 --- a/frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx +++ b/frontend/src/pages/board-planning/InvestmentScenarioDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon, Loader, Center, Select, Modal, TextInput, Alert, SimpleGrid, Tooltip, @@ -106,6 +106,34 @@ export function InvestmentScenarioDetailPage() { const investments = scenario.investments || []; const summary = projection?.summary; + // Compute shared time range for aligned charts + const { sharedStartDate, sharedEndDate } = useMemo(() => { + const allDates: Date[] = []; + + // Dates from investments + for (const inv of investments) { + if (inv.purchase_date) allDates.push(new Date(inv.purchase_date)); + if (inv.maturity_date) allDates.push(new Date(inv.maturity_date)); + } + + // Dates from projection datapoints + const dps = projection?.datapoints || []; + if (dps.length > 0) { + allDates.push(new Date(dps[0].year, dps[0].monthNum - 1, 1)); + const last = dps[dps.length - 1]; + allDates.push(new Date(last.year, last.monthNum - 1, 1)); + } + + if (allDates.length === 0) return { sharedStartDate: undefined, sharedEndDate: undefined }; + + const min = new Date(Math.min(...allDates.map((d) => d.getTime()))); + const max = new Date(Math.max(...allDates.map((d) => d.getTime()))); + return { + sharedStartDate: new Date(min.getFullYear(), min.getMonth(), 1), + sharedEndDate: new Date(max.getFullYear(), max.getMonth(), 1), + }; + }, [investments, projection]); + // Build a lookup of per-investment interest from the projection const interestDetailMap: Record = {}; if (summary?.investment_interest_details) { @@ -259,7 +287,13 @@ export function InvestmentScenarioDetailPage() { {/* Investment Timeline */} - {investments.length > 0 && } + {investments.length > 0 && ( + + )} {/* Projection Chart */} {projection && ( @@ -267,6 +301,8 @@ export function InvestmentScenarioDetailPage() { datapoints={projection.datapoints || []} title="Scenario Projection" summary={projection.summary} + sharedStartDate={sharedStartDate} + sharedEndDate={sharedEndDate} /> )} {projLoading &&
} diff --git a/frontend/src/pages/board-planning/components/InvestmentTimeline.tsx b/frontend/src/pages/board-planning/components/InvestmentTimeline.tsx index 49e23f2..890a5d1 100644 --- a/frontend/src/pages/board-planning/components/InvestmentTimeline.tsx +++ b/frontend/src/pages/board-planning/components/InvestmentTimeline.tsx @@ -13,9 +13,12 @@ const typeColors: Record = { interface Props { investments: any[]; + /** Optional shared time range to align with ProjectionChart */ + sharedStartDate?: Date; + sharedEndDate?: Date; } -export function InvestmentTimeline({ investments }: Props) { +export function InvestmentTimeline({ investments, sharedStartDate, sharedEndDate }: Props) { const { items, startDate, endDate, totalMonths } = useMemo(() => { const now = new Date(); const items = investments @@ -28,16 +31,24 @@ export function InvestmentTimeline({ investments }: Props) { if (!items.length) return { items: [], startDate: now, endDate: now, totalMonths: 1 }; - const allDates = items.flatMap((i: any) => [i.start, i.end].filter(Boolean)) as Date[]; - const startDate = new Date(Math.min(...allDates.map((d) => d.getTime()))); - const endDate = new Date(Math.max(...allDates.map((d) => d.getTime()))); + // Use shared range if provided (to align with ProjectionChart), otherwise compute from investments + let startDate: Date; + let endDate: Date; + if (sharedStartDate && sharedEndDate) { + startDate = sharedStartDate; + endDate = sharedEndDate; + } else { + const allDates = items.flatMap((i: any) => [i.start, i.end].filter(Boolean)) as Date[]; + startDate = new Date(Math.min(...allDates.map((d) => d.getTime()))); + endDate = new Date(Math.max(...allDates.map((d) => d.getTime()))); + } const totalMonths = Math.max( (endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + 1, 1, ); return { items, startDate, endDate, totalMonths }; - }, [investments]); + }, [investments, sharedStartDate, sharedEndDate]); if (!items.length) return null; diff --git a/frontend/src/pages/board-planning/components/ProjectionChart.tsx b/frontend/src/pages/board-planning/components/ProjectionChart.tsx index 5fb904f..6309f76 100644 --- a/frontend/src/pages/board-planning/components/ProjectionChart.tsx +++ b/frontend/src/pages/board-planning/components/ProjectionChart.tsx @@ -23,18 +23,31 @@ interface Props { datapoints: Datapoint[]; title?: string; summary?: any; + /** Optional shared time range to align with InvestmentTimeline */ + sharedStartDate?: Date; + sharedEndDate?: Date; } -export function ProjectionChart({ datapoints, title = 'Financial Projection', summary }: Props) { +export function ProjectionChart({ datapoints, title = 'Financial Projection', summary, sharedStartDate, sharedEndDate }: Props) { const [fundFilter, setFundFilter] = useState('all'); const chartData = useMemo(() => { - return datapoints.map((d) => ({ + let filtered = datapoints; + // If shared range provided, filter datapoints to match + if (sharedStartDate && sharedEndDate) { + const startKey = sharedStartDate.getFullYear() * 12 + sharedStartDate.getMonth(); + const endKey = sharedEndDate.getFullYear() * 12 + sharedEndDate.getMonth(); + filtered = datapoints.filter((d) => { + const dpKey = d.year * 12 + (d.monthNum - 1); + return dpKey >= startKey && dpKey <= endKey; + }); + } + return filtered.map((d) => ({ ...d, label: `${d.month}`, total: d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments, })); - }, [datapoints]); + }, [datapoints, sharedStartDate, sharedEndDate]); // Find first forecast month for reference line const forecastStart = chartData.findIndex((d) => d.is_forecast); diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx index e46dd9b..9ebff28 100644 --- a/frontend/src/pages/dashboard/DashboardPage.tsx +++ b/frontend/src/pages/dashboard/DashboardPage.tsx @@ -15,6 +15,8 @@ import { IconHeartbeat, IconRefresh, IconInfoCircle, + IconCoin, + IconCalendarEvent, } from '@tabler/icons-react'; import { useState, useCallback } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; @@ -362,6 +364,16 @@ export function DashboardPage() { enabled: !!currentOrg, }); + const { data: investmentActivities } = useQuery<{ + maturing_investments: any[]; + upcoming_scenario_investments: any[]; + total_activities: number; + }>({ + queryKey: ['upcoming-investment-activities'], + queryFn: async () => { const { data } = await api.get('/reports/upcoming-investment-activities'); return data; }, + enabled: !!currentOrg, + }); + const { data: healthScores } = useQuery({ queryKey: ['health-scores'], queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; }, @@ -531,6 +543,97 @@ export function DashboardPage() { + {/* Upcoming Investment Activities */} + {(investmentActivities?.total_activities || 0) > 0 && ( + + + + + + + Upcoming Investment Activities + + {investmentActivities?.total_activities} upcoming + + + + + Activity + Type + Fund + Amount + Date + Timeline + + + + {(investmentActivities?.maturing_investments || []).map((inv: any) => ( + + + + + {inv.name} + + {inv.institution && {inv.institution}} + + + Maturing + + + + {inv.fund_type} + + + + {fmt(inv.maturity_value)} + +{fmt(inv.interest_earned)} interest + + + {new Date(inv.maturity_date).toLocaleDateString()} + + + + {inv.days_remaining} days + + + + ))} + {(investmentActivities?.upcoming_scenario_investments || []).map((si: any) => ( + + + + + {si.label} + + Scenario: {si.scenario_name} + + + Planned Purchase + + + + {si.fund_type} + + + + {fmt(si.principal)} + {si.interest_rate && {parseFloat(si.interest_rate).toFixed(2)}% APY} + + + {new Date(si.purchase_date).toLocaleDateString()} + + + + {si.days_until} days + + + + ))} + +
+
+ )} + Quick Stats diff --git a/frontend/src/pages/reports/CapitalPlanningPage.tsx b/frontend/src/pages/reports/CapitalPlanningPage.tsx new file mode 100644 index 0000000..e009c57 --- /dev/null +++ b/frontend/src/pages/reports/CapitalPlanningPage.tsx @@ -0,0 +1,196 @@ +import { useState } from 'react'; +import { + Title, Text, Card, Table, Group, Stack, Badge, Loader, Center, + Button, NumberInput, +} from '@mantine/core'; +import { IconPrinter } from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import api from '../../services/api'; + +interface ProjectItem { + id: string; + name: string; + description: string; + category: string; + estimated_cost: number; + target_year: number | null; + useful_life_years: number | null; + last_replacement_date: string | null; + fund_source: string; + status: string; + priority: number; + condition_rating: number | null; + year_amounts: Record; + beyond: number; +} + +interface CategoryGroup { + category: string; + projects: ProjectItem[]; +} + +interface CapitalPlanningData { + title: string; + start_year: number; + years: number[]; + categories: CategoryGroup[]; + year_totals: Record; + beyond_total: number; + grand_total: number; + generated_at: string; +} + +const fmt = (v: number) => + v === 0 ? '-' : v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }); + +export function CapitalPlanningPage() { + const [startYear, setStartYear] = useState(new Date().getFullYear()); + + const { data, isLoading } = useQuery({ + queryKey: ['capital-planning', startYear], + queryFn: async () => { + const { data } = await api.get(`/reports/capital-planning?startYear=${startYear}`); + return data; + }, + }); + + if (isLoading) return
; + + const years = data?.years || []; + const hasProjects = (data?.categories || []).some((c) => c.projects.length > 0); + + return ( + + +
+ Capital Planning Report + {data?.title || '5-Year Capital Project Forecast'} +
+ + v && setStartYear(Number(v))} + min={2020} + max={2050} + /> + + +
+ + {!hasProjects ? ( + + + No capital projects found. Add projects on the Projects page to generate this report. + + + ) : ( + + {data?.title} + + Generated {new Date(data?.generated_at || '').toLocaleDateString()} + + + + + + Description + Life (yr) + Last Done + {years.map((y) => ( + {y} + ))} + Beyond + + + + {(data?.categories || []).map((cat) => { + const catTotals: Record = {}; + let catBeyond = 0; + for (const y of years) catTotals[y] = 0; + for (const p of cat.projects) { + for (const y of years) catTotals[y] += p.year_amounts[y] || 0; + catBeyond += p.beyond; + } + + return [ + + + {cat.category} + + , + ...cat.projects.map((p) => ( + + + {p.name} + {p.status !== 'planned' && ( + + {p.status} + + )} + + + {p.useful_life_years || '-'} + + + + {p.last_replacement_date + ? new Date(p.last_replacement_date).getFullYear() + : '-'} + + + {years.map((y) => ( + + {fmt(p.year_amounts[y] || 0)} + + ))} + + {fmt(p.beyond)} + + + )), + + + Subtotal — {cat.category} + + {years.map((y) => ( + + {fmt(catTotals[y])} + + ))} + + {fmt(catBeyond)} + + , + ]; + })} + + + + + TOTAL + + {years.map((y) => ( + + {fmt(data?.year_totals[y] || 0)} + + ))} + + {fmt(data?.beyond_total || 0)} + + + +
+
+ )} +
+ ); +} -- 2.49.1