feat(purga): Monday eliminado completamente del sistema

14 servicios eliminados, 7 controllers local-only, crons locales,
config sin MONDAY_*, frontend sin banners/avisos Monday.
552 tests API + 7 apps construidas.
This commit is contained in:
2026-09-11 11:47:17 +00:00
parent 64b01246d6
commit 89d95eb53b
130 changed files with 2018 additions and 14747 deletions

View File

@@ -1,85 +0,0 @@
// @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()
})
})

View File

@@ -1,42 +0,0 @@
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>
)
}

View File

@@ -1,12 +0,0 @@
/**
* 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>
)
}

View File

@@ -151,7 +151,7 @@ export function aplicarFiltros(
if (accessor.origen(item) !== filtros.origen) return false
}
// Estado (comparación case-insensitive: Monday devuelve labels con mayúsculas variables)
// Estado (comparación case-insensitive: los labels pueden variar en mayúsculas)
if (filtros.estado !== '' && accessor.estado) {
if (accessor.estado(item).trim().toLowerCase() !== filtros.estado.trim().toLowerCase()) return false
}

View File

@@ -14,7 +14,7 @@ import {
} from './mi-dia'
describe('catálogos', () => {
it('orígenes = vocabulario FRASA sembrado en Monday (Leads y Oportunidades)', () => {
it('orígenes = vocabulario FRASA (captura ligera y afluencia)', () => {
expect([...ORIGENES_LEAD]).toEqual(['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'])
})

View File

@@ -9,12 +9,10 @@ export interface LeadMiDia {
origen: string
desarrollo?: string
updatedAt: string
/** Tablero del item: 'lead' (tablero Leads) u 'oportunidad' (Oportunidades) */
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
fuente: 'lead' | 'oportunidad'
/** Último evento del activity log del board; null si el item no tiene eventos */
/** Último cambio conocido; null si no hay */
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,11 +36,9 @@ export interface CapturaMiDia {
leadId: string
nombre: string
telefono: string
estado: string // estado actual en Monday; '' si el lead ya no existe en el board
estado: string
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 {
@@ -52,7 +48,7 @@ export interface ClienteMiDia {
unidadCodigo: string
completado: boolean
capturadoEn: string // ISO
/** Estado actual del item en el tablero Apartado (Monday); null si no hay apartado ligado */
/** Estado actual del apartado ligado; null si no hay apartado ligado */
estadoApartado: string | null
}
@@ -70,8 +66,6 @@ export interface OportunidadMiDia {
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** Fecha y hora de la cita agendada (ISO local, ej. '2026-09-09T14:30') */
fechaCita?: string
/** 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") */
@@ -82,13 +76,10 @@ 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 {
generadoEn: string
sinMapeoMonday: boolean
misLeads: LeadMiDia[]
misCapturas: CapturaMiDia[]
misClientes: ClienteMiDia[]
@@ -104,7 +95,7 @@ export interface ResumenMiDia {
equipo?: {
leadsNuevosSinAsignar: number
porVendedor: {
mondayUserId: number
usuarioId: string
nombre: string | null
leadsAsignados: number
leadsNuevosSemana: number
@@ -143,10 +134,9 @@ export const VOCABULARIO = {
Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'],
Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'],
},
// Labels de la columna 'Origen' (board Leads) / 'Origen del Lead'
// (Oportunidades), verificados y sembrados en Monday el 2026-09-04
// Labels de origen (captura ligera y afluencia), verificados el 2026-09-04
ORIGENES: ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'],
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
// Labels oficiales de desarrollo
DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'],
} as const
@@ -255,8 +245,7 @@ export type SlotDocApartado = (typeof SLOTS_DOCS_APARTADO)[number]['tipo']
/**
* URL del cotizador (/quote/) con los datos del cliente prellenados y el id de
* la oportunidad: al guardar, la cotización queda vinculada a ese cliente en
* Monday (el API actualiza ese item en vez de crear uno nuevo).
* la oportunidad: al guardar, la cotización queda ligada a ese cliente.
*/
export function urlCotizadorParaOportunidad(op: OportunidadMiDia): string {
const params = new URLSearchParams()

View File

@@ -901,7 +901,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
/**
* Botón "Cotizar": pasa la oportunidad a etapa Cotizador y abre el cotizador
* con los datos del cliente prellenados y la oportunidad vinculada (al
* guardar, la cotización actualiza ESE item en Monday). Si el cambio de etapa
* guardar, la cotización queda ligada a ese cliente). Si el cambio de etapa
* falla, el cotizador se abre igual: el guardado reintenta la actualización.
*/
async function cotizarOportunidad(op: OportunidadMiDia) {
@@ -910,7 +910,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
try {
await cambiarEtapaOportunidad(op.id, 'Cotizador')
} catch {
toast.error('No se pudo actualizar la etapa en Monday; el cotizador se abrirá de todos modos.')
toast.error('No se pudo actualizar la etapa; el cotizador se abrirá de todos modos.')
}
window.open(urlCotizadorParaOportunidad(op), '_blank')
}
@@ -1065,12 +1065,6 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
{aviso}
</p>
)}
{resumen?.sinMapeoMonday && (
<p className="mb-6 rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-sm font-medium text-[#64748B] dark:text-[#94A3B8] shadow-sm dark:bg-[#1E293B] dark:border-[#1E293B]">
Tu usuario aún no está vinculado a Monday; las acciones siguen funcionando
</p>
)}
{!resumen && cargando && <SkeletonList count={4} />}
{resumen && (
@@ -1392,7 +1386,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</p>
</div>
<div className="flex items-center gap-3">
{/* Estado del apartado en Monday (bidireccional); solo si hay apartado ligado */}
{/* Estado del apartado; solo si hay apartado ligado */}
{c.estadoApartado !== null && (
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
{c.estadoApartado}
@@ -1616,10 +1610,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{resumen.equipo.porVendedor.map((v) => (
<li
key={v.mondayUserId}
key={v.usuarioId}
className="flex items-center justify-between rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm dark:bg-[#1E293B] dark:border-[#1E293B]"
>
<p className="text-sm">{v.nombre ?? `Monday #${v.mondayUserId}`}</p>
<p className="text-sm">{v.nombre}</p>
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
{v.leadsAsignados} lead(s)
</span>

View File

@@ -1,25 +0,0 @@
// 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 || '/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
}
}