feat(modo-local): fallback completo sin Monday + cola de sincronización

- Circuit breaker saludMonday (3 fallos → modo local; ping cada 60s)
- Endpoint GET /api/sistema/estado {mondayOk, pendientesSync}
- LeadCaptura extendida (board, etapa, origen, motivo, pendienteSync)
- Tablas Unidad (espejo inventario, 288 seed) + SyncPendiente
- crmLocal: crearLead, afluencia, cambio etapa, descarte, conversión, apartar
- Ruteo en controllers: fallback local ante 502 o Monday caído
- Mi día local: resumen completo desde SQLite + unión con Monday
- Fallbacks: unidades, contactos, reportes, prefill, buscador
- Sync worker cada 2min + handlers 15 tipos (con DependenciaPendienteError)
- Gate automatizaciones: skip si !mondayOk
- Banner 'Modo local' en 5 apps + badge pendiente en mi-dia
- 597 API tests + 263 front tests verdes

Env MODO_LOCAL_FORZADO=true para testing sin Monday.
This commit is contained in:
2026-09-06 08:08:56 +00:00
parent af2fb10956
commit 60649890eb
70 changed files with 8763 additions and 240 deletions

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react'
import { fetchEstadoSistema, type EstadoSistema } from '../services/sistema'
const INTERVALO_POLL_MS = 60_000
export function useEstadoSistema(): EstadoSistema | null | undefined {
const [estado, setEstado] = useState<EstadoSistema | null | undefined>(undefined)
useEffect(() => {
let activo = true
const consultar = () => {
void fetchEstadoSistema().then((e) => { if (activo) setEstado(e) })
}
consultar()
const id = setInterval(consultar, INTERVALO_POLL_MS)
return () => { activo = false; clearInterval(id) }
}, [])
return estado
}
export function useModoLocal(): boolean {
const estado = useEstadoSistema()
return estado !== undefined && estado !== null && !estado.mondayOk
}
export default function BannerModoLocal({ parcial }: { parcial?: boolean }) {
const modoLocal = useModoLocal()
if (!modoLocal && !parcial) return null
return (
<div role="status" className="border-b border-amber-200 bg-amber-50">
<p className="mx-auto max-w-[1280px] px-4 py-2 text-xs font-medium text-amber-700 sm:px-6">
{modoLocal
? 'Modo local — Monday no disponible. Tus capturas se guardan y se sincronizarán automáticamente.'
: 'Datos parciales (modo local)'}
</p>
</div>
)
}

View File

@@ -4,6 +4,7 @@ import TableroGeneral from '../components/TableroGeneral'
import type { AccionesTablero } from '../components/comunes'
import { esRolConEquipo } from '../lib/mi-tablero'
import { getMe, getSesion } from '../services/auth'
import BannerModoLocal from '../components/BannerModoLocal'
type Vista = 'mia' | 'general'
@@ -43,6 +44,7 @@ export default function DashboardPage() {
return (
<div>
<BannerModoLocal />
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-[1280px] flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-2 sm:px-6">
<div className="flex items-center gap-2.5">

View File

@@ -0,0 +1,19 @@
import { conAuth, getToken } from './auth'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
export interface EstadoSistema {
mondayOk: boolean
pendientesSync: number
}
export async function fetchEstadoSistema(): Promise<EstadoSistema | null> {
if (!getToken()) return null
try {
const res = await fetch(`${API_URL}/sistema/estado`, conAuth())
if (!res.ok) return null
return (await res.json()) as EstadoSistema
} catch {
return null
}
}