Sync Status: per-source ingestion health page with nav warning badge

The n8n flows fail invisibly from the app's perspective (review UX-17,
the top trust gap). /api/v1/sync-status derives last-received + cadence
thresholds for BAC, salary, municipal, pensions and exchange rate from
existing data — no n8n integration needed. New Sincronización page
shows per-source freshness and a hint to check n8n; the sidebar item
gets an amber dot when any source is stale. Verified live against the
dev DB (correctly flags its stale BAC data at 43 days).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-10 18:03:55 -06:00
parent 8b7f3dff40
commit 4ceb67564a
8 changed files with 392 additions and 3 deletions

View File

@@ -0,0 +1,118 @@
import { useQuery } from '@tanstack/react-query';
import {
AlertTriangle,
CheckCircle2,
HelpCircle,
RefreshCw,
} from 'lucide-react';
import { getSyncStatus, type SyncSource } from '@/lib/api';
import { formatRelativeAge } from '@/lib/dates';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import ErrorState from '@/components/ErrorState';
import { Skeleton } from '@/components/ui/skeleton';
function StatusIcon({ status }: { status: SyncSource['status'] }) {
if (status === 'ok')
return <CheckCircle2 className="w-5 h-5 text-emerald-500" aria-hidden="true" />;
if (status === 'warning')
return <AlertTriangle className="w-5 h-5 text-amber-500" aria-hidden="true" />;
return <HelpCircle className="w-5 h-5 text-muted-foreground" aria-hidden="true" />;
}
function statusLabel(s: SyncSource): string {
if (s.status === 'never') return 'Nunca recibido';
if (s.status === 'warning')
return `Sin datos nuevos (esperado cada ~${s.warn_after_days} días)`;
return 'Al día';
}
export default function SyncStatus() {
const query = useQuery({
queryKey: ['sync-status'],
queryFn: () => getSyncStatus().then((r) => r.data),
staleTime: 5 * 60_000,
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RefreshCw className="w-6 h-6 text-primary" aria-hidden="true" />
<div>
<h1 className="text-2xl font-bold tracking-tight">Sincronización</h1>
<p className="text-sm text-muted-foreground">
Estado de los flujos automáticos de datos (n8n y tipo de cambio)
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw
className={query.isFetching ? 'w-4 h-4 mr-2 animate-spin' : 'w-4 h-4 mr-2'}
aria-hidden="true"
/>
Actualizar
</Button>
</div>
{query.isError ? (
<ErrorState
message="No se pudo consultar el estado de sincronización"
onRetry={() => query.refetch()}
/>
) : query.isPending ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
) : (
<div className="space-y-3">
{query.data.sources.map((s) => (
<Card
key={s.key}
className={
s.status !== 'ok' ? 'border-amber-500/40' : undefined
}
>
<CardContent className="p-4 flex items-center gap-4">
<StatusIcon status={s.status} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{s.label}</span>
{s.status !== 'ok' && (
<Badge variant="destructive">Atención</Badge>
)}
</div>
<p className="text-sm text-muted-foreground truncate">
{s.description}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-medium">{statusLabel(s)}</p>
<p className="text-xs text-muted-foreground">
{s.last_received
? `Último: ${formatRelativeAge(s.last_received)}`
: '—'}
</p>
</div>
</CardContent>
</Card>
))}
<p className="text-xs text-muted-foreground">
Si una fuente aparece atrasada, revisá el flujo correspondiente en
n8n un cambio de formato en los correos del banco es la causa más
común.
</p>
</div>
)}
</div>
);
}