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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.19",
|
"version": "2026.3.24",
|
||||||
"description": "HOA LedgerIQ - Backend API",
|
"description": "HOA LedgerIQ - Backend API",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ export class AccountsController {
|
|||||||
return this.accountsService.adjustBalance(id, dto);
|
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')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get account by ID' })
|
@ApiOperation({ summary: 'Get account by ID' })
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -360,6 +360,62 @@ export class AccountsService {
|
|||||||
return journalEntry;
|
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) {
|
async getTrialBalance(asOfDate?: string) {
|
||||||
const dateFilter = asOfDate
|
const dateFilter = asOfDate
|
||||||
? `AND je.entry_date <= $1`
|
? `AND je.entry_date <= $1`
|
||||||
|
|||||||
@@ -25,12 +25,15 @@ export class BoardPlanningProjectionService {
|
|||||||
return this.computeProjection(scenarioId);
|
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) {
|
async computeProjection(scenarioId: string) {
|
||||||
const scenarioRows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]);
|
const scenarioRows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]);
|
||||||
if (!scenarioRows.length) throw new NotFoundException('Scenario not found');
|
if (!scenarioRows.length) throw new NotFoundException('Scenario not found');
|
||||||
const scenario = scenarioRows[0];
|
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(
|
const investments = await this.tenant.query(
|
||||||
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY purchase_date', [scenarioId],
|
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY purchase_date', [scenarioId],
|
||||||
);
|
);
|
||||||
@@ -152,6 +155,53 @@ export class BoardPlanningProjectionService {
|
|||||||
|
|
||||||
// ── Private Helpers ──
|
// ── 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) {
|
private async getBaselineState(startYear: number, months: number) {
|
||||||
// Current balances from asset accounts
|
// Current balances from asset accounts
|
||||||
const opCashRows = await this.tenant.query(`
|
const opCashRows = await this.tenant.query(`
|
||||||
@@ -403,11 +453,9 @@ export class BoardPlanningProjectionService {
|
|||||||
if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; }
|
if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; }
|
||||||
else { resCashFlow += maturityTotal; resInvChange -= principal; }
|
else { resCashFlow += maturityTotal; resInvChange -= principal; }
|
||||||
|
|
||||||
// Auto-renew: immediately reinvest
|
// Note: auto_renew investments now create separate renewal records
|
||||||
if (inv.auto_renew) {
|
// (via ensureRenewalRecords), so the renewal purchase is handled by
|
||||||
if (isOp) { opCashFlow -= principal; opInvChange += principal; }
|
// that record's purchase_date logic above — no inline reinvest needed.
|
||||||
else { resCashFlow -= principal; resInvChange += principal; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ export class ReportsController {
|
|||||||
return this.reportsService.getDashboardKPIs();
|
return this.reportsService.getDashboardKPIs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('upcoming-investment-activities')
|
||||||
|
getUpcomingInvestmentActivities() {
|
||||||
|
return this.reportsService.getUpcomingInvestmentActivities();
|
||||||
|
}
|
||||||
|
|
||||||
@Get('cash-flow-forecast')
|
@Get('cash-flow-forecast')
|
||||||
getCashFlowForecast(
|
getCashFlowForecast(
|
||||||
@Query('startYear') startYear?: string,
|
@Query('startYear') startYear?: string,
|
||||||
@@ -75,6 +80,13 @@ export class ReportsController {
|
|||||||
return this.reportsService.getCashFlowForecast(yr, mo);
|
return this.reportsService.getCashFlowForecast(yr, mo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('capital-planning')
|
||||||
|
getCapitalPlanningReport(@Query('startYear') startYear?: string) {
|
||||||
|
return this.reportsService.getCapitalPlanningReport(
|
||||||
|
parseInt(startYear || '') || undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('quarterly')
|
@Get('quarterly')
|
||||||
getQuarterlyFinancial(
|
getQuarterlyFinancial(
|
||||||
@Query('year') year?: string,
|
@Query('year') year?: string,
|
||||||
|
|||||||
@@ -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).
|
* Cash Flow Forecast: monthly datapoints with actuals (historical) and projections (future).
|
||||||
* Each month has: operating_cash, operating_investments, reserve_cash, reserve_investments.
|
* Each month has: operating_cash, operating_investments, reserve_cash, reserve_investments.
|
||||||
@@ -1264,4 +1336,120 @@ export class ReportsService {
|
|||||||
over_budget_items: overBudgetItems,
|
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<string, any[]> = {};
|
||||||
|
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<number, number> = {};
|
||||||
|
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<number, number> = {};
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.19",
|
"version": "2026.3.24",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { CashFlowPage } from './pages/reports/CashFlowPage';
|
|||||||
import { AgingReportPage } from './pages/reports/AgingReportPage';
|
import { AgingReportPage } from './pages/reports/AgingReportPage';
|
||||||
import { YearEndPage } from './pages/reports/YearEndPage';
|
import { YearEndPage } from './pages/reports/YearEndPage';
|
||||||
import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage';
|
import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage';
|
||||||
|
import { CapitalPlanningPage } from './pages/reports/CapitalPlanningPage';
|
||||||
import { SettingsPage } from './pages/settings/SettingsPage';
|
import { SettingsPage } from './pages/settings/SettingsPage';
|
||||||
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
|
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
|
||||||
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
||||||
@@ -167,6 +168,7 @@ export function App() {
|
|||||||
<Route path="reports/sankey" element={<SankeyPage />} />
|
<Route path="reports/sankey" element={<SankeyPage />} />
|
||||||
<Route path="reports/year-end" element={<YearEndPage />} />
|
<Route path="reports/year-end" element={<YearEndPage />} />
|
||||||
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
|
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
|
||||||
|
<Route path="reports/capital-planning" element={<CapitalPlanningPage />} />
|
||||||
<Route path="board-planning/budgets" element={<BudgetPlanningPage />} />
|
<Route path="board-planning/budgets" element={<BudgetPlanningPage />} />
|
||||||
<Route path="board-planning/investments" element={<InvestmentScenariosPage />} />
|
<Route path="board-planning/investments" element={<InvestmentScenariosPage />} />
|
||||||
<Route path="board-planning/investments/:id" element={<InvestmentScenarioDetailPage />} />
|
<Route path="board-planning/investments/:id" element={<InvestmentScenarioDetailPage />} />
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ const navSections = [
|
|||||||
{ label: 'Sankey Diagram', path: '/reports/sankey' },
|
{ label: 'Sankey Diagram', path: '/reports/sankey' },
|
||||||
{ label: 'Year-End', path: '/reports/year-end' },
|
{ label: 'Year-End', path: '/reports/year-end' },
|
||||||
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
|
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
|
||||||
|
{ label: 'Capital Planning', path: '/reports/capital-planning' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
IconAdjustments,
|
IconAdjustments,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
|
IconArrowsTransferDown,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
@@ -126,6 +127,7 @@ export function AccountsPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filterType, setFilterType] = useState<string | null>(null);
|
const [filterType, setFilterType] = useState<string | null>(null);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [transferOpened, { open: openTransfer, close: closeTransfer }] = useDisclosure(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
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 ──
|
// ── Investment edit form ──
|
||||||
const invForm = useForm({
|
const invForm = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
@@ -408,6 +443,9 @@ export function AccountsPage() {
|
|||||||
const activeAccounts = filtered.filter((a) => a.is_active);
|
const activeAccounts = filtered.filter((a) => a.is_active);
|
||||||
const archivedAccounts = filtered.filter((a) => !a.is_active);
|
const archivedAccounts = filtered.filter((a) => !a.is_active);
|
||||||
|
|
||||||
|
// 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 ──
|
// ── Investments split by fund type ──
|
||||||
const operatingInvestments = investments.filter((i) => i.fund_type === 'operating' && i.is_active);
|
const operatingInvestments = investments.filter((i) => i.fund_type === 'operating' && i.is_active);
|
||||||
const reserveInvestments = investments.filter((i) => i.fund_type === 'reserve' && i.is_active);
|
const reserveInvestments = investments.filter((i) => i.fund_type === 'reserve' && i.is_active);
|
||||||
@@ -505,9 +543,14 @@ export function AccountsPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
{!isReadOnly && (
|
{!isReadOnly && (
|
||||||
|
<>
|
||||||
|
<Button variant="light" leftSection={<IconArrowsTransferDown size={16} />} onClick={openTransfer}>
|
||||||
|
Transfer Funds
|
||||||
|
</Button>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||||
Add Account
|
Add Account
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -854,6 +897,69 @@ export function AccountsPage() {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Transfer Funds Modal */}
|
||||||
|
<Modal opened={transferOpened} onClose={closeTransfer} title="Transfer Funds Between Accounts" size="md" closeOnClickOutside={false}>
|
||||||
|
<form onSubmit={transferForm.onSubmit((values) => {
|
||||||
|
transferMutation.mutate({
|
||||||
|
...values,
|
||||||
|
transferDate: values.transferDate ? values.transferDate.toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
|
||||||
|
});
|
||||||
|
})}>
|
||||||
|
<Stack>
|
||||||
|
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||||
|
This creates a journal entry transferring funds between asset accounts.
|
||||||
|
Both accounts will be updated in the general ledger.
|
||||||
|
</Alert>
|
||||||
|
<Select
|
||||||
|
label="From Account"
|
||||||
|
placeholder="Select source account"
|
||||||
|
required
|
||||||
|
data={assetAccounts.map((a) => ({
|
||||||
|
value: a.id,
|
||||||
|
label: `${a.name} (${a.fund_type}) — ${fmt(a.balance)}`,
|
||||||
|
}))}
|
||||||
|
searchable
|
||||||
|
{...transferForm.getInputProps('fromAccountId')}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="To Account"
|
||||||
|
placeholder="Select destination account"
|
||||||
|
required
|
||||||
|
data={assetAccounts
|
||||||
|
.filter((a) => a.id !== transferForm.values.fromAccountId)
|
||||||
|
.map((a) => ({
|
||||||
|
value: a.id,
|
||||||
|
label: `${a.name} (${a.fund_type}) — ${fmt(a.balance)}`,
|
||||||
|
}))}
|
||||||
|
searchable
|
||||||
|
{...transferForm.getInputProps('toAccountId')}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Amount"
|
||||||
|
required
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
thousandSeparator=","
|
||||||
|
min={0.01}
|
||||||
|
{...transferForm.getInputProps('amount')}
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
label="Transfer Date"
|
||||||
|
required
|
||||||
|
{...transferForm.getInputProps('transferDate')}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Memo (optional)"
|
||||||
|
placeholder="e.g. Monthly reserve contribution"
|
||||||
|
{...transferForm.getInputProps('memo')}
|
||||||
|
/>
|
||||||
|
<Button type="submit" leftSection={<IconArrowsTransferDown size={16} />} loading={transferMutation.isPending}>
|
||||||
|
Complete Transfer
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Investment Edit Modal */}
|
{/* Investment Edit Modal */}
|
||||||
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md" closeOnClickOutside={false}>
|
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md" closeOnClickOutside={false}>
|
||||||
{editingInvestment && (
|
{editingInvestment && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
||||||
Loader, Center, Select, Modal, TextInput, Alert, SimpleGrid, Tooltip,
|
Loader, Center, Select, Modal, TextInput, Alert, SimpleGrid, Tooltip,
|
||||||
@@ -106,6 +106,34 @@ export function InvestmentScenarioDetailPage() {
|
|||||||
const investments = scenario.investments || [];
|
const investments = scenario.investments || [];
|
||||||
const summary = projection?.summary;
|
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
|
// Build a lookup of per-investment interest from the projection
|
||||||
const interestDetailMap: Record<string, { interest: number; principal: number }> = {};
|
const interestDetailMap: Record<string, { interest: number; principal: number }> = {};
|
||||||
if (summary?.investment_interest_details) {
|
if (summary?.investment_interest_details) {
|
||||||
@@ -259,7 +287,13 @@ export function InvestmentScenarioDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Investment Timeline */}
|
{/* Investment Timeline */}
|
||||||
{investments.length > 0 && <InvestmentTimeline investments={investments} />}
|
{investments.length > 0 && (
|
||||||
|
<InvestmentTimeline
|
||||||
|
investments={investments}
|
||||||
|
sharedStartDate={sharedStartDate}
|
||||||
|
sharedEndDate={sharedEndDate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Projection Chart */}
|
{/* Projection Chart */}
|
||||||
{projection && (
|
{projection && (
|
||||||
@@ -267,6 +301,8 @@ export function InvestmentScenarioDetailPage() {
|
|||||||
datapoints={projection.datapoints || []}
|
datapoints={projection.datapoints || []}
|
||||||
title="Scenario Projection"
|
title="Scenario Projection"
|
||||||
summary={projection.summary}
|
summary={projection.summary}
|
||||||
|
sharedStartDate={sharedStartDate}
|
||||||
|
sharedEndDate={sharedEndDate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{projLoading && <Center py="xl"><Loader /></Center>}
|
{projLoading && <Center py="xl"><Loader /></Center>}
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ const typeColors: Record<string, string> = {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
investments: any[];
|
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 { items, startDate, endDate, totalMonths } = useMemo(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const items = investments
|
const items = investments
|
||||||
@@ -28,16 +31,24 @@ export function InvestmentTimeline({ investments }: Props) {
|
|||||||
|
|
||||||
if (!items.length) return { items: [], startDate: now, endDate: now, totalMonths: 1 };
|
if (!items.length) return { items: [], startDate: now, endDate: now, totalMonths: 1 };
|
||||||
|
|
||||||
|
// 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[];
|
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())));
|
startDate = new Date(Math.min(...allDates.map((d) => d.getTime())));
|
||||||
const endDate = new Date(Math.max(...allDates.map((d) => d.getTime())));
|
endDate = new Date(Math.max(...allDates.map((d) => d.getTime())));
|
||||||
|
}
|
||||||
const totalMonths = Math.max(
|
const totalMonths = Math.max(
|
||||||
(endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + 1,
|
(endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + 1,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { items, startDate, endDate, totalMonths };
|
return { items, startDate, endDate, totalMonths };
|
||||||
}, [investments]);
|
}, [investments, sharedStartDate, sharedEndDate]);
|
||||||
|
|
||||||
if (!items.length) return null;
|
if (!items.length) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -23,18 +23,31 @@ interface Props {
|
|||||||
datapoints: Datapoint[];
|
datapoints: Datapoint[];
|
||||||
title?: string;
|
title?: string;
|
||||||
summary?: any;
|
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 [fundFilter, setFundFilter] = useState('all');
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
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,
|
...d,
|
||||||
label: `${d.month}`,
|
label: `${d.month}`,
|
||||||
total: d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
|
total: d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
|
||||||
}));
|
}));
|
||||||
}, [datapoints]);
|
}, [datapoints, sharedStartDate, sharedEndDate]);
|
||||||
|
|
||||||
// Find first forecast month for reference line
|
// Find first forecast month for reference line
|
||||||
const forecastStart = chartData.findIndex((d) => d.is_forecast);
|
const forecastStart = chartData.findIndex((d) => d.is_forecast);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
IconHeartbeat,
|
IconHeartbeat,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
|
IconCoin,
|
||||||
|
IconCalendarEvent,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -362,6 +364,16 @@ export function DashboardPage() {
|
|||||||
enabled: !!currentOrg,
|
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<HealthScoresData>({
|
const { data: healthScores } = useQuery<HealthScoresData>({
|
||||||
queryKey: ['health-scores'],
|
queryKey: ['health-scores'],
|
||||||
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
|
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
|
||||||
@@ -531,6 +543,97 @@ export function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Upcoming Investment Activities */}
|
||||||
|
{(investmentActivities?.total_activities || 0) > 0 && (
|
||||||
|
<Card withBorder padding="lg" radius="md">
|
||||||
|
<Group justify="space-between" mb="sm">
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon color="teal" variant="light" size={28} radius="md">
|
||||||
|
<IconCalendarEvent size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Title order={4}>Upcoming Investment Activities</Title>
|
||||||
|
</Group>
|
||||||
|
<Badge variant="light" color="teal">{investmentActivities?.total_activities} upcoming</Badge>
|
||||||
|
</Group>
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Activity</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Fund</Table.Th>
|
||||||
|
<Table.Th ta="right">Amount</Table.Th>
|
||||||
|
<Table.Th>Date</Table.Th>
|
||||||
|
<Table.Th>Timeline</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{(investmentActivities?.maturing_investments || []).map((inv: any) => (
|
||||||
|
<Table.Tr key={`mat-${inv.id}`}>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={6}>
|
||||||
|
<IconCoin size={14} color="var(--mantine-color-orange-6)" />
|
||||||
|
<Text size="sm" fw={500}>{inv.name}</Text>
|
||||||
|
</Group>
|
||||||
|
{inv.institution && <Text size="xs" c="dimmed">{inv.institution}</Text>}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="xs" color="orange" variant="light">Maturing</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="xs" color={inv.fund_type === 'reserve' ? 'violet' : 'blue'} variant="light">
|
||||||
|
{inv.fund_type}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
<Text size="sm" fw={500}>{fmt(inv.maturity_value)}</Text>
|
||||||
|
<Text size="xs" c="green">+{fmt(inv.interest_earned)} interest</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{new Date(inv.maturity_date).toLocaleDateString()}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" color={inv.days_remaining <= 14 ? 'red' : inv.days_remaining <= 30 ? 'yellow' : 'gray'} variant="light">
|
||||||
|
{inv.days_remaining} days
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
{(investmentActivities?.upcoming_scenario_investments || []).map((si: any) => (
|
||||||
|
<Table.Tr key={`plan-${si.id}`}>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={6}>
|
||||||
|
<IconTrendingUp size={14} color="var(--mantine-color-blue-6)" />
|
||||||
|
<Text size="sm" fw={500}>{si.label}</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">Scenario: {si.scenario_name}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="xs" color="blue" variant="light">Planned Purchase</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="xs" color={si.fund_type === 'reserve' ? 'violet' : 'blue'} variant="light">
|
||||||
|
{si.fund_type}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
<Text size="sm" fw={500}>{fmt(si.principal)}</Text>
|
||||||
|
{si.interest_rate && <Text size="xs" c="dimmed">{parseFloat(si.interest_rate).toFixed(2)}% APY</Text>}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{new Date(si.purchase_date).toLocaleDateString()}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" color={si.days_until <= 14 ? 'red' : si.days_until <= 30 ? 'yellow' : 'gray'} variant="light">
|
||||||
|
{si.days_until} days
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||||
<Card withBorder padding="lg" radius="md">
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Title order={4}>Quick Stats</Title>
|
<Title order={4}>Quick Stats</Title>
|
||||||
|
|||||||
196
frontend/src/pages/reports/CapitalPlanningPage.tsx
Normal file
196
frontend/src/pages/reports/CapitalPlanningPage.tsx
Normal file
@@ -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<number, number>;
|
||||||
|
beyond: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryGroup {
|
||||||
|
category: string;
|
||||||
|
projects: ProjectItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CapitalPlanningData {
|
||||||
|
title: string;
|
||||||
|
start_year: number;
|
||||||
|
years: number[];
|
||||||
|
categories: CategoryGroup[];
|
||||||
|
year_totals: Record<number, number>;
|
||||||
|
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<CapitalPlanningData>({
|
||||||
|
queryKey: ['capital-planning', startYear],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get(`/reports/capital-planning?startYear=${startYear}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
|
const years = data?.years || [];
|
||||||
|
const hasProjects = (data?.categories || []).some((c) => c.projects.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Capital Planning Report</Title>
|
||||||
|
<Text c="dimmed" size="sm">{data?.title || '5-Year Capital Project Forecast'}</Text>
|
||||||
|
</div>
|
||||||
|
<Group>
|
||||||
|
<NumberInput
|
||||||
|
size="xs"
|
||||||
|
w={100}
|
||||||
|
value={startYear}
|
||||||
|
onChange={(v) => v && setStartYear(Number(v))}
|
||||||
|
min={2020}
|
||||||
|
max={2050}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconPrinter size={16} />}
|
||||||
|
onClick={() => window.print()}
|
||||||
|
>
|
||||||
|
Print / PDF
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!hasProjects ? (
|
||||||
|
<Card withBorder p="xl">
|
||||||
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
|
No capital projects found. Add projects on the Projects page to generate this report.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card withBorder p="lg" className="capital-planning-print">
|
||||||
|
<Title order={3} ta="center" mb="xs">{data?.title}</Title>
|
||||||
|
<Text ta="center" c="dimmed" size="sm" mb="md">
|
||||||
|
Generated {new Date(data?.generated_at || '').toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Table striped withTableBorder withColumnBorders>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Description</Table.Th>
|
||||||
|
<Table.Th ta="center" w={60}>Life (yr)</Table.Th>
|
||||||
|
<Table.Th ta="center" w={90}>Last Done</Table.Th>
|
||||||
|
{years.map((y) => (
|
||||||
|
<Table.Th key={y} ta="right" w={100}>{y}</Table.Th>
|
||||||
|
))}
|
||||||
|
<Table.Th ta="right" w={100}>Beyond</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{(data?.categories || []).map((cat) => {
|
||||||
|
const catTotals: Record<number, number> = {};
|
||||||
|
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 [
|
||||||
|
<Table.Tr key={`cat-${cat.category}`} style={{ background: 'var(--mantine-color-blue-0)' }}>
|
||||||
|
<Table.Td colSpan={3 + years.length + 1}>
|
||||||
|
<Text fw={700} size="sm">{cat.category}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>,
|
||||||
|
...cat.projects.map((p) => (
|
||||||
|
<Table.Tr key={p.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{p.name}</Text>
|
||||||
|
{p.status !== 'planned' && (
|
||||||
|
<Badge size="xs" variant="light" ml={4}
|
||||||
|
color={p.status === 'completed' ? 'green' : p.status === 'in_progress' ? 'blue' : 'gray'}>
|
||||||
|
{p.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="center">
|
||||||
|
<Text size="sm">{p.useful_life_years || '-'}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="center">
|
||||||
|
<Text size="sm">
|
||||||
|
{p.last_replacement_date
|
||||||
|
? new Date(p.last_replacement_date).getFullYear()
|
||||||
|
: '-'}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
{years.map((y) => (
|
||||||
|
<Table.Td key={y} ta="right" ff="monospace">
|
||||||
|
<Text size="sm">{fmt(p.year_amounts[y] || 0)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
<Text size="sm">{fmt(p.beyond)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)),
|
||||||
|
<Table.Tr key={`subtotal-${cat.category}`} style={{ borderTop: '2px solid var(--mantine-color-gray-4)' }}>
|
||||||
|
<Table.Td colSpan={3}>
|
||||||
|
<Text size="sm" fw={600} fs="italic">Subtotal — {cat.category}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
{years.map((y) => (
|
||||||
|
<Table.Td key={y} ta="right" ff="monospace">
|
||||||
|
<Text size="sm" fw={600}>{fmt(catTotals[y])}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
<Text size="sm" fw={600}>{fmt(catBeyond)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>,
|
||||||
|
];
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
<Table.Tfoot>
|
||||||
|
<Table.Tr style={{ background: 'var(--mantine-color-dark-0)' }}>
|
||||||
|
<Table.Td colSpan={3}>
|
||||||
|
<Text fw={700}>TOTAL</Text>
|
||||||
|
</Table.Td>
|
||||||
|
{years.map((y) => (
|
||||||
|
<Table.Td key={y} ta="right" ff="monospace">
|
||||||
|
<Text fw={700}>{fmt(data?.year_totals[y] || 0)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
<Text fw={700}>{fmt(data?.beyond_total || 0)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Tfoot>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user