Files
HOA_Financial_Platform/frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx
olsch01 337b6061b2 feat: reliability enhancements for AI services and capital planning
1. Health Scores — separate operating/reserve refresh
   - Added POST /health-scores/calculate/operating and /calculate/reserve
   - Each health card now has its own Refresh button
   - On failure, shows cached (last good) data with "last analysis failed"
     watermark instead of blank "Error calculating score"
   - Backend getLatestScores returns latest complete score + failure flag

2. Investment Planning — increased AI timeout to 5 minutes
   - Backend callAI timeout: 180s → 300s
   - Frontend axios timeout: set explicitly to 300s (was browser default)
   - Host nginx proxy_read_timeout: 180s → 300s
   - Loading message updated to reflect longer wait times

3. Capital Planning — Unscheduled column moved to rightmost position
   - Kanban column order: current year → future → unscheduled (was leftmost)
   - Puts immediate/near-term projects front and center

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 12:02:30 -05:00

698 lines
22 KiB
TypeScript

import { useState, useEffect } from 'react';
import {
Title,
Text,
Stack,
Card,
SimpleGrid,
Group,
Button,
Table,
Badge,
Loader,
Center,
Alert,
ThemeIcon,
Divider,
Accordion,
Paper,
Tabs,
Collapse,
ActionIcon,
} from '@mantine/core';
import {
IconBulb,
IconCash,
IconBuildingBank,
IconChartAreaLine,
IconAlertTriangle,
IconSparkles,
IconRefresh,
IconCoin,
IconPigMoney,
IconChevronDown,
IconChevronUp,
} 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 MarketRate {
bank_name: string;
apy: string;
min_deposit: string | null;
term: string;
term_months: number | null;
rate_type: string;
fetched_at: string;
}
interface MarketRatesResponse {
cd: MarketRate[];
money_market: MarketRate[];
high_yield_savings: MarketRate[];
}
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[];
}
interface SavedRecommendation {
id: string;
recommendations: Recommendation[];
overall_assessment: string;
risk_notes: string[];
response_time_ms: number;
created_at: 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',
};
// ── Rate Table Component ──
function RateTable({ rates, showTerm }: { rates: MarketRate[]; showTerm: boolean }) {
if (rates.length === 0) {
return (
<Text ta="center" c="dimmed" py="lg">
No rates available. Run the market rate fetcher to populate data.
</Text>
);
}
return (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Bank</Table.Th>
<Table.Th ta="right">APY</Table.Th>
{showTerm && <Table.Th>Term</Table.Th>}
<Table.Th ta="right">Min Deposit</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rates.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>
{showTerm && <Table.Td>{r.term}</Table.Td>}
<Table.Td ta="right" ff="monospace">
{r.min_deposit
? `$${parseFloat(r.min_deposit).toLocaleString()}`
: '-'}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}
// ── Recommendations Display Component ──
function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIResponse; lastUpdated?: string }) {
return (
<Stack>
{/* Last Updated timestamp */}
{lastUpdated && (
<Text size="xs" c="dimmed" ta="right">
Last updated: {new Date(lastUpdated).toLocaleString()}
</Text>
)}
{/* 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>
);
}
// ── Main Component ──
export function InvestmentPlanningPage() {
const [aiResult, setAiResult] = useState<AIResponse | null>(null);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const [ratesExpanded, setRatesExpanded] = useState(true);
// 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 market rates (all types) on mount
const { data: marketRates, isLoading: ratesLoading } = useQuery<MarketRatesResponse>({
queryKey: ['investment-planning-market-rates'],
queryFn: async () => {
const { data } = await api.get('/investment-planning/market-rates');
return data;
},
});
// Load saved recommendation on mount
const { data: savedRec } = useQuery<SavedRecommendation | null>({
queryKey: ['investment-planning-saved-recommendation'],
queryFn: async () => {
const { data } = await api.get('/investment-planning/saved-recommendation');
return data;
},
});
// Populate AI results from saved recommendation on load
useEffect(() => {
if (savedRec && !aiResult) {
setAiResult({
recommendations: savedRec.recommendations,
overall_assessment: savedRec.overall_assessment,
risk_notes: savedRec.risk_notes,
});
setLastUpdated(savedRec.created_at);
}
}, [savedRec]); // eslint-disable-line react-hooks/exhaustive-deps
// AI recommendation (on-demand)
const aiMutation = useMutation({
mutationFn: async () => {
const { data } = await api.post('/investment-planning/recommendations', {}, { timeout: 300000 });
return data as AIResponse;
},
onSuccess: (data) => {
setAiResult(data);
setLastUpdated(new Date().toISOString());
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;
// Determine the latest fetched_at timestamp across all rate types
const allRatesList = [
...(marketRates?.cd || []),
...(marketRates?.money_market || []),
...(marketRates?.high_yield_savings || []),
];
const latestFetchedAt = allRatesList.length > 0
? allRatesList.reduce((latest, r) =>
new Date(r.fetched_at) > new Date(latest.fetched_at) ? r : latest,
).fetched_at
: null;
const totalRateCount =
(marketRates?.cd?.length || 0) +
(marketRates?.money_market?.length || 0) +
(marketRates?.high_yield_savings?.length || 0);
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: Today's Market Rates (Collapsible with Tabs) ── */}
<Card withBorder p="lg">
<Group justify="space-between" mb={ratesExpanded ? 'md' : 0}>
<Group gap="xs">
<Title order={4}>Today&apos;s Market Rates</Title>
{totalRateCount > 0 && (
<Badge size="sm" variant="light" color="gray">
{totalRateCount} rates
</Badge>
)}
</Group>
<Group gap="xs">
{latestFetchedAt && (
<Text size="xs" c="dimmed">
Last fetched: {new Date(latestFetchedAt).toLocaleString()}
</Text>
)}
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setRatesExpanded((v) => !v)}
title={ratesExpanded ? 'Collapse rates' : 'Expand rates'}
>
{ratesExpanded ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />}
</ActionIcon>
</Group>
</Group>
<Collapse in={ratesExpanded}>
{ratesLoading ? (
<Center py="lg">
<Loader />
</Center>
) : (
<Tabs defaultValue="cd">
<Tabs.List>
<Tabs.Tab value="cd">
CDs {(marketRates?.cd?.length || 0) > 0 && (
<Badge size="xs" variant="light" ml={4}>{marketRates?.cd?.length}</Badge>
)}
</Tabs.Tab>
<Tabs.Tab value="money_market">
Money Market {(marketRates?.money_market?.length || 0) > 0 && (
<Badge size="xs" variant="light" ml={4}>{marketRates?.money_market?.length}</Badge>
)}
</Tabs.Tab>
<Tabs.Tab value="high_yield_savings">
High Yield Savings {(marketRates?.high_yield_savings?.length || 0) > 0 && (
<Badge size="xs" variant="light" ml={4}>{marketRates?.high_yield_savings?.length}</Badge>
)}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="cd" pt="sm">
<RateTable rates={marketRates?.cd || []} showTerm={true} />
</Tabs.Panel>
<Tabs.Panel value="money_market" pt="sm">
<RateTable rates={marketRates?.money_market || []} showTerm={false} />
</Tabs.Panel>
<Tabs.Panel value="high_yield_savings" pt="sm">
<RateTable rates={marketRates?.high_yield_savings || []} showTerm={false} />
</Tabs.Panel>
</Tabs>
)}
</Collapse>
</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 a few minutes for complex tenant data
</Text>
</Stack>
</Center>
)}
{/* Results */}
{aiResult && !aiMutation.isPending && (
<RecommendationsDisplay aiResult={aiResult} lastUpdated={lastUpdated || undefined} />
)}
{/* 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 &quot;Get AI Recommendations&quot; 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>
);
}