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

@@ -20,7 +20,7 @@ interface Props {
onPasarAAltaCliente: (lead: LeadResumen) => void
/** Prefill desde Mis oportunidades (Mi día): valores iniciales del formulario */
valoresIniciales?: Partial<typeof FORM_INICIAL>
/** Oportunidad de Monday a ACTUALIZAR al enviar (sin duplicar el item) */
/** Oportunidad a ACTUALIZAR al enviar (sin duplicar el registro) */
oportunidadId?: string
}
@@ -62,7 +62,7 @@ const FORM_INICIAL = {
/**
* Captura ligera del interesado al entrar al embudo (POST /leads): crea el
* lead en Monday y su oportunidad en etapa Afluencia. El alta completa de
* lead y su oportunidad en etapa Afluencia. El alta completa de
* cliente sigue siendo el wizard del picker.
*/
export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valoresIniciales, oportunidadId }: Props) {
@@ -90,7 +90,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
MODELOS_POR_PROYECTO[form.desarrollo as keyof typeof MODELOS_POR_PROYECTO] ?? []
/**
* Jala los datos de un lead u oportunidad ya capturados en Monday:
* Jala los datos de un lead u oportunidad ya capturados:
* nombre/contacto siempre; origen, desarrollo y modelo solo si el valor del
* lead existe entre las opciones del formulario.
*/
@@ -267,13 +267,13 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-xs font-medium text-blue-800"
>
Estás completando la afluencia de {form.nombre || 'esta oportunidad'} — al enviar se actualiza su
oportunidad en Monday (no se duplica).
oportunidad ya capturada (no se duplica).
</p>
) : (
<div className="mb-6 border-b border-[#E5E7EB] pb-6">
<BusquedaLead onSeleccion={prellenarDesdeLead} />
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">
Si el interesado ya está en Monday (lead u oportunidad), sus datos se prellenan solos.
Si el interesado ya está capturado (lead u oportunidad), sus datos se prellenan solos.
</p>
</div>
)}

View File

@@ -1,32 +0,0 @@
import { useEffect, useState } from 'react'
import { fetchEstadoSistema } from '../services/sistema'
const INTERVALO_POLL_MS = 60_000
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
}
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

@@ -67,6 +67,6 @@ describe('BusquedaLead', () => {
fetchMock.mockRejectedValueOnce(new Error('red caída'))
fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'otro' } })
expect((await screen.findByRole('alert')).textContent).toContain('No se pudo buscar en Monday')
expect((await screen.findByRole('alert')).textContent).toContain('No se pudo buscar')
})
})

View File

@@ -8,7 +8,7 @@ interface Props {
}
/**
* Buscador con debounce de leads/afluencias de Monday (GET /leads/buscar,
* Buscador con debounce de leads/afluencias (GET /leads/buscar,
* búsqueda dual: tablero Leads histórico + Oportunidades). No llama al API
* con menos de 2 caracteres; al elegir un resultado avisa con onSeleccion.
*/
@@ -35,7 +35,7 @@ export default function BusquedaLead({ onSeleccion }: Props) {
})
.catch(() => {
setResultados(null)
setError('No se pudo buscar en Monday. Intenta de nuevo.')
setError('No se pudo buscar. Intenta de nuevo.')
})
.finally(() => setBuscando(false))
}, DEBOUNCE_MS)

View File

