feat(mejoras): 25 mejoras UX en todos los módulos + purga Monday

Backend: mi-dia siempre local, vocabulario canónico, AplicacionPago con usuario
Mi día: modal español real, cita desde modal, score falso eliminado
Cotizador: barra sticky, persistencia local con folio
Alta Clientes: redirect eliminado, foto conectada, asesor desde sesión, refs dinámicas
Cobranza: contrato Mora arreglado, link a Plan, abono próximo pago, WhatsApp
Landing: contactos locales, ModalContacto eliminado, ModoTrabajo eliminado
Dashboards: MiTablero unificado, funnel con descartados, drill-down, rango fechas
Chat FRASA: corpus 5 desarrollos, 6 herramientas, historial conversaciones

69 archivos, 7 apps construidas
This commit is contained in:
2026-09-11 06:52:37 +00:00
parent 075d47c304
commit 64b01246d6
69 changed files with 1600 additions and 1133 deletions

View File

@@ -54,7 +54,7 @@ describe('login', () => {
rol: 'vendedor',
asignacion: 'MONTEBELLO',
mondayUserId: null,
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'],
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'],
acciones: [],
})
expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos')
@@ -103,7 +103,7 @@ describe('me', () => {
it('incluye los módulos visibles del rol (matriz de config/roles)', async () => {
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ rol: 'direccion_general' }))
const resultado = await me({ sub: 'u-1', username: 'x', rol: 'direccion_general' })
expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat'])
expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro'])
})
it('rechaza si el usuario ya no existe o está inactivo', async () => {

View File

@@ -602,7 +602,7 @@ export class AutomatizacionesService {
pendienteSync: false,
},
})
} catch (e) { console.error('[APARTADO-ERR]', e.message?.slice(0,150)) }
} catch (e) { console.error('[APARTADO-ERR]', e instanceof Error ? e.message.slice(0, 150) : e) }
} catch (err) {
resultado.errores.push({ oportunidadId: op.id, mensaje: err instanceof Error ? err.message : String(err) })
}

View File

