import { Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table, Badge, Loader, Center, Divider, RingProgress, Tooltip, Button, Popover, List, } from '@mantine/core'; import { IconCash, IconFileInvoice, IconShieldCheck, IconAlertTriangle, IconBuildingBank, IconTrendingUp, IconTrendingDown, IconMinus, IconHeartbeat, IconRefresh, IconInfoCircle, } from '@tabler/icons-react'; import { useState, useCallback } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useAuthStore } from '../../stores/authStore'; import api from '../../services/api'; interface HealthScore { id: string; score_type: string; score: number; previous_score: number | null; trajectory: string | null; label: string; summary: string; factors: Array<{ name: string; impact: 'positive' | 'neutral' | 'negative'; detail: string }>; recommendations: Array<{ priority: string; text: string }>; missing_data: string[] | null; status: string; response_time_ms: number | null; calculated_at: string; } interface HealthScoresData { operating: HealthScore | null; reserve: HealthScore | null; operating_last_failed?: boolean; reserve_last_failed?: boolean; } function getScoreColor(score: number): string { if (score >= 75) return 'green'; if (score >= 60) return 'yellow'; if (score >= 40) return 'orange'; return 'red'; } function TrajectoryIcon({ trajectory }: { trajectory: string | null }) { if (trajectory === 'improving') return ; if (trajectory === 'declining') return ; if (trajectory === 'stable') return ; return null; } function HealthScoreCard({ score, title, icon, isRefreshing, onRefresh, lastFailed, }: { score: HealthScore | null; title: string; icon: React.ReactNode; isRefreshing?: boolean; onRefresh?: () => void; lastFailed?: boolean; }) { // No score at all yet if (!score) { return ( {title} Health {onRefresh && ( )} {icon}
No health score yet
); } // Pending — missing data, can't calculate if (score.status === 'pending') { const missingItems = Array.isArray(score.missing_data) ? score.missing_data : (typeof score.missing_data === 'string' ? JSON.parse(score.missing_data) : []); return ( {title} Health {onRefresh && ( )} {icon}
Pending Missing data: {missingItems.map((item: string, i: number) => ( {item} ))}
); } // For error status, we still render the score data (cached from the previous // successful run) rather than blanking the card with "Error calculating score". // A small watermark under the timestamp tells the user it's stale. const showAsError = score.status === 'error' && score.score === 0 && !score.summary; // Pure error with no cached data to fall back on if (showAsError) { return ( {title} Health {onRefresh && ( )} {icon}
Error calculating score Click Retry to try again
); } // Normal display — works for both 'complete' and 'error' (with cached data) const color = getScoreColor(score.score); const factors = Array.isArray(score.factors) ? score.factors : (typeof score.factors === 'string' ? JSON.parse(score.factors) : []); const recommendations = Array.isArray(score.recommendations) ? score.recommendations : (typeof score.recommendations === 'string' ? JSON.parse(score.recommendations) : []); return ( {title} Health {onRefresh && ( )} {icon} {score.score} /100 } /> {score.label} {score.trajectory && ( {score.trajectory} )} {score.previous_score !== null && ( (prev: {score.previous_score}) )} {score.summary} {factors.slice(0, 3).map((f: any, i: number) => ( {f.name} ))} {(factors.length > 3 || recommendations.length > 0) && ( Details {factors.length > 0 && ( <> Factors {factors.map((f: any, i: number) => ( {f.name} {f.detail} ))} )} {recommendations.length > 0 && ( <> Recommendations {recommendations.map((r: any, i: number) => ( {r.priority} {r.text} ))} )} {score.calculated_at && ( Updated: {new Date(score.calculated_at).toLocaleString()} )} )} {score.calculated_at && ( Last updated {new Date(score.calculated_at).toLocaleDateString()} at {new Date(score.calculated_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {lastFailed && ( last analysis failed — showing cached data )} )} ); } interface DashboardData { total_cash: string; total_receivables: string; reserve_fund_balance: string; delinquent_units: number; recent_transactions: { id: string; entry_date: string; description: string; entry_type: string; amount: string; }[]; // Enhanced split data operating_cash: string; reserve_cash: string; operating_investments: string; reserve_investments: string; est_monthly_interest: string; interest_earned_ytd: string; planned_capital_spend: string; } export function DashboardPage() { const currentOrg = useAuthStore((s) => s.currentOrg); const queryClient = useQueryClient(); // Track whether a refresh is in progress (per score type) for async polling const [operatingRefreshing, setOperatingRefreshing] = useState(false); const [reserveRefreshing, setReserveRefreshing] = useState(false); const { data, isLoading } = useQuery({ queryKey: ['dashboard'], queryFn: async () => { const { data } = await api.get('/reports/dashboard'); return data; }, enabled: !!currentOrg, }); const { data: healthScores } = useQuery({ queryKey: ['health-scores'], queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; }, enabled: !!currentOrg, // Poll every 3 seconds while a refresh is in progress refetchInterval: (operatingRefreshing || reserveRefreshing) ? 3000 : false, }); // Async refresh handlers — trigger the backend and poll for results const handleRefreshOperating = useCallback(async () => { const prevId = healthScores?.operating?.id; setOperatingRefreshing(true); try { await api.post('/health-scores/calculate/operating'); } catch { // Trigger failed at network level — polling will pick up any backend-saved error } // Start polling — watch for the health score to change (new id or updated timestamp) const pollUntilDone = () => { const checkInterval = setInterval(async () => { try { const { data: latest } = await api.get('/health-scores/latest'); const newScore = latest?.operating; if (newScore && newScore.id !== prevId) { setOperatingRefreshing(false); queryClient.setQueryData(['health-scores'], latest); clearInterval(checkInterval); } } catch { // Keep polling } }, 3000); // Safety timeout — stop polling after 11 minutes setTimeout(() => { clearInterval(checkInterval); setOperatingRefreshing(false); }, 660000); }; pollUntilDone(); }, [healthScores?.operating?.id, queryClient]); const handleRefreshReserve = useCallback(async () => { const prevId = healthScores?.reserve?.id; setReserveRefreshing(true); try { await api.post('/health-scores/calculate/reserve'); } catch { // Trigger failed at network level } const pollUntilDone = () => { const checkInterval = setInterval(async () => { try { const { data: latest } = await api.get('/health-scores/latest'); const newScore = latest?.reserve; if (newScore && newScore.id !== prevId) { setReserveRefreshing(false); queryClient.setQueryData(['health-scores'], latest); clearInterval(checkInterval); } } catch { // Keep polling } }, 3000); setTimeout(() => { clearInterval(checkInterval); setReserveRefreshing(false); }, 660000); }; pollUntilDone(); }, [healthScores?.reserve?.id, queryClient]); const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); const opInv = parseFloat(data?.operating_investments || '0'); const resInv = parseFloat(data?.reserve_investments || '0'); const entryTypeColors: Record = { manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red', transfer: 'cyan', adjustment: 'yellow', closing: 'dark', opening_balance: 'indigo', }; return ( Dashboard {!currentOrg ? ( Welcome to HOA LedgerIQ Create or select an organization to get started. ) : isLoading ? (
) : ( <> AI Health Scores } isRefreshing={operatingRefreshing} onRefresh={handleRefreshOperating} lastFailed={!!healthScores?.operating_last_failed} /> } isRefreshing={reserveRefreshing} onRefresh={handleRefreshReserve} lastFailed={!!healthScores?.reserve_last_failed} />
Operating Fund {fmt(data?.operating_cash || '0')} {opInv > 0 && Investments: {fmt(opInv)}}
Reserve Fund {fmt(data?.reserve_cash || '0')} {resInv > 0 && Investments: {fmt(resInv)}}
Total Receivables {fmt(data?.total_receivables || '0')}
Delinquent Accounts {String(data?.delinquent_units || 0)}
Recent Transactions {(data?.recent_transactions || []).length === 0 ? ( No transactions yet. Start by entering journal entries. ) : ( {(data?.recent_transactions || []).map((tx) => ( {new Date(tx.entry_date).toLocaleDateString()} {tx.description} {tx.entry_type} {fmt(tx.amount)} ))}
)}
Quick Stats Operating Cash {fmt(data?.operating_cash || '0')} Reserve Cash {fmt(data?.reserve_cash || '0')} Est. Monthly Interest {fmt(data?.est_monthly_interest || '0')} Interest Earned YTD {fmt(data?.interest_earned_ytd || '0')} Planned Capital Spend {fmt(data?.planned_capital_spend || '0')} Outstanding AR {fmt(data?.total_receivables || '0')} Delinquent Units {data?.delinquent_units || 0}
)}
); }