diff --git a/api-gateway/src/controllers/clientes.controller.ts b/api-gateway/src/controllers/clientes.controller.ts index cdae572..1d533fa 100644 --- a/api-gateway/src/controllers/clientes.controller.ts +++ b/api-gateway/src/controllers/clientes.controller.ts @@ -248,6 +248,40 @@ async function crearApartadoSiAplica( }) } +/** + * Apartado Monday del alta: con apartadoMondayId (alta arrancada desde un + * apartado existente, botón "Firma Cliente (Venta)" de Mis apartados) se + * LIGA y enriquece ese item; sin él, se crea uno nuevo como siempre. + */ +async function apartadoDeAlta( + dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined }, + vc: { + ubicacion?: string | undefined + valorVenta?: string | number | undefined + enganche?: string | number | undefined + tiposCredito?: unknown + }, + capturadoPorId: string | null, + apartadoMondayId: unknown, +): Promise { + const existente = typeof apartadoMondayId === 'string' && apartadoMondayId !== '' ? apartadoMondayId : null + if (existente === null) return crearApartadoSiAplica(dg, vc, capturadoPorId) + const valorVenta = aNumero(vc.valorVenta) + const enganche = aNumero(vc.enganche) + // Solo se mandan los campos con valor (exactOptionalPropertyTypes) + await mondayService.actualizarApartado(existente, { + clienteNombre: dg.nombreCompleto, + ...(vc.ubicacion !== undefined && vc.ubicacion !== '' ? { unidadCodigo: vc.ubicacion } : {}), + ...(dg.celular !== undefined && dg.celular !== '' ? { telefono: dg.celular } : {}), + ...(dg.email !== undefined && dg.email !== '' ? { email: dg.email } : {}), + ...(dg.curp !== undefined && dg.curp !== '' ? { curp: dg.curp } : {}), + ...(valorVenta > 0 ? { precioFinal: valorVenta } : {}), + ...(enganche > 0 ? { enganche } : {}), + ...(formaPagoLabel(vc.tiposCredito) !== undefined ? { formaPagoLabel: formaPagoLabel(vc.tiposCredito) } : {}), + }) + return existente +} + /** POST /api/clientes — persiste el alta y la replica en Monday (Contactos y Apartado) */ export async function crearCliente(req: Request, res: Response): Promise { const body = crearClienteSchema.parse(req.body) @@ -290,7 +324,7 @@ export async function crearCliente(req: Request, res: Response): Promise { ...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) }, }) - mondayApartadoId = await crearApartadoSiAplica(dg, vc, req.usuarioToken?.sub ?? null) + mondayApartadoId = await apartadoDeAlta(dg, vc, req.usuarioToken?.sub ?? null, body.apartadoMondayId) await prisma.cliente.update({ where: { id: cliente.id }, @@ -493,8 +527,10 @@ export async function completarProgreso(req: Request, res: Response): Promise { + const itemId = String(req.params['itemId']) + const apartado = (await mondayService.getApartados()).find((a) => a.id === itemId) + if (!apartado) throw new NotFoundError('Apartado no encontrado') + + const tel = (apartado.telefono ?? '').replace(/\D/g, '').slice(-10) + const email = (apartado.email ?? '').trim().toLowerCase() + const coincide = (t: string, e: string): boolean => + (tel !== '' && t.replace(/\D/g, '').length >= 10 && t.replace(/\D/g, '').slice(-10) === tel) || + (email !== '' && e.trim().toLowerCase() === email) + + const [contactos, oportunidades] = await Promise.all([ + mondayService.getContactos(), + mondayService.getOportunidades(), + ]) + const contactoBase = contactos.find((c) => coincide(c.telefono, c.email)) + const contacto = contactoBase ? await mondayService.getContactoDetalle(contactoBase.id) : null + const oportunidad = oportunidades.find((o) => coincide(o.telefono, o.email)) ?? null + + // Cotización local más reciente: vinculada a la oportunidad o por teléfono + let cotizacion = null + if (oportunidad) { + cotizacion = await prisma.cotizacion.findFirst({ + where: { mondayItemId: oportunidad.id }, + orderBy: { createdAt: 'desc' }, + }) + } + if (!cotizacion && tel !== '') { + cotizacion = await prisma.cotizacion.findFirst({ + where: { clienteTelefono: { contains: tel } }, + orderBy: { createdAt: 'desc' }, + }) + } + + res.json({ + apartado, + contacto, + oportunidad, + cotizacion: cotizacion + ? { + tipoPropuesta: cotizacion.tipoPropuesta, + acabados: cotizacion.acabados, + apartado: cotizacion.apartado, + precioLista: cotizacion.precioLista, + precioFinal: cotizacion.precioFinal, + enganche: cotizacion.enganche, + numMensualidades: cotizacion.numMensualidades, + mensualidad: cotizacion.mensualidad, + pagoFinal: cotizacion.pagoFinal, + } + : null, + }) +} diff --git a/api-gateway/src/controllers/leads.controller.test.ts b/api-gateway/src/controllers/leads.controller.test.ts index 8f3d3be..0265476 100644 --- a/api-gateway/src/controllers/leads.controller.test.ts +++ b/api-gateway/src/controllers/leads.controller.test.ts @@ -4,6 +4,7 @@ import { buscarLeads, buscarLeadsEndpoint, cambiarEstadoLead, + convertirLead, crearAfluencia, crearAfluenciaEndpoint, crearLead, @@ -22,6 +23,7 @@ const prismaMock = vi.hoisted(() => ({ }, leadOportunidad: { create: vi.fn(), + findUnique: vi.fn(), }, usuario: { findUnique: vi.fn(), @@ -37,6 +39,7 @@ vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) const LABELS_DESARROLLO = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] const LABELS_ORIGEN = ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro', 'Instagram', 'Broker', 'Base de datos', 'Prospección'] const LABELS_MOTIVO = ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing'] +const LABELS_MODELO = ['1R', '2RA', '2RB', '2RC', 'HIMALAYA', 'SANTIAGO', 'SOHO', 'MIRADOR', 'PANORAMA'] /** Monday mockeado: registra escrituras y devuelve catálogos controlados */ function mondayMock( @@ -46,6 +49,7 @@ function mondayMock( labelsMotivo?: string[] duplicadoLead?: LeadMonday | null duplicadoOportunidad?: OportunidadMonday | null + leads?: LeadMonday[] } = {}, ) { return { @@ -65,6 +69,9 @@ function mondayMock( this.oportunidadesCreadas.push(datos) return 'op-nueva-1' }, + async getLeads() { + return opts.leads ?? [] + }, oportunidadesActualizadas: [] as { id: string; datos: Record }[], async actualizarOportunidadAfluencia(id: string, datos: Record) { this.oportunidadesActualizadas.push({ id, datos }) @@ -78,6 +85,8 @@ function mondayMock( async labelsDeColumna(_boardId: string, titulo: string) { if (titulo === 'Desarrollo') return opts.labelsDesarrollo ?? LABELS_DESARROLLO if (titulo === 'Motivo No Compra') return opts.labelsMotivo ?? LABELS_MOTIVO + if (titulo === 'Modelo de Interés') return LABELS_MODELO + if (titulo === 'Origen del Lead') return opts.labelsOrigen ?? ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'OTRO'] return opts.labelsOrigen ?? LABELS_ORIGEN }, async obtenerNombreItem(_itemId: string) { @@ -186,7 +195,7 @@ describe('crearLead (POST /api/leads) — captura rápida en el tablero Leads', describe('crearAfluencia (POST /api/afluencias) — la afluencia ES la oportunidad', () => { it('crea UN item en Oportunidades (Estado Afluencia) y devuelve su id como id y oportunidadId', async () => { const monday = mondayMock() - const r = await crearAfluencia(monday, { ...datosBase, origen: 'Facebook', notas: 'Vio el volante' }) + const r = await crearAfluencia(monday, { ...datosBase, origen: 'PASE', notas: 'Vio el volante' }) expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' }) expect(monday.oportunidadesCreadas).toEqual([ { @@ -194,7 +203,7 @@ describe('crearAfluencia (POST /api/afluencias) — la afluencia ES la oportunid telefono: '4771234567', email: undefined, desarrolloLabel: undefined, - origenLabel: 'Facebook', + origenLabel: 'PASE', modeloLabel: undefined, medioContacto: undefined, edad: undefined, @@ -707,3 +716,74 @@ describe('buscarLeads (GET /api/leads/buscar) — búsqueda dual Leads + Oportun ).rejects.toMatchObject({ status: 400 }) }) }) + +describe('convertirLead (POST /api/leads/:itemId/convertir) — botón "Agendo" de Mis leads', () => { + const leadConvertible = leadMonday({ + id: 'lead-conv-1', + nombre: 'Ana Demo', + telefono: '+52 6641234567', + email: 'ana@correo.com', + origen: 'PASE', + desarrollo: 'SENDA', + modeloInteres: 'SOHO', + vendedorIds: [106358096], + }) + + beforeEach(() => { + vi.clearAllMocks() + prismaMock.leadOportunidad.findUnique.mockResolvedValue(null) + prismaMock.leadOportunidad.create.mockResolvedValue({}) + prismaMock.automatizacionState.upsert.mockResolvedValue({}) + }) + + it('crea la oportunidad en Agendo Cita con los datos del lead, asigna al vendedor, califica el lead y guarda el mapping', async () => { + const monday = mondayMock({ leads: [leadConvertible] }) + const r = await convertirLead(monday, 'lead-conv-1') + + expect(r).toEqual({ oportunidadId: 'op-nueva-1' }) + expect(monday.oportunidadesCreadas).toEqual([ + expect.objectContaining({ + nombre: 'Ana Demo', + estadoLabel: 'Agendo Cita', + telefono: '+52 6641234567', + email: 'ana@correo.com', + origenLabel: 'PASE', + desarrolloLabel: 'SENDA', + modeloLabel: 'SOHO', + }), + ]) + // Asesor = vendedor del lead; lead calificado + expect(monday.cambios).toEqual([ + { boardId: '555', itemId: 'op-nueva-1', columna: 'Asesor', valor: 106358096 }, + { boardId: '444', itemId: 'lead-conv-1', columna: 'Estado', valor: 'Calificado' }, + ]) + expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ + data: { leadId: 'lead-conv-1', oportunidadId: 'op-nueva-1' }, + }) + }) + + it('sin vendedor en el lead, asigna al usuario que convierte (vía Monday vinculado)', async () => { + prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 111 }) + const monday = mondayMock({ leads: [{ ...leadConvertible, vendedorIds: [] }] }) + await convertirLead(monday, 'lead-conv-1', 'usr-9') + expect(monday.cambios[0]).toEqual({ boardId: '555', itemId: 'op-nueva-1', columna: 'Asesor', valor: 111 }) + }) + + it('lead ya convertido → 409 sin crear nada', async () => { + prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'op-vieja' }) + const monday = mondayMock({ leads: [leadConvertible] }) + await expect(convertirLead(monday, 'lead-conv-1')).rejects.toMatchObject({ status: 409 }) + expect(monday.oportunidadesCreadas).toEqual([]) + }) + + it('lead inexistente en el tablero → 404', async () => { + const monday = mondayMock({ leads: [] }) + await expect(convertirLead(monday, 'no-existe')).rejects.toMatchObject({ status: 404 }) + }) + + it('omite labels que no existen en el board Oportunidades (dropdown no auto-crea)', async () => { + const monday = mondayMock({ leads: [{ ...leadConvertible, origen: 'WhatsApp' }] }) // WhatsApp no está en Origen del Lead + await convertirLead(monday, 'lead-conv-1') + expect(monday.oportunidadesCreadas[0]?.['origenLabel']).toBeUndefined() + }) +}) diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index 454b738..b11aa7d 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -7,6 +7,7 @@ import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../ser import { prisma } from '../services/prisma.js' import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js' import { AppError } from '../utils/errors.js' +import { NotFoundError } from '../utils/errors.js' /** * Captura y acciones del CRM ligero, sobre DOS tableros: @@ -179,6 +180,7 @@ type MondayLeads = Pick< | 'crearMotivoNoCompra' | 'buscarLeadPorTelefono' | 'buscarOportunidadPorTelefono' + | 'getLeads' | 'labelsDeColumna' | 'obtenerNombreItem' | 'columnaIdPorTitulo' @@ -535,6 +537,91 @@ export async function descartarLeadEndpoint(req: Request, res: Response): Promis res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId }) } +// ---------- Conversión lead → oportunidad (botón "Agendo" de Mis leads) ---------- + +const convertirSchema = z.object({ + fecha: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe ser YYYY-MM-DD') + .optional(), +}) + +/** + * Convierte un lead del tablero Leads en oportunidad (botón "Agendo" de Mis + * leads): crea el item en Oportunidades con etapa 'Agendo Cita' y los datos + * del lead (los labels se omiten si no existen en el board destino), asigna + * como Asesor al vendedor del lead (o a quien convierte), marca el lead como + * 'Calificado' (sale de Mis leads) y guarda el mapping LeadOportunidad para + * que el alta de clientes la encuentre. Si el lead ya fue convertido → 409. + */ +export async function convertirLead( + monday: MondayLeads, + itemId: string, + usuarioId?: string, + fecha?: string, +): Promise<{ oportunidadId: string }> { + const existente = await prisma.leadOportunidad.findUnique({ where: { leadId: itemId } }) + if (existente) throw new AppError(409, 'El lead ya fue convertido en oportunidad') + + const lead = (await monday.getLeads()).find((l) => l.id === itemId) + if (!lead) throw new NotFoundError('Lead no encontrado en el tablero Leads') + + const boardOportunidades = config.monday.boards.oportunidades + // Los labels solo se copian si existen en el board destino (dropdown/status + // no auto-crean labels al escribir) + const [labelsOrigen, labelsDesarrollo, labelsModelo] = await Promise.all([ + monday.labelsDeColumna(boardOportunidades, 'Origen del Lead'), + monday.labelsDeColumna(boardOportunidades, 'Desarrollo'), + monday.labelsDeColumna(boardOportunidades, 'Modelo de Interés'), + ]) + const oportunidadId = await monday.crearOportunidadAfluencia({ + nombre: lead.nombre, + estadoLabel: 'Agendo Cita', + telefono: lead.telefono || undefined, + email: lead.email || undefined, + origenLabel: lead.origen && labelsOrigen.includes(lead.origen) ? lead.origen : undefined, + desarrolloLabel: lead.desarrollo && labelsDesarrollo.includes(lead.desarrollo) ? lead.desarrollo : undefined, + modeloLabel: lead.modeloInteres && labelsModelo.includes(lead.modeloInteres) ? lead.modeloInteres : undefined, + }) + + if (fecha !== undefined) { + await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Fecha', fecha) + } + + // Asesor de la oportunidad: el vendedor del lead, o quien la convierte + let asesorId = lead.vendedorIds[0] + if (asesorId === undefined && usuarioId !== undefined) { + const usuario = await prisma.usuario.findUnique({ where: { id: usuarioId }, select: { mondayUserId: true } }) + asesorId = usuario?.mondayUserId ?? undefined + } + if (asesorId !== undefined) { + // Bandera anti-carrera: el webhook create_item no debe round-robinnearla + await prismaStateStore.set(`${CLAVE_RR_SKIP}${oportunidadId}`, 'capturista') + try { + await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Asesor', asesorId) + } catch (err) { + await prismaStateStore.set(`${CLAVE_RR_SKIP}${oportunidadId}`, 'round-robin') + throw err + } + } + + // El lead queda Calificado (convertido: sale de Mis leads) y ambos items a su grupo + await monday.cambiarValorColumna(config.monday.boards.leads, itemId, 'Estado', 'Calificado') + await moverAGrupoSegunEstado(monday, config.monday.boards.leads, itemId) + await moverAGrupoSegunEstado(monday, boardOportunidades, oportunidadId) + + await prisma.leadOportunidad.create({ data: { leadId: itemId, oportunidadId } }) + return { oportunidadId } +} + +/** POST /api/leads/:itemId/convertir { fecha? } */ +export async function convertirLeadEndpoint(req: Request, res: Response): Promise { + const body = convertirSchema.parse(req.body ?? {}) + const itemId = String(req.params['itemId']) + const { oportunidadId } = await convertirLead(mondayService, itemId, req.usuarioToken?.sub, body.fecha) + res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' }) +} + /** * GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de * clientes. Busca en AMBOS tableros (Leads + Oportunidades); q mínimo 2 diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index f17a343..3e9ca45 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -16,6 +16,7 @@ import { iniciarProgreso, listarDocumentos, obtenerCliente, + prefillApartadoEndpoint, subirDocumentos, uploadDocumentos, } from '../controllers/clientes.controller.js' @@ -54,6 +55,7 @@ import { crearAfluenciaEndpoint, crearLeadEndpoint, buscarLeadsEndpoint, + convertirLeadEndpoint, descartarLeadEndpoint, } from '../controllers/leads.controller.js' import { @@ -100,6 +102,9 @@ apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica) apiRouter.get('/cotizaciones/:id', obtenerCotizacion) apiRouter.post('/clientes', crearCliente) +// Prefill del alta desde un apartado de Monday (botón "Firma Cliente (Venta)" +// de Mis apartados). Va ANTES de /clientes/:id para no colisionar. +apiRouter.get('/altas/prefill-apartado/:itemId', prefillApartadoEndpoint) // Alta progresiva: guardado etapa por etapa del wizard (Contacto PROSPECTO → CLIENTE). // Van ANTES de /clientes/:id para no colisionar con el parámetro. apiRouter.post('/clientes/progreso/iniciar', iniciarProgreso) @@ -157,6 +162,8 @@ apiRouter.get('/leads/buscar', buscarLeadsEndpoint) apiRouter.post('/leads', crearLeadEndpoint) apiRouter.post('/leads/:itemId/estado', cambiarEstadoLeadEndpoint) apiRouter.post('/leads/:itemId/descartar', descartarLeadEndpoint) +// Botón "Agendo" de Mis leads: convierte el lead en oportunidad (Agendo Cita) +apiRouter.post('/leads/:itemId/convertir', convertirLeadEndpoint) // Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades apiRouter.post('/afluencias', crearAfluenciaEndpoint) diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts index f5a5727..40e2f8c 100644 --- a/api-gateway/src/services/monday.service.ts +++ b/api-gateway/src/services/monday.service.ts @@ -84,6 +84,10 @@ export interface ApartadoMonday { email?: string // columna email 'Correo electrónico' ('' si no tiene) precioFinal?: number // columna numbers 'Precio Final' (0 si no tiene) fechaCompra?: string // 'YYYY-MM-DD' de 'Fecha Compra' ('' si no tiene) + curp?: string // columna text 'curp' ('' si no tiene) + formaPago?: string // dropdown 'Forma de Pago' ('' si no tiene) + enganche?: number // columna numbers 'Enganche' (0 si no tiene) + montoApartado?: number // columna numbers 'Monto apartado' (0 si no tiene) } /** Contacto del tablero Contactos (módulo Contactos de la landing) */ @@ -510,13 +514,15 @@ export class MondayService { * Oportunidad en etapa 'Afluencia' con todo el perfil de captura (la * afluencia ES la oportunidad: no hay item aparte en el tablero Leads). * La columna date 'Fecha' lleva la fecha de captura (hoy, hora local). + * estadoLabel permite crearla en otra etapa (p. ej. 'Agendo Cita' al + * convertir un lead del tablero Leads). */ async crearOportunidadAfluencia(datos: DatosOportunidadAfluencia): Promise { const boardId = config.monday.boards.oportunidades const hoy = new Date() const fechaHoy = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(hoy.getDate()).padStart(2, '0')}` const columnValues = await this.construirColumnValues(boardId, { - Estado: 'Afluencia', + Estado: datos.estadoLabel ?? 'Afluencia', Fecha: fechaHoy, 'Cliente teléfono': datos.telefono, 'Cliente email': datos.email, @@ -682,6 +688,41 @@ export class MondayService { return this.crearItem(boardId, datos.clienteNombre, columnValues) } + /** + * Actualiza un apartado existente con los campos presentes (mismo builder de + * valores que crearApartado) — usado cuando el alta de cliente arranca desde + * un apartado ya creado (botón "Firma Cliente (Venta)" de Mis apartados): + * se enriquece el item en vez de crear uno duplicado. + */ + async actualizarApartado(itemId: string, datos: Partial): Promise { + const boardId = config.monday.boards.apartado + const valores = JSON.parse( + await this.construirColumnValues(boardId, { + Unidad: datos.unidadCodigo, + 'Monto apartado': datos.montoApartado, + 'Fecha apartado': datos.fechaApartado, + 'Fecha límite': datos.fechaLimite, + Estado: datos.estadoLabel, + Teléfono: datos.telefono, + 'Correo electrónico': datos.email, + curp: datos.curp, + 'Precio Final': datos.precioFinal, + Enganche: datos.enganche, + 'Fecha Compra': datos.fechaCompra, + Vendedor: datos.vendedorId, + 'Forma de Pago': datos.formaPagoLabel, + Observaciones: datos.observaciones, + }), + ) as Record + if (datos.clienteNombre !== undefined && datos.clienteNombre !== '') valores['name'] = datos.clienteNombre + await this.graphql( + `mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) { + change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id } + }`, + { boardId, itemId, columnValues: JSON.stringify(valores) }, + ) + } + async actualizarDisponibilidadUnidad(codigo: string, status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'): Promise { const boardId = config.monday.boards.unidades // Asegura cache fresco para resolver el itemId @@ -1190,6 +1231,10 @@ export class MondayService { const colEmail = cols.get('Correo electrónico') const colPrecioFinal = cols.get('Precio Final') const colFechaCompra = cols.get('Fecha Compra') + const colCurp = cols.get('curp') + const colFormaPago = cols.get('Forma de Pago') + const colEnganche = cols.get('Enganche') + const colMontoApartado = cols.get('Monto apartado') const items: ItemMonday[] = [] let cursor: string | null = null @@ -1241,6 +1286,10 @@ export class MondayService { email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '', precioFinal: colPrecioFinal ? Number(porId.get(colPrecioFinal.id)?.text ?? 0) || 0 : 0, fechaCompra: colFechaCompra ? (porId.get(colFechaCompra.id)?.text ?? '') : '', + curp: colCurp ? (porId.get(colCurp.id)?.text ?? '') : '', + formaPago: colFormaPago ? (porId.get(colFormaPago.id)?.text ?? '') : '', + enganche: colEnganche ? Number(porId.get(colEnganche.id)?.text ?? 0) || 0 : 0, + montoApartado: colMontoApartado ? Number(porId.get(colMontoApartado.id)?.text ?? 0) || 0 : 0, } }) } @@ -1515,6 +1564,8 @@ export interface DatosOportunidad { export interface DatosOportunidadAfluencia { nombre: string // name del item = nombre del interesado + /** Etapa inicial (default 'Afluencia'; 'Agendo Cita' al convertir un lead) */ + estadoLabel?: string | undefined telefono?: string | undefined email?: string | undefined desarrolloLabel?: string | undefined // label tal cual de la columna status 'Desarrollo' diff --git a/apps/alta-clientes/src/hooks/useAltaCliente.ts b/apps/alta-clientes/src/hooks/useAltaCliente.ts index 3962f9e..207c49b 100644 --- a/apps/alta-clientes/src/hooks/useAltaCliente.ts +++ b/apps/alta-clientes/src/hooks/useAltaCliente.ts @@ -58,6 +58,7 @@ const economicosVacios = (): AltaCliente['economicos'] => ({ const estadoInicial = (): Omit => ({ leadMondayId: null, + apartadoMondayId: null, progresoId: null, datosGenerales: datosGeneralesVacios(), empleo: empleoVacio(), diff --git a/apps/alta-clientes/src/lib/apartadoAlta.test.ts b/apps/alta-clientes/src/lib/apartadoAlta.test.ts new file mode 100644 index 0000000..327d58d --- /dev/null +++ b/apps/alta-clientes/src/lib/apartadoAlta.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { apartadoAPrellenado, type PrefillApartado } from './apartadoAlta' + +const base: PrefillApartado = { + apartado: { + id: 'a1', + nombre: 'Ana Demo', + telefono: '+52 6641234567', + email: 'ana@correo.com', + curp: '', + unidadCodigo: 'SENDA-409-1-2', + precioFinal: 2646000, + enganche: 132300, + formaPago: 'Cofinavit', + }, + contacto: { + nombre: 'Ana Demo', + CURP: 'AAXX010101XXXXXXX0', + RFC: 'AAXX010101XXX', + Dirección: 'Calle 1, Col. Centro, CP 38000', + 'Estado Civil': 'Casado(a)', + 'Fecha Nacimiento': '1990-05-12', + }, + oportunidad: { id: 'op-1', desarrollo: 'SENDA', modeloInteres: 'SOHO', credito: 'Cofinavit' }, + cotizacion: { tipoPropuesta: 'INFONAVIT', acabados: true, precioFinal: 2646000, enganche: 132300 }, +} + +describe('apartadoAPrellenado (alta desde apartado de Monday)', () => { + it('prellena datos generales (contacto manda) y vivienda/crédito (apartado > oportunidad > cotización)', () => { + const p = apartadoAPrellenado(base) + expect(p.datosGenerales).toMatchObject({ + nombreCompleto: 'ANA DEMO', + email: 'ana@correo.com', + celular: '+52 6641234567', + curp: 'AAXX010101XXXXXXX0', + rfc: 'AAXX010101XXX', + direccion: 'CALLE 1, COL. CENTRO, CP 38000', + fechaNacimiento: '1990-05-12', + estadoCivil: 'CASADO', + }) + expect(p.viviendaCredito).toMatchObject({ + proyecto: 'SENDA', + modelo: 'SOHO', + ubicacion: 'SENDA-409-1-2', + valorVenta: '2646000', + enganche: '132300', + tiposCredito: ['COFINAVIT'], // formaPago del apartado manda sobre la cotización + equipamientoAdicional: 'COCINA Y CLOSET', // cotización con acabados + }) + expect(p.leadMondayId).toBe('op-1') + }) + + it('desarrollo desconocido cae en OTRO con el nombre en texto libre', () => { + const p = apartadoAPrellenado({ ...base, oportunidad: { id: 'op-1', desarrollo: 'MONTEBELLO' } }) + expect(p.viviendaCredito.proyecto).toBe('OTRO') + expect(p.viviendaCredito.proyectoOtro).toBe('MONTEBELLO') + }) + + it('sin contacto usa los datos del apartado; sin créditos no prellena tiposCredito', () => { + const p = apartadoAPrellenado({ + ...base, + contacto: null, + apartado: { ...base.apartado, formaPago: '' }, + oportunidad: { id: 'op-1', desarrollo: 'SENDA' }, + cotizacion: null, + }) + expect(p.datosGenerales).toMatchObject({ nombreCompleto: 'ANA DEMO', email: 'ana@correo.com', celular: '+52 6641234567' }) + expect(p.datosGenerales.curp).toBeUndefined() + expect(p.datosGenerales.estadoCivil).toBeUndefined() + expect(p.viviendaCredito.tiposCredito).toBeUndefined() + expect(p.viviendaCredito.equipamientoAdicional).toBeUndefined() + expect(p.leadMondayId).toBe('op-1') + }) + + it('sin oportunidad relacionada el alta queda sin lead ligado', () => { + const p = apartadoAPrellenado({ ...base, oportunidad: null }) + expect(p.leadMondayId).toBeNull() + }) +}) diff --git a/apps/alta-clientes/src/lib/apartadoAlta.ts b/apps/alta-clientes/src/lib/apartadoAlta.ts new file mode 100644 index 0000000..a663e6e --- /dev/null +++ b/apps/alta-clientes/src/lib/apartadoAlta.ts @@ -0,0 +1,138 @@ +import type { DatosGenerales, EstadoCivil, TipoCredito, ViviendaCredito } from '../types' +import { PROYECTOS } from '../data/constantes' + +/** + * Transformación pura: todo lo recolectado de un apartado de Monday (ficha del + * apartado + contacto + oportunidad + cotización vinculada) → campos del + * wizard de alta. Prellena solo lo que exista (nunca inventa datos). + */ + +/** Shape de GET /api/altas/prefill-apartado/:itemId que usa el pre-llenado */ +export interface PrefillApartado { + apartado: { + id: string + nombre: string + telefono?: string + email?: string + curp?: string + unidadCodigo: string + precioFinal?: number + enganche?: number + formaPago?: string + } + contacto: Record | null + oportunidad: { + id: string + desarrollo?: string + modeloInteres?: string + credito?: string + } | null + cotizacion: { + tipoPropuesta: string + acabados: boolean + precioFinal: number + enganche: number + } | null +} + +export interface PrellenadoDesdeApartado { + datosGenerales: Partial + viviendaCredito: Partial + /** Oportunidad relacionada (para que el alta mueva el funnel como siempre) */ + leadMondayId: string | null +} + +// Label de Monday (Contacto/Oportunidad) → enum del wizard +const ESTADO_CIVIL_ENUM: Record = { + 'SOLTERO(A)': 'SOLTERO', + SOLTERO: 'SOLTERO', + 'CASADO(A)': 'CASADO', + CASADO: 'CASADO', + 'UNION LIBRE': 'UNION_LIBRE', + 'UNIÓN LIBRE': 'UNION_LIBRE', + 'DIVORCIADO(A)': 'DIVORCIADO', + DIVORCIADO: 'DIVORCIADO', + 'VIUDO(A)': 'VIUDO', + VIUDO: 'VIUDO', +} + +// Label del dropdown 'Forma de Pago' (Apartado) o 'Crédito' (Oportunidades) → enum +const CREDITO_ENUM: Record = { + INFONAVIT: 'INFONAVIT', + 'APOYO INFONAVIT': 'APOYO_INFONAVIT', + COFINAVIT: 'COFINAVIT', + 'SEGUNDO CRÉDITO': 'SEGUNDO_CREDITO', + 'SEGUNDO CREDITO': 'SEGUNDO_CREDITO', + CONTADO: 'CONTADO', + FOVISSSTE: 'FOVISSSTE', + 'COFI-FOVISSSTE': 'COFI_FOVISSSTE', + BANCARIO: 'BANCARIO', +} + +// Tipo de propuesta del cotizador → enum de crédito del wizard +const PROPUESTA_ENUM: Record = { + BANCARIO: 'BANCARIO', + INFONAVIT: 'INFONAVIT', + COFINAVIT: 'COFINAVIT', + CONTADO: 'CONTADO', +} + +const texto = (v: string | undefined | null): string | undefined => { + const t = (v ?? '').trim() + return t === '' ? undefined : t +} + +const monto = (n: number | undefined | null): string | undefined => + n !== undefined && n !== null && n > 0 ? String(n) : undefined + +/** apartado/contacto/oportunidad/cotización → campos del wizard de alta */ +export function apartadoAPrellenado(pre: PrefillApartado): PrellenadoDesdeApartado { + const { apartado, contacto, oportunidad, cotizacion } = pre + const c = contacto ?? {} + + // Datos generales: el contacto (perfil del alta progresiva) manda; el + // apartado cubre lo que falte + const estadoCivilLabel = (texto(c['Estado Civil']) ?? '').toUpperCase() + const datosGenerales: Partial = { + nombreCompleto: (texto(c['nombre']) ?? apartado.nombre).toUpperCase(), + ...(texto(c['Email']) ?? texto(apartado.email) ? { email: (texto(c['Email']) ?? texto(apartado.email))! } : {}), + ...(texto(c['Teléfono']) ?? texto(apartado.telefono) ? { celular: (texto(c['Teléfono']) ?? texto(apartado.telefono))! } : {}), + ...(texto(c['CURP']) ?? texto(apartado.curp) ? { curp: (texto(c['CURP']) ?? texto(apartado.curp))!.toUpperCase() } : {}), + ...(texto(c['RFC']) ? { rfc: texto(c['RFC'])!.toUpperCase() } : {}), + ...(texto(c['Dirección']) ? { direccion: texto(c['Dirección'])!.toUpperCase() } : {}), + ...(texto(c['Fecha Nacimiento']) ? { fechaNacimiento: texto(c['Fecha Nacimiento'])!.slice(0, 10) } : {}), + ...(ESTADO_CIVIL_ENUM[estadoCivilLabel] ? { estadoCivil: ESTADO_CIVIL_ENUM[estadoCivilLabel] } : {}), + } + + // Vivienda/crédito: desarrollo → proyecto del dropdown; la "ubicación" del + // formato es el código de unidad. Forma de pago: apartado > crédito de la + // oportunidad > tipo de propuesta de la cotización. + const desarrollo = (oportunidad?.desarrollo ?? '').trim().toUpperCase() + const conocido = (PROYECTOS as readonly string[]).includes(desarrollo) + const formaPago = CREDITO_ENUM[(apartado.formaPago ?? '').trim().toUpperCase()] + const creditoOp = CREDITO_ENUM[(oportunidad?.credito ?? '').trim().toUpperCase()] + const creditoCot = cotizacion ? PROPUESTA_ENUM[cotizacion.tipoPropuesta] : undefined + const tipoCredito = formaPago ?? creditoOp ?? creditoCot + + const viviendaCredito: Partial = { + ...(desarrollo !== '' + ? { proyecto: conocido ? desarrollo : 'OTRO', proyectoOtro: conocido ? '' : desarrollo } + : {}), + ...(texto(oportunidad?.modeloInteres) ? { modelo: texto(oportunidad?.modeloInteres)!.toUpperCase() } : {}), + ...(texto(apartado.unidadCodigo) ? { ubicacion: apartado.unidadCodigo } : {}), + ...(monto(apartado.precioFinal) ?? monto(cotizacion?.precioFinal) + ? { valorVenta: (monto(apartado.precioFinal) ?? monto(cotizacion?.precioFinal))! } + : {}), + ...(monto(apartado.enganche) ?? monto(cotizacion?.enganche) + ? { enganche: (monto(apartado.enganche) ?? monto(cotizacion?.enganche))! } + : {}), + ...(tipoCredito ? { tiposCredito: [tipoCredito] } : {}), + ...(cotizacion?.acabados ? { equipamientoAdicional: 'COCINA Y CLOSET' } : {}), + } + + return { + datosGenerales, + viviendaCredito, + leadMondayId: oportunidad?.id ?? null, + } +} diff --git a/apps/alta-clientes/src/lib/fixture.ts b/apps/alta-clientes/src/lib/fixture.ts index 140738a..3c75bb1 100644 --- a/apps/alta-clientes/src/lib/fixture.ts +++ b/apps/alta-clientes/src/lib/fixture.ts @@ -10,6 +10,7 @@ export const FIRMA_PRUEBA = */ export const fixtureAlta: AltaCliente = { leadMondayId: null, + apartadoMondayId: null, progresoId: null, datosGenerales: { campaniaVenta: '', diff --git a/apps/alta-clientes/src/pages/AltaClientePage.tsx b/apps/alta-clientes/src/pages/AltaClientePage.tsx index 4acdc36..bd87177 100644 --- a/apps/alta-clientes/src/pages/AltaClientePage.tsx +++ b/apps/alta-clientes/src/pages/AltaClientePage.tsx @@ -21,11 +21,13 @@ import { listarBorradores, obtenerBorrador, obtenerCotizacion, + obtenerPrefillApartado, subirDocumentos, type BorradorResumen, type LeadResumen, } from '../services/api' import { cotizacionAPrellenado } from '../lib/cotizacionAlta' +import { apartadoAPrellenado } from '../lib/apartadoAlta' import { leadAPrellenado } from '../lib/leadAlta' import BusquedaLead from '../components/BusquedaLead' import AltaAfluenciaForm from '../components/AltaAfluenciaForm' @@ -54,6 +56,11 @@ function paramsAfluencia(): { oportunidadId: string; valores: Record — botón "Firma Cliente (Venta)" de Mis apartados en Mi día */ +function paramApartado(): string | null { + return new URLSearchParams(window.location.search).get('apartado') +} + const formatoFechaHora = (iso: string): string => new Date(iso).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) @@ -127,10 +134,25 @@ export default function AltaClientePage() { valores: Record } | null>(null) - // Al entrar: ?cotizacion= prellena el alta y salta directo al wizard; - // ?afluencia= abre el formulario de afluencia prellenado; si no, la - // entrada ofrece Alta Afluencia (captura ligera) y Alta de Cliente + // Al entrar: ?apartado= prellena el alta con todo lo recolectado del + // apartado y salta al wizard ligado a ese item; ?afluencia= abre el + // formulario de afluencia prellenado; ?cotizacion= prellena el alta; + // si no, la entrada ofrece Alta Afluencia (captura ligera) y Alta de Cliente useEffect(() => { + const apartadoId = paramApartado() + if (apartadoId) { + obtenerPrefillApartado(apartadoId) + .then((pre) => { + const p = apartadoAPrellenado(pre) + actualizar('datosGenerales', { ...datos.datosGenerales, ...p.datosGenerales }) + actualizar('viviendaCredito', { ...datos.viviendaCredito, ...p.viviendaCredito }) + actualizar('apartadoMondayId', apartadoId) + if (p.leadMondayId) actualizar('leadMondayId', p.leadMondayId) + }) + .catch(() => setErrorCarga('No se pudo cargar el apartado para prellenar el alta.')) + .finally(() => setPantalla('wizard')) + return + } const afl = paramsAfluencia() if (afl) { setAfluenciaPrevia(afl) @@ -487,6 +509,11 @@ export default function AltaClientePage() { {errorCarga}

)} + {datos.apartadoMondayId && ( +

+ Alta ligada al apartado del cliente en Monday — al completarla se actualiza ese item (no se duplica). +

+ )} {paso === 0 && ( diff --git a/apps/alta-clientes/src/services/api.ts b/apps/alta-clientes/src/services/api.ts index 006e59c..2dbb999 100644 --- a/apps/alta-clientes/src/services/api.ts +++ b/apps/alta-clientes/src/services/api.ts @@ -216,6 +216,14 @@ export async function obtenerCotizacion(id: string): Promise) */ +export async function obtenerPrefillApartado(itemId: string): Promise { + const res = await fetch(`${API_URL}/altas/prefill-apartado/${itemId}`, conAuth()) + manejar401(res) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as import('../lib/apartadoAlta').PrefillApartado +} + // ---------- Pre-llenado desde lead de Monday (auth) ---------- export interface LeadResumen { diff --git a/apps/alta-clientes/src/types/index.ts b/apps/alta-clientes/src/types/index.ts index 429621c..9389f86 100644 --- a/apps/alta-clientes/src/types/index.ts +++ b/apps/alta-clientes/src/types/index.ts @@ -149,6 +149,10 @@ export interface AltaCliente { // ID del item del tablero Leads de Monday cuando el alta se prellenó desde // un lead (null = captura manual). Passthrough hasta payloadJson del API. leadMondayId: string | null + // ID del item del tablero Apartado de Monday cuando el alta arrancó desde un + // apartado existente (botón "Firma Cliente (Venta)" de Mis apartados): el + // API lo LIGA y enriquece en vez de crear un apartado duplicado. + apartadoMondayId: string | null // ID del Cliente creado por el guardado progresivo (POST /clientes/progreso/ // iniciar); viaja con el borrador (datosJson) y no se captura en el formato. progresoId: string | null diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx index bb3ecbe..7dae4fa 100644 --- a/apps/mi-dia/src/pages/MiDiaPage.tsx +++ b/apps/mi-dia/src/pages/MiDiaPage.tsx @@ -32,6 +32,7 @@ import { cambiarEstadoApartado, cambiarEstadoLead, cambiarEtapaOportunidad, + convertirLead, crearLead, descartarLead, fetchResumenMiDia, @@ -180,9 +181,8 @@ function FilaOportunidad({ }) { const [conCita, setConCita] = useState(false) const [fechaCita, setFechaCita] = useState('') - // 'Afluencia' no es un cambio de etapa directo: abre el formulario de - // afluencia prellenado para capturar/actualizar el perfil completo - const fases = ['Agendo Cita', 'Aparto', 'No Asistio', 'Abandono'] as const + // Sin 'No Asistio' ni 'Abandono': son redundantes con Descartar + const fases = ['Agendo Cita', 'Aparto'] as const return (
  • @@ -311,16 +311,26 @@ function FilaApartado({ key={fase} type="button" disabled={enviando} - onClick={() => onFase(fase)} + onClick={() => { + // Firma Cliente (Venta): abre el alta de cliente prellenada con + // todo lo recolectado del apartado (ligada, sin duplicar) + if (fase === 'Firma Cliente (Venta)') { + window.location.href = `/client/?apartado=${apartado.id}` + return + } + onFase(fase) + }} className={ fase === 'Cancelacion Apartado' ? 'rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50' - : fase === apartado.estado - ? 'rounded-lg border border-accent bg-accent/5 px-3 py-1.5 text-xs font-medium text-accent shadow-sm disabled:opacity-50' - : CLASE_BOTON_ACCION + : fase === 'Firma Cliente (Venta)' + ? 'rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50' + : fase === apartado.estado + ? 'rounded-lg border border-accent bg-accent/5 px-3 py-1.5 text-xs font-medium text-accent shadow-sm disabled:opacity-50' + : CLASE_BOTON_ACCION } > - {ETIQUETA_FASE_APARTADO[fase]} + {fase === 'Firma Cliente (Venta)' ? 'Firma cliente (venta) →' : ETIQUETA_FASE_APARTADO[fase]} ))} + {/* Agendo: convierte el lead en oportunidad (etapa Agendo Cita) */} +