feat(mejoras): 22 mejoras UX en todos los módulos

Mi día: cancelación con motivo, conteo escrituración real, cita sin retroceder, calendario completo
Cobranza: MoraDashboard respeta alcance, hoy dinámico, cancelar con motivo
Landing: contactos paginados, error vs vacío diferenciado
Dashboards: KPI renombrado, drill-down planes activos
Chat: historial recarga, preguntas rotativas, error solo toast
Transversal: shadow-sm corrupto corregido en 9 archivos

553 API tests + 7 apps construidas
This commit is contained in:
2026-09-11 12:48:47 +00:00
parent 89d95eb53b
commit 1ba7aa49da
28 changed files with 518 additions and 160 deletions

View File

@@ -75,16 +75,18 @@ describe('listarContactosEndpoint (GET /api/contactos) — local', () => {
it('lista desde LeadCaptura/Cliente con la misma forma (fuente local)', async () => {
const { res, json } = resFake()
await listarContactosEndpoint({ usuarioToken: { sub: 'u-1' } } as unknown as Request, res)
await listarContactosEndpoint({ usuarioToken: { sub: 'u-1' }, query: {} } as unknown as Request, res)
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
expect(lista[0]).toMatchObject({ id: 'loc_1', nombre: 'ANA DEMO', estado: 'Afluencia', fuente: 'local' })
const respuesta = json.mock.calls[0]?.[0] as { contactos: Record<string, unknown>[]; pagina: number; hayMas: boolean }
expect(respuesta.contactos[0]).toMatchObject({ id: 'loc_1', nombre: 'ANA DEMO', estado: 'Afluencia', fuente: 'local' })
expect(respuesta.pagina).toBe(1)
expect(respuesta.hayMas).toBe(false)
})
it('filtra por el usuario del token (sus clientes y sus capturas)', async () => {
const { res } = resFake()
await listarContactosEndpoint({ usuarioToken: { sub: 'u-1' } } as unknown as Request, res)
await listarContactosEndpoint({ usuarioToken: { sub: 'u-1' }, query: {} } as unknown as Request, res)
expect(prismaMock.cliente.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { capturadoPorId: 'u-1' } }),

View File

@@ -5,11 +5,14 @@ import { NotFoundError } from '../utils/errors.js'
/**
* GET /api/contactos (Bearer) → contactos locales (tabla Cliente + LeadCaptura:
* nombre, teléfono, email, estado), lo más reciente primero, máx 100. Filtra
* por usuario actual: clientes capturados por él + leads asignados a él.
* nombre, teléfono, email, estado), lo más reciente primero, paginado de 100 en
* 100 (?pagina=N; el front muestra el límite). Filtra por usuario actual:
* clientes capturados por él + leads asignados a él.
*/
export async function listarContactosEndpoint(req: Request, res: Response): Promise<void> {
res.json(await listarContactosLocal(req.usuarioToken?.sub))
const paginaParam = Number(req.query['pagina'])
const pagina = Number.isFinite(paginaParam) && paginaParam >= 1 ? Math.floor(paginaParam) : 1
res.json(await listarContactosLocal(req.usuarioToken?.sub, pagina))
}
/**

View File

@@ -85,7 +85,7 @@ describe('listarContactosLocal', () => {
capturaFake({ leadId: 'loc_1', nombreLead: 'ANA DEMO', telefono: '6649998877', etapa: 'Pago Apartado', board: 'APARTADO' }),
])
const lista = await listarContactosLocal()
const lista = (await listarContactosLocal()).contactos
expect(lista).toHaveLength(2)
expect(lista[0]).toMatchObject({
@@ -113,10 +113,34 @@ describe('listarContactosLocal', () => {
capturaFake({ leadId: 'loc_1', telefono: '4771234567' }),
])
const lista = await listarContactosLocal()
const lista = (await listarContactosLocal()).contactos
expect(lista).toHaveLength(1)
expect(lista[0]?.id).toBe('c1')
})
it('pagina de 100 en 100 con hayMas (la página 2 trae el siguiente bloque)', async () => {
// 101 clientes: la página 1 llena 100 y detecta que hay más
prismaMock.cliente.findMany.mockResolvedValue(
Array.from({ length: 101 }, (_, i) =>
clienteFake({ id: `c${i + 1}`, celular: `4770000${String(i).padStart(3, '0')}`, createdAt: new Date(2026, 8, 1, 0, 0, 0, i * 1000) }),
),
)
const pagina1 = await listarContactosLocal(undefined, 1)
expect(pagina1.pagina).toBe(1)
expect(pagina1.porPagina).toBe(100)
expect(pagina1.contactos).toHaveLength(100)
expect(pagina1.hayMas).toBe(true)
// La página 2 recibe el siguiente bloque (skip=100, take=101)
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({ id: 'c101', celular: '4770000100' }),
])
const pagina2 = await listarContactosLocal(undefined, 2)
expect(pagina2.pagina).toBe(2)
expect(pagina2.contactos).toHaveLength(1)
expect(pagina2.hayMas).toBe(false)
})
})
describe('detalleContactoLocal', () => {

View File

@@ -21,14 +21,30 @@ export interface ContactoListaLocal {
/**
* Lista local: Clientes (alta de clientes) + capturas del embudo en boards
* OPORTUNIDADES/APARTADO, deduplicados por teléfono (gana el Cliente: tiene la
* ficha completa), más reciente primero, máx 100 (como getContactos).
* Filtra por usuario actual: clientes capturados por él + leads asignados a él.
* ficha completa), más reciente primero. Paginada de 100 en 100 (el front
* pagina con `?pagina=N` y muestra el límite). Filtra por usuario actual:
* clientes capturados por él + leads asignados a él.
*/
export async function listarContactosLocal(usuarioId?: string): Promise<ContactoListaLocal[]> {
export const CONTACTOS_POR_PAGINA = 100
export interface ListaContactos {
contactos: ContactoListaLocal[]
pagina: number
porPagina: number
/** true si quedan contactos en la página siguiente */
hayMas: boolean
}
export async function listarContactosLocal(usuarioId?: string, pagina = 1): Promise<ListaContactos> {
const porPagina = CONTACTOS_POR_PAGINA
const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1
const skip = (paginaValida - 1) * porPagina
// +1 por fuente para detectar si queda más (hayMas) sin una consulta extra
const clientes = await prisma.cliente.findMany({
where: usuarioId ? { capturadoPorId: usuarioId } : {},
orderBy: { createdAt: 'desc' },
take: 100,
skip,
take: porPagina + 1,
})
const capturas = await prisma.leadCaptura.findMany({
where: {
@@ -36,7 +52,8 @@ export async function listarContactosLocal(usuarioId?: string): Promise<Contacto
...(usuarioId ? { usuarioId } : {}),
},
orderBy: { updatedAt: 'desc' },
take: 100,
skip,
take: porPagina + 1,
})
const vistos = new Set<string>()
@@ -72,7 +89,14 @@ export async function listarContactosLocal(usuarioId?: string): Promise<Contacto
fuente: 'local',
})
}
return lista.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 100)
const ordenada = lista.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
const hayMas = ordenada.length > porPagina
return {
contactos: ordenada.slice(0, porPagina),
pagina: paginaValida,
porPagina,
hayMas,
}
}
/**

View File

@@ -148,6 +148,8 @@ export interface ResumenMiDia {
misCapturas: CapturaMiDia[]
misClientes: ClienteMiDia[]
misActividadesHoy: ActividadMiDia[]
/** Todas las actividades ABIERTAS del usuario (sin filtro de fecha): alimenta el calendario */
todasMisActividades: ActividadMiDia[]
apartadosPorVencer: ApartadoPorVencer[]
/** MIS oportunidades: conteo por etapa + lista de trabajo (etapas no terminales) */
misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] }
@@ -381,13 +383,8 @@ export function resumenMiDiaLocal(
const hoy = fechaLocal(ahora, 0)
const manana = fechaLocal(ahora, 1)
const en7Dias = fechaLocal(ahora, 7)
const misActividadesHoy: ActividadMiDia[] = actividades
const actividadesAbiertasOrdenadas = actividades
.filter((a) => a.estado.toUpperCase() === 'ABIERTA')
.filter((a) => {
if (!a.fechaVencimiento) return true
const fv = a.fechaVencimiento.toISOString().slice(0, 10)
return fv <= manana
})
.sort((a, b) => {
const fa = a.fechaVencimiento?.toISOString() ?? '9999'
const fb = b.fechaVencimiento?.toISOString() ?? '9999'
@@ -400,6 +397,12 @@ export function resumenMiDiaLocal(
fecha: a.fechaVencimiento ? a.fechaVencimiento.toISOString().slice(0, 10) : '',
estado: a.estado,
}))
const todasMisActividades: ActividadMiDia[] = actividadesAbiertasOrdenadas
const misActividadesHoy: ActividadMiDia[] = actividadesAbiertasOrdenadas
.filter((a) => {
if (a.fecha === '') return true
return a.fecha <= manana
})
// --- Apartados por vencer ---
const apartadosPorVencer: ApartadoPorVencer[] = apartadosLocales
@@ -479,6 +482,7 @@ export function resumenMiDiaLocal(
misCapturas: misCapturasSinDuplicadosLocal,
misClientes,
misActividadesHoy,
todasMisActividades,
apartadosPorVencer,
misOportunidades: { porEtapa, items: itemsOportunidades },
misApartados,