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:
@@ -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ó
|
||||
|
||||
Reference in New Issue
Block a user