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 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 14:22:37 -05:00
parent f1e66966f3
commit 07347a644f
7 changed files with 250 additions and 50 deletions

View File

@@ -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) {

View File

@@ -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')

View File

@@ -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<string, number>();
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<string, { amount: number; fund_type: string }>();
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 };

View File

@@ -53,12 +53,7 @@ export function DashboardPage() {
return (
<Stack data-tour="dashboard-content">
<div>
<Title order={2}>Dashboard</Title>
<Text c="dimmed" size="sm">
{currentOrg ? `${currentOrg.name} - ${currentOrg.role}` : 'No organization selected'}
</Text>
</div>
<Title order={2}>Dashboard</Title>
{!currentOrg ? (
<Card withBorder p="xl" ta="center">

View File

@@ -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() {
<Title order={2}>Investment Accounts</Title>
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button>
</Group>
<SimpleGrid cols={{ base: 1, sm: 4 }}>
<SimpleGrid cols={{ base: 1, sm: 3, lg: 5 }}>
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Principal</Text><Text fw={700} size="xl">{fmt(totalPrincipal)}</Text></Card>
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Current Value</Text><Text fw={700} size="xl" c="green">{fmt(totalValue)}</Text></Card>
<Card withBorder p="md"><Text size="xs" c="dimmed">Interest Earned</Text><Text fw={700} size="xl" c="teal">{fmt(totalInterestEarned)}</Text></Card>
<Card withBorder p="md"><Text size="xs" c="dimmed">Projected Annual Interest</Text><Text fw={700} size="xl" c="blue">{fmt(projectedInterest)}</Text></Card>
<Card withBorder p="md"><Text size="xs" c="dimmed">Avg Interest Rate</Text><Text fw={700} size="xl">{avgRate.toFixed(2)}%</Text></Card>
</SimpleGrid>
<Table striped highlightOnHover>

View File

@@ -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() {
<ThemeIcon variant="light" color={totalOperating >= 0 ? 'green' : 'red'} size="sm">
{totalOperating >= 0 ? <IconArrowUpRight size={14} /> : <IconArrowDownRight size={14} />}
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Operating</Text>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Activity</Text>
</Group>
<Text fw={700} size="xl" ff="monospace" c={totalOperating >= 0 ? 'green' : 'red'}>
{fmt(totalOperating)}
<Group justify="space-between" mb={4}>
<Text size="xs" c="green">In: {fmt(opInflows)}</Text>
<Text size="xs" c="red">Out: {fmt(opOutflows)}</Text>
</Group>
<Text fw={700} size="lg" ff="monospace" c={totalOperating >= 0 ? 'green' : 'red'}>
{totalOperating >= 0 ? '+' : ''}{fmt(totalOperating)}
</Text>
</Card>
<Card withBorder p="md">
@@ -143,20 +161,31 @@ export function CashFlowPage() {
<ThemeIcon variant="light" color={totalReserve >= 0 ? 'green' : 'red'} size="sm">
<IconReportMoney size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Reserve</Text>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Activity</Text>
</Group>
<Text fw={700} size="xl" ff="monospace" c={totalReserve >= 0 ? 'green' : 'red'}>
{fmt(totalReserve)}
<Group justify="space-between" mb={4}>
<Text size="xs" c="green">In: {fmt(resInflows)}</Text>
<Text size="xs" c="red">Out: {fmt(resOutflows)}</Text>
</Group>
<Text fw={700} size="lg" ff="monospace" c={totalReserve >= 0 ? 'green' : 'red'}>
{totalReserve >= 0 ? '+' : ''}{fmt(totalReserve)}
</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="teal" size="sm">
<IconCash size={14} />
<ThemeIcon variant="light" color={aiRec?.overall_assessment ? 'teal' : 'gray'} size="sm">
<IconHeartRateMonitor size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Ending {balanceLabel}</Text>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Financial Health</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">{fmt(endingCash)}</Text>
{aiRec?.overall_assessment ? (
<Text fw={600} size="sm" lineClamp={3}>{aiRec.overall_assessment}</Text>
) : (
<>
<Text fw={700} size="xl" c="dimmed">TBD</Text>
<Text size="xs" c="dimmed">Pending AI Analysis</Text>
</>
)}
</Card>
</SimpleGrid>

View File

@@ -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<HTMLDivElement | null>(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<CashFlowData>({
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() {
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
</Group>
<Group>
<Text size="sm" fw={500}>Data source:</Text>
<SegmentedControl
size="sm"
value={source}
onChange={setSource}
data={[
{ label: 'Actuals', value: 'actuals' },
{ label: 'Budget', value: 'budget' },
{ label: 'Forecast', value: 'forecast' },
]}
/>
<Text size="sm" fw={500} ml="md">Fund:</Text>
<SegmentedControl
size="sm"
value={fundFilter}
onChange={setFundFilter}
data={[
{ label: 'All Funds', value: 'all' },
{ label: 'Operating', value: 'operating' },
{ label: 'Reserve', value: 'reserve' },
]}
/>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Income</Text>