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:
@@ -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' } }),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user