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:
@@ -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) {
|
async update(id: string, dto: UpdateAccountDto) {
|
||||||
|
|||||||
@@ -24,8 +24,16 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('cash-flow-sankey')
|
@Get('cash-flow-sankey')
|
||||||
getCashFlowSankey(@Query('year') year?: string) {
|
getCashFlowSankey(
|
||||||
return this.reportsService.getCashFlowSankey(parseInt(year || '') || new Date().getFullYear());
|
@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')
|
@Get('cash-flow')
|
||||||
|
|||||||
@@ -83,33 +83,151 @@ export class ReportsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCashFlowSankey(year: number) {
|
async getCashFlowSankey(year: number, source = 'actuals', fundType = 'all') {
|
||||||
// Get income accounts with amounts
|
let income: any[];
|
||||||
const income = await this.tenant.query(`
|
let expenses: any[];
|
||||||
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]);
|
|
||||||
|
|
||||||
const expenses = await this.tenant.query(`
|
const fundCondition = fundType !== 'all' ? ` AND a.fund_type = $2` : '';
|
||||||
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
|
const fundParams = fundType !== 'all' ? [year, fundType] : [year];
|
||||||
FROM accounts a
|
|
||||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
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)`;
|
||||||
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
|
||||||
AND je.is_posted = true AND je.is_void = false
|
if (source === 'budget') {
|
||||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
income = await this.tenant.query(`
|
||||||
WHERE a.account_type = 'expense' AND a.is_active = true
|
SELECT a.name, SUM(${monthSum}) as amount
|
||||||
GROUP BY a.id, a.name, a.fund_type
|
FROM budgets b
|
||||||
HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0
|
JOIN accounts a ON a.id = b.account_id
|
||||||
ORDER BY amount DESC
|
WHERE b.fiscal_year = $1 AND a.account_type = 'income' AND a.is_active = true${fundCondition}
|
||||||
`, [year]);
|
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) {
|
if (!income.length && !expenses.length) {
|
||||||
return { nodes: [], links: [], total_income: 0, total_expenses: 0, net_cash_flow: 0 };
|
return { nodes: [], links: [], total_income: 0, total_expenses: 0, net_cash_flow: 0 };
|
||||||
|
|||||||
@@ -53,12 +53,7 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack data-tour="dashboard-content">
|
<Stack data-tour="dashboard-content">
|
||||||
<div>
|
<Title order={2}>Dashboard</Title>
|
||||||
<Title order={2}>Dashboard</Title>
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{currentOrg ? `${currentOrg.name} - ${currentOrg.role}` : 'No organization selected'}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!currentOrg ? (
|
{!currentOrg ? (
|
||||||
<Card withBorder p="xl" ta="center">
|
<Card withBorder p="xl" ta="center">
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ export function InvestmentsPage() {
|
|||||||
const totalValue = investments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
|
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 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 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) => {
|
const daysRemainingColor = (days: number | null) => {
|
||||||
if (days === null) return 'gray';
|
if (days === null) return 'gray';
|
||||||
@@ -92,10 +97,11 @@ export function InvestmentsPage() {
|
|||||||
<Title order={2}>Investment Accounts</Title>
|
<Title order={2}>Investment Accounts</Title>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button>
|
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button>
|
||||||
</Group>
|
</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 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">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">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>
|
<Card withBorder p="md"><Text size="xs" c="dimmed">Avg Interest Rate</Text><Text fw={700} size="xl">{avgRate.toFixed(2)}%</Text></Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
IconCash, IconArrowUpRight, IconArrowDownRight,
|
IconCash, IconArrowUpRight, IconArrowDownRight,
|
||||||
IconWallet, IconReportMoney, IconSearch,
|
IconWallet, IconReportMoney, IconSearch, IconHeartRateMonitor,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import api from '../../services/api';
|
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 = () => {
|
const handleApply = () => {
|
||||||
setQueryFrom(fromDate);
|
setQueryFrom(fromDate);
|
||||||
setQueryTo(toDate);
|
setQueryTo(toDate);
|
||||||
@@ -68,6 +78,10 @@ export function CashFlowPage() {
|
|||||||
|
|
||||||
const totalOperating = parseFloat(data?.total_operating || '0');
|
const totalOperating = parseFloat(data?.total_operating || '0');
|
||||||
const totalReserve = parseFloat(data?.total_reserve || '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 beginningCash = parseFloat(data?.beginning_cash || '0');
|
||||||
const endingCash = parseFloat(data?.ending_cash || '0');
|
const endingCash = parseFloat(data?.ending_cash || '0');
|
||||||
const balanceLabel = includeInvestments ? 'Cash + Investments' : 'Cash';
|
const balanceLabel = includeInvestments ? 'Cash + Investments' : 'Cash';
|
||||||
@@ -132,10 +146,14 @@ export function CashFlowPage() {
|
|||||||
<ThemeIcon variant="light" color={totalOperating >= 0 ? 'green' : 'red'} size="sm">
|
<ThemeIcon variant="light" color={totalOperating >= 0 ? 'green' : 'red'} size="sm">
|
||||||
{totalOperating >= 0 ? <IconArrowUpRight size={14} /> : <IconArrowDownRight size={14} />}
|
{totalOperating >= 0 ? <IconArrowUpRight size={14} /> : <IconArrowDownRight size={14} />}
|
||||||
</ThemeIcon>
|
</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>
|
</Group>
|
||||||
<Text fw={700} size="xl" ff="monospace" c={totalOperating >= 0 ? 'green' : 'red'}>
|
<Group justify="space-between" mb={4}>
|
||||||
{fmt(totalOperating)}
|
<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>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
<Card withBorder p="md">
|
<Card withBorder p="md">
|
||||||
@@ -143,20 +161,31 @@ export function CashFlowPage() {
|
|||||||
<ThemeIcon variant="light" color={totalReserve >= 0 ? 'green' : 'red'} size="sm">
|
<ThemeIcon variant="light" color={totalReserve >= 0 ? 'green' : 'red'} size="sm">
|
||||||
<IconReportMoney size={14} />
|
<IconReportMoney size={14} />
|
||||||
</ThemeIcon>
|
</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>
|
</Group>
|
||||||
<Text fw={700} size="xl" ff="monospace" c={totalReserve >= 0 ? 'green' : 'red'}>
|
<Group justify="space-between" mb={4}>
|
||||||
{fmt(totalReserve)}
|
<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>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
<Card withBorder p="md">
|
<Card withBorder p="md">
|
||||||
<Group gap="xs" mb={4}>
|
<Group gap="xs" mb={4}>
|
||||||
<ThemeIcon variant="light" color="teal" size="sm">
|
<ThemeIcon variant="light" color={aiRec?.overall_assessment ? 'teal' : 'gray'} size="sm">
|
||||||
<IconCash size={14} />
|
<IconHeartRateMonitor size={14} />
|
||||||
</ThemeIcon>
|
</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>
|
</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>
|
</Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Group, Stack, Text, Card, Loader, Center, Select, SimpleGrid,
|
Title, Group, Stack, Text, Card, Loader, Center, Select, SimpleGrid, SegmentedControl,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
@@ -52,6 +52,8 @@ export function SankeyPage() {
|
|||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [dimensions, setDimensions] = useState({ width: 900, height: 500 });
|
const [dimensions, setDimensions] = useState({ width: 900, height: 500 });
|
||||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
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 yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||||
const y = new Date().getFullYear() - 2 + i;
|
const y = new Date().getFullYear() - 2 + i;
|
||||||
@@ -59,9 +61,12 @@ export function SankeyPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery<CashFlowData>({
|
const { data, isLoading, isError } = useQuery<CashFlowData>({
|
||||||
queryKey: ['sankey', year],
|
queryKey: ['sankey', year, source, fundFilter],
|
||||||
queryFn: async () => {
|
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;
|
return data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -191,6 +196,31 @@ export function SankeyPage() {
|
|||||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
|
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
|
||||||
</Group>
|
</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 }}>
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
<Card withBorder p="md">
|
<Card withBorder p="md">
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Income</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Income</Text>
|
||||||
|
|||||||
Reference in New Issue
Block a user