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

@@ -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ó