Files
HOA_Financial_Platform/frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx
olsch01 e893319cfe Merge branch 'fix/viewer-readonly-audit'
# Conflicts:
#	frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx
2026-03-16 16:33:24 -04:00

970 lines
32 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from 'react';
import {
Title,
Text,
Stack,
Card,
SimpleGrid,
Group,
Button,
Table,
Badge,
Loader,
Center,
Alert,
ThemeIcon,
Divider,
Accordion,
Paper,
Tabs,
Collapse,
ActionIcon,
Modal,
Select,
TextInput,
Progress,
} from '@mantine/core';
import {
IconBulb,
IconCash,
IconBuildingBank,
IconChartAreaLine,
IconAlertTriangle,
IconSparkles,
IconRefresh,
IconCoin,
IconPigMoney,
IconChevronDown,
IconChevronUp,
IconPlaylistAdd,
} from '@tabler/icons-react';
import { DateInput } from '@mantine/dates';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
// ── 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 RecommendationComponent {
label: string;
amount: number;
term_months: number;
rate: number;
bank_name?: string;
investment_type?: 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;
components?: RecommendationComponent[];
}
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,
onAddToPlan,
}: {
aiResult: AIResponse;
lastUpdated?: string;
lastFailed?: boolean;
onAddToPlan?: (rec: Recommendation) => void;
}) {
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>
{onAddToPlan && rec.type !== 'liquidity_warning' && rec.type !== 'general' && (
<Button
size="sm"
variant="light"
leftSection={<IconPlaylistAdd size={16} />}
onClick={() => onAddToPlan(rec)}
>
Add to Investment Plan
</Button>
)}
</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 navigate = useNavigate();
const queryClient = useQueryClient();
const [ratesExpanded, setRatesExpanded] = useState(true);
const [isTriggering, setIsTriggering] = useState(false);
const [planModalOpen, setPlanModalOpen] = useState(false);
const [selectedRec, setSelectedRec] = useState<Recommendation | null>(null);
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
const [newScenarioName, setNewScenarioName] = useState('');
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
const isReadOnly = useIsReadOnly();
// Load investment scenarios for the "Add to Plan" modal
const { data: investmentScenarios } = useQuery<any[]>({
queryKey: ['board-planning-scenarios', 'investment'],
queryFn: async () => {
const { data } = await api.get('/board-planning/scenarios?type=investment');
return data;
},
});
const addToPlanMutation = useMutation({
mutationFn: async ({ scenarioId, rec }: { scenarioId: string; rec: Recommendation }) => {
await api.post(`/board-planning/scenarios/${scenarioId}/investments/from-recommendation`, {
title: rec.title,
investmentType: rec.type === 'cd_ladder' ? 'cd' : rec.type === 'new_investment' ? undefined : undefined,
fundType: rec.fund_type || 'reserve',
suggestedAmount: rec.suggested_amount,
suggestedRate: rec.suggested_rate,
termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null,
bankName: rec.bank_name,
rationale: rec.rationale,
components: rec.components || undefined,
startDate: investmentStartDate ? investmentStartDate.toISOString().split('T')[0] : null,
});
return scenarioId;
},
onSuccess: (scenarioId) => {
setPlanModalOpen(false);
setSelectedRec(null);
setTargetScenarioId(null);
notifications.show({
message: 'Recommendation added to investment scenario',
color: 'green',
autoClose: 5000,
});
},
});
const createAndAddMutation = useMutation({
mutationFn: async ({ name, rec }: { name: string; rec: Recommendation }) => {
const { data: scenario } = await api.post('/board-planning/scenarios', {
name, scenarioType: 'investment',
});
await api.post(`/board-planning/scenarios/${scenario.id}/investments/from-recommendation`, {
title: rec.title,
investmentType: rec.type === 'cd_ladder' ? 'cd' : undefined,
fundType: rec.fund_type || 'reserve',
suggestedAmount: rec.suggested_amount,
suggestedRate: rec.suggested_rate,
termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null,
bankName: rec.bank_name,
rationale: rec.rationale,
components: rec.components || undefined,
startDate: investmentStartDate ? investmentStartDate.toISOString().split('T')[0] : null,
});
return scenario.id;
},
onSuccess: (scenarioId) => {
setPlanModalOpen(false);
setSelectedRec(null);
setNewScenarioName('');
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
notifications.show({
message: 'New scenario created with recommendation',
color: 'green',
autoClose: 5000,
});
},
});
const handleAddToPlan = (rec: Recommendation) => {
setSelectedRec(rec);
setTargetScenarioId(null);
setNewScenarioName('');
setInvestmentStartDate(new Date());
setPlanModalOpen(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 — 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]);
// Ref for scrolling to AI section on completion
const aiSectionRef = useRef<HTMLDivElement>(null);
// 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({
title: 'AI Analysis Complete',
message: `Generated ${savedRec.recommendations.length} investment recommendations`,
color: 'green',
autoClose: 8000,
});
// Scroll the AI section into view so user sees the new results
setTimeout(() => {
aiSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 300);
}
if (prevStatus === 'processing' && savedRec?.status === 'error') {
notifications.show({
title: 'AI Analysis Failed',
message: savedRec.error_message || 'AI recommendation analysis failed',
color: 'red',
autoClose: 8000,
});
}
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&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" ref={aiSectionRef}>
<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>
{!isReadOnly && (
<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 - shown as banner when refreshing with existing results */}
{isProcessing && (
<Alert variant="light" color="grape" mb="md" styles={{ root: { overflow: 'visible' } }}>
<Group gap="sm">
<Loader size="sm" color="grape" />
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{aiResult ? 'Refreshing AI analysis...' : 'Running AI analysis...'}
</Text>
<Text size="xs" c="dimmed">
Analyzing your financial data, accounts, budgets, and current market rates
</Text>
</div>
</Group>
<Progress value={100} animated color="grape" size="xs" mt="xs" />
</Alert>
)}
{/* 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 - keep visible even while refreshing (with optional failure watermark) */}
{aiResult && (
<div style={isProcessing ? { opacity: 0.5, pointerEvents: 'none' } : undefined}>
<RecommendationsDisplay
aiResult={aiResult}
lastUpdated={savedRec?.created_at || undefined}
lastFailed={lastFailed}
onAddToPlan={handleAddToPlan}
/>
</div>
)}
{/* Empty State - only when no results and not processing */}
{!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 &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>
{/* Add to Investment Plan Modal */}
<Modal opened={planModalOpen} onClose={() => setPlanModalOpen(false)} title="Add to Investment Plan">
<Stack>
{selectedRec && (
<Alert variant="light" color="blue">
<Text size="sm" fw={600}>{selectedRec.title}</Text>
{selectedRec.suggested_amount != null && (
<Text size="sm">Amount: {fmt(selectedRec.suggested_amount)}</Text>
)}
{selectedRec.components && selectedRec.components.length > 0 && (
<Stack gap={2} mt={6}>
<Text size="xs" c="dimmed" fw={600}>{selectedRec.components.length} investments will be created:</Text>
{selectedRec.components.map((c, i) => (
<Text key={i} size="xs" c="dimmed">
{c.label}: {fmt(c.amount)} @ {c.rate}% ({c.term_months} mo)
</Text>
))}
</Stack>
)}
</Alert>
)}
<DateInput
label="Start Date"
description="Purchase date for the investment(s). Maturity dates are calculated automatically from term length."
value={investmentStartDate}
onChange={setInvestmentStartDate}
/>
{investmentScenarios && investmentScenarios.length > 0 && (
<Select
label="Add to existing scenario"
placeholder="Select a scenario..."
data={investmentScenarios.map((s: any) => ({ value: s.id, label: s.name }))}
value={targetScenarioId}
onChange={setTargetScenarioId}
clearable
/>
)}
<Divider label="or" labelPosition="center" />
<TextInput
label="Create new scenario"
placeholder="e.g. Conservative Strategy"
value={newScenarioName}
onChange={(e) => { setNewScenarioName(e.target.value); setTargetScenarioId(null); }}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setPlanModalOpen(false)}>Cancel</Button>
{targetScenarioId && selectedRec && (
<Button
onClick={() => addToPlanMutation.mutate({ scenarioId: targetScenarioId, rec: selectedRec })}
loading={addToPlanMutation.isPending}
>
Add to Scenario
</Button>
)}
{newScenarioName && !targetScenarioId && selectedRec && (
<Button
onClick={() => createAndAddMutation.mutate({ name: newScenarioName, rec: selectedRec })}
loading={createAndAddMutation.isPending}
>
Create & Add
</Button>
)}
</Group>
</Stack>
</Modal>
</Stack>
);
}