From 07347a644f3d11ea3b14ea8418efe529dd2ba440 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Fri, 27 Feb 2026 14:22:37 -0500 Subject: [PATCH] QoL tweaks: Cash Flow cards, auto-primary accounts, investment projections, Sankey filters - Dashboard: Remove tenant name/role subtitle - Cash Flow: Replace Operating/Reserve net cards with inflow vs outflow breakdown showing In/Out amounts and signed net; replace Ending Cash card with AI Financial Health status from saved recommendation - Accounts: Auto-set first asset account per fund_type as primary on creation - Investments: Add 5th summary card for projected annual interest earnings - Sankey: Add Actuals/Budget/Forecast data source toggle and All Funds/Operating/Reserve fund filter SegmentedControls with backend support for budget-based and forecast (actuals+budget) queries Co-Authored-By: Claude Opus 4.6 --- .../src/modules/accounts/accounts.service.ts | 16 +- .../src/modules/reports/reports.controller.ts | 12 +- .../src/modules/reports/reports.service.ts | 170 +++++++++++++++--- .../src/pages/dashboard/DashboardPage.tsx | 7 +- .../src/pages/investments/InvestmentsPage.tsx | 8 +- frontend/src/pages/reports/CashFlowPage.tsx | 51 ++++-- frontend/src/pages/reports/SankeyPage.tsx | 36 +++- 7 files changed, 250 insertions(+), 50 deletions(-) diff --git a/backend/src/modules/accounts/accounts.service.ts b/backend/src/modules/accounts/accounts.service.ts index 02dccc6..8a0a5bd 100644 --- a/backend/src/modules/accounts/accounts.service.ts +++ b/backend/src/modules/accounts/accounts.service.ts @@ -142,7 +142,21 @@ export class AccountsService { } } - return account; + // Auto-set as primary if this is the first asset account for this fund_type + if (dto.accountType === 'asset') { + const existingPrimary = await this.tenant.query( + 'SELECT id FROM accounts WHERE fund_type = $1 AND is_primary = true AND id != $2', + [dto.fundType, accountId], + ); + if (!existingPrimary.length) { + await this.tenant.query( + 'UPDATE accounts SET is_primary = true WHERE id = $1', + [accountId], + ); + } + } + + return this.findOne(accountId); } async update(id: string, dto: UpdateAccountDto) { diff --git a/backend/src/modules/reports/reports.controller.ts b/backend/src/modules/reports/reports.controller.ts index baf1be9..8f9e270 100644 --- a/backend/src/modules/reports/reports.controller.ts +++ b/backend/src/modules/reports/reports.controller.ts @@ -24,8 +24,16 @@ export class ReportsController { } @Get('cash-flow-sankey') - getCashFlowSankey(@Query('year') year?: string) { - return this.reportsService.getCashFlowSankey(parseInt(year || '') || new Date().getFullYear()); + getCashFlowSankey( + @Query('year') year?: string, + @Query('source') source?: string, + @Query('fundType') fundType?: string, + ) { + return this.reportsService.getCashFlowSankey( + parseInt(year || '') || new Date().getFullYear(), + source || 'actuals', + fundType || 'all', + ); } @Get('cash-flow') diff --git a/backend/src/modules/reports/reports.service.ts b/backend/src/modules/reports/reports.service.ts index cbb9e99..cb3e59a 100644 --- a/backend/src/modules/reports/reports.service.ts +++ b/backend/src/modules/reports/reports.service.ts @@ -83,33 +83,151 @@ export class ReportsService { }; } - async getCashFlowSankey(year: number) { - // Get income accounts with amounts - const income = await this.tenant.query(` - SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount - FROM accounts a - JOIN journal_entry_lines jel ON jel.account_id = a.id - JOIN journal_entries je ON je.id = jel.journal_entry_id - AND je.is_posted = true AND je.is_void = false - AND EXTRACT(YEAR FROM je.entry_date) = $1 - WHERE a.account_type = 'income' AND a.is_active = true - GROUP BY a.id, a.name - HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0 - ORDER BY amount DESC - `, [year]); + async getCashFlowSankey(year: number, source = 'actuals', fundType = 'all') { + let income: any[]; + let expenses: any[]; - const expenses = await this.tenant.query(` - SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount - FROM accounts a - JOIN journal_entry_lines jel ON jel.account_id = a.id - JOIN journal_entries je ON je.id = jel.journal_entry_id - AND je.is_posted = true AND je.is_void = false - AND EXTRACT(YEAR FROM je.entry_date) = $1 - WHERE a.account_type = 'expense' AND a.is_active = true - GROUP BY a.id, a.name, a.fund_type - HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0 - ORDER BY amount DESC - `, [year]); + const fundCondition = fundType !== 'all' ? ` AND a.fund_type = $2` : ''; + const fundParams = fundType !== 'all' ? [year, fundType] : [year]; + + const monthSum = `COALESCE(b.jan,0)+COALESCE(b.feb,0)+COALESCE(b.mar,0)+COALESCE(b.apr,0)+COALESCE(b.may,0)+COALESCE(b.jun,0)+COALESCE(b.jul,0)+COALESCE(b.aug,0)+COALESCE(b.sep,0)+COALESCE(b.oct,0)+COALESCE(b.nov,0)+COALESCE(b.dec_amt,0)`; + + if (source === 'budget') { + income = await this.tenant.query(` + SELECT a.name, SUM(${monthSum}) as amount + FROM budgets b + JOIN accounts a ON a.id = b.account_id + WHERE b.fiscal_year = $1 AND a.account_type = 'income' AND a.is_active = true${fundCondition} + GROUP BY a.id, a.name + HAVING SUM(${monthSum}) > 0 + ORDER BY SUM(${monthSum}) DESC + `, fundParams); + + expenses = await this.tenant.query(` + SELECT a.name, a.fund_type, SUM(${monthSum}) as amount + FROM budgets b + JOIN accounts a ON a.id = b.account_id + WHERE b.fiscal_year = $1 AND a.account_type = 'expense' AND a.is_active = true${fundCondition} + GROUP BY a.id, a.name, a.fund_type + HAVING SUM(${monthSum}) > 0 + ORDER BY SUM(${monthSum}) DESC + `, fundParams); + + } else if (source === 'forecast') { + // Combine actuals (Jan to current date) + budget (remaining months) + const now = new Date(); + const currentMonth = now.getMonth(); // 0-indexed + const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt']; + const remainingMonths = monthNames.slice(currentMonth + 1); + + const actualsFundCond = fundType !== 'all' ? ' AND a.fund_type = $2' : ''; + const actualsParams: any[] = fundType !== 'all' ? [`${year}-01-01`, fundType] : [`${year}-01-01`]; + + const actualsIncome = await this.tenant.query(` + SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount + FROM accounts a + JOIN journal_entry_lines jel ON jel.account_id = a.id + 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 AND je.entry_date <= CURRENT_DATE + WHERE a.account_type = 'income' AND a.is_active = true${actualsFundCond} + GROUP BY a.id, a.name + `, actualsParams); + + const actualsExpenses = await this.tenant.query(` + SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount + FROM accounts a + JOIN journal_entry_lines jel ON jel.account_id = a.id + 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 AND je.entry_date <= CURRENT_DATE + WHERE a.account_type = 'expense' AND a.is_active = true${actualsFundCond} + GROUP BY a.id, a.name, a.fund_type + `, actualsParams); + + // Budget for remaining months + let budgetIncome: any[] = []; + let budgetExpenses: any[] = []; + if (remainingMonths.length > 0) { + const budgetMonthSum = remainingMonths.map(m => `COALESCE(b.${m},0)`).join('+'); + budgetIncome = await this.tenant.query(` + SELECT a.name, SUM(${budgetMonthSum}) as amount + FROM budgets b + JOIN accounts a ON a.id = b.account_id + WHERE b.fiscal_year = $1 AND a.account_type = 'income' AND a.is_active = true${fundCondition} + GROUP BY a.id, a.name + `, fundParams); + + budgetExpenses = await this.tenant.query(` + SELECT a.name, a.fund_type, SUM(${budgetMonthSum}) as amount + FROM budgets b + JOIN accounts a ON a.id = b.account_id + WHERE b.fiscal_year = $1 AND a.account_type = 'expense' AND a.is_active = true${fundCondition} + GROUP BY a.id, a.name, a.fund_type + `, fundParams); + } + + // Merge actuals + budget by account name + const incomeMap = new Map(); + for (const a of actualsIncome) { + const amt = parseFloat(a.amount) || 0; + if (amt > 0) incomeMap.set(a.name, (incomeMap.get(a.name) || 0) + amt); + } + for (const b of budgetIncome) { + const amt = parseFloat(b.amount) || 0; + if (amt > 0) incomeMap.set(b.name, (incomeMap.get(b.name) || 0) + amt); + } + income = Array.from(incomeMap.entries()) + .map(([name, amount]) => ({ name, amount: String(amount) })) + .sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount)); + + const expenseMap = new Map(); + for (const a of actualsExpenses) { + const amt = parseFloat(a.amount) || 0; + if (amt > 0) { + const existing = expenseMap.get(a.name); + expenseMap.set(a.name, { amount: (existing?.amount || 0) + amt, fund_type: a.fund_type }); + } + } + for (const b of budgetExpenses) { + const amt = parseFloat(b.amount) || 0; + if (amt > 0) { + const existing = expenseMap.get(b.name); + expenseMap.set(b.name, { amount: (existing?.amount || 0) + amt, fund_type: b.fund_type }); + } + } + expenses = Array.from(expenseMap.entries()) + .map(([name, { amount, fund_type }]) => ({ name, amount: String(amount), fund_type })) + .sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount)); + + } else { + // Actuals: query journal entries for the year + income = await this.tenant.query(` + SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount + FROM accounts a + JOIN journal_entry_lines jel ON jel.account_id = a.id + JOIN journal_entries je ON je.id = jel.journal_entry_id + AND je.is_posted = true AND je.is_void = false + AND EXTRACT(YEAR FROM je.entry_date) = $1 + WHERE a.account_type = 'income' AND a.is_active = true${fundCondition} + GROUP BY a.id, a.name + HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0 + ORDER BY amount DESC + `, fundParams); + + expenses = await this.tenant.query(` + SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount + FROM accounts a + JOIN journal_entry_lines jel ON jel.account_id = a.id + JOIN journal_entries je ON je.id = jel.journal_entry_id + AND je.is_posted = true AND je.is_void = false + AND EXTRACT(YEAR FROM je.entry_date) = $1 + WHERE a.account_type = 'expense' AND a.is_active = true${fundCondition} + GROUP BY a.id, a.name, a.fund_type + HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0 + ORDER BY amount DESC + `, fundParams); + } if (!income.length && !expenses.length) { return { nodes: [], links: [], total_income: 0, total_expenses: 0, net_cash_flow: 0 }; diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx index fe25026..0d9860d 100644 --- a/frontend/src/pages/dashboard/DashboardPage.tsx +++ b/frontend/src/pages/dashboard/DashboardPage.tsx @@ -53,12 +53,7 @@ export function DashboardPage() { return ( -
- Dashboard - - {currentOrg ? `${currentOrg.name} - ${currentOrg.role}` : 'No organization selected'} - -
+ Dashboard {!currentOrg ? ( diff --git a/frontend/src/pages/investments/InvestmentsPage.tsx b/frontend/src/pages/investments/InvestmentsPage.tsx index aef478b..e73d511 100644 --- a/frontend/src/pages/investments/InvestmentsPage.tsx +++ b/frontend/src/pages/investments/InvestmentsPage.tsx @@ -76,6 +76,11 @@ export function InvestmentsPage() { const totalValue = investments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0); const totalInterestEarned = investments.reduce((s, i) => s + parseFloat(i.interest_earned || '0'), 0); const avgRate = investments.length > 0 ? investments.reduce((s, i) => s + parseFloat(i.interest_rate || '0'), 0) / investments.length : 0; + const projectedInterest = investments.reduce((s, i) => { + const value = parseFloat(i.current_value || i.principal || '0'); + const rate = parseFloat(i.interest_rate || '0'); + return s + (value * rate / 100); + }, 0); const daysRemainingColor = (days: number | null) => { if (days === null) return 'gray'; @@ -92,10 +97,11 @@ export function InvestmentsPage() { Investment Accounts - + Total Principal{fmt(totalPrincipal)} Total Current Value{fmt(totalValue)} Interest Earned{fmt(totalInterestEarned)} + Projected Annual Interest{fmt(projectedInterest)} Avg Interest Rate{avgRate.toFixed(2)}% diff --git a/frontend/src/pages/reports/CashFlowPage.tsx b/frontend/src/pages/reports/CashFlowPage.tsx index 31983a8..cc0b764 100644 --- a/frontend/src/pages/reports/CashFlowPage.tsx +++ b/frontend/src/pages/reports/CashFlowPage.tsx @@ -6,7 +6,7 @@ import { import { useQuery } from '@tanstack/react-query'; import { IconCash, IconArrowUpRight, IconArrowDownRight, - IconWallet, IconReportMoney, IconSearch, + IconWallet, IconReportMoney, IconSearch, IconHeartRateMonitor, } from '@tabler/icons-react'; import api from '../../services/api'; @@ -58,6 +58,16 @@ export function CashFlowPage() { }, }); + const { data: aiRec } = useQuery<{ overall_assessment?: string; risk_notes?: string[] } | null>({ + queryKey: ['saved-recommendation'], + queryFn: async () => { + try { + const { data } = await api.get('/investment-planning/saved-recommendation'); + return data; + } catch { return null; } + }, + }); + const handleApply = () => { setQueryFrom(fromDate); setQueryTo(toDate); @@ -68,6 +78,10 @@ export function CashFlowPage() { const totalOperating = parseFloat(data?.total_operating || '0'); const totalReserve = parseFloat(data?.total_reserve || '0'); + const opInflows = (data?.operating_activities || []).filter(a => a.amount > 0).reduce((s, a) => s + a.amount, 0); + const opOutflows = Math.abs((data?.operating_activities || []).filter(a => a.amount < 0).reduce((s, a) => s + a.amount, 0)); + const resInflows = (data?.reserve_activities || []).filter(a => a.amount > 0).reduce((s, a) => s + a.amount, 0); + const resOutflows = Math.abs((data?.reserve_activities || []).filter(a => a.amount < 0).reduce((s, a) => s + a.amount, 0)); const beginningCash = parseFloat(data?.beginning_cash || '0'); const endingCash = parseFloat(data?.ending_cash || '0'); const balanceLabel = includeInvestments ? 'Cash + Investments' : 'Cash'; @@ -132,10 +146,14 @@ export function CashFlowPage() { = 0 ? 'green' : 'red'} size="sm"> {totalOperating >= 0 ? : } - Net Operating + Operating Activity - = 0 ? 'green' : 'red'}> - {fmt(totalOperating)} + + In: {fmt(opInflows)} + Out: {fmt(opOutflows)} + + = 0 ? 'green' : 'red'}> + {totalOperating >= 0 ? '+' : ''}{fmt(totalOperating)} @@ -143,20 +161,31 @@ export function CashFlowPage() { = 0 ? 'green' : 'red'} size="sm"> - Net Reserve + Reserve Activity - = 0 ? 'green' : 'red'}> - {fmt(totalReserve)} + + In: {fmt(resInflows)} + Out: {fmt(resOutflows)} + + = 0 ? 'green' : 'red'}> + {totalReserve >= 0 ? '+' : ''}{fmt(totalReserve)} - - + + - Ending {balanceLabel} + Financial Health - {fmt(endingCash)} + {aiRec?.overall_assessment ? ( + {aiRec.overall_assessment} + ) : ( + <> + TBD + Pending AI Analysis + + )} diff --git a/frontend/src/pages/reports/SankeyPage.tsx b/frontend/src/pages/reports/SankeyPage.tsx index d412697..3de4fb0 100644 --- a/frontend/src/pages/reports/SankeyPage.tsx +++ b/frontend/src/pages/reports/SankeyPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { - Title, Group, Stack, Text, Card, Loader, Center, Select, SimpleGrid, + Title, Group, Stack, Text, Card, Loader, Center, Select, SimpleGrid, SegmentedControl, } from '@mantine/core'; import { useQuery } from '@tanstack/react-query'; import { @@ -52,6 +52,8 @@ export function SankeyPage() { const containerRef = useRef(null); const [dimensions, setDimensions] = useState({ width: 900, height: 500 }); const [year, setYear] = useState(new Date().getFullYear().toString()); + const [source, setSource] = useState('actuals'); + const [fundFilter, setFundFilter] = useState('all'); const yearOptions = Array.from({ length: 5 }, (_, i) => { const y = new Date().getFullYear() - 2 + i; @@ -59,9 +61,12 @@ export function SankeyPage() { }); const { data, isLoading, isError } = useQuery({ - queryKey: ['sankey', year], + queryKey: ['sankey', year, source, fundFilter], queryFn: async () => { - const { data } = await api.get(`/reports/cash-flow-sankey?year=${year}`); + const params = new URLSearchParams({ year }); + if (source !== 'actuals') params.set('source', source); + if (fundFilter !== 'all') params.set('fundType', fundFilter); + const { data } = await api.get(`/reports/cash-flow-sankey?${params}`); return data; }, }); @@ -191,6 +196,31 @@ export function SankeyPage() {