diff --git a/api-gateway/src/controllers/ai.controller.ts b/api-gateway/src/controllers/ai.controller.ts index 65aa5f8..25a4713 100644 --- a/api-gateway/src/controllers/ai.controller.ts +++ b/api-gateway/src/controllers/ai.controller.ts @@ -114,6 +114,12 @@ export async function obtenerConversacion(req: Request, res: Response): Promise< include: { mensajes: { orderBy: { createdAt: 'asc' } } }, }) if (!conversacion) throw new NotFoundError('Conversación no encontrada') + // Propiedad: misma regla que al borrar — sólo el dueño o un rol de + // supervisión puede leer la conversación. + const tokenLectura = req.usuarioToken + if (tokenLectura && !esAdminOManager(tokenLectura.rol) && conversacion.usuarioId !== tokenLectura.sub) { + throw new AppError(403, 'Solo puedes ver tus conversaciones') + } res.json({ id: conversacion.id, createdAt: conversacion.createdAt, diff --git a/api-gateway/src/controllers/auditoria.controller.test.ts b/api-gateway/src/controllers/auditoria.controller.test.ts index cd6fa8f..7d773c9 100644 --- a/api-gateway/src/controllers/auditoria.controller.test.ts +++ b/api-gateway/src/controllers/auditoria.controller.test.ts @@ -68,7 +68,7 @@ describe('listarAuditoriaEndpoint (GET /api/auditoria)', () => { expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ - createdAt: expect.objectContaining({ gte: expect.any(Date), lte: expect.any(Date) }), + createdAt: expect.objectContaining({ gte: expect.any(Date), lt: expect.any(Date) }), }), }), ) diff --git a/api-gateway/src/controllers/clientes.controller.ts b/api-gateway/src/controllers/clientes.controller.ts index 9920893..8f3f157 100644 --- a/api-gateway/src/controllers/clientes.controller.ts +++ b/api-gateway/src/controllers/clientes.controller.ts @@ -525,6 +525,22 @@ async function prefillApartadoLocal(itemId: string) { }) } + // Datos del alta (payloadJson) que el prefill del front consume con claves + // legacy de Monday: dirección, estado civil y fecha de nacimiento + const contactoExtra: Record = {} + if (cliente?.payloadJson) { + try { + const payload = JSON.parse(cliente.payloadJson) as { datosGenerales?: Record } + const g = payload.datosGenerales ?? {} + const str = (v: unknown): string => (typeof v === 'string' && v.trim() !== '' ? v : '') + if (str(g['direccion']) !== '') contactoExtra['Dirección'] = str(g['direccion']) + if (str(g['estadoCivil']) !== '') contactoExtra['Estado Civil'] = str(g['estadoCivil']) + if (str(g['fechaNacimiento']) !== '') contactoExtra['Fecha Nacimiento'] = str(g['fechaNacimiento']) + } catch { + // payload inválido: el prefill sale parcial, como antes + } + } + return { apartado: { id: captura.leadId, @@ -545,6 +561,7 @@ async function prefillApartadoLocal(itemId: string) { Email: cliente.email ?? '', CURP: cliente.curp, RFC: cliente.rfc, + ...contactoExtra, } : null, oportunidad: oportunidadLocal diff --git a/api-gateway/src/controllers/cotizaciones.controller.ts b/api-gateway/src/controllers/cotizaciones.controller.ts index f980511..5824dea 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.ts @@ -149,8 +149,12 @@ export async function crearCotizacion(req: Request, res: Response): Promise { const pagado = p.pagos.reduce((a, x) => a + x.montoPagado, 0) const saldo = p.pagos.reduce((a, x) => a + (x.monto - x.montoPagado), 0) - const vencidos = p.pagos.filter((x) => x.fechaVencimiento < hoy && x.montoPagado < x.monto) + const vencidos = p.pagos.filter((x) => x.fechaVencimiento < inicioHoy && x.montoPagado < x.monto) const proximo = p.pagos.filter((x) => x.montoPagado < x.monto && x.fechaVencimiento >= hoy) .sort((a, b) => a.fechaVencimiento.getTime() - b.fechaVencimiento.getTime())[0] const abonos = p.pagos @@ -1054,7 +1057,8 @@ export function crearEjecutoresHerramientas( prisma.planPago.count({ where: { status: 'ACTIVO', ...alcance } }), prisma.planPago.count({ where: { status: 'CANCELADO', ...alcance } }), prisma.pago.findMany({ - where: { fechaVencimiento: { lt: new Date() }, plan: alcance }, + // Sólo planes activos: el adeudo de planes cancelados no cuenta + where: { fechaVencimiento: { lt: new Date() }, plan: { ...alcance, status: 'ACTIVO' } }, select: { monto: true, montoPagado: true, @@ -1505,6 +1509,18 @@ export async function chat( ? await prisma.aIConversacion.findUnique({ where: { id: sessionId } }) : null if (sessionId && !conversacion) throw new NotFoundError('Conversación no encontrada') + // Propiedad: sólo el dueño (o un rol de supervisión) puede reanudar una + // conversación existente. Sin esto, cualquiera con el uuid continuaba el + // chat ajeno con todo su historial. + if ( + conversacion && + opciones.usuarioId && + conversacion.usuarioId && + conversacion.usuarioId !== opciones.usuarioId && + !esAdminOManager(opciones.contexto?.rol) + ) { + throw new AppError(403, 'No puedes continuar la conversación de otro usuario') + } conversacion ??= await prisma.aIConversacion.create({ data: { usuarioId: opciones.usuarioId ?? null }, }) diff --git a/api-gateway/src/services/auditoria.service.test.ts b/api-gateway/src/services/auditoria.service.test.ts index 8abb601..4014315 100644 --- a/api-gateway/src/services/auditoria.service.test.ts +++ b/api-gateway/src/services/auditoria.service.test.ts @@ -94,7 +94,8 @@ describe('buscar', () => { prismaMock.usuario.findMany.mockResolvedValue([]) await buscar({ desde, hasta }) expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({ - where: { createdAt: { gte: desde, lte: hasta } }, + // "hasta" incluye todo el día: se excluye con lt al día siguiente + where: { createdAt: { gte: desde, lt: new Date(hasta.getTime() + 24 * 60 * 60 * 1000) } }, orderBy: { createdAt: 'desc' }, take: 100, }) diff --git a/api-gateway/src/services/auditoria.service.ts b/api-gateway/src/services/auditoria.service.ts index 966abbb..82cd7ef 100644 --- a/api-gateway/src/services/auditoria.service.ts +++ b/api-gateway/src/services/auditoria.service.ts @@ -43,7 +43,9 @@ export async function buscar(filtros: FiltrosAuditoria, limite = 100) { if (filtros.desde !== undefined || filtros.hasta !== undefined) { where['createdAt'] = { ...(filtros.desde !== undefined ? { gte: filtros.desde } : {}), - ...(filtros.hasta !== undefined ? { lte: filtros.hasta } : {}), + // "hasta" es inclusive de todo el día: se excluye con lt al día siguiente + // (antes con lte a las 00:00 del día se perdía casi todo el día elegido) + ...(filtros.hasta !== undefined ? { lt: new Date(filtros.hasta.getTime() + 24 * 60 * 60 * 1000) } : {}), } } const logs = await prisma.auditoriaLog.findMany({ diff --git a/api-gateway/src/services/buscar.service.test.ts b/api-gateway/src/services/buscar.service.test.ts index 963221c..1903e98 100644 --- a/api-gateway/src/services/buscar.service.test.ts +++ b/api-gateway/src/services/buscar.service.test.ts @@ -155,8 +155,8 @@ describe('buscar (GET /api/buscar)', () => { ]) const r = await buscar('X') - expect(r.find((i) => i.tipo === 'lead')?.url).toBe('/app/#leads') - expect(r.find((i) => i.tipo === 'contacto')?.url).toBe('/app/#clientes') + expect(r.find((i) => i.tipo === 'lead')?.url).toBe('/hoy/') + expect(r.find((i) => i.tipo === 'contacto')?.url).toBe('/') expect(r.find((i) => i.tipo === 'oportunidad')?.url).toBe('/quote/?cotizacion=ct1') expect(r.find((i) => i.tipo === 'unidad')?.url).toBe('/quote/?unidad=X') }) diff --git a/api-gateway/src/services/buscar.service.ts b/api-gateway/src/services/buscar.service.ts index 26447df..af61ee5 100644 --- a/api-gateway/src/services/buscar.service.ts +++ b/api-gateway/src/services/buscar.service.ts @@ -88,14 +88,14 @@ export async function buscar( id: l.leadId, nombre: l.nombreLead, detalle: [l.telefono, l.email].filter(Boolean).join(' · ') || l.origen || l.board, - url: `/app/#leads`, + url: `/hoy/`, })), ...clientes.map((c) => ({ tipo: 'contacto' as const, id: c.id, nombre: c.nombreCompleto, detalle: [c.email, c.celular, c.proyecto].filter(Boolean).join(' · ') || '', - url: `/app/#clientes`, + url: `/`, })), ...cotizaciones.map((ct) => ({ tipo: 'oportunidad' as const, diff --git a/api-gateway/src/services/contactos-local.service.test.ts b/api-gateway/src/services/contactos-local.service.test.ts index 2f251ca..dc3752d 100644 --- a/api-gateway/src/services/contactos-local.service.test.ts +++ b/api-gateway/src/services/contactos-local.service.test.ts @@ -212,7 +212,8 @@ describe('listarContactosLocal', () => { }) 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 + // 101 clientes: la paginación se aplica DESPUÉS de fusionar fuentes, así + // que el servicio pide el universo completo y corta en memoria 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) }), @@ -225,13 +226,12 @@ describe('listarContactosLocal', () => { 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' }), - ]) + // La página 2 trae la fila más vieja (orden desc por fecha: la 101 va + // primero en la página 1) const pagina2 = await listarContactosLocal(undefined, 2) expect(pagina2.pagina).toBe(2) expect(pagina2.contactos).toHaveLength(1) + expect(pagina2.contactos[0]?.id).toBe('c1') expect(pagina2.hayMas).toBe(false) }) }) diff --git a/api-gateway/src/services/contactos-local.service.ts b/api-gateway/src/services/contactos-local.service.ts index 50a1a81..a924f94 100644 --- a/api-gateway/src/services/contactos-local.service.ts +++ b/api-gateway/src/services/contactos-local.service.ts @@ -92,12 +92,12 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom 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 + // La paginación se aplica DESPUÉS de fusionar las dos fuentes: antes se hacía + // skip/take por fuente y el dedupe posterior perdía contactos entre páginas + // y daba hayMas=false antes de tiempo. const clientes = await prisma.cliente.findMany({ where: usuarioId ? { capturadoPorId: usuarioId } : {}, orderBy: { createdAt: 'desc' }, - skip, - take: porPagina + 1, }) const capturas = await prisma.leadCaptura.findMany({ where: { @@ -105,8 +105,6 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom ...(usuarioId ? { usuarioId } : {}), }, orderBy: { updatedAt: 'desc' }, - skip, - take: porPagina + 1, }) const lista: Candidato[] = [] @@ -207,12 +205,11 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom }) } const ordenada = lista.map((c) => c.contacto).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) - const hayMas = ordenada.length > porPagina return { - contactos: ordenada.slice(0, porPagina), + contactos: ordenada.slice(skip, skip + porPagina), pagina: paginaValida, porPagina, - hayMas, + hayMas: ordenada.length > skip + porPagina, } } diff --git a/api-gateway/src/services/crm-local.service.ts b/api-gateway/src/services/crm-local.service.ts index 3751b14..06ad65b 100644 --- a/api-gateway/src/services/crm-local.service.ts +++ b/api-gateway/src/services/crm-local.service.ts @@ -124,27 +124,11 @@ export class CrmLocalService { if (oportunidadId !== undefined) { const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } }) - // Sin fila local (id heredado del CRM anterior): se crea la sombra con - // ese id para que la captura no se pierda en silencio. + // Sin fila local: id inválido o liga guardada de una limpieza anterior. + // Antes se creaba una oportunidad "sombra" fantasma con ese id; ahora se + // rechaza para no sembrar registros huérfanos en Oportunidades. if (!existente) { - await prisma.leadCaptura.create({ - data: { - leadId: oportunidadId, - oportunidadId, - usuarioId: usuarioId ?? 'desconocido', - nombreLead: datos.nombre, - telefono: datos.telefono ?? null, - email: datos.email ?? null, - origen: datos.origen ?? null, - desarrollo: datos.desarrollo ?? null, - modelo: modelo ?? null, - board: 'OPORTUNIDADES', - etapa: 'Afluencia', - datosJson: JSON.stringify(datos), - pendienteSync: true, - }, - }) - return { id: oportunidadId, oportunidadId, actualizada: true } + throw new NotFoundError(`No existe la oportunidad ${oportunidadId}`) } // Sólo se pisan los campos capturados: lo que no viene en el payload se diff --git a/api-gateway/src/services/lead-scoring.service.test.ts b/api-gateway/src/services/lead-scoring.service.test.ts index 5055a18..81d6267 100644 --- a/api-gateway/src/services/lead-scoring.service.test.ts +++ b/api-gateway/src/services/lead-scoring.service.test.ts @@ -137,6 +137,8 @@ describe('obtenerScores', () => { where: { board: { in: ['LEADS', 'OPORTUNIDADES'] }, usuarioId: 'u1', + // Los cerrados (descartados/perdidos) no reciben score + etapa: { notIn: ['Descartado', 'Perdido'] }, }, }) }) diff --git a/api-gateway/src/services/lead-scoring.service.ts b/api-gateway/src/services/lead-scoring.service.ts index a4818e5..248d559 100644 --- a/api-gateway/src/services/lead-scoring.service.ts +++ b/api-gateway/src/services/lead-scoring.service.ts @@ -10,9 +10,10 @@ import { prisma } from './prisma.js' const ETAPA_SCORE: Record = { NUEVO: 20, + 'INTENTO DE CONTACTO': 30, CONTACTADO: 40, INTERESADO: 60, - AGENDO: 80, + 'AGENDO CITA': 80, CALIFICADO: 90, } @@ -74,6 +75,8 @@ export function calcularScore(lead: { export async function obtenerScores(usuarioId?: string): Promise { const where: Record = { board: { in: ['LEADS', 'OPORTUNIDADES'] }, + // Los cerrados (descartados/perdidos) no reciben score ni salen en el ranking + etapa: { notIn: ['Descartado', 'Perdido'] }, } if (usuarioId) { where['usuarioId'] = usuarioId diff --git a/api-gateway/src/services/recordatorios-cobranza.service.test.ts b/api-gateway/src/services/recordatorios-cobranza.service.test.ts index f0e7715..fac8477 100644 --- a/api-gateway/src/services/recordatorios-cobranza.service.test.ts +++ b/api-gateway/src/services/recordatorios-cobranza.service.test.ts @@ -1,4 +1,11 @@ import { describe, expect, it, vi } from 'vitest' + +// ---------- Prisma mockeado ---------- +// La pasada consulta un admin de respaldo para los planes sin vendedor +// (antes caían a 'sin-destinatario' y nadie los veía). +const prismaMock = vi.hoisted(() => ({ usuario: { findFirst: vi.fn() } })) +vi.mock('./prisma.js', () => ({ prisma: prismaMock })) + import type { Pago, PlanPago } from './cobranza.service.js' import { RecordatoriosCobranzaService, @@ -231,7 +238,29 @@ describe('RecordatoriosCobranzaService.ejecutarPasada', () => { expect(log).toHaveLength(1) }) - it('sin destinatario (plan sin vendedor): solo registra en el log, sin canal', async () => { + it('sin destinatario (plan sin vendedor): el recordatorio cae al administrador de respaldo', async () => { + prismaMock.usuario.findFirst.mockResolvedValue({ id: 'admin-qa' }) + const planesSinVendedor = [ + plan({ + id: 'p1', + vendedorId: null, + pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, 2) })], + }), + ] + const { store, log } = storeFake(planesSinVendedor) + const { canal, envios } = canalFake() + const svc = new RecordatoriosCobranzaService(store, [canal]) + + const r = await svc.ejecutarPasada({ ahora: HOY }) + expect(r.enviados).toHaveLength(1) + expect(r.enviados[0]).toMatchObject({ canal: 'fake', destinatario: 'admin-qa' }) + expect(envios).toHaveLength(1) + expect(envios[0].destinatario).toMatchObject({ vendedorId: 'admin-qa', origen: 'admin-fallback' }) + expect(log).toHaveLength(1) + }) + + it('sin destinatario y SIN admin en el sistema: solo registra en el log, sin canal', async () => { + prismaMock.usuario.findFirst.mockResolvedValue(null) const planesSinVendedor = [ plan({ id: 'p1', diff --git a/api-gateway/src/services/recordatorios-cobranza.service.ts b/api-gateway/src/services/recordatorios-cobranza.service.ts index 10a6a41..1bdb7ea 100644 --- a/api-gateway/src/services/recordatorios-cobranza.service.ts +++ b/api-gateway/src/services/recordatorios-cobranza.service.ts @@ -130,10 +130,11 @@ export function debeEnviar( export interface DestinatarioRecordatorio { /** usuarioId del vendedor responsable; null = no hay a quién asignar */ vendedorId: string | null - origen: 'vendedor' | 'ninguno' + origen: 'vendedor' | 'ninguno' | 'admin-fallback' } -/** Regla pura de selección: vendedor del plan → nadie */ +/** Regla pura de selección: vendedor del plan → nadie (el fallback a admin se + * resuelve en la pasada, que tiene acceso a la BD). */ export function elegirDestinatario(vendedorId: string | null): DestinatarioRecordatorio { if (vendedorId !== null) return { vendedorId, origen: 'vendedor' } return { vendedorId: null, origen: 'ninguno' } @@ -218,9 +219,21 @@ export class RecordatoriosCobranzaService { const planes = await this.store.cargarPlanes() const candidatos = candidatosRecordatorio(planes, ahora, diasAntes) + // Planes sin vendedor asignado (p. ej. cartera importada sin asesor): el + // recordatorio cae al administrador para que no se pierda y pueda + // reasignar el plan. Antes quedaban en 'sin-destinatario' y nadie los veía. + const adminFallback = await prisma.usuario.findFirst({ + where: { rol: 'admin', activo: true }, + orderBy: { createdAt: 'asc' }, + select: { id: true }, + }) + const resultado: ResultadoPasada = { dryRun, evaluados: [], enviados: [], fallidos: [] } for (const c of candidatos) { - const destinatario = elegirDestinatario(c.vendedorId) + let destinatario = elegirDestinatario(c.vendedorId) + if (destinatario.origen === 'ninguno' && adminFallback) { + destinatario = { vendedorId: adminFallback.id, origen: 'admin-fallback' } + } const ultimo = await this.store.ultimoRecordatorio(c.pagoId, c.tipo) if (!debeEnviar(ultimo, c.tipo, ahora, revolverDias)) { diff --git a/api-gateway/src/services/reporte-mi-tablero.service.test.ts b/api-gateway/src/services/reporte-mi-tablero.service.test.ts index 16e12bc..d057474 100644 --- a/api-gateway/src/services/reporte-mi-tablero.service.test.ts +++ b/api-gateway/src/services/reporte-mi-tablero.service.test.ts @@ -77,15 +77,17 @@ describe('construirMiTablero', () => { captura({ board: 'LEADS' }), captura({ board: 'LEADS' }), captura({ board: 'OPORTUNIDADES' }), - captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }), - captura({ board: 'APARTADO', etapa: 'CANCELADO' }), + captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE' }), + captura({ board: 'APARTADO', etapa: 'CANCELO' }), + captura({ board: 'APARTADO', etapa: 'ESCRITURADO' }), captura({ board: 'LEADS', etapa: 'DESCARTADO' }), ] const tablero = construirMiTablero(capturas, [], [], AHORA) expect(tablero.conversion.leads).toBe(3) // 2 activos + 1 descartado expect(tablero.conversion.oportunidades).toBe(1) - expect(tablero.conversion.apartados).toBe(1) // CANCELADO no cuenta - expect(tablero.conversion.vendidos).toBe(1) + // Cancelo no cuenta; Firma Cliente y Escriturado ya son ventas + expect(tablero.conversion.apartados).toBe(0) + expect(tablero.conversion.vendidos).toBe(2) expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO }) @@ -146,9 +148,9 @@ describe('construirMiTablero', () => { it('agrupa por desarrollo solo con vendidos', () => { const capturas = [ - captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)', desarrollo: 'DIAGONAL' }), + captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE', desarrollo: 'DIAGONAL' }), captura({ board: 'APARTADO', etapa: 'ESCRITURADO', desarrollo: 'DIAGONAL' }), - captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)', desarrollo: 'ATELIER' }), + captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE', desarrollo: 'ATELIER' }), captura({ board: 'APARTADO', etapa: 'NUEVO', desarrollo: 'SENDA' }), // no vendido ] const tablero = construirMiTablero(capturas, [], [], AHORA) diff --git a/api-gateway/src/services/reporte-mi-tablero.service.ts b/api-gateway/src/services/reporte-mi-tablero.service.ts index 41c717e..75ec359 100644 --- a/api-gateway/src/services/reporte-mi-tablero.service.ts +++ b/api-gateway/src/services/reporte-mi-tablero.service.ts @@ -122,13 +122,19 @@ export function construirMiTablero( // -- Conversión (funnel) -- const leads = capturas.filter((c) => c.board === 'LEADS').length const oportunidades = capturas.filter((c) => c.board === 'OPORTUNIDADES').length + // Apartados ACTIVOS del funnel: ni cancelados (Cancelo/Cancelacion Apartado) + // ni ya vendidos (Firma Cliente/Escriturado/Titulado pasan a "vendidos") const apartados = capturas.filter( - (c) => c.board === 'APARTADO' && c.etapa.toUpperCase() !== 'CANCELADO', + (c) => + c.board === 'APARTADO' && + !['FIRMA CLIENTE', 'CANCELO', 'CANCELACION APARTADO', 'ESCRITURADO', 'TITULADO'].includes( + c.etapa.toUpperCase(), + ), ).length const vendidos = capturas.filter( (c) => c.board === 'APARTADO' && - ['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()), + ['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()), ).length const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length @@ -184,7 +190,7 @@ export function construirMiTablero( for (const c of capturas) { if ( c.board === 'APARTADO' && - ['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()) && + ['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()) && c.desarrollo ) { const dev = c.desarrollo.trim() diff --git a/apps/alta-clientes/src/components/SignaturePad.tsx b/apps/alta-clientes/src/components/SignaturePad.tsx index 430d9d9..8e45291 100644 --- a/apps/alta-clientes/src/components/SignaturePad.tsx +++ b/apps/alta-clientes/src/components/SignaturePad.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect } from 'react' +import { useRef } from 'react' import SignatureCanvas from 'react-signature-canvas' interface Props { @@ -9,11 +9,10 @@ interface Props { export default function SignaturePad({ onFirma }: Props) { const ref = useRef(null) - useEffect(() => { - // Notifica firma vacía al montar (estado inicial) - onFirma('') - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + // NOTA: no se notifica '' al montar. El pad se desmonta/remonta cada vez + // que el asesor sale y vuelve al paso de firmas, y ese reset borraba la + // firma ya capturada (los PDFs salían sin firma). El estado vacío inicial + // ya lo maneja el formulario padre. const limpiar = () => { ref.current?.clear() diff --git a/apps/alta-clientes/src/pages/AltaClientePage.tsx b/apps/alta-clientes/src/pages/AltaClientePage.tsx index 1a69ad8..8bfac87 100644 --- a/apps/alta-clientes/src/pages/AltaClientePage.tsx +++ b/apps/alta-clientes/src/pages/AltaClientePage.tsx @@ -337,8 +337,8 @@ export default function AltaClientePage() { const res = datos.progresoId ? await completarProgreso(datos.progresoId, alta) : await crearAltaCliente(alta) - toast.success("Alta de cliente completada") if (res.ok) { + toast.success('Alta de cliente completada') const { fallidos } = await subirDocumentos(res.id, alta) setRegistro(res.id) setSinConexion(false) diff --git a/apps/cobranza/src/lib/pdf/reciboAbono.ts b/apps/cobranza/src/lib/pdf/reciboAbono.ts index ff8b33b..d7ab8ad 100644 --- a/apps/cobranza/src/lib/pdf/reciboAbono.ts +++ b/apps/cobranza/src/lib/pdf/reciboAbono.ts @@ -92,7 +92,8 @@ export function crearDocumento( doc.setFontSize(9) doc.text('_______________________________', 105, y, { align: 'center' }) y += 5 - doc.text(`Recibió — ${plan.clienteNombre}`, 105, y, { align: 'center' }) + // Quien recibe el pago es FRASA (el cliente es quien paga) + doc.text('Recibió — FRASA DESARROLLOS S.A.P.I. DE C.V.', 105, y, { align: 'center' }) return { doc, nombreArchivo: `recibo_${plan.unidadCodigo}_${folio}.pdf` } } diff --git a/apps/cobranza/src/pages/NuevoPlanPage.tsx b/apps/cobranza/src/pages/NuevoPlanPage.tsx index a23274c..b3c8975 100644 --- a/apps/cobranza/src/pages/NuevoPlanPage.tsx +++ b/apps/cobranza/src/pages/NuevoPlanPage.tsx @@ -1,20 +1,22 @@ import toast from "react-hot-toast" -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { useNavigate } from 'react-router-dom' -import { crearPlanAsync } from '../services/repository' +import { crearPlanAsync, listarProyectosAsync } from '../services/repository' import { generarCalendario } from '../lib/cobranza' import { fechaISOHoy, formatoFechaCorta, formatoMoneda } from '../lib/formatters' -const PROYECTOS = ['RESIDENCIAL MONTEBELLO', 'SENDA', 'DIAGONAL'] as const +// Proyecto libre (con sugerencias del datalist): la cartera real incluye +// desarrollos que un enum hardcodeado no cubría (ZAFIRO, UMBRAL, SOLE, +// DIAGONAL 40, SOLANA I/II...), así que ya no se limita a una lista fija. const esquema = z.object({ clienteNombre: z.string().trim().min(3, 'Nombre del cliente requerido (mín. 3 caracteres)'), clienteTelefono: z.string().trim().optional(), unidadCodigo: z.string().trim().min(1, 'Código de unidad requerido'), - proyecto: z.enum(PROYECTOS), + proyecto: z.string().trim().min(1, 'Proyecto requerido'), ventaRef: z.string().trim(), fechaInicio: z.string().min(1, 'Fecha de inicio requerida'), engancheTotal: z @@ -46,6 +48,11 @@ export default function NuevoPlanPage() { const navigate = useNavigate() const [guardando, setGuardando] = useState(false) const [errorGuardado, setErrorGuardado] = useState(null) + // Proyectos existentes en la cartera: alimentan el datalist de sugerencias + const [proyectosSugeridos, setProyectosSugeridos] = useState([]) + useEffect(() => { + void listarProyectosAsync().then(setProyectosSugeridos) + }, []) const { register, handleSubmit, @@ -56,7 +63,7 @@ export default function NuevoPlanPage() { defaultValues: { clienteNombre: '', unidadCodigo: '', - proyecto: 'SENDA', + proyecto: '', ventaRef: '', fechaInicio: fechaISOHoy(), numPagos: 6, @@ -122,13 +129,19 @@ export default function NuevoPlanPage() {
- + + {proyectosSugeridos.map((p) => ( + + {errors.proyecto &&

{errors.proyecto.message}

}
diff --git a/apps/cobranza/src/services/repository.ts b/apps/cobranza/src/services/repository.ts index 2d6d4ce..38dd96a 100644 --- a/apps/cobranza/src/services/repository.ts +++ b/apps/cobranza/src/services/repository.ts @@ -365,3 +365,13 @@ export async function actualizarPlanAsync( export function sinPlanesAsignados(resultado: ResultadoPlanes, mios: boolean): boolean { return mios && resultado.origen === 'api' && resultado.planes.length === 0 } + +/** GET /planes — proyectos distintos de la cartera (datalist del alta de plan) */ +export async function listarProyectosAsync(): Promise { + try { + const planes = await request('/planes') + return [...new Set(planes.map((p) => p.proyecto).filter((p) => typeof p === 'string' && p.trim() !== ''))].sort() + } catch { + return [] + } +} diff --git a/apps/cotizador/src/pages/CotizadorPage.tsx b/apps/cotizador/src/pages/CotizadorPage.tsx index aed321a..73aa8c4 100644 --- a/apps/cotizador/src/pages/CotizadorPage.tsx +++ b/apps/cotizador/src/pages/CotizadorPage.tsx @@ -25,6 +25,7 @@ import { indiceMenorTotal, totalPagado, ENGANCHE_MAXIMO_PCT } from '../lib/calcu import { desarrolloEfectivo, idsCotizables, propuestaVigente } from '../lib/reglas' import { getMisCotizaciones, + getCotizacion, guardarCotizacion, urlPublicaCotizacion, ErrorApiCotizacion, @@ -103,6 +104,9 @@ export default function CotizadorPage() { // ligado a la cotización al guardar; la unidad se preselecciona al cargar const [oportunidadId, setOportunidadId] = useState(null) const [unidadPendiente, setUnidadPendiente] = useState(null) + // Retomar una cotización llegada por URL (/quote/?cotizacion=, desde la + // búsqueda global): se aplica cuando termina de cargar inventario+reglas + const [cotizacionPendiente, setCotizacionPendiente] = useState(null) // Si el desarrollo activo deja de ofrecerse (lo desactivó el admin), se cae al // primero disponible para no dejar el cotizador en un desarrollo muerto. @@ -189,6 +193,8 @@ export default function CotizadorPage() { } if (unidadParam) setUnidadPendiente(unidadParam) if (opId) setOportunidadId(opId) + const cotizacionParam = qs.get('cotizacion') + if (cotizacionParam) setCotizacionPendiente(cotizacionParam) // Solo al montar: es el punto de entrada desde Mi día // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -221,6 +227,20 @@ export default function CotizadorPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [unidades, cargando, unidadPendiente]) + // Retoma la cotización pedida por URL cuando el inventario ya cargó + useEffect(() => { + if (cotizacionPendiente === null || cargando) return + const id = cotizacionPendiente + setCotizacionPendiente(null) + void getCotizacion(id) + .then((c) => { + if (c) handleRetomar(c) + else setAviso(`No se encontró la cotización entre las tuyas (${id}).`) + }) + .catch(() => setAviso('No se pudo cargar la cotización de la liga.')) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cotizacionPendiente, cargando, unidades]) + // La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))] // Total pagado por el cliente con la propuesta elegida; null si no se puede conocer diff --git a/apps/cotizador/src/services/api.ts b/apps/cotizador/src/services/api.ts index b368ddf..111b6c2 100644 --- a/apps/cotizador/src/services/api.ts +++ b/apps/cotizador/src/services/api.ts @@ -176,6 +176,15 @@ export async function getMisCotizaciones(): Promise { return (await resp.json()) as CotizacionGuardada[] } +/** GET /cotizaciones/:id — una cotización del vendedor (retomar desde URL) */ +export async function getCotizacion(id: string): Promise { + const resp = await fetch(`${API_URL}/cotizaciones/${encodeURIComponent(id)}`, conAuth()) + manejar401(resp) + if (resp.status === 404) return null + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + return (await resp.json()) as CotizacionGuardada +} + // ---------- Búsqueda de clientes para el prellenado del cotizador ---------- export interface LeadResumen { diff --git a/apps/landing/src/services/config.ts b/apps/landing/src/services/config.ts index 81d4325..b592f6c 100644 --- a/apps/landing/src/services/config.ts +++ b/apps/landing/src/services/config.ts @@ -188,7 +188,7 @@ export async function getAuditoria(filtros?: { }): Promise { const params = new URLSearchParams() if (filtros?.entidad) params.set('entidad', filtros.entidad) - if (filtros?.usuario) params.set('usuario', filtros.usuario) + if (filtros?.usuario) params.set('usuarioId', filtros.usuario) if (filtros?.desde) params.set('desde', filtros.desde) if (filtros?.hasta) params.set('hasta', filtros.hasta) const qs = params.toString() diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx index 374bbf8..b234c49 100644 --- a/apps/mi-dia/src/pages/MiDiaPage.tsx +++ b/apps/mi-dia/src/pages/MiDiaPage.tsx @@ -209,7 +209,16 @@ function FilaOportunidad({