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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user