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:
85
apps/mi-dia/src/components/BannerModoLocal.test.tsx
Normal file
85
apps/mi-dia/src/components/BannerModoLocal.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import BannerModoLocal from './BannerModoLocal'
|
||||
|
||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
/** Stub sencillo de localStorage (jsdom no lo expone como global con vitest 2) */
|
||||
function crearStorageStub(): Storage {
|
||||
const mapa = new Map<string, string>()
|
||||
return {
|
||||
getItem: (k: string) => mapa.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void mapa.set(k, v),
|
||||
removeItem: (k: string) => void mapa.delete(k),
|
||||
clear: () => mapa.clear(),
|
||||
key: () => null,
|
||||
get length() {
|
||||
return mapa.size
|
||||
},
|
||||
} as Storage
|
||||
}
|
||||
|
||||
const sesionFake = {
|
||||
token: 'jwt-de-prueba',
|
||||
usuario: { nombre: 'ERICK PEÑA', rol: 'vendedor', asignacion: null },
|
||||
}
|
||||
|
||||
const respuestaOk = (data: unknown) =>
|
||||
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
const TEXTO_BANNER = /Modo local — Monday no disponible/
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<FetchFn>()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.stubGlobal('localStorage', crearStorageStub())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('BannerModoLocal', () => {
|
||||
it('se muestra cuando /sistema/estado reporta Monday caído', async () => {
|
||||
localStorage.setItem('frasa_auth', JSON.stringify(sesionFake))
|
||||
fetchMock.mockResolvedValue(respuestaOk({ mondayOk: false, pendientesSync: 3 }))
|
||||
|
||||
render(<BannerModoLocal />)
|
||||
|
||||
expect(await screen.findByText(TEXTO_BANNER)).toBeTruthy()
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/sistema/estado')
|
||||
})
|
||||
|
||||
it('no se muestra cuando Monday está bien', async () => {
|
||||
localStorage.setItem('frasa_auth', JSON.stringify(sesionFake))
|
||||
fetchMock.mockResolvedValue(respuestaOk({ mondayOk: true, pendientesSync: 0 }))
|
||||
|
||||
render(<BannerModoLocal />)
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce())
|
||||
expect(screen.queryByText(TEXTO_BANNER)).toBeNull()
|
||||
})
|
||||
|
||||
it('no se muestra si la consulta falla (el banner nunca rompe la app)', async () => {
|
||||
localStorage.setItem('frasa_auth', JSON.stringify(sesionFake))
|
||||
fetchMock.mockRejectedValue(new TypeError('fetch failed'))
|
||||
|
||||
render(<BannerModoLocal />)
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce())
|
||||
expect(screen.queryByText(TEXTO_BANNER)).toBeNull()
|
||||
})
|
||||
|
||||
it('sin sesión no consulta el API ni se muestra', async () => {
|
||||
render(<BannerModoLocal />)
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText(TEXTO_BANNER)).toBeNull()
|
||||
})
|
||||
})
|
||||
42
apps/mi-dia/src/components/BannerModoLocal.tsx
Normal file
42
apps/mi-dia/src/components/BannerModoLocal.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchEstadoSistema } from '../services/sistema'
|
||||
|
||||
const INTERVALO_POLL_MS = 60_000
|
||||
|
||||
/** true mientras Monday esté caído (modo local); poll al montar y cada 60 s */
|
||||
export function useModoLocal(): boolean {
|
||||
const [modoLocal, setModoLocal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true
|
||||
const consultar = () => {
|
||||
void fetchEstadoSistema().then((estado) => {
|
||||
if (activo) setModoLocal(estado !== null && !estado.mondayOk)
|
||||
})
|
||||
}
|
||||
consultar()
|
||||
const id = setInterval(consultar, INTERVALO_POLL_MS)
|
||||
return () => {
|
||||
activo = false
|
||||
clearInterval(id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return modoLocal
|
||||
}
|
||||
|
||||
/**
|
||||
* Aviso discreto de "modo local": solo aparece cuando /sistema/estado reporta
|
||||
* Monday caído. No bloquea; todo sigue funcionando contra el espejo local.
|
||||
*/
|
||||
export default function BannerModoLocal() {
|
||||
const modoLocal = useModoLocal()
|
||||
if (!modoLocal) 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">
|
||||
Modo local — Monday no disponible. Tus capturas se guardan y se sincronizarán automáticamente.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
apps/mi-dia/src/components/ChipPendienteSync.tsx
Normal file
12
apps/mi-dia/src/components/ChipPendienteSync.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Badge "⏳ por sincronizar": el item se creó en modo local y aún no sube a
|
||||
* Monday (el API lo marca con pendienteSync: true). Las acciones funcionan
|
||||
* igual: el backend rutea los ids locales (loc_*).
|
||||
*/
|
||||
export default function ChipPendienteSync() {
|
||||
return (
|
||||
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700">
|
||||
⏳ por sincronizar
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export interface LeadMiDia {
|
||||
fuente: 'lead' | 'oportunidad'
|
||||
/** Último evento del activity log del board; null si el item no tiene eventos */
|
||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||
/** true cuando el item se creó en modo local y aún no se sincroniza a Monday */
|
||||
pendienteSync?: boolean
|
||||
}
|
||||
|
||||
export interface ActividadMiDia {
|
||||
@@ -38,6 +40,8 @@ export interface CapturaMiDia {
|
||||
estado: string // estado actual en Monday; '' si el lead ya no existe en el board
|
||||
origen: string
|
||||
capturadoEn: string // ISO
|
||||
/** true cuando la captura se creó en modo local y aún no se sincroniza a Monday */
|
||||
pendienteSync?: boolean
|
||||
}
|
||||
|
||||
export interface ClienteMiDia {
|
||||
@@ -63,6 +67,8 @@ export interface OportunidadMiDia {
|
||||
unidadCodigo: string
|
||||
updatedAt: string
|
||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||
/** true cuando el item se creó en modo local y aún no se sincroniza a Monday */
|
||||
pendienteSync?: boolean
|
||||
}
|
||||
|
||||
/** Apartado asignado al vendedor (tab "Mis apartados") */
|
||||
@@ -73,6 +79,8 @@ export interface ApartadoMiDia {
|
||||
estado: string
|
||||
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||
/** true cuando el item se creó en modo local y aún no se sincroniza a Monday */
|
||||
pendienteSync?: boolean
|
||||
}
|
||||
|
||||
export interface ResumenMiDia {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react'
|
||||
import KpiCard from '../components/KpiCard'
|
||||
import BannerModoLocal from '../components/BannerModoLocal'
|
||||
import ChipPendienteSync from '../components/ChipPendienteSync'
|
||||
import type { AccionesSesion } from '../components/AuthGate'
|
||||
import {
|
||||
esActividadVencida,
|
||||
@@ -187,7 +189,10 @@ function FilaOportunidad({
|
||||
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{op.nombre}</p>
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
{op.nombre}
|
||||
{op.pendienteSync === true && <ChipPendienteSync />}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#64748B]">
|
||||
{op.telefono || 'Sin teléfono'}
|
||||
{op.desarrollo ? ` · ${op.desarrollo}` : ''}
|
||||
@@ -285,10 +290,11 @@ function FilaApartado({
|
||||
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
{apartado.nombre}
|
||||
{apartado.pendienteSync === true && <ChipPendienteSync />}
|
||||
{apartado.unidadCodigo ? (
|
||||
<span className="ml-2 text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
|
||||
<span className="text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#64748B]">
|
||||
@@ -510,6 +516,7 @@ function FilaLead({
|
||||
<div>
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
{lead.nombre}
|
||||
{lead.pendienteSync === true && <ChipPendienteSync />}
|
||||
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
|
||||
<span
|
||||
className={
|
||||
@@ -757,6 +764,8 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<BannerModoLocal />
|
||||
|
||||
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-semibold text-[#111827]">{nombreVendedor}</h1>
|
||||
@@ -884,7 +893,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{c.nombre}</p>
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
{c.nombre}
|
||||
{c.pendienteSync === true && <ChipPendienteSync />}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#64748B]">
|
||||
{c.telefono || 'Sin teléfono'}
|
||||
{c.origen ? ` · ${c.origen}` : ''}
|
||||
|
||||
25
apps/mi-dia/src/services/sistema.ts
Normal file
25
apps/mi-dia/src/services/sistema.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Estado del sistema (GET /sistema/estado): salud de Monday y cola de sincronización
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /sistema/estado. Devuelve null si no hay sesión o si la consulta falla:
|
||||
* el banner de modo local es informativo y nunca debe romper la app (ni
|
||||
* provocar logout/reload por un 401 del poll).
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user