Phase 5: AI investment planning - CD rate fetcher and AI recommendation engine
- Add shared.cd_rates table for cross-tenant market data (CD rates from Bankrate) - Create standalone Puppeteer scraper script (scripts/fetch-cd-rates.ts) for cron-based rate fetching - Add investment-planning backend module with 3 endpoints: snapshot, cd-rates, recommendations - AI service gathers tenant financial data (accounts, investments, budgets, projects, cash flow) and calls OpenAI-compatible API (NVIDIA endpoint) for structured investment recommendations - Create InvestmentPlanningPage with summary cards, current investments table, market CD rates table, and AI recommendation accordion - Add Investment Planning to sidebar under Planning menu - Configure AI_API_URL, AI_API_KEY, AI_MODEL environment variables Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import { AdminPage } from './pages/admin/AdminPage';
|
||||
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
|
||||
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
|
||||
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
|
||||
import { InvestmentPlanningPage } from './pages/investment-planning/InvestmentPlanningPage';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
@@ -117,6 +118,7 @@ export function App() {
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="investments" element={<InvestmentsPage />} />
|
||||
<Route path="capital-projects" element={<CapitalProjectsPage />} />
|
||||
<Route path="investment-planning" element={<InvestmentPlanningPage />} />
|
||||
<Route path="assessment-groups" element={<AssessmentGroupsPage />} />
|
||||
<Route path="cash-flow" element={<CashFlowForecastPage />} />
|
||||
<Route path="monthly-actuals" element={<MonthlyActualsPage />} />
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
IconCategory,
|
||||
IconChartAreaLine,
|
||||
IconClipboardCheck,
|
||||
IconSparkles,
|
||||
} from '@tabler/icons-react';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
@@ -54,6 +55,7 @@ const navSections = [
|
||||
items: [
|
||||
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
|
||||
{ label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' },
|
||||
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning' },
|
||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Stack,
|
||||
Card,
|
||||
SimpleGrid,
|
||||
Group,
|
||||
Button,
|
||||
Table,
|
||||
Badge,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
ThemeIcon,
|
||||
Divider,
|
||||
Accordion,
|
||||
Paper,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBulb,
|
||||
IconCash,
|
||||
IconBuildingBank,
|
||||
IconChartAreaLine,
|
||||
IconAlertTriangle,
|
||||
IconSparkles,
|
||||
IconRefresh,
|
||||
IconCoin,
|
||||
IconPigMoney,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import api from '../../services/api';
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface FinancialSummary {
|
||||
operating_cash: number;
|
||||
reserve_cash: number;
|
||||
operating_investments: number;
|
||||
reserve_investments: number;
|
||||
total_operating: number;
|
||||
total_reserve: number;
|
||||
total_all: number;
|
||||
}
|
||||
|
||||
interface FinancialSnapshot {
|
||||
summary: FinancialSummary;
|
||||
investment_accounts: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
institution: string;
|
||||
investment_type: string;
|
||||
fund_type: string;
|
||||
principal: string;
|
||||
interest_rate: string;
|
||||
maturity_date: string | null;
|
||||
current_value: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface CdRate {
|
||||
bank_name: string;
|
||||
apy: string;
|
||||
min_deposit: string | null;
|
||||
term: string;
|
||||
term_months: number | null;
|
||||
fetched_at: string;
|
||||
}
|
||||
|
||||
interface Recommendation {
|
||||
type: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
title: string;
|
||||
summary: string;
|
||||
details: string;
|
||||
fund_type: string;
|
||||
suggested_amount?: number;
|
||||
suggested_term?: string;
|
||||
suggested_rate?: number;
|
||||
bank_name?: string;
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
interface AIResponse {
|
||||
recommendations: Recommendation[];
|
||||
overall_assessment: string;
|
||||
risk_notes: string[];
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
const fmt = (v: number) =>
|
||||
v.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
high: 'red',
|
||||
medium: 'yellow',
|
||||
low: 'blue',
|
||||
};
|
||||
|
||||
const typeIcons: Record<string, any> = {
|
||||
cd_ladder: IconChartAreaLine,
|
||||
new_investment: IconBuildingBank,
|
||||
reallocation: IconRefresh,
|
||||
maturity_action: IconCash,
|
||||
liquidity_warning: IconAlertTriangle,
|
||||
general: IconBulb,
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
cd_ladder: 'CD Ladder',
|
||||
new_investment: 'New Investment',
|
||||
reallocation: 'Reallocation',
|
||||
maturity_action: 'Maturity Action',
|
||||
liquidity_warning: 'Liquidity',
|
||||
general: 'General',
|
||||
};
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function InvestmentPlanningPage() {
|
||||
const [aiResult, setAiResult] = useState<AIResponse | null>(null);
|
||||
|
||||
// Load financial snapshot on mount
|
||||
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
||||
queryKey: ['investment-planning-snapshot'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/investment-planning/snapshot');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Load CD rates on mount
|
||||
const { data: cdRates = [], isLoading: ratesLoading } = useQuery<CdRate[]>({
|
||||
queryKey: ['investment-planning-cd-rates'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/investment-planning/cd-rates');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// AI recommendation (on-demand)
|
||||
const aiMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await api.post('/investment-planning/recommendations');
|
||||
return data as AIResponse;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setAiResult(data);
|
||||
if (data.recommendations.length > 0) {
|
||||
notifications.show({
|
||||
message: `Generated ${data.recommendations.length} investment recommendations`,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
notifications.show({
|
||||
message: err.response?.data?.message || 'Failed to get AI recommendations',
|
||||
color: 'red',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (snapshotLoading) {
|
||||
return (
|
||||
<Center h={400}>
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const s = snapshot?.summary;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{/* Page Header */}
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={2}>Investment Planning</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Account overview, market rates, and AI-powered investment recommendations
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{/* ── Section 1: Financial Snapshot Cards ── */}
|
||||
{s && (
|
||||
<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 Cash
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace">
|
||||
{fmt(s.operating_cash)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Investments: {fmt(s.operating_investments)}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="violet" size="sm">
|
||||
<IconPigMoney size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
Reserve Cash
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace">
|
||||
{fmt(s.reserve_cash)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Investments: {fmt(s.reserve_investments)}
|
||||
</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}>
|
||||
Total All Funds
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace">
|
||||
{fmt(s.total_all)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Operating: {fmt(s.total_operating)} | Reserve: {fmt(s.total_reserve)}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="green" size="sm">
|
||||
<IconCoin size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
Total Invested
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace">
|
||||
{fmt(s.operating_investments + s.reserve_investments)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Earning interest across all accounts
|
||||
</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* ── Section 2: Current Investments Table ── */}
|
||||
{snapshot?.investment_accounts && snapshot.investment_accounts.length > 0 && (
|
||||
<Card withBorder p="lg">
|
||||
<Title order={4} mb="md">
|
||||
Current Investments
|
||||
</Title>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Institution</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Fund</Table.Th>
|
||||
<Table.Th ta="right">Principal</Table.Th>
|
||||
<Table.Th ta="right">Rate</Table.Th>
|
||||
<Table.Th>Maturity</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{snapshot.investment_accounts.map((inv) => (
|
||||
<Table.Tr key={inv.id}>
|
||||
<Table.Td fw={500}>{inv.name}</Table.Td>
|
||||
<Table.Td>{inv.institution || '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light">
|
||||
{inv.investment_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={inv.fund_type === 'reserve' ? 'violet' : 'blue'}
|
||||
>
|
||||
{inv.fund_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">
|
||||
{fmt(parseFloat(inv.principal))}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{parseFloat(inv.interest_rate || '0').toFixed(2)}%
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{inv.maturity_date
|
||||
? new Date(inv.maturity_date).toLocaleDateString()
|
||||
: '-'}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Section 3: Market CD Rates ── */}
|
||||
<Card withBorder p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>Market CD Rates</Title>
|
||||
{cdRates.length > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Last fetched: {new Date(cdRates[0].fetched_at).toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{ratesLoading ? (
|
||||
<Center py="lg">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Bank</Table.Th>
|
||||
<Table.Th ta="right">APY</Table.Th>
|
||||
<Table.Th>Term</Table.Th>
|
||||
<Table.Th ta="right">Min Deposit</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{cdRates.map((r, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td fw={500}>{r.bank_name}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} c="green">
|
||||
{parseFloat(r.apy).toFixed(2)}%
|
||||
</Table.Td>
|
||||
<Table.Td>{r.term}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">
|
||||
{r.min_deposit
|
||||
? `$${parseFloat(r.min_deposit).toLocaleString()}`
|
||||
: '-'}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{cdRates.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No CD rates available. Run the fetch-cd-rates script to populate market data.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Section 4: AI Investment Recommendations ── */}
|
||||
<Card withBorder p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" color="grape" size="md">
|
||||
<IconSparkles size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={4}>AI Investment Recommendations</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
Powered by AI analysis of your complete financial picture
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={() => aiMutation.mutate()}
|
||||
loading={aiMutation.isPending}
|
||||
variant="gradient"
|
||||
gradient={{ from: 'grape', to: 'violet' }}
|
||||
>
|
||||
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Loading State */}
|
||||
{aiMutation.isPending && (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="sm">
|
||||
<Loader size="lg" type="dots" />
|
||||
<Text c="dimmed" size="sm">
|
||||
Analyzing your financial data and market rates...
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
This may take up to 30 seconds
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{aiResult && !aiMutation.isPending && (
|
||||
<Stack>
|
||||
{/* Overall Assessment */}
|
||||
<Alert color="blue" variant="light" title="Overall Assessment">
|
||||
<Text size="sm">{aiResult.overall_assessment}</Text>
|
||||
</Alert>
|
||||
|
||||
{/* Risk Notes */}
|
||||
{aiResult.risk_notes && aiResult.risk_notes.length > 0 && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
title="Risk Notes"
|
||||
icon={<IconAlertTriangle />}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{aiResult.risk_notes.map((note, i) => (
|
||||
<Text key={i} size="sm">
|
||||
{note}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Recommendation Cards */}
|
||||
{aiResult.recommendations.length > 0 ? (
|
||||
<Accordion variant="separated">
|
||||
{aiResult.recommendations.map((rec, i) => {
|
||||
const Icon = typeIcons[rec.type] || IconBulb;
|
||||
return (
|
||||
<Accordion.Item key={i} value={`rec-${i}`}>
|
||||
<Accordion.Control>
|
||||
<Group>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={priorityColors[rec.priority] || 'gray'}
|
||||
size="md"
|
||||
>
|
||||
<Icon size={16} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>{rec.title}</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
color={priorityColors[rec.priority]}
|
||||
>
|
||||
{rec.priority}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light">
|
||||
{typeLabels[rec.type] || rec.type}
|
||||
</Badge>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="dot"
|
||||
color={
|
||||
rec.fund_type === 'reserve'
|
||||
? 'violet'
|
||||
: rec.fund_type === 'operating'
|
||||
? 'blue'
|
||||
: 'gray'
|
||||
}
|
||||
>
|
||||
{rec.fund_type}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt={2}>
|
||||
{rec.summary}
|
||||
</Text>
|
||||
</div>
|
||||
{rec.suggested_amount != null && (
|
||||
<Text fw={700} ff="monospace" c="green" size="lg">
|
||||
{fmt(rec.suggested_amount)}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{rec.details}</Text>
|
||||
|
||||
{(rec.suggested_term ||
|
||||
rec.suggested_rate != null ||
|
||||
rec.bank_name) && (
|
||||
<Paper withBorder p="sm" radius="sm">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
{rec.suggested_term && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
Suggested Term
|
||||
</Text>
|
||||
<Text fw={600}>{rec.suggested_term}</Text>
|
||||
</div>
|
||||
)}
|
||||
{rec.suggested_rate != null && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
Target Rate
|
||||
</Text>
|
||||
<Text fw={600}>
|
||||
{rec.suggested_rate}% APY
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{rec.bank_name && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
Bank
|
||||
</Text>
|
||||
<Text fw={600}>{rec.bank_name}</Text>
|
||||
</div>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Alert variant="light" color="gray" title="Rationale">
|
||||
<Text size="sm">{rec.rationale}</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
) : (
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No specific recommendations at this time.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!aiResult && !aiMutation.isPending && (
|
||||
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
|
||||
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
|
||||
<IconSparkles size={28} />
|
||||
</ThemeIcon>
|
||||
<Text fw={500} mb={4}>
|
||||
AI-Powered Investment Analysis
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" maw={500} mx="auto">
|
||||
Click "Get AI Recommendations" to analyze your accounts, cash flow,
|
||||
budget, and capital projects against current market rates. The AI will
|
||||
suggest specific investment moves to maximize interest income while
|
||||
maintaining adequate liquidity.
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user