Phase 8: AI-driven operating and reserve fund health scores
Add daily AI health score calculation (0-100) for both operating and reserve funds. Scores include trajectory tracking, factor analysis, recommendations, and data readiness checks. Dashboard displays graphical RingProgress gauges with color-coded scores, trend indicators, and expandable detail popovers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
|
||||
Badge, Loader, Center, Divider,
|
||||
Badge, Loader, Center, Divider, RingProgress, Tooltip, Button,
|
||||
Popover, List,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCash,
|
||||
@@ -8,11 +9,215 @@ import {
|
||||
IconShieldCheck,
|
||||
IconAlertTriangle,
|
||||
IconBuildingBank,
|
||||
IconTrendingUp,
|
||||
IconTrendingDown,
|
||||
IconMinus,
|
||||
IconHeartbeat,
|
||||
IconRefresh,
|
||||
IconInfoCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, 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;
|
||||
}
|
||||
|
||||
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 <IconTrendingUp size={16} color="var(--mantine-color-green-6)" />;
|
||||
if (trajectory === 'declining') return <IconTrendingDown size={16} color="var(--mantine-color-red-6)" />;
|
||||
if (trajectory === 'stable') return <IconMinus size={16} color="var(--mantine-color-gray-6)" />;
|
||||
return null;
|
||||
}
|
||||
|
||||
function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; title: string; icon: React.ReactNode }) {
|
||||
if (!score) {
|
||||
return (
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||
{icon}
|
||||
</Group>
|
||||
<Center h={100}>
|
||||
<Text c="dimmed" size="sm">No health score yet</Text>
|
||||
</Center>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||
{icon}
|
||||
</Group>
|
||||
<Center>
|
||||
<Stack align="center" gap="xs">
|
||||
<Badge color="gray" variant="light" size="lg">Pending</Badge>
|
||||
<Text size="xs" c="dimmed" ta="center">Missing data:</Text>
|
||||
{missingItems.map((item: string, i: number) => (
|
||||
<Text key={i} size="xs" c="dimmed" ta="center">{item}</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (score.status === 'error') {
|
||||
return (
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||
{icon}
|
||||
</Group>
|
||||
<Center h={100}>
|
||||
<Badge color="red" variant="light">Error calculating score</Badge>
|
||||
</Center>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||
{icon}
|
||||
</Group>
|
||||
<Group align="flex-start" gap="lg">
|
||||
<RingProgress
|
||||
size={120}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: score.score, color }]}
|
||||
label={
|
||||
<Stack align="center" gap={0}>
|
||||
<Text fw={700} size="xl" ta="center" lh={1}>{score.score}</Text>
|
||||
<Text size="xs" c="dimmed" ta="center">/100</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6}>
|
||||
<Badge color={color} variant="light" size="sm">{score.label}</Badge>
|
||||
{score.trajectory && (
|
||||
<Tooltip label={`Trend: ${score.trajectory}`}>
|
||||
<Group gap={2}>
|
||||
<TrajectoryIcon trajectory={score.trajectory} />
|
||||
<Text size="xs" c="dimmed">{score.trajectory}</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
)}
|
||||
{score.previous_score !== null && (
|
||||
<Text size="xs" c="dimmed">(prev: {score.previous_score})</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="sm" lineClamp={2}>{score.summary}</Text>
|
||||
<Group gap={4} mt={2}>
|
||||
{factors.slice(0, 3).map((f: any, i: number) => (
|
||||
<Tooltip key={i} label={f.detail} multiline w={280}>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="dot"
|
||||
color={f.impact === 'positive' ? 'green' : f.impact === 'negative' ? 'red' : 'gray'}
|
||||
>
|
||||
{f.name}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
))}
|
||||
{(factors.length > 3 || recommendations.length > 0) && (
|
||||
<Popover width={350} position="bottom" shadow="md">
|
||||
<Popover.Target>
|
||||
<Badge size="xs" variant="light" color="blue" style={{ cursor: 'pointer' }}>
|
||||
<IconInfoCircle size={10} /> Details
|
||||
</Badge>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
{factors.length > 0 && (
|
||||
<>
|
||||
<Text fw={600} size="xs">Factors</Text>
|
||||
{factors.map((f: any, i: number) => (
|
||||
<Group key={i} gap={6} wrap="nowrap">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="dot"
|
||||
color={f.impact === 'positive' ? 'green' : f.impact === 'negative' ? 'red' : 'gray'}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{f.name}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">{f.detail}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{recommendations.length > 0 && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<Text fw={600} size="xs">Recommendations</Text>
|
||||
<List size="xs" spacing={4}>
|
||||
{recommendations.map((r: any, i: number) => (
|
||||
<List.Item key={i}>
|
||||
<Badge size="xs" color={r.priority === 'high' ? 'red' : r.priority === 'medium' ? 'yellow' : 'blue'} variant="light" mr={4}>
|
||||
{r.priority}
|
||||
</Badge>
|
||||
{r.text}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
{score.calculated_at && (
|
||||
<Text size="xs" c="dimmed" ta="right" mt={4}>
|
||||
Updated: {new Date(score.calculated_at).toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
total_cash: string;
|
||||
total_receivables: string;
|
||||
@@ -33,6 +238,7 @@ interface DashboardData {
|
||||
|
||||
export function DashboardPage() {
|
||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery<DashboardData>({
|
||||
queryKey: ['dashboard'],
|
||||
@@ -40,6 +246,19 @@ export function DashboardPage() {
|
||||
enabled: !!currentOrg,
|
||||
});
|
||||
|
||||
const { data: healthScores } = useQuery<HealthScoresData>({
|
||||
queryKey: ['health-scores'],
|
||||
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
|
||||
enabled: !!currentOrg,
|
||||
});
|
||||
|
||||
const recalcMutation = useMutation({
|
||||
mutationFn: () => api.post('/health-scores/calculate'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||
},
|
||||
});
|
||||
|
||||
const fmt = (v: string | number) =>
|
||||
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
@@ -66,6 +285,41 @@ export function DashboardPage() {
|
||||
<Center h={200}><Loader /></Center>
|
||||
) : (
|
||||
<>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" fw={600} c="dimmed">AI Health Scores</Text>
|
||||
<Tooltip label="Recalculate health scores now">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
loading={recalcMutation.isPending}
|
||||
onClick={() => recalcMutation.mutate()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||
<HealthScoreCard
|
||||
score={healthScores?.operating || null}
|
||||
title="Operating Fund"
|
||||
icon={
|
||||
<ThemeIcon color="green" variant="light" size={36} radius="md">
|
||||
<IconHeartbeat size={20} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
/>
|
||||
<HealthScoreCard
|
||||
score={healthScores?.reserve || null}
|
||||
title="Reserve Fund"
|
||||
icon={
|
||||
<ThemeIcon color="violet" variant="light" size={36} radius="md">
|
||||
<IconHeartbeat size={20} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between">
|
||||
|
||||
Reference in New Issue
Block a user