- Make all AI endpoints (health scores + investment recommendations) fire-and-forget: POST returns immediately, frontend polls for results - Extend AI API timeout from 2-5 min to 10 min for both services - Add "last analysis failed — showing cached data" message to the Investment Recommendations panel (matches health score widgets) - Add status/error_message columns to ai_recommendations table - Remove nginx AI timeout overrides (no longer needed) - Users can now navigate away during AI processing without interruption Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
764 lines
24 KiB
TypeScript
764 lines
24 KiB
TypeScript
import { useState, useEffect, useCallback } 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 } 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;
|
|
status: 'processing' | 'complete' | 'error';
|
|
last_failed: boolean;
|
|
error_message?: 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,
|
|
lastFailed,
|
|
}: {
|
|
aiResult: AIResponse;
|
|
lastUpdated?: string;
|
|
lastFailed?: boolean;
|
|
}) {
|
|
return (
|
|
<Stack>
|
|
{/* Last Updated timestamp + failure message */}
|
|
{lastUpdated && (
|
|
<Stack gap={0} align="flex-end">
|
|
<Text size="xs" c="dimmed" ta="right">
|
|
Last updated: {new Date(lastUpdated).toLocaleString()}
|
|
</Text>
|
|
{lastFailed && (
|
|
<Text size="10px" c="orange" fw={500} style={{ opacity: 0.85 }}>
|
|
last analysis failed — showing cached data
|
|
</Text>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|
|
|
|
// ── Main Component ──
|
|
|
|
export function InvestmentPlanningPage() {
|
|
const [ratesExpanded, setRatesExpanded] = useState(true);
|
|
const [isTriggering, setIsTriggering] = useState(false);
|
|
|
|
// 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 — polls every 3s when processing
|
|
const { data: savedRec } = useQuery<SavedRecommendation | null>({
|
|
queryKey: ['investment-planning-saved-recommendation'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get('/investment-planning/saved-recommendation');
|
|
return data;
|
|
},
|
|
refetchInterval: (query) => {
|
|
const rec = query.state.data;
|
|
// Poll every 3 seconds while processing
|
|
if (rec?.status === 'processing') return 3000;
|
|
// Also poll if we just triggered (status may not be 'processing' yet)
|
|
if (isTriggering) return 3000;
|
|
return false;
|
|
},
|
|
});
|
|
|
|
// Derive display state from saved recommendation
|
|
const isProcessing = savedRec?.status === 'processing' || isTriggering;
|
|
const lastFailed = savedRec?.last_failed || false;
|
|
const hasResults = savedRec && savedRec.status === 'complete' && savedRec.recommendations.length > 0;
|
|
const hasError = savedRec?.status === 'error' && !savedRec?.recommendations?.length;
|
|
|
|
// Clear triggering flag once backend confirms processing or completes
|
|
useEffect(() => {
|
|
if (isTriggering && savedRec?.status === 'processing') {
|
|
setIsTriggering(false);
|
|
}
|
|
if (isTriggering && savedRec?.status === 'complete') {
|
|
setIsTriggering(false);
|
|
}
|
|
}, [savedRec?.status, isTriggering]);
|
|
|
|
// Show notification when processing completes (transition from processing)
|
|
const prevStatusRef = useState<string | null>(null);
|
|
useEffect(() => {
|
|
const [prevStatus, setPrevStatus] = prevStatusRef;
|
|
if (prevStatus === 'processing' && savedRec?.status === 'complete') {
|
|
notifications.show({
|
|
message: `Generated ${savedRec.recommendations.length} investment recommendations`,
|
|
color: 'green',
|
|
});
|
|
}
|
|
if (prevStatus === 'processing' && savedRec?.status === 'error') {
|
|
notifications.show({
|
|
message: savedRec.error_message || 'AI recommendation analysis failed',
|
|
color: 'red',
|
|
});
|
|
}
|
|
setPrevStatus(savedRec?.status || null);
|
|
}, [savedRec?.status]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Trigger AI recommendations (async — returns immediately)
|
|
const handleTriggerAI = useCallback(async () => {
|
|
setIsTriggering(true);
|
|
try {
|
|
await api.post('/investment-planning/recommendations');
|
|
} catch (err: any) {
|
|
setIsTriggering(false);
|
|
notifications.show({
|
|
message: err.response?.data?.message || 'Failed to start AI analysis',
|
|
color: 'red',
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
// Build AI result from saved recommendation for display
|
|
const aiResult: AIResponse | null = hasResults
|
|
? {
|
|
recommendations: savedRec!.recommendations,
|
|
overall_assessment: savedRec!.overall_assessment,
|
|
risk_notes: savedRec!.risk_notes,
|
|
}
|
|
: (lastFailed && savedRec?.recommendations?.length)
|
|
? {
|
|
recommendations: savedRec!.recommendations,
|
|
overall_assessment: savedRec!.overall_assessment,
|
|
risk_notes: savedRec!.risk_notes,
|
|
}
|
|
: null;
|
|
|
|
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'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={handleTriggerAI}
|
|
loading={isProcessing}
|
|
variant="gradient"
|
|
gradient={{ from: 'grape', to: 'violet' }}
|
|
>
|
|
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
|
|
</Button>
|
|
</Group>
|
|
|
|
{/* Processing State */}
|
|
{isProcessing && (
|
|
<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">
|
|
You can navigate away — results will appear when ready
|
|
</Text>
|
|
</Stack>
|
|
</Center>
|
|
)}
|
|
|
|
{/* Error State (no cached data) */}
|
|
{hasError && !isProcessing && (
|
|
<Alert color="red" variant="light" title="Analysis Failed" mb="md">
|
|
<Text size="sm">
|
|
{savedRec?.error_message || 'The last AI analysis failed. Please try again.'}
|
|
</Text>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Results (with optional failure watermark) */}
|
|
{aiResult && !isProcessing && (
|
|
<RecommendationsDisplay
|
|
aiResult={aiResult}
|
|
lastUpdated={savedRec?.created_at || undefined}
|
|
lastFailed={lastFailed}
|
|
/>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{!aiResult && !isProcessing && !hasError && (
|
|
<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>
|
|
);
|
|
}
|