Add dark mode support using Mantine's built-in color scheme system, persisted via a new Zustand preferences store. Includes a quick toggle in the app header and an enabled switch in User Preferences. Also removes the "AI Health Scores" title from the dashboard to reclaim vertical space. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
566 lines
22 KiB
TypeScript
566 lines
22 KiB
TypeScript
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 <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,
|
|
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 (
|
|
<Card withBorder padding="lg" radius="md">
|
|
<Group justify="space-between" mb="xs">
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
|
<Group gap={6}>
|
|
{onRefresh && (
|
|
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
|
|
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
|
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
|
|
</Tooltip>
|
|
)}
|
|
{icon}
|
|
</Group>
|
|
</Group>
|
|
<Center h={100}>
|
|
<Text c="dimmed" size="sm">No health score yet</Text>
|
|
</Center>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<Card withBorder padding="lg" radius="md">
|
|
<Group justify="space-between" mb="xs">
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
|
<Group gap={6}>
|
|
{onRefresh && (
|
|
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
|
|
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
|
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
|
|
</Tooltip>
|
|
)}
|
|
{icon}
|
|
</Group>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<Card withBorder padding="lg" radius="md">
|
|
<Group justify="space-between" mb="xs">
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
|
<Group gap={6}>
|
|
{onRefresh && (
|
|
<Tooltip label={`Retry ${title.toLowerCase()} score`}>
|
|
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
|
loading={isRefreshing} onClick={onRefresh}>Retry</Button>
|
|
</Tooltip>
|
|
)}
|
|
{icon}
|
|
</Group>
|
|
</Group>
|
|
<Center h={100}>
|
|
<Stack align="center" gap={4}>
|
|
<Badge color="red" variant="light">Error calculating score</Badge>
|
|
<Text size="xs" c="dimmed">Click Retry to try again</Text>
|
|
</Stack>
|
|
</Center>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<Card withBorder padding="lg" radius="md">
|
|
<Group justify="space-between" mb="xs">
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
|
<Group gap={6}>
|
|
{onRefresh && (
|
|
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
|
|
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
|
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
|
|
</Tooltip>
|
|
)}
|
|
{icon}
|
|
</Group>
|
|
</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>
|
|
{score.calculated_at && (
|
|
<Stack gap={0} mt={6} align="flex-end">
|
|
<Text size="10px" c="dimmed" style={{ opacity: 0.7 }}>
|
|
Last updated {new Date(score.calculated_at).toLocaleDateString()} at {new Date(score.calculated_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
</Text>
|
|
{lastFailed && (
|
|
<Text size="10px" c="orange" fw={500} style={{ opacity: 0.85 }}>
|
|
last analysis failed — showing cached data
|
|
</Text>
|
|
)}
|
|
</Stack>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
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<DashboardData>({
|
|
queryKey: ['dashboard'],
|
|
queryFn: async () => { const { data } = await api.get('/reports/dashboard'); return data; },
|
|
enabled: !!currentOrg,
|
|
});
|
|
|
|
const { data: healthScores } = useQuery<HealthScoresData>({
|
|
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<string, string> = {
|
|
manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red',
|
|
transfer: 'cyan', adjustment: 'yellow', closing: 'dark', opening_balance: 'indigo',
|
|
};
|
|
|
|
return (
|
|
<Stack data-tour="dashboard-content">
|
|
<Title order={2}>Dashboard</Title>
|
|
|
|
{!currentOrg ? (
|
|
<Card withBorder p="xl" ta="center">
|
|
<Text size="lg" fw={500}>Welcome to HOA LedgerIQ</Text>
|
|
<Text c="dimmed" mt="sm">
|
|
Create or select an organization to get started.
|
|
</Text>
|
|
</Card>
|
|
) : isLoading ? (
|
|
<Center h={200}><Loader /></Center>
|
|
) : (
|
|
<>
|
|
<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>
|
|
}
|
|
isRefreshing={operatingRefreshing}
|
|
onRefresh={handleRefreshOperating}
|
|
lastFailed={!!healthScores?.operating_last_failed}
|
|
/>
|
|
<HealthScoreCard
|
|
score={healthScores?.reserve || null}
|
|
title="Reserve Fund"
|
|
icon={
|
|
<ThemeIcon color="violet" variant="light" size={36} radius="md">
|
|
<IconHeartbeat size={20} />
|
|
</ThemeIcon>
|
|
}
|
|
isRefreshing={reserveRefreshing}
|
|
onRefresh={handleRefreshReserve}
|
|
lastFailed={!!healthScores?.reserve_last_failed}
|
|
/>
|
|
</SimpleGrid>
|
|
|
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
|
<Card withBorder padding="lg" radius="md">
|
|
<Group justify="space-between">
|
|
<div>
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Fund</Text>
|
|
<Text fw={700} size="xl">{fmt(data?.operating_cash || '0')}</Text>
|
|
{opInv > 0 && <Text size="xs" c="teal">Investments: {fmt(opInv)}</Text>}
|
|
</div>
|
|
<ThemeIcon color="green" variant="light" size={48} radius="md">
|
|
<IconCash size={28} />
|
|
</ThemeIcon>
|
|
</Group>
|
|
</Card>
|
|
<Card withBorder padding="lg" radius="md">
|
|
<Group justify="space-between">
|
|
<div>
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Fund</Text>
|
|
<Text fw={700} size="xl">{fmt(data?.reserve_cash || '0')}</Text>
|
|
{resInv > 0 && <Text size="xs" c="teal">Investments: {fmt(resInv)}</Text>}
|
|
</div>
|
|
<ThemeIcon color="violet" variant="light" size={48} radius="md">
|
|
<IconShieldCheck size={28} />
|
|
</ThemeIcon>
|
|
</Group>
|
|
</Card>
|
|
<Card withBorder padding="lg" radius="md">
|
|
<Group justify="space-between">
|
|
<div>
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Receivables</Text>
|
|
<Text fw={700} size="xl">{fmt(data?.total_receivables || '0')}</Text>
|
|
</div>
|
|
<ThemeIcon color="blue" variant="light" size={48} radius="md">
|
|
<IconFileInvoice size={28} />
|
|
</ThemeIcon>
|
|
</Group>
|
|
</Card>
|
|
<Card withBorder padding="lg" radius="md">
|
|
<Group justify="space-between">
|
|
<div>
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Delinquent Accounts</Text>
|
|
<Text fw={700} size="xl">{String(data?.delinquent_units || 0)}</Text>
|
|
</div>
|
|
<ThemeIcon color="orange" variant="light" size={48} radius="md">
|
|
<IconAlertTriangle size={28} />
|
|
</ThemeIcon>
|
|
</Group>
|
|
</Card>
|
|
</SimpleGrid>
|
|
|
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
|
<Card withBorder padding="lg" radius="md">
|
|
<Title order={4} mb="sm">Recent Transactions</Title>
|
|
{(data?.recent_transactions || []).length === 0 ? (
|
|
<Text c="dimmed" size="sm">No transactions yet. Start by entering journal entries.</Text>
|
|
) : (
|
|
<Table striped highlightOnHover>
|
|
<Table.Tbody>
|
|
{(data?.recent_transactions || []).map((tx) => (
|
|
<Table.Tr key={tx.id}>
|
|
<Table.Td>
|
|
<Text size="xs" c="dimmed">{new Date(tx.entry_date).toLocaleDateString()}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm" lineClamp={1}>{tx.description}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge size="xs" color={entryTypeColors[tx.entry_type] || 'gray'} variant="light">
|
|
{tx.entry_type}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td ta="right" ff="monospace" fw={500}>
|
|
{fmt(tx.amount)}
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
)}
|
|
</Card>
|
|
<Card withBorder padding="lg" radius="md">
|
|
<Title order={4}>Quick Stats</Title>
|
|
<Stack mt="sm" gap="xs">
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Operating Cash</Text>
|
|
<Text size="sm" fw={500} c="green">{fmt(data?.operating_cash || '0')}</Text>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Reserve Cash</Text>
|
|
<Text size="sm" fw={500} c="violet">{fmt(data?.reserve_cash || '0')}</Text>
|
|
</Group>
|
|
<Divider my={4} />
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Est. Monthly Interest</Text>
|
|
<Text size="sm" fw={500} c="blue">{fmt(data?.est_monthly_interest || '0')}</Text>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Interest Earned YTD</Text>
|
|
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_earned_ytd || '0')}</Text>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Planned Capital Spend</Text>
|
|
<Text size="sm" fw={500} c="orange">{fmt(data?.planned_capital_spend || '0')}</Text>
|
|
</Group>
|
|
<Divider my={4} />
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Outstanding AR</Text>
|
|
<Text size="sm" fw={500} c="blue">{fmt(data?.total_receivables || '0')}</Text>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Delinquent Units</Text>
|
|
<Text size="sm" fw={500} c={data?.delinquent_units ? 'red' : 'green'}>
|
|
{data?.delinquent_units || 0}
|
|
</Text>
|
|
</Group>
|
|
</Stack>
|
|
</Card>
|
|
</SimpleGrid>
|
|
</>
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|