diff --git a/api-gateway/src/controllers/contactos.controller.test.ts b/api-gateway/src/controllers/contactos.controller.test.ts index 32e6cd1..0e3280f 100644 --- a/api-gateway/src/controllers/contactos.controller.test.ts +++ b/api-gateway/src/controllers/contactos.controller.test.ts @@ -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[] - expect(lista[0]).toMatchObject({ id: 'loc_1', nombre: 'ANA DEMO', estado: 'Afluencia', fuente: 'local' }) + const respuesta = json.mock.calls[0]?.[0] as { contactos: Record[]; 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' } }), diff --git a/api-gateway/src/controllers/contactos.controller.ts b/api-gateway/src/controllers/contactos.controller.ts index b125b6e..7e2d9aa 100644 --- a/api-gateway/src/controllers/contactos.controller.ts +++ b/api-gateway/src/controllers/contactos.controller.ts @@ -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 { - 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)) } /** diff --git a/api-gateway/src/services/contactos-local.service.test.ts b/api-gateway/src/services/contactos-local.service.test.ts index 41743f0..7ca4313 100644 --- a/api-gateway/src/services/contactos-local.service.test.ts +++ b/api-gateway/src/services/contactos-local.service.test.ts @@ -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', () => { diff --git a/api-gateway/src/services/contactos-local.service.ts b/api-gateway/src/services/contactos-local.service.ts index f30a66f..58b860a 100644 --- a/api-gateway/src/services/contactos-local.service.ts +++ b/api-gateway/src/services/contactos-local.service.ts @@ -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 { +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 { + 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() @@ -72,7 +89,14 @@ export async function listarContactosLocal(usuarioId?: string): Promise 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, + } } /** diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index 5f6c451..1e26a79 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -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; 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, diff --git a/apps/agente-ia/src/lib/vocabulario.test.ts b/apps/agente-ia/src/lib/vocabulario.test.ts new file mode 100644 index 0000000..59567ee --- /dev/null +++ b/apps/agente-ia/src/lib/vocabulario.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { DESARROLLOS, preguntasSugeridas } from './vocabulario' + +describe('preguntasSugeridas', () => { + it('devuelve 4 preguntas, cada una de un desarrollo distinto', () => { + const preguntas = preguntasSugeridas(new Date('2026-09-11T12:00:00')) + expect(preguntas).toHaveLength(4) + const usados = DESARROLLOS.filter((d) => preguntas.some((p) => p.includes(d))) + expect(usados).toHaveLength(4) + }) + + it('rota día con día: dos fechas consecutivas no comparten desarrollo en la primera pregunta', () => { + const lunes = preguntasSugeridas(new Date('2026-09-07T00:00:00')) + const martes = preguntasSugeridas(new Date('2026-09-08T00:00:00')) + expect(lunes[0]).not.toBe(martes[0]) + }) + + it('tras DESARROLLOS.length días vuelve a la combinación inicial', () => { + const base = new Date('2026-01-01T00:00:00') + const inicial = preguntasSugeridas(base) + const vuelta = preguntasSugeridas(new Date(base.getTime() + DESARROLLOS.length * 86_400_000)) + expect(vuelta).toEqual(inicial) + }) +}) diff --git a/apps/agente-ia/src/lib/vocabulario.ts b/apps/agente-ia/src/lib/vocabulario.ts new file mode 100644 index 0000000..61da304 --- /dev/null +++ b/apps/agente-ia/src/lib/vocabulario.ts @@ -0,0 +1,33 @@ +/** + * Espejo del vocabulario del API (api-gateway/src/config/vocabulario.ts): + * DESARROLLOS es la lista canónica de desarrollos FRASA. Mantener sincronizado. + */ +export const DESARROLLOS = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const + +/** Plantillas de preguntas sugeridas, parametrizadas por desarrollo */ +const PLANTILLAS: ((desarrollo: string) => string)[] = [ + (d) => `¿Qué modelos hay en el catálogo ${d}?`, + (d) => `¿Qué incluye la propuesta del cotizador ${d}?`, + (d) => `¿Cuál es la disponibilidad de unidades en ${d}?`, + (d) => `¿Qué amenidades y ubicación tiene ${d}?`, +] + +/** Día del año (1..366) para la rotación diaria */ +function diaDelAnio(fecha: Date): number { + const inicio = Date.UTC(fecha.getFullYear(), 0, 1) + const actual = Date.UTC(fecha.getFullYear(), fecha.getMonth(), fecha.getDate()) + return Math.round((actual - inicio) / 86_400_000) + 1 +} + +/** + * Preguntas sugeridas rotativas: cada una toma un desarrollo distinto + * (VOCABULARIO.DESARROLLOS) y la combinación avanza día con día, de modo que + * las sugerencias del chat rotan por desarrollo sin repetirse el mismo día. + */ +export function preguntasSugeridas(fecha: Date = new Date()): string[] { + const dia = diaDelAnio(fecha) + return PLANTILLAS.map((plantilla, i) => { + const desarrollo = DESARROLLOS[(dia + i) % DESARROLLOS.length]! + return plantilla(desarrollo) + }) +} diff --git a/apps/agente-ia/src/pages/ChatPage.tsx b/apps/agente-ia/src/pages/ChatPage.tsx index cb45fb2..0a539de 100644 --- a/apps/agente-ia/src/pages/ChatPage.tsx +++ b/apps/agente-ia/src/pages/ChatPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import Burbuja from '../components/Burbuja' import DarkModeToggle from '../components/DarkModeToggle' import toast from "react-hot-toast" @@ -10,25 +10,20 @@ import { type ConversacionResumen, } from '../services/api' import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion' +import { preguntasSugeridas } from '../lib/vocabulario' import type { Mensaje } from '../types' let contadorLocal = 0 const idLocal = () => `local-${++contadorLocal}` -const PREGUNTAS_SUGERIDAS = [ - '¿Qué incluye la propuesta del cotizador DIAGONAL?', - '¿Cuáles son los pasos del proceso de venta?', - '¿Qué políticas de Frasa debo conocer?', - '¿Qué modelos hay en el catálogo DIAGONAL?', -] - export default function ChatPage() { const [sessionId, setSessionId] = useState(null) const [mensajes, setMensajes] = useState([]) const [entrada, setEntrada] = useState('') const [escribiendo, setEscribiendo] = useState(false) const [sinConexion, setSinConexion] = useState(false) - const [error, setError] = useState(null) + // Preguntas sugeridas del día: rotan por desarrollo (VOCABULARIO.DESARROLLOS) + const preguntasSugeridasHoy = useMemo(() => preguntasSugeridas(), []) // Texto del último envío fallido (habilita "Reintentar" junto al input) const [pendiente, setPendiente] = useState(null) // Panel de conversaciones previas @@ -70,7 +65,7 @@ export default function ChatPage() { if (esErrorDeRed(err)) { setSinConexion(true) } else { - toast.error('Error al enviar el mensaje'); setError(err instanceof Error ? err.message : 'Error al enviar el mensaje') + toast.error('Error al enviar el mensaje') } setPendiente(texto) // el mensaje del usuario se queda visible; puede reintentar } finally { @@ -82,7 +77,6 @@ export default function ChatPage() { const texto = entrada.trim() if (!texto || escribiendo) return setEntrada('') - setError(null) setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }]) await transmitir(texto) } @@ -90,7 +84,6 @@ export default function ChatPage() { /** Reenvía el último mensaje que falló (sin duplicar la burbuja del usuario) */ async function reintentar() { if (!pendiente || escribiendo) return - setError(null) setSinConexion(false) await transmitir(pendiente) } @@ -99,15 +92,14 @@ export default function ChatPage() { limpiarSesion() setSessionId(null) setMensajes([]) - setError(null) setSinConexion(false) setPendiente(null) } - /** Abre el panel y carga (una vez) el listado de conversaciones del usuario */ + /** Abre el panel y recarga SIEMPRE el listado de conversaciones del usuario */ function abrirPanel() { setPanelAbierto(true) - if (conversaciones !== null || cargandoConversaciones) return + if (cargandoConversaciones) return setCargandoConversaciones(true) listarConversaciones() .then(setConversaciones) @@ -123,7 +115,6 @@ export default function ChatPage() { guardarSesion(id) setMensajes(historial) setPendiente(null) - setError(null) setSinConexion(false) setPanelAbierto(false) } catch { @@ -132,7 +123,7 @@ export default function ChatPage() { } return ( -
+
@@ -176,11 +167,6 @@ export default function ChatPage() { nuevo.

)} - {error && ( -

- {error} -

- )} {mensajes.length === 0 && !escribiendo ? (
@@ -193,7 +179,7 @@ export default function ChatPage() { venta o las políticas de Frasa.

- {PREGUNTAS_SUGERIDAS.map((pregunta) => ( + {preguntasSugeridasHoy.map((pregunta) => (