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

@@ -23,6 +23,7 @@ import Salarios from "./pages/Salarios";
import Pensions from "./pages/Pensions";
import Proyecciones from "./pages/Proyecciones";
import ServiciosMunicipales from "./pages/ServiciosMunicipales";
import SyncStatus from "./pages/SyncStatus";
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
@@ -56,6 +57,7 @@ function AppRoutes() {
<Route path="/salarios" element={<Salarios />} />
<Route path="/pensions" element={<Pensions />} />
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
<Route path="/sync" element={<SyncStatus />} />
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
</Route>

View File

@@ -11,12 +11,16 @@ import {
TrendingUp,
Wallet,
Menu,
RefreshCw,
Sun,
Moon,
Eye,
EyeOff,
type LucideIcon,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { getSyncStatus } from "@/lib/api";
import { useTheme } from "@/contexts/theme-context";
import { usePrivacy } from "@/contexts/privacy-context";
import { useAuth } from "@/AuthContext";
@@ -39,7 +43,10 @@ interface NavSection {
const navSections: NavSection[] = [
{
label: "General",
items: [{ to: "/asistente", icon: Sparkles, label: "Asistente" }],
items: [
{ to: "/asistente", icon: Sparkles, label: "Asistente" },
{ to: "/sync", icon: RefreshCw, label: "Sincronización" },
],
},
{
label: "Finanzas",
@@ -64,6 +71,14 @@ function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
const isActive = (to: string) =>
pathname === to || pathname.startsWith(`${to}/`);
// Surface stalled ingestion sources as a dot on the Sincronización item.
const syncQ = useQuery({
queryKey: ['sync-status'],
queryFn: () => getSyncStatus().then((r) => r.data),
staleTime: 5 * 60_000,
});
const syncWarnings = syncQ.data?.warnings ?? 0;
return (
<nav className="flex flex-col gap-0.5 px-3">
{navSections.map((section) => (
@@ -85,6 +100,13 @@ function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
>
<Icon className="w-4 h-4" />
{label}
{to === "/sync" && syncWarnings > 0 && (
<span
className="ml-auto w-2 h-2 rounded-full bg-amber-500"
title={`${syncWarnings} fuente(s) atrasada(s)`}
aria-label={`${syncWarnings} fuentes de datos atrasadas`}
/>
)}
</Link>
))}
</div>
@@ -129,10 +151,10 @@ export default function Layout() {
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={togglePrivacy} title="Toggle privacy mode" aria-label="Toggle privacy mode">
<Button variant="ghost" size="icon" onClick={togglePrivacy} title="Toggle privacy mode" aria-label="Toggle privacy mode" aria-pressed={privacyMode}>
{privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
<Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme">
<Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme" aria-pressed={theme === "dark"}>
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
<Button

View File

@@ -485,3 +485,22 @@ export const getWaterConsumption = (months?: number) =>
api.get<WaterMeterReading[]>("/municipal-receipts/water-consumption", {
params: months ? { months } : undefined,
});
// --- Sync Status ---
export interface SyncSource {
key: string;
label: string;
description: string;
last_received: string | null;
age_days: number | null;
warn_after_days: number;
status: 'ok' | 'warning' | 'never';
}
export interface SyncStatusResponse {
sources: SyncSource[];
warnings: number;
}
export const getSyncStatus = () => api.get<SyncStatusResponse>('/sync-status/');

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>
);
}