@@ -94,7 +94,7 @@ export const SLOTS_DOCUMENTOS: { slot: SlotDocumento; etiqueta: string }[] = [
export const TAMANO_MAX_DOCUMENTO = 10 * 1024 * 1024 // 10 MB
// Catálogos de Monday (labels exactos). Origen: vocabulario FRASA sembrado en
// Catálogos (labels exactos). Origen: vocabulario FRASA sembrado en
// los tableros Leads y Oportunidades el 2026-09-04 (reemplaza al set anterior)
export const ORIGENES_LEAD = ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'] as const
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const

View File

@@ -25,7 +25,7 @@ const base: PrefillApartado = {
cotizacion: { tipoPropuesta: 'INFONAVIT', acabados: true, precioFinal: 2646000, enganche: 132300 },
}
describe('apartadoAPrellenado (alta desde apartado de Monday)', () => {
describe('apartadoAPrellenado (alta desde apartado)', () => {
it('prellena datos generales (contacto manda) y vivienda/crédito (apartado > oportunidad > cotización)', () => {
const p = apartadoAPrellenado(base)
expect(p.datosGenerales).toMatchObject({

View File

@@ -2,7 +2,7 @@ import type { DatosGenerales, EstadoCivil, TipoCredito, ViviendaCredito } from '
import { PROYECTOS } from '../data/constantes'
/**
* Transformación pura: todo lo recolectado de un apartado de Monday (ficha del
* Transformación pura: todo lo recolectado de un apartado (ficha del
* apartado + contacto + oportunidad + cotización vinculada) → campos del
* wizard de alta. Prellena solo lo que exista (nunca inventa datos).
*/
@@ -42,7 +42,7 @@ export interface PrellenadoDesdeApartado {
leadMondayId: string | null
}
// Label de Monday (Contacto/Oportunidad) → enum del wizard
// Label de etapa (Contacto/Oportunidad) → enum del wizard
const ESTADO_CIVIL_ENUM: Record<string, EstadoCivil> = {
'SOLTERO(A)': 'SOLTERO',
SOLTERO: 'SOLTERO',

View File

@@ -2,7 +2,7 @@ import type { DatosGenerales } from '../types'
import type { LeadResumen } from '../services/api'
/**
* Transformación pura lead de Monday → campos del wizard de alta.
* Transformación pura lead → campos del wizard de alta.
* Prellena solo lo que el lead tenga (nombre en mayúsculas, como el formato).
*/
export function leadAPrellenado(

View File

@@ -113,7 +113,7 @@ describe('AltaClientePage — guardado progresivo (etapa por etapa)', () => {
expect(await screen.findByLabelText(/Tipo de empleo/)).toBeTruthy()
await waitFor(() => {
const avisos = screen.getAllByRole('status').map((el) => el.textContent ?? '')
expect(avisos.some((a) => a.includes('El avance quedó guardado local'))).toBe(true)
expect(avisos.some((a) => a.includes('El avance quedó guardado en este dispositivo'))).toBe(true)
})
})

View File

@@ -68,7 +68,7 @@ describe('AltaClientePage — pantalla de entrada', () => {
})
})
describe('AltaClientePage — alta desde lead de Monday', () => {
describe('AltaClientePage — alta desde lead capturado', () => {
it('al elegir un lead el wizard arranca con datosGenerales prellenados', async () => {
render(<AltaClientePage />)

View File

@@ -216,7 +216,7 @@ export default function AltaClientePage() {
setPantalla('wizard')
}
// Alta nueva prellenada desde un lead de Monday (nombre, email, celular)
// Alta nueva prellenada desde un lead ya capturado (nombre, email, celular)
const empezarDesdeLead = (lead: LeadResumen) => {
reiniciar()
setBorradorId(null)
@@ -282,7 +282,7 @@ export default function AltaClientePage() {
irAPaso(destino)
}
// ---------- Guardado progresivo (borrador local + Contacto en Monday) ----------
// ---------- Guardado progresivo (borrador local + alta en progreso) ----------
// Guard in-flight: con clicks rápidos se omite el sync en curso; el siguiente
// avance manda el estado completo (el PATCH es idempotente).
const syncEnCurso = useRef(false)
@@ -304,13 +304,13 @@ export default function AltaClientePage() {
setAvisoProgreso('')
} catch (err) {
console.warn('[alta] no se pudo sincronizar el avance con el servidor', err)
setAvisoProgreso('El avance quedó guardado local; no se pudo subir a Monday')
setAvisoProgreso('El avance quedó guardado en este dispositivo; no se pudo subir al servidor')
} finally {
syncEnCurso.current = false
}
}
/** Al avanzar de paso: guarda el borrador en silencio y sube el avance a Monday */
/** Al avanzar de paso: guarda el borrador en silencio y sube el avance al servidor */
const guardarAvance = (datosNuevos: typeof datos, pasoDestino: number) => {
guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso: pasoDestino, datos: datosNuevos })
.then((b) => {
@@ -519,7 +519,7 @@ export default function AltaClientePage() {
<section aria-labelledby="titulo-lead" className="mt-12 border-t border-[#E5E7EB] dark:border-[#1E293B] pt-8">
<h3 id="titulo-lead" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Desde un lead de Monday
Desde un lead ya capturado
</h3>
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
Empieza un alta nueva con el nombre, email y celular del lead ya capturados.
@@ -646,7 +646,7 @@ export default function AltaClientePage() {
)}
{datos.apartadoMondayId && (
<p role="note" className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-xs font-medium text-blue-800">
Alta ligada al apartado del cliente en Monday — al completarla se actualiza ese item (no se duplica).
Alta ligada al apartado del cliente — al completarla se actualiza ese apartado (no se duplica).
</p>
)}

View File

@@ -72,7 +72,7 @@ async function enviarAlta(ruta: string, payload: AltaCliente): Promise<Resultado
/**
* Registra el alta en el API Gateway (POST /clientes). El backend persiste en
* SQLite y replica en Monday.
* SQLite.
*/
export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoAlta> {
return enviarAlta('/clientes', payload)
@@ -85,7 +85,7 @@ export interface ProgresoIniciado {
mondayContactoId: string | null
}
/** POST /clientes/progreso/iniciar — crea el alta en progreso (Contacto PROSPECTO en Monday) */
/** POST /clientes/progreso/iniciar — crea el alta en progreso (Contacto PROSPECTO) */
export async function iniciarProgreso(payload: {
datosGenerales: unknown
leadMondayId?: string
@@ -216,7 +216,7 @@ export async function obtenerCotizacion(id: string): Promise<import('../lib/coti
return (await res.json()) as import('../lib/cotizacionAlta').CotizacionParaAlta
}
/** GET /altas/prefill-apartado/:itemId — todo lo recolectado de un apartado de Monday (?apartado=<id>) */
/** GET /altas/prefill-apartado/:itemId — todo lo recolectado de un apartado (?apartado=<id>) */
export async function obtenerPrefillApartado(itemId: string): Promise<import('../lib/apartadoAlta').PrefillApartado> {
const res = await fetch(`${API_URL}/altas/prefill-apartado/${itemId}`, conAuth())
manejar401(res)
@@ -224,7 +224,7 @@ export async function obtenerPrefillApartado(itemId: string): Promise<import('..
return (await res.json()) as import('../lib/apartadoAlta').PrefillApartado
}
// ---------- Pre-llenado desde lead de Monday (auth) ----------
// ---------- Pre-llenado desde lead (auth) ----------
export interface LeadResumen {
id: string
@@ -279,7 +279,7 @@ export async function crearAfluencia(payload: {
origen?: string
desarrollo?: string
notas?: string
/** Oportunidad de Monday a actualizar (botón "Afluencia" de Mi día) */
/** Oportunidad a actualizar (botón "Afluencia" de Mi día) */
oportunidadId?: string
modelo?: string
numDependientes?: string

View File

@@ -1,15 +0,0 @@
import type { AltaCliente } from '../types'
/**
* OBSOLETO: la réplica en Monday (tableros Contactos y Apartado) ya la hace
* el API Gateway en POST /api/clientes (ver services/api.ts y
* api-gateway/src/controllers/clientes.controller.ts). Se conserva el
* archivo solo como referencia; no llamar desde el flujo del wizard.
*/
export async function enviarAMonday(payload: AltaCliente): Promise<void> {
console.warn('[monday] enviarAMonday está obsoleto: el backend ya replica en Monday', {
nombre: payload.datosGenerales.nombreCompleto,
proyecto: payload.viviendaCredito.proyecto,
ubicacion: payload.viviendaCredito.ubicacion,
})
}

View File

@@ -1,19 +0,0 @@
import { conAuth, getToken } from './auth'
const API_URL = import.meta.env.VITE_API_URL || '/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
}
}

View File

@@ -147,10 +147,10 @@ export interface DatosFirma {
}
export interface AltaCliente {
// ID del item del tablero Leads de Monday cuando el alta se prellenó desde
// ID del lead cuando el alta se prellenó desde
// un lead (null = captura manual). Passthrough hasta payloadJson del API.
leadMondayId: string | null
// ID del item del tablero Apartado de Monday cuando el alta arrancó desde un
// ID del apartado cuando el alta arrancó desde un
// apartado existente (botón "Alta Enkontrol" de Mis apartados): el
// API lo LIGA y enriquece en vez de crear un apartado duplicado.
apartadoMondayId: string | null

View File

@@ -3,7 +3,7 @@ export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL'
export interface PlanPago {
id: string // uuid
ventaRef: string // referencia de la venta (en el futuro: mondayItemId)
ventaRef: string // referencia de la venta
clienteNombre: string
clienteTelefono?: string // teléfono de contacto (opcional)
unidadCodigo: string // p. ej. 'U-101'

View File

@@ -1,32 +0,0 @@
import { useEffect, useState } from 'react'
import { fetchEstadoSistema } from '../services/sistema'
const INTERVALO_POLL_MS = 60_000
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
}
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

@@ -8,7 +8,7 @@ interface Props {
}
/**
* Buscador con debounce de leads/oportunidades de Monday (GET /leads/buscar):
* Buscador con debounce de leads/oportunidades (GET /leads/buscar):
* jala nombre, teléfono y email del cliente al cotizador; si el resultado es
* una oportunidad, la cotización queda vinculada a ella al guardar.
*/
@@ -35,7 +35,7 @@ export function BuscadorCliente({ onSeleccion }: Props) {
})
.catch(() => {
setResultados(null)
setError('No se pudo buscar en Monday. Intenta de nuevo.')
setError('No se pudo buscar. Intenta de nuevo.')
})
.finally(() => setBuscando(false))
}, DEBOUNCE_MS)
@@ -45,7 +45,7 @@ export function BuscadorCliente({ onSeleccion }: Props) {
return (
<div>
<label htmlFor="busqueda-cliente" className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Buscar cliente en Monday (lead u oportunidad)
Buscar cliente (lead u oportunidad)
</label>
<input
id="busqueda-cliente"

View File

@@ -438,7 +438,7 @@ export default function CotizadorPage() {
role="status"
className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm font-medium text-blue-800"
>
Cotizando para {nombreCliente} — al guardar, la cotización queda vinculada a su oportunidad en Monday.
Cotizando para {nombreCliente} — al guardar, la cotización queda ligada a su oportunidad.
</p>
)}

View File

@@ -96,7 +96,7 @@ export function urlPublicaCotizacion(id: string): string {
return `${API_URL}/cotizaciones/${id}/publico`
}
/** POST /cotizaciones — persiste y registra la oportunidad en Monday */
/** POST /cotizaciones — persiste y liga la oportunidad del cliente */
export async function guardarCotizacion(payload: GuardarCotizacionPayload): Promise<CotizacionGuardada> {
const resp = await fetch(`${API_URL}/cotizaciones`, conAuth({
method: 'POST',
@@ -116,7 +116,7 @@ export async function getMisCotizaciones(): Promise<CotizacionGuardada[]> {
return (await resp.json()) as CotizacionGuardada[]
}
// ---------- Búsqueda de clientes en Monday (prellenado del cotizador) ----------
// ---------- Búsqueda de clientes para el prellenado del cotizador ----------
export interface LeadResumen {
id: string

View File

@@ -1,19 +0,0 @@
import { conAuth, getToken } from './auth'
const API_URL = import.meta.env.VITE_API_URL || '/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
}
}

View File

@@ -1,37 +0,0 @@
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

@@ -57,8 +57,8 @@ export default function SeccionEquipo() {
</thead>
<tbody className="divide-y divide-[#E5E7EB]">
{equipo.porVendedor.map((v) => (
<tr key={v.mondayUserId} className="transition-colors duration-100 hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
<td className={CLASE_CELDA}>{v.nombre ?? `Monday #${v.mondayUserId}`}</td>
<tr key={v.usuarioId} className="transition-colors duration-100 hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
<td className={CLASE_CELDA}>{v.nombre ?? 'Sin nombre'}</td>
<td className={CLASE_CELDA}>{v.leadsAsignados}</td>
<td className={CLASE_CELDA}>{v.leadsNuevosSemana}</td>
</tr>

View File

@@ -29,7 +29,7 @@ export interface ApartadoPorVencer {
}
export interface VendedorEquipo {
mondayUserId: number
usuarioId: string
nombre: string | null
leadsAsignados: number
leadsNuevosSemana: number
@@ -37,7 +37,6 @@ export interface VendedorEquipo {
export interface ResumenMiDia {
generadoEn: string
sinMapeoMonday: boolean
misLeads: LeadMiDia[]
misActividadesHoy: ActividadMiDia[]
apartadosPorVencer: ApartadoPorVencer[]

View File

@@ -1,19 +0,0 @@
import { conAuth, getToken } from './auth'
const API_URL = import.meta.env.VITE_API_URL || '/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
}
}

View File

@@ -1,32 +0,0 @@
import { useEffect, useState } from 'react'
import { fetchEstadoSistema } from '../services/sistema'
const INTERVALO_POLL_MS = 60_000
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
}
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,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { ConfiguracionCuenta } from './ConfiguracionCuenta'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
@@ -20,11 +20,6 @@ function crearStorageStub(): Storage {
} as Storage
}
const USUARIOS_MONDAY = [
{ id: 13676, nombre: 'Erick Peña', email: 'erick@frasa.mx' },
{ id: 20400, nombre: 'Cristina Barrera', email: 'cristina@frasa.mx' },
]
const sesionFake = {
token: 'jwt-de-prueba',
usuario: {
@@ -78,64 +73,29 @@ describe('ConfiguracionCuenta con sesión', () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionFake))
})
it('muestra el perfil, el dropdown de usuarios Monday y guarda el vínculo con PATCH', async () => {
fetchMock.mockImplementation((input, init) => {
const url = String(input)
if (url.endsWith('/auth/me')) return Promise.resolve(respuestaOk(sesionFake.usuario))
if (url.endsWith('/monday/usuarios')) return Promise.resolve(respuestaOk(USUARIOS_MONDAY))
if (url.endsWith('/usuarios/me/monday') && init?.method === 'PATCH') {
return Promise.resolve(respuestaOk({ ...sesionFake.usuario, mondayUserId: 13676 }))
}
return Promise.reject(new Error(`fetch inesperado: ${url}`))
})
render(<ConfiguracionCuenta />)
// Perfil y dropdown cargados
expect(await screen.findByText('ERICK ISRAEL PEÑA RAMOS')).toBeTruthy()
expect(screen.getByText('Rol: Vendedor')).toBeTruthy()
const dropdown = await screen.findByLabelText('Tu usuario de Monday')
expect(screen.getByText('sin vincular')).toBeTruthy()
fireEvent.change(dropdown, { target: { value: '13676' } })
fireEvent.click(screen.getByRole('button', { name: 'Guardar' }))
await waitFor(() => {
const llamadaPatch = fetchMock.mock.calls.find(
([url, init]) => String(url).endsWith('/usuarios/me/monday') && init?.method === 'PATCH',
)
expect(llamadaPatch).toBeTruthy()
expect(JSON.parse(String(llamadaPatch?.[1]?.body))).toEqual({ mondayUserId: 13676 })
})
expect((await screen.findByRole('status')).textContent).toContain('Vínculo con Monday guardado')
// El PATCH fue con Bearer
const [, initPatch] = fetchMock.mock.calls.find(
([url, init]) => String(url).endsWith('/usuarios/me/monday') && init?.method === 'PATCH',
) as [string, RequestInit]
expect((initPatch.headers as Record<string, string>)['Authorization']).toBe('Bearer jwt-de-prueba')
})
it('si falla /monday/usuarios muestra error reintentable y el reintento carga la lista', async () => {
let intentos = 0
it('muestra el perfil (nombre y rol) y el botón de cerrar sesión', async () => {
fetchMock.mockImplementation((input) => {
const url = String(input)
if (url.endsWith('/auth/me')) return Promise.resolve(respuestaOk(sesionFake.usuario))
if (url.endsWith('/monday/usuarios')) {
intentos += 1
return intentos === 1
? Promise.resolve(new Response('{}', { status: 500 }))
: Promise.resolve(respuestaOk(USUARIOS_MONDAY))
}
return Promise.reject(new Error(`fetch inesperado: ${url}`))
})
render(<ConfiguracionCuenta />)
expect(
await screen.findByText('No se pudo cargar la lista de usuarios de Monday.'),
).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Reintentar' }))
expect(await screen.findByLabelText('Tu usuario de Monday')).toBeTruthy()
expect(intentos).toBe(2)
expect(await screen.findByText('ERICK ISRAEL PEÑA RAMOS')).toBeTruthy()
expect(screen.getByText('Rol: Vendedor')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Cerrar sesión' })).toBeTruthy()
})
it('si falla la carga del perfil la sección sigue usable (logout disponible)', async () => {
fetchMock.mockImplementation((input) => {
const url = String(input)
if (url.endsWith('/auth/me')) return Promise.resolve(new Response('{}', { status: 500 }))
return Promise.reject(new Error(`fetch inesperado: ${url}`))
})
render(<ConfiguracionCuenta />)
expect(await screen.findByRole('button', { name: 'Cerrar sesión' })).toBeTruthy()
})
})

View File

@@ -15,8 +15,8 @@ function formatearRol(rol: string): string {
}
/**
* Sección "Configuración de cuenta": login inline y, con sesión, el vínculo
* con el usuario de Monday (alimenta round-robin, mis-leads y alertas).
* Sección "Configuración de cuenta": login inline y, con sesión, el perfil del
* usuario.
*/
export function ConfiguracionCuenta() {
const [conSesion, setConSesion] = useState(() => getSesion() !== null)

View File

@@ -10,7 +10,7 @@ interface Props {
}
/**
* Vista interna "Contactos": lista del tablero Contactos de Monday (lo más
* Vista interna "Contactos": lista de contactos locales (lo más
* reciente primero). Vive dentro de la landing (no hay router); se abre desde
* la tarjeta Contactos del grid. Click en un contacto → modal con toda su
* información, propiedad, quién lo atiende e historial de atención.

View File

@@ -31,7 +31,7 @@ beforeEach(() => {
// Mockear las llamadas iniciales que los tabs pueden hacer
fetchMock.mockImplementation((input) => {
const url = String(input)
if (url.endsWith('/sistema/estado')) return Promise.resolve(respuestaOk({ mondayOk: true, pendientesSync: 0 }))
if (url.endsWith('/sistema/estado')) return Promise.resolve(respuestaOk({ mondayOk: false, pendientesSync: 0 }))
if (url.endsWith('/config/sistema')) return Promise.resolve(respuestaOk({ modo_local_forzado: 'false' }))
if (url.endsWith('/config/roles')) return Promise.resolve(respuestaOk([]))
if (url.endsWith('/config/usuarios')) return Promise.resolve(respuestaOk([]))

View File

@@ -1,12 +1,6 @@
import { API_URL, conAuth, manejar401, type UsuarioSesion } from './auth'
export interface UsuarioMonday {
id: number
nombre: string
email: string
}
/** GET /auth/me — perfil del usuario autenticado (incluye mondayUserId y modulos) */
/** GET /auth/me — perfil del usuario autenticado (incluye modulos y acciones) */
export async function getMe(): Promise<UsuarioSesion> {
const resp = await fetch(`${API_URL}/auth/me`, conAuth())
manejar401(resp)
@@ -23,7 +17,7 @@ export interface Contacto {
updatedAt: string
}
/** GET /contactos — contactos del tablero Contactos de Monday (máx 100) */
/** GET /contactos — contactos locales (máx 100) */
export async function getContactos(): Promise<Contacto[]> {
const resp = await fetch(`${API_URL}/contactos`, conAuth())
manejar401(resp)
@@ -31,36 +25,13 @@ export async function getContactos(): Promise<Contacto[]> {
return (await resp.json()) as Contacto[]
}
/** GET /monday/usuarios — usuarios activos del workspace para el dropdown */
export async function getUsuariosMonday(): Promise<UsuarioMonday[]> {
const resp = await fetch(`${API_URL}/monday/usuarios`, conAuth())
manejar401(resp)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
return (await resp.json()) as UsuarioMonday[]
}
/** PATCH /usuarios/me/monday — vincula (number) o desvincula (null) la cuenta Monday */
export async function guardarVinculoMonday(mondayUserId: number | null): Promise<UsuarioSesion> {
const resp = await fetch(
`${API_URL}/usuarios/me/monday`,
conAuth({
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mondayUserId }),
}),
)
manejar401(resp)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
return (await resp.json()) as UsuarioSesion
}
// ---------- Detalle de contacto (modal del módulo Contactos) ----------
export interface RelacionCrm {
id: string
nombre: string
estado: string
/** Quién lo atiende ahora (columna people del tablero); null si sin asignar */
/** Quién lo atiende ahora; null si sin asignar */
atendidoPor: string | null
detalle: string
}
@@ -88,7 +59,7 @@ export interface DatosContacto {
}
export interface DetalleContacto {
/** Campos nombrados del modelo local (antes: títulos de columna de Monday) */
/** Campos nombrados del modelo local */
contacto: DatosContacto
propiedad: {
id: string

View File

@@ -1,17 +0,0 @@
import { API_URL, conAuth, getToken } from './auth'
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
}
}

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