Add Phase 4 Cash Flow Visualization with forecast endpoint and Recharts chart
New feature: Cash Flow page under Financials showing stacked area chart of operating/reserve cash and investment balances over time. Backend forecast endpoint integrates assessment income schedules, budget expenses, capital project costs, and investment maturities to project 24+ months forward. Historical months show actual journal entry balances; future months are projected. Includes Operating/Reserve/All fund filter, 12-month sliding window navigation, forecast reference line, and monthly detail table. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -50,4 +50,14 @@ export class ReportsController {
|
|||||||
getDashboardKPIs() {
|
getDashboardKPIs() {
|
||||||
return this.reportsService.getDashboardKPIs();
|
return this.reportsService.getDashboardKPIs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('cash-flow-forecast')
|
||||||
|
getCashFlowForecast(
|
||||||
|
@Query('startYear') startYear?: string,
|
||||||
|
@Query('months') months?: string,
|
||||||
|
) {
|
||||||
|
const yr = parseInt(startYear || '') || new Date().getFullYear();
|
||||||
|
const mo = Math.min(parseInt(months || '') || 24, 48);
|
||||||
|
return this.reportsService.getCashFlowForecast(yr, mo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -493,4 +493,294 @@ export class ReportsService {
|
|||||||
recent_transactions: recentTx,
|
recent_transactions: recentTx,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cash Flow Forecast: monthly datapoints with actuals (historical) and projections (future).
|
||||||
|
* Each month has: operating_cash, operating_investments, reserve_cash, reserve_investments.
|
||||||
|
* Historical months use journal entry balances; future months project from budgets,
|
||||||
|
* assessment income schedules, capital project expenses, and investment maturities.
|
||||||
|
*/
|
||||||
|
async getCashFlowForecast(startYear: number, months: number) {
|
||||||
|
const now = new Date();
|
||||||
|
const currentYear = now.getFullYear();
|
||||||
|
const currentMonth = now.getMonth() + 1; // 1-indexed
|
||||||
|
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
||||||
|
const monthLabels = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
|
||||||
|
// ── 1) Get current balances as of now ──
|
||||||
|
// Operating cash (asset accounts with fund_type=operating)
|
||||||
|
const opCashRows = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||||
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||||
|
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
|
||||||
|
GROUP BY a.id
|
||||||
|
) sub
|
||||||
|
`);
|
||||||
|
// Reserve cash (equity fund balance for reserve)
|
||||||
|
const resCashRows = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||||
|
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as bal
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||||
|
WHERE a.fund_type = 'reserve' AND a.account_type = 'equity' AND a.is_active = true
|
||||||
|
GROUP BY a.id
|
||||||
|
) sub
|
||||||
|
`);
|
||||||
|
// Operating investments
|
||||||
|
const opInvRows = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(current_value), 0) as total
|
||||||
|
FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true
|
||||||
|
`);
|
||||||
|
// Reserve investments
|
||||||
|
const resInvRows = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(current_value), 0) as total
|
||||||
|
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
|
||||||
|
`);
|
||||||
|
|
||||||
|
let opCash = parseFloat(opCashRows[0]?.total || '0');
|
||||||
|
let resCash = parseFloat(resCashRows[0]?.total || '0');
|
||||||
|
let opInv = parseFloat(opInvRows[0]?.total || '0');
|
||||||
|
let resInv = parseFloat(resInvRows[0]?.total || '0');
|
||||||
|
|
||||||
|
// ── 2) Get assessment income schedule ──
|
||||||
|
// Assessment groups define income frequency: monthly, quarterly (Jan/Apr/Jul/Oct), annual (Jan)
|
||||||
|
const assessmentGroups = await this.tenant.query(`
|
||||||
|
SELECT frequency, regular_assessment, special_assessment, unit_count
|
||||||
|
FROM assessment_groups WHERE is_active = true
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Compute per-month income from assessments
|
||||||
|
const getAssessmentIncome = (month: number): { operating: number; reserve: number } => {
|
||||||
|
let operating = 0;
|
||||||
|
let reserve = 0;
|
||||||
|
for (const g of assessmentGroups) {
|
||||||
|
const units = parseInt(g.unit_count) || 0;
|
||||||
|
const regular = parseFloat(g.regular_assessment) || 0;
|
||||||
|
const special = parseFloat(g.special_assessment) || 0;
|
||||||
|
const freq = g.frequency || 'monthly';
|
||||||
|
let applies = false;
|
||||||
|
if (freq === 'monthly') applies = true;
|
||||||
|
else if (freq === 'quarterly') applies = [1,4,7,10].includes(month);
|
||||||
|
else if (freq === 'annual') applies = month === 1;
|
||||||
|
if (applies) {
|
||||||
|
operating += regular * units;
|
||||||
|
reserve += special * units;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { operating, reserve };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 3) Get budget expenses by month (for forecast months) ──
|
||||||
|
// We need budgets for startYear and startYear+1 to cover 24 months
|
||||||
|
const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
|
||||||
|
|
||||||
|
for (const yr of [startYear, startYear + 1, startYear + 2]) {
|
||||||
|
const budgetRows = await this.tenant.query(
|
||||||
|
`SELECT b.fund_type, a.account_type,
|
||||||
|
b.jan, b.feb, b.mar, b.apr, b.may, b.jun,
|
||||||
|
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
|
||||||
|
FROM budgets b
|
||||||
|
JOIN accounts a ON a.id = b.account_id
|
||||||
|
WHERE b.fiscal_year = $1`, [yr],
|
||||||
|
);
|
||||||
|
for (let m = 0; m < 12; m++) {
|
||||||
|
const key = `${yr}-${m + 1}`;
|
||||||
|
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||||
|
for (const row of budgetRows) {
|
||||||
|
const amt = parseFloat(row[monthNames[m]]) || 0;
|
||||||
|
if (amt === 0) continue;
|
||||||
|
const isOp = row.fund_type === 'operating';
|
||||||
|
if (row.account_type === 'income') {
|
||||||
|
if (isOp) budgetsByYearMonth[key].opIncome += amt;
|
||||||
|
else budgetsByYearMonth[key].resIncome += amt;
|
||||||
|
} else if (row.account_type === 'expense') {
|
||||||
|
if (isOp) budgetsByYearMonth[key].opExpense += amt;
|
||||||
|
else budgetsByYearMonth[key].resExpense += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4) Get historical monthly balances ──
|
||||||
|
// For months before current month, compute actual cash position at end of each month
|
||||||
|
const historicalCash = await this.tenant.query(`
|
||||||
|
SELECT
|
||||||
|
EXTRACT(YEAR FROM je.entry_date)::int as yr,
|
||||||
|
EXTRACT(MONTH FROM je.entry_date)::int as mo,
|
||||||
|
a.fund_type,
|
||||||
|
COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as net_change
|
||||||
|
FROM journal_entry_lines jel
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||||
|
JOIN accounts a ON a.id = jel.account_id AND a.account_type = 'asset' AND a.is_active = true
|
||||||
|
WHERE je.entry_date >= $1::date
|
||||||
|
GROUP BY yr, mo, a.fund_type
|
||||||
|
ORDER BY yr, mo
|
||||||
|
`, [`${startYear}-01-01`]);
|
||||||
|
|
||||||
|
// ── 5) Get investment maturities (for forecast: when CDs mature, money returns to cash) ──
|
||||||
|
const maturities = await this.tenant.query(`
|
||||||
|
SELECT fund_type, current_value, maturity_date, interest_rate, purchase_date
|
||||||
|
FROM investment_accounts
|
||||||
|
WHERE is_active = true AND maturity_date IS NOT NULL AND maturity_date > CURRENT_DATE
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── 6) Get capital project planned expenses ──
|
||||||
|
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
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── Build monthly datapoints ──
|
||||||
|
const datapoints: any[] = [];
|
||||||
|
|
||||||
|
// For historical months, compute cumulative balances from journal entries
|
||||||
|
// We'll track running balances
|
||||||
|
// First compute opening balance at start of startYear
|
||||||
|
const openingOp = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||||
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND je.entry_date < $1::date
|
||||||
|
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
|
||||||
|
GROUP BY a.id
|
||||||
|
) sub
|
||||||
|
`, [`${startYear}-01-01`]);
|
||||||
|
|
||||||
|
const openingRes = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||||
|
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as bal
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND je.entry_date < $1::date
|
||||||
|
WHERE a.fund_type = 'reserve' AND a.account_type = 'equity' AND a.is_active = true
|
||||||
|
GROUP BY a.id
|
||||||
|
) sub
|
||||||
|
`, [`${startYear}-01-01`]);
|
||||||
|
|
||||||
|
let runOpCash = parseFloat(openingOp[0]?.total || '0');
|
||||||
|
let runResCash = parseFloat(openingRes[0]?.total || '0');
|
||||||
|
|
||||||
|
// Index historical cash changes by year-month-fund
|
||||||
|
const histIndex: Record<string, number> = {};
|
||||||
|
for (const row of historicalCash) {
|
||||||
|
const key = `${row.yr}-${row.mo}-${row.fund_type}`;
|
||||||
|
histIndex[key] = parseFloat(row.net_change) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index maturities by year-month
|
||||||
|
const maturityIndex: Record<string, { operating: number; reserve: number }> = {};
|
||||||
|
for (const inv of maturities) {
|
||||||
|
const d = new Date(inv.maturity_date);
|
||||||
|
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
||||||
|
if (!maturityIndex[key]) maturityIndex[key] = { operating: 0, reserve: 0 };
|
||||||
|
// At maturity, investment value returns to cash
|
||||||
|
const val = parseFloat(inv.current_value) || 0;
|
||||||
|
// Estimate simple interest earned by maturity
|
||||||
|
const rate = parseFloat(inv.interest_rate) || 0;
|
||||||
|
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
|
||||||
|
const matDate = new Date(inv.maturity_date);
|
||||||
|
const daysHeld = Math.max((matDate.getTime() - purchaseDate.getTime()) / 86400000, 1);
|
||||||
|
const interestEarned = val * (rate / 100) * (daysHeld / 365);
|
||||||
|
const maturityTotal = val + interestEarned;
|
||||||
|
if (inv.fund_type === 'operating') maturityIndex[key].operating += maturityTotal;
|
||||||
|
else maturityIndex[key].reserve += maturityTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index project expenses by year-month
|
||||||
|
const projectIndex: Record<string, { operating: number; reserve: number }> = {};
|
||||||
|
for (const p of projectExpenses) {
|
||||||
|
const yr = parseInt(p.target_year);
|
||||||
|
const mo = parseInt(p.target_month) || 6; // default mid-year if no month
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Investment opening balances at start of period (approximate: use current values)
|
||||||
|
let runOpInv = opInv;
|
||||||
|
let runResInv = resInv;
|
||||||
|
|
||||||
|
for (let i = 0; i < months; i++) {
|
||||||
|
const year = startYear + Math.floor(i / 12);
|
||||||
|
const month = (i % 12) + 1;
|
||||||
|
const key = `${year}-${month}`;
|
||||||
|
const isHistorical = year < currentYear || (year === currentYear && month <= currentMonth);
|
||||||
|
const label = `${monthLabels[month - 1]} ${year}`;
|
||||||
|
|
||||||
|
if (isHistorical) {
|
||||||
|
// Use actual journal entry changes
|
||||||
|
const opChange = histIndex[`${year}-${month}-operating`] || 0;
|
||||||
|
runOpCash += opChange;
|
||||||
|
|
||||||
|
// For reserve, we need the equity-based changes
|
||||||
|
const resEquityChange = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(jel.credit - jel.debit), 0) as net
|
||||||
|
FROM journal_entry_lines jel
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||||
|
JOIN accounts a ON a.id = jel.account_id AND a.fund_type = 'reserve' AND a.account_type = 'equity'
|
||||||
|
WHERE EXTRACT(YEAR FROM je.entry_date) = $1 AND EXTRACT(MONTH FROM je.entry_date) = $2
|
||||||
|
`, [year, month]);
|
||||||
|
runResCash += parseFloat(resEquityChange[0]?.net || '0');
|
||||||
|
|
||||||
|
datapoints.push({
|
||||||
|
month: label,
|
||||||
|
year, monthNum: month,
|
||||||
|
is_forecast: false,
|
||||||
|
operating_cash: Math.round(runOpCash * 100) / 100,
|
||||||
|
operating_investments: Math.round(runOpInv * 100) / 100,
|
||||||
|
reserve_cash: Math.round(runResCash * 100) / 100,
|
||||||
|
reserve_investments: Math.round(runResInv * 100) / 100,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Forecast: use budget + assessment data
|
||||||
|
const assessments = getAssessmentIncome(month);
|
||||||
|
const budget = budgetsByYearMonth[key] || { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||||
|
const maturity = maturityIndex[key] || { operating: 0, reserve: 0 };
|
||||||
|
const project = projectIndex[key] || { operating: 0, reserve: 0 };
|
||||||
|
|
||||||
|
// Use budget income if available, else assessment income
|
||||||
|
const opIncomeMonth = budget.opIncome > 0 ? budget.opIncome : assessments.operating;
|
||||||
|
const resIncomeMonth = budget.resIncome > 0 ? budget.resIncome : assessments.reserve;
|
||||||
|
|
||||||
|
// Net change: income - expenses - project costs + maturity returns
|
||||||
|
runOpCash += opIncomeMonth - budget.opExpense - project.operating + maturity.operating;
|
||||||
|
runResCash += resIncomeMonth - budget.resExpense - project.reserve + maturity.reserve;
|
||||||
|
|
||||||
|
// Subtract maturing investment values from investment balances
|
||||||
|
runOpInv -= maturity.operating > 0 ? (maturity.operating - (maturity.operating * 0.04 * 0.5)) : 0; // rough: subtract principal
|
||||||
|
runResInv -= maturity.reserve > 0 ? (maturity.reserve - (maturity.reserve * 0.04 * 0.5)) : 0;
|
||||||
|
// Floor at 0
|
||||||
|
if (runOpInv < 0) runOpInv = 0;
|
||||||
|
if (runResInv < 0) runResInv = 0;
|
||||||
|
|
||||||
|
datapoints.push({
|
||||||
|
month: label,
|
||||||
|
year, monthNum: month,
|
||||||
|
is_forecast: true,
|
||||||
|
operating_cash: Math.round(runOpCash * 100) / 100,
|
||||||
|
operating_investments: Math.round(runOpInv * 100) / 100,
|
||||||
|
reserve_cash: Math.round(runResCash * 100) / 100,
|
||||||
|
reserve_investments: Math.round(runResInv * 100) / 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start_year: startYear,
|
||||||
|
months: months,
|
||||||
|
current_month: `${monthLabels[currentMonth - 1]} ${currentYear}`,
|
||||||
|
datapoints,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { YearEndPage } from './pages/reports/YearEndPage';
|
|||||||
import { SettingsPage } from './pages/settings/SettingsPage';
|
import { SettingsPage } from './pages/settings/SettingsPage';
|
||||||
import { AdminPage } from './pages/admin/AdminPage';
|
import { AdminPage } from './pages/admin/AdminPage';
|
||||||
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
|
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
|
||||||
|
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const token = useAuthStore((s) => s.token);
|
const token = useAuthStore((s) => s.token);
|
||||||
@@ -114,6 +115,7 @@ export function App() {
|
|||||||
<Route path="investments" element={<InvestmentsPage />} />
|
<Route path="investments" element={<InvestmentsPage />} />
|
||||||
<Route path="capital-projects" element={<CapitalProjectsPage />} />
|
<Route path="capital-projects" element={<CapitalProjectsPage />} />
|
||||||
<Route path="assessment-groups" element={<AssessmentGroupsPage />} />
|
<Route path="assessment-groups" element={<AssessmentGroupsPage />} />
|
||||||
|
<Route path="cash-flow" element={<CashFlowForecastPage />} />
|
||||||
<Route path="reports/balance-sheet" element={<BalanceSheetPage />} />
|
<Route path="reports/balance-sheet" element={<BalanceSheetPage />} />
|
||||||
<Route path="reports/income-statement" element={<IncomeStatementPage />} />
|
<Route path="reports/income-statement" element={<IncomeStatementPage />} />
|
||||||
<Route path="reports/budget-vs-actual" element={<BudgetVsActualPage />} />
|
<Route path="reports/budget-vs-actual" element={<BudgetVsActualPage />} />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
IconSettings,
|
IconSettings,
|
||||||
IconCrown,
|
IconCrown,
|
||||||
IconCategory,
|
IconCategory,
|
||||||
|
IconChartAreaLine,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ const navSections = [
|
|||||||
label: 'Financials',
|
label: 'Financials',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Accounts', icon: IconListDetails, path: '/accounts' },
|
{ label: 'Accounts', icon: IconListDetails, path: '/accounts' },
|
||||||
|
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' },
|
||||||
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' },
|
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
475
frontend/src/pages/cash-flow/CashFlowForecastPage.tsx
Normal file
475
frontend/src/pages/cash-flow/CashFlowForecastPage.tsx
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Title, Text, Stack, Card, Group, SimpleGrid, ThemeIcon,
|
||||||
|
SegmentedControl, Loader, Center, ActionIcon, Tooltip, Badge,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconCash, IconBuildingBank, IconChartAreaLine,
|
||||||
|
IconArrowLeft, IconArrowRight, IconCalendar,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
AreaChart, Area, XAxis, YAxis, CartesianGrid,
|
||||||
|
Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
|
||||||
|
ReferenceLine,
|
||||||
|
} from 'recharts';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface Datapoint {
|
||||||
|
month: string;
|
||||||
|
year: number;
|
||||||
|
monthNum: number;
|
||||||
|
is_forecast: boolean;
|
||||||
|
operating_cash: number;
|
||||||
|
operating_investments: number;
|
||||||
|
reserve_cash: number;
|
||||||
|
reserve_investments: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForecastData {
|
||||||
|
start_year: number;
|
||||||
|
months: number;
|
||||||
|
current_month: string;
|
||||||
|
datapoints: Datapoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (v: number) =>
|
||||||
|
v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
const fmtAxis = (v: number) => {
|
||||||
|
if (Math.abs(v) >= 1000000) return `$${(v / 1000000).toFixed(1)}M`;
|
||||||
|
if (Math.abs(v) >= 1000) return `$${(v / 1000).toFixed(0)}K`;
|
||||||
|
return `$${v}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom tooltip component
|
||||||
|
function CustomTooltip({ active, payload, label }: any) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const dp = payload[0]?.payload as Datapoint;
|
||||||
|
const isForecast = dp?.is_forecast;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card shadow="md" p="sm" withBorder style={{ minWidth: 220 }}>
|
||||||
|
<Group gap="xs" mb={6}>
|
||||||
|
<Text fw={700} size="sm">{label}</Text>
|
||||||
|
{isForecast && (
|
||||||
|
<Badge size="xs" variant="light" color="orange">Forecast</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{payload.map((entry: any) => (
|
||||||
|
<Group key={entry.name} justify="space-between" gap="lg">
|
||||||
|
<Group gap={6}>
|
||||||
|
<div style={{ width: 10, height: 10, borderRadius: 2, backgroundColor: entry.color }} />
|
||||||
|
<Text size="xs">{entry.name}</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" fw={600} ff="monospace">{fmt(entry.value)}</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
<Group justify="space-between" mt={6} pt={6} style={{ borderTop: '1px solid var(--mantine-color-gray-3)' }}>
|
||||||
|
<Text size="xs" fw={700}>Total</Text>
|
||||||
|
<Text size="xs" fw={700} ff="monospace">
|
||||||
|
{fmt(payload.reduce((s: number, p: any) => s + p.value, 0))}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CashFlowForecastPage() {
|
||||||
|
const now = new Date();
|
||||||
|
const currentYear = now.getFullYear();
|
||||||
|
const currentMonth = now.getMonth() + 1;
|
||||||
|
|
||||||
|
// Filter: All, Operating, Reserve
|
||||||
|
const [fundFilter, setFundFilter] = useState<string>('all');
|
||||||
|
// View window: which 12-month period to show
|
||||||
|
const [viewStartIndex, setViewStartIndex] = useState(0);
|
||||||
|
|
||||||
|
// Fetch 36 months of data starting from current fiscal year (Jan)
|
||||||
|
const { data, isLoading } = useQuery<ForecastData>({
|
||||||
|
queryKey: ['cash-flow-forecast', currentYear],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get(
|
||||||
|
`/reports/cash-flow-forecast?startYear=${currentYear}&months=36`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const datapoints = data?.datapoints || [];
|
||||||
|
|
||||||
|
// Determine available view windows (12-month slices)
|
||||||
|
const maxStartIndex = Math.max(0, datapoints.length - 12);
|
||||||
|
|
||||||
|
// Compute the chart data based on the current view window
|
||||||
|
const viewData = useMemo(() => {
|
||||||
|
return datapoints.slice(viewStartIndex, viewStartIndex + 12);
|
||||||
|
}, [datapoints, viewStartIndex]);
|
||||||
|
|
||||||
|
// Compute summary stats for the current view
|
||||||
|
const summaryStats = useMemo(() => {
|
||||||
|
if (!viewData.length) return null;
|
||||||
|
const last = viewData[viewData.length - 1];
|
||||||
|
const first = viewData[0];
|
||||||
|
|
||||||
|
const totalOperating = last.operating_cash + last.operating_investments;
|
||||||
|
const totalReserve = last.reserve_cash + last.reserve_investments;
|
||||||
|
const totalAll = totalOperating + totalReserve;
|
||||||
|
|
||||||
|
const firstTotal = first.operating_cash + first.operating_investments +
|
||||||
|
first.reserve_cash + first.reserve_investments;
|
||||||
|
const netChange = totalAll - firstTotal;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalOperating,
|
||||||
|
totalReserve,
|
||||||
|
totalAll,
|
||||||
|
netChange,
|
||||||
|
periodStart: first.month,
|
||||||
|
periodEnd: last.month,
|
||||||
|
};
|
||||||
|
}, [viewData]);
|
||||||
|
|
||||||
|
// Determine the first forecast month index within the view
|
||||||
|
const forecastStartLabel = useMemo(() => {
|
||||||
|
const idx = viewData.findIndex((d) => d.is_forecast);
|
||||||
|
return idx >= 0 ? viewData[idx].month : null;
|
||||||
|
}, [viewData]);
|
||||||
|
|
||||||
|
// Transform data for chart based on filter
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
return viewData.map((d) => {
|
||||||
|
const base: any = { month: d.month, is_forecast: d.is_forecast };
|
||||||
|
if (fundFilter === 'all' || fundFilter === 'operating') {
|
||||||
|
base['Operating Cash'] = d.operating_cash;
|
||||||
|
base['Operating Investments'] = d.operating_investments;
|
||||||
|
}
|
||||||
|
if (fundFilter === 'all' || fundFilter === 'reserve') {
|
||||||
|
base['Reserve Cash'] = d.reserve_cash;
|
||||||
|
base['Reserve Investments'] = d.reserve_investments;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
}, [viewData, fundFilter]);
|
||||||
|
|
||||||
|
// Determine view period label
|
||||||
|
const viewLabel = viewData.length > 0
|
||||||
|
? `${viewData[0].month} - ${viewData[viewData.length - 1].month}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Cash Flow</Title>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Cash and investment balances over time with forward projections
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<SegmentedControl
|
||||||
|
value={fundFilter}
|
||||||
|
onChange={setFundFilter}
|
||||||
|
data={[
|
||||||
|
{ label: 'All Funds', value: 'all' },
|
||||||
|
{ label: 'Operating', value: 'operating' },
|
||||||
|
{ label: 'Reserve', value: 'reserve' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{summaryStats && (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<ThemeIcon variant="light" color="blue" size="sm">
|
||||||
|
<IconCash size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Total</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700} size="xl" ff="monospace">
|
||||||
|
{fmt(summaryStats.totalOperating)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<ThemeIcon variant="light" color="violet" size="sm">
|
||||||
|
<IconBuildingBank size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Total</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700} size="xl" ff="monospace">
|
||||||
|
{fmt(summaryStats.totalReserve)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<ThemeIcon variant="light" color="teal" size="sm">
|
||||||
|
<IconChartAreaLine size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Combined Total</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700} size="xl" ff="monospace">
|
||||||
|
{fmt(summaryStats.totalAll)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color={summaryStats.netChange >= 0 ? 'green' : 'red'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<IconCash size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Period Change</Text>
|
||||||
|
</Group>
|
||||||
|
<Text
|
||||||
|
fw={700}
|
||||||
|
size="xl"
|
||||||
|
ff="monospace"
|
||||||
|
c={summaryStats.netChange >= 0 ? 'green' : 'red'}
|
||||||
|
>
|
||||||
|
{fmt(summaryStats.netChange)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chart Navigation */}
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Tooltip label="Previous 12 months">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
disabled={viewStartIndex <= 0}
|
||||||
|
onClick={() => setViewStartIndex(Math.max(0, viewStartIndex - 12))}
|
||||||
|
>
|
||||||
|
<IconArrowLeft size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Group gap={6}>
|
||||||
|
<IconCalendar size={16} style={{ color: 'var(--mantine-color-dimmed)' }} />
|
||||||
|
<Text fw={600} size="sm">{viewLabel}</Text>
|
||||||
|
</Group>
|
||||||
|
<Tooltip label="Next 12 months">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
disabled={viewStartIndex >= maxStartIndex}
|
||||||
|
onClick={() => setViewStartIndex(Math.min(maxStartIndex, viewStartIndex + 12))}
|
||||||
|
>
|
||||||
|
<IconArrowRight size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
{forecastStartLabel && (
|
||||||
|
<Badge variant="light" color="orange" size="sm">
|
||||||
|
Forecast begins {forecastStartLabel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="light" color="blue" size="sm">
|
||||||
|
{viewData.filter((d) => !d.is_forecast).length} actual
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="light" color="orange" size="sm">
|
||||||
|
{viewData.filter((d) => d.is_forecast).length} projected
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Stacked Area Chart */}
|
||||||
|
<ResponsiveContainer width="100%" height={420}>
|
||||||
|
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#339af0" stopOpacity={0.4} />
|
||||||
|
<stop offset="95%" stopColor="#339af0" stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.4} />
|
||||||
|
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.4} />
|
||||||
|
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.4} />
|
||||||
|
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: '#dee2e6' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={fmtAxis}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: '#dee2e6' }}
|
||||||
|
width={70}
|
||||||
|
/>
|
||||||
|
<RechartsTooltip content={<CustomTooltip />} />
|
||||||
|
<Legend
|
||||||
|
verticalAlign="top"
|
||||||
|
height={36}
|
||||||
|
iconType="rect"
|
||||||
|
formatter={(value: string) => (
|
||||||
|
<span style={{ fontSize: 12, color: '#495057' }}>{value}</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Draw a reference line where forecast begins */}
|
||||||
|
{forecastStartLabel && (
|
||||||
|
<ReferenceLine
|
||||||
|
x={forecastStartLabel}
|
||||||
|
stroke="#fd7e14"
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
strokeWidth={2}
|
||||||
|
label={{ value: 'Forecast', position: 'top', fill: '#fd7e14', fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(fundFilter === 'all' || fundFilter === 'operating') && (
|
||||||
|
<>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="Operating Cash"
|
||||||
|
stackId="1"
|
||||||
|
stroke="#339af0"
|
||||||
|
fill="url(#opCash)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="Operating Investments"
|
||||||
|
stackId="1"
|
||||||
|
stroke="#74c0fc"
|
||||||
|
fill="url(#opInv)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(fundFilter === 'all' || fundFilter === 'reserve') && (
|
||||||
|
<>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="Reserve Cash"
|
||||||
|
stackId="1"
|
||||||
|
stroke="#7950f2"
|
||||||
|
fill="url(#resCash)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="Reserve Investments"
|
||||||
|
stackId="1"
|
||||||
|
stroke="#b197fc"
|
||||||
|
fill="url(#resInv)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Data Table */}
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Title order={4} mb="md">Monthly Detail</Title>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid var(--mantine-color-gray-3)' }}>
|
||||||
|
<th style={{ textAlign: 'left', padding: '8px 12px' }}>Month</th>
|
||||||
|
<th style={{ textAlign: 'center', padding: '8px 12px', width: 70 }}>Type</th>
|
||||||
|
{(fundFilter === 'all' || fundFilter === 'operating') && (
|
||||||
|
<>
|
||||||
|
<th style={{ textAlign: 'right', padding: '8px 12px' }}>Op. Cash</th>
|
||||||
|
<th style={{ textAlign: 'right', padding: '8px 12px' }}>Op. Investments</th>
|
||||||
|
<th style={{ textAlign: 'right', padding: '8px 12px' }}>Op. Total</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(fundFilter === 'all' || fundFilter === 'reserve') && (
|
||||||
|
<>
|
||||||
|
<th style={{ textAlign: 'right', padding: '8px 12px' }}>Res. Cash</th>
|
||||||
|
<th style={{ textAlign: 'right', padding: '8px 12px' }}>Res. Investments</th>
|
||||||
|
<th style={{ textAlign: 'right', padding: '8px 12px' }}>Res. Total</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<th style={{ textAlign: 'right', padding: '8px 12px', fontWeight: 700 }}>Grand Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{viewData.map((d, i) => {
|
||||||
|
const opTotal = d.operating_cash + d.operating_investments;
|
||||||
|
const resTotal = d.reserve_cash + d.reserve_investments;
|
||||||
|
const grandTotal =
|
||||||
|
(fundFilter === 'all' ? opTotal + resTotal :
|
||||||
|
fundFilter === 'operating' ? opTotal : resTotal);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={d.month}
|
||||||
|
style={{
|
||||||
|
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
||||||
|
backgroundColor: d.is_forecast
|
||||||
|
? 'var(--mantine-color-orange-0)'
|
||||||
|
: i % 2 === 0 ? 'transparent' : 'var(--mantine-color-gray-0)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td style={{ padding: '6px 12px', fontWeight: 500 }}>{d.month}</td>
|
||||||
|
<td style={{ textAlign: 'center', padding: '6px 12px' }}>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color={d.is_forecast ? 'orange' : 'blue'}
|
||||||
|
>
|
||||||
|
{d.is_forecast ? 'Forecast' : 'Actual'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
{(fundFilter === 'all' || fundFilter === 'operating') && (
|
||||||
|
<>
|
||||||
|
<td style={{ textAlign: 'right', padding: '6px 12px', fontFamily: 'monospace' }}>
|
||||||
|
{fmt(d.operating_cash)}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: '6px 12px', fontFamily: 'monospace' }}>
|
||||||
|
{fmt(d.operating_investments)}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: '6px 12px', fontFamily: 'monospace', fontWeight: 600 }}>
|
||||||
|
{fmt(opTotal)}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(fundFilter === 'all' || fundFilter === 'reserve') && (
|
||||||
|
<>
|
||||||
|
<td style={{ textAlign: 'right', padding: '6px 12px', fontFamily: 'monospace' }}>
|
||||||
|
{fmt(d.reserve_cash)}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: '6px 12px', fontFamily: 'monospace' }}>
|
||||||
|
{fmt(d.reserve_investments)}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: '6px 12px', fontFamily: 'monospace', fontWeight: 600 }}>
|
||||||
|
{fmt(resTotal)}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<td style={{ textAlign: 'right', padding: '6px 12px', fontFamily: 'monospace', fontWeight: 700 }}>
|
||||||
|
{fmt(grandTotal)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user