@@ -68,7 +68,8 @@ describe('detalleContacto (GET /api/contactos/:id/detalle)', () => {
},
)
expect(d).not.toBeNull()
expect(d!.contacto['CURP']).toBe('XAXX010101XXXXXXX0')
// El contacto del detalle se devuelve con campos nombrados del modelo local
expect(d!.contacto['curp']).toBe('XAXX010101XXXXXXX0')
// Propiedad comprada (apartado relacionado, enriquecido con el desarrollo)
expect(d!.propiedad).toMatchObject({
unidadCodigo: 'SENDA-409-1-2',

View File

@@ -31,7 +31,7 @@ export interface EventoHistorialContacto {
}
export interface DetalleContacto {
contacto: Record<string, string> // columnas del board por título ('nombre' incluido)
contacto: Record<string, string> // campos nombrados del modelo local (nombre, telefono, email, …)
/** Apartado relacionado más reciente (la propiedad comprada o en proceso) */
propiedad: {
id: string
@@ -57,6 +57,32 @@ export function claveTelefono(telefono: string): string {
return digitos.length >= 10 ? digitos.slice(-10) : ''
}
/**
* Mapeo título de columna del board Contactos (Monday) → campo nombrado del
* modelo local. Los títulos sin entrada se conservan tal cual.
*/
const CAMPOS_CONTACTO: Record<string, string> = {
nombre: 'nombre',
'Teléfono': 'telefono',
Email: 'email',
Estado: 'estado',
CURP: 'curp',
RFC: 'rfc',
'Estado Civil': 'estadoCivil',
'Fecha Nacimiento': 'fechaNacimiento',
'Actividad Economica': 'actividadEconomica',
'Dirección': 'direccion',
}
/** Normaliza un record por título de columna a campos nombrados del modelo local */
export function normalizarCamposContacto(contacto: Record<string, string>): Record<string, string> {
const normalizado: Record<string, string> = {}
for (const [titulo, valor] of Object.entries(contacto)) {
normalizado[CAMPOS_CONTACTO[titulo] ?? titulo] = valor
}
return normalizado
}
type MondayDetalle = Pick<
MondayService,
| 'getContactoDetalle'
@@ -169,7 +195,7 @@ export async function detalleContacto(
].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
return {
contacto,
contacto: normalizarCamposContacto(contacto),
propiedad,
relaciones: {
leads: relLeads.map((l) => ({

View File

@@ -162,10 +162,11 @@ describe('detalleContactoLocal', () => {
expect(detalle.contacto).toMatchObject({
nombre: 'JUAN PÉREZ',
Teléfono: '4771234567',
Email: 'juan@correo.mx',
CURP: 'CURP123',
RFC: 'RFC123',
telefono: '4771234567',
email: 'juan@correo.mx',
estado: 'Cliente',
curp: 'CURP123',
rfc: 'RFC123',
})
expect(detalle.propiedad).toMatchObject({
id: 'loc_a1',
@@ -203,7 +204,7 @@ describe('detalleContactoLocal', () => {
const detalle = await detalleContactoLocal('loc_1')
expect(detalle).not.toBeNull()
if (detalle === null) return
expect(detalle.contacto).toMatchObject({ nombre: 'ANA DEMO', Teléfono: '6649998877', Estado: 'Afluencia' })
expect(detalle.contacto).toMatchObject({ nombre: 'ANA DEMO', telefono: '6649998877', estado: 'Afluencia' })
expect(detalle.propiedad).toBeNull()
expect(detalle.relaciones.oportunidades).toHaveLength(1)
expect(detalle.historial.length).toBeGreaterThan(0)

View File

@@ -202,17 +202,17 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
cliente !== null
? {
nombre: cliente.nombreCompleto,
Teléfono: cliente.celular ?? '',
Email: cliente.email ?? '',
CURP: cliente.curp,
RFC: cliente.rfc,
Estado: 'Cliente',
telefono: cliente.celular ?? '',
email: cliente.email ?? '',
estado: 'Cliente',
curp: cliente.curp,
rfc: cliente.rfc,
}
: {
nombre: capturaBase!.nombreLead,
Teléfono: capturaBase!.telefono ?? '',
Email: capturaBase!.email ?? '',
Estado: capturaBase!.etapa,
telefono: capturaBase!.telefono ?? '',
email: capturaBase!.email ?? '',
estado: capturaBase!.etapa,
}
return { contacto, propiedad, relaciones, historial }

View File

@@ -218,7 +218,14 @@ export async function resumenMiDia(
/** mondayUserId -> nombre para mostrar (usuarios del ecosistema ya mapeados) */
nombresPorMondayId: Map<number, string>,
/** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */
capturas: { leadId: string; nombreLead: string; telefono: string | null; createdAt: Date }[] = [],
capturas: {
leadId: string
nombreLead: string
telefono: string | null
createdAt: Date
/** Oportunidad ligada (LeadOportunidad); null si la captura aún no convertió */
oportunidadId?: string | null
}[] = [],
/** Clientes cuyo alta inició el usuario (tabla Cliente), ya filtrados por él */
clientes: {
id: string
@@ -332,7 +339,10 @@ export async function resumenMiDia(
)
// Set de oportunidades mías (por LeadCaptura o por vendedorIds)
const miosOportunidadesIds = new Set(
capturas.filter((c) => c.oportunidadId).map((c) => c.oportunidadId),
capturas
.filter((c) => c.oportunidadId)
.map((c) => c.oportunidadId)
.filter((id): id is string => id != null),
)
const miosApartadosTodos = apartados.filter((a) =>
esMio(a.vendedorIds, a.id) || apartadosDeMisClientes.has(a.id) || miosOportunidadesIds.has(a.id),
@@ -387,7 +397,8 @@ export async function resumenMiDia(
estado: a.estado,
fechaLimite: a.fechaLimite,
ultimoCambio: null as { fechaISO: string; usuario: string | null; detalle: string } | null,
pendienteSync: a.pendienteSync,
// Los apartados leídos de Monday ya están sincronizados por definición
pendienteSync: false,
}))
const misApartados: ApartadoMiDia[] = miosApartados
@@ -874,9 +885,9 @@ export function resumenMiDiaLocal(
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
unidadCodigo: '',
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
estado: c.etapa,
fechaLimite: '',
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
ultimoCambio: null,
pendienteSync: c.pendienteSync,
})),

View File

@@ -1672,6 +1672,8 @@ export interface DatosOportunidadAfluencia {
tipoEconomiaLabel?: string | undefined // status 'Tipo de Econimia' (SIC: typo real)
calificaLabel?: string | undefined // dropdown 'Lead Califica'
loQuierePara?: string | undefined // dropdown 'Proposito de Compra'
/** text '# Dependientes' (si el board aún no tiene la columna, Monday lo ignora) */
numDependientes?: string | undefined
notas?: string | undefined // long_text 'COMENTARIOS'
}

View File

@@ -75,12 +75,14 @@ describe('construirMiTablero', () => {
captura({ board: 'OPORTUNIDADES' }),
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }),
captura({ board: 'APARTADO', etapa: 'CANCELADO' }),
captura({ board: 'LEADS', etapa: 'DESCARTADO' }),
]
const tablero = construirMiTablero(capturas, [], [], AHORA)
expect(tablero.conversion.leads).toBe(2)
expect(tablero.conversion.leads).toBe(3) // 2 activos + 1 descartado
expect(tablero.conversion.oportunidades).toBe(1)
expect(tablero.conversion.apartados).toBe(1) // CANCELADO no cuenta
expect(tablero.conversion.vendidos).toBe(1)
expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO
})
it('calcula ventas del mes correctamente', () => {

View File

@@ -11,6 +11,8 @@ export interface MiTablero {
oportunidades: number
apartados: number
vendidos: number
/** Leads descartados (etapa terminal DESCARTADO); etapa extra al final del funnel */
descartados: number
}
ventasMes: {
monto: number
@@ -125,6 +127,7 @@ export function construirMiTablero(
c.board === 'APARTADO' &&
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()),
).length
const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length
// -- Ventas del mes --
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
@@ -183,7 +186,7 @@ export function construirMiTablero(
return {
generadoEn: ahora.toISOString(),
leadsPorDia,
conversion: { leads, oportunidades, apartados, vendidos },
conversion: { leads, oportunidades, apartados, vendidos, descartados },
ventasMes,
porOrigen,
porDesarrollo,
@@ -203,6 +206,7 @@ export async function obtenerMiTablero(usuarioId: string): Promise<MiTablero> {
const capturas = await prisma.leadCaptura.findMany({
where: { usuarioId },
select: {
leadId: true,
board: true,
etapa: true,
origen: true,
@@ -246,11 +250,11 @@ export async function obtenerMetricasUso(usuarioId?: string): Promise<MetricasUs
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
// Filtro por usuario específico (coordinador ve solo su equipo; admin ve todos)
const whereUsuario = usuarioId ? { usuarioId } : undefined
const usuarioFiltro = usuarioId ?? undefined
const [capturas, actividades, usuarios] = await Promise.all([
prisma.leadCaptura.findMany({
where: whereUsuario,
...(usuarioFiltro ? { where: { usuarioId: usuarioFiltro } } : {}),
select: {
usuarioId: true,
etapa: true,
@@ -258,14 +262,14 @@ export async function obtenerMetricasUso(usuarioId?: string): Promise<MetricasUs
},
}),
prisma.actividadLocal.findMany({
where: whereUsuario,
...(usuarioFiltro ? { where: { usuarioId: usuarioFiltro } } : {}),
select: {
usuarioId: true,
estado: true,
},
}),
prisma.usuario.findMany({
where: whereUsuario ? { id: usuarioId } : { activo: true },
where: usuarioFiltro ? { id: usuarioFiltro } : { activo: true },
select: {
id: true,
nombre: true,

View File

@@ -168,23 +168,38 @@ function inicioDelDia(d: Date): Date {
/**
* Construye el resumen a partir de los datos crudos. Función pura: toda la
* lógica de conteo/tasas se prueba sin red ni base de datos.
*
* `dias` (opcional, 7/30/90) acota leads y oportunidades a las capturas del
* rango (por createdAt); inventario, cobranza y actividades son posición
* actual y no se filtran. Sin `dias` se cuenta TODO el histórico (igual que antes).
*/
export function construirResumen(datos: DatosResumen, ahora: Date): ResumenReportes {
export function construirResumen(datos: DatosResumen, ahora: Date, dias?: number): ResumenReportes {
const hoy = inicioDelDia(ahora)
const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000)
const rangoValido = dias !== undefined && Number.isFinite(dias) && dias > 0
const desdeRango = rangoValido
? new Date(hoy.getTime() - (Math.floor(dias!) - 1) * 24 * 60 * 60 * 1000)
: null
const enRango = (createdAt: string): boolean => {
if (!desdeRango) return true
const t = Date.parse(createdAt)
return !Number.isNaN(t) && t >= desdeRango.getTime()
}
const leadsRango = datos.leads.filter((l) => enRango(l.createdAt))
const oportunidadesRango = datos.oportunidades.filter((o) => enRango(o.createdAt))
const creadosEn = (desde: Date): number =>
datos.leads.filter((l) => {
leadsRango.filter((l) => {
const t = Date.parse(l.createdAt)
return !Number.isNaN(t) && t >= desde.getTime()
}).length
const porEtapa = contarPor(datos.oportunidades.map((o) => o.etapa))
const totalOps = datos.oportunidades.length
const ganadas = datos.oportunidades.filter((o) => o.etapa.trim().toUpperCase() === 'GANADO').length
const apartadosOMas = datos.oportunidades.filter((o) =>
const porEtapa = contarPor(oportunidadesRango.map((o) => o.etapa))
const totalOps = oportunidadesRango.length
const ganadas = oportunidadesRango.filter((o) => o.etapa.trim().toUpperCase() === 'GANADO').length
const apartadosOMas = oportunidadesRango.filter((o) =>
['APARTO', 'GANADO'].includes(o.etapa.trim().toUpperCase()),
).length
const activas = datos.oportunidades.filter(
const activas = oportunidadesRango.filter(
(o) =>
!ETAPAS_CERRADAS.includes(o.etapa.trim().toUpperCase()) &&
o.etapa.trim().toUpperCase() !== 'GANADO',
@@ -233,28 +248,28 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
// Sección "leads" = tablero Leads (captura ligera); el perfilamiento de la
// afluencia se lee de Oportunidades (el Alta Afluencia vive ahí)
leads: {
total: datos.leads.length,
total: leadsRango.length,
nuevosHoy: creadosEn(hoy),
nuevosSemana: creadosEn(hace7dias),
porEstado: contarPor(datos.leads.map((l) => l.estado)),
porOrigen: contarPor(datos.leads.map((l) => l.origen)),
porEstado: contarPor(leadsRango.map((l) => l.estado)),
porOrigen: contarPor(leadsRango.map((l) => l.origen)),
perfil: {
porDesarrollo: contarPorNoVacios(datos.oportunidades.map((o) => o.desarrollo ?? '')),
porModelo: contarPorNoVacios(datos.oportunidades.map((o) => o.modeloInteres ?? '')),
porEstadoCivil: contarPorNoVacios(datos.oportunidades.map((o) => o.estadoCivil ?? '')),
porTipoEmpleo: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEmpleo ?? '')),
porTipoEconomia: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEconomia ?? '')),
porCalifica: contarPorNoVacios(datos.oportunidades.map((o) => o.califica ?? '')),
porCredito: contarPorNoVacios(datos.oportunidades.map((o) => o.credito ?? '')),
porDesarrollo: contarPorNoVacios(oportunidadesRango.map((o) => o.desarrollo ?? '')),
porModelo: contarPorNoVacios(oportunidadesRango.map((o) => o.modeloInteres ?? '')),
porEstadoCivil: contarPorNoVacios(oportunidadesRango.map((o) => o.estadoCivil ?? '')),
porTipoEmpleo: contarPorNoVacios(oportunidadesRango.map((o) => o.tipoEmpleo ?? '')),
porTipoEconomia: contarPorNoVacios(oportunidadesRango.map((o) => o.tipoEconomia ?? '')),
porCalifica: contarPorNoVacios(oportunidadesRango.map((o) => o.califica ?? '')),
porCredito: contarPorNoVacios(oportunidadesRango.map((o) => o.credito ?? '')),
porRangoSalarial: contarPorNoVacios(
datos.oportunidades
oportunidadesRango
.map((o) => bucketSalario(o.rangoSalarial ?? ''))
.filter((b): b is string => b !== null),
),
porMedioContacto: contarPorNoVacios(datos.oportunidades.map((o) => o.medioContacto ?? '')),
porLoQuierePara: contarPorNoVacios(datos.oportunidades.map((o) => o.loQuierePara ?? '')),
porMedioContacto: contarPorNoVacios(oportunidadesRango.map((o) => o.medioContacto ?? '')),
porLoQuierePara: contarPorNoVacios(oportunidadesRango.map((o) => o.loQuierePara ?? '')),
porEdad: contarPorNoVacios(
datos.oportunidades.filter((o) => o.edad != null).map((o) => bucketEdad(o.edad!)),
oportunidadesRango.filter((o) => o.edad != null).map((o) => bucketEdad(o.edad!)),
),
},
},
@@ -391,7 +406,8 @@ export class ReportesService {
private readonly cacheMs: number
private readonly salud: Pick<SaludMondayService, 'estaDisponible'>
private readonly cargarDatosLocales: CargadorDatosLocales
private cache: { resumen: ResumenReportes; expira: number } | null = null
/** Cache por rango (clave '0' = todo el histórico) para no mezclar ventanas */
private cache: Map<string, { resumen: ResumenReportes; expira: number }> = new Map()
constructor(
private readonly monday: MondayReportes,
@@ -406,15 +422,18 @@ export class ReportesService {
/** Invalida el cache (útil tras escrituras que cambian los números) */
invalidarCache(): void {
this.cache = null
this.cache.clear()
}
async resumen(): Promise<ResumenReportes> {
async resumen(dias?: number): Promise<ResumenReportes> {
const rango = dias !== undefined && Number.isFinite(dias) && dias > 0 ? Math.floor(dias) : undefined
const clave = String(rango ?? 0)
const ahoraMs = this.now()
if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen
const hit = this.cache.get(clave)
if (hit && hit.expira > ahoraMs) return hit.resumen
const resumen = await this.cargarResumen(ahoraMs)
this.cache = { resumen, expira: ahoraMs + this.cacheMs }
const resumen = await this.cargarResumen(ahoraMs, rango)
this.cache.set(clave, { resumen, expira: ahoraMs + this.cacheMs })
return resumen
}
@@ -423,7 +442,7 @@ export class ReportesService {
* de siempre). Camino degradado: Monday caído (circuit breaker) o AppError
* 502 a media lectura → resumen desde tablas locales con `parcial: true`.
*/
private async cargarResumen(ahoraMs: number): Promise<ResumenReportes> {
private async cargarResumen(ahoraMs: number, dias?: number): Promise<ResumenReportes> {
if (this.salud.estaDisponible()) {
try {
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
@@ -434,7 +453,7 @@ export class ReportesService {
this.monday.getActividadesAbiertas(),
this.cargarPlanes(),
])
return construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
return construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs), dias)
} catch (err) {
if (!(err instanceof AppError && err.status === 502)) throw err
// Monday se cayó a media lectura: cae al camino local
@@ -444,6 +463,7 @@ export class ReportesService {
const resumen = construirResumen(
{ ...locales, planes, actividadesAbiertas: locales.actividadesAbiertas ?? 0 },
new Date(ahoraMs),
dias,
)
// En modo local, los datos son completos (no parciales): todas las métricas
// vienen de tablas locales. `parcial` solo se marca true cuando Monday cayó