From 27ba9ea8fa165fe0a50738df2293859ec172c99f Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Fri, 4 Sep 2026 23:28:56 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Interesado=20en=20leads,=20lada=20MX/US?= =?UTF-8?q?A,=20or=C3=ADgenes=20FRASA=20y=20subcategor=C3=ADa=20de=20desca?= =?UTF-8?q?rte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mis leads: botón Interesado (label sembrado en Monday, mueve a Leads Progreso); el botón Agenda cita ahora solo vive en Mis oportunidades - Descarte: además de la categoría, subcategoría del catálogo de Monday (workspace de referencia CRM FRASA); se guarda en la columna Subcategoria del tablero Motivos No Compra y el item queda en el grupo de su categoría - Teléfonos con selector de lada 🇲🇽 +52 / 🇺🇸 +1 en captura rápida (Mi día), Alta Afluencia y Alta de Cliente (tel. casa y celular); Monday escribe countryShortName MX/US y el número compacto (rechaza espacios); el dedup sigue comparando por los últimos 10 dígitos - Origen nuevo en captura rápida y afluencia: PASE, PROSPECCION ASESOR, ACTIVACION, FACEBOOK FRASA, REFERIDO, OTRO (sembrados en Leads.Origen y Oportunidades.Origen del Lead) - Tests: 461 API + 16 mi-dia + 116 alta-clientes, verde --- .../src/controllers/leads.controller.test.ts | 30 +++++- .../src/controllers/leads.controller.ts | 28 ++++-- .../controllers/oportunidades.controller.ts | 17 ++++ .../src/services/grupos-estado.service.ts | 1 + api-gateway/src/services/monday.service.ts | 13 ++- .../src/components/AltaAfluenciaForm.test.tsx | 10 +- .../src/components/AltaAfluenciaForm.tsx | 53 ++++++++--- apps/alta-clientes/src/components/Campos.tsx | 48 ++++++++++ apps/alta-clientes/src/data/constantes.ts | 5 +- .../src/forms/DatosGeneralesCampos.tsx | 6 +- apps/mi-dia/src/lib/mi-dia.test.ts | 4 +- apps/mi-dia/src/lib/mi-dia.ts | 38 +++++++- apps/mi-dia/src/pages/MiDiaPage.tsx | 95 ++++++++++++------- apps/mi-dia/src/services/mi-dia.ts | 3 +- 14 files changed, 279 insertions(+), 72 deletions(-) diff --git a/api-gateway/src/controllers/leads.controller.test.ts b/api-gateway/src/controllers/leads.controller.test.ts index ef355f9..8f3d3be 100644 --- a/api-gateway/src/controllers/leads.controller.test.ts +++ b/api-gateway/src/controllers/leads.controller.test.ts @@ -308,7 +308,7 @@ describe('crearAfluencia (POST /api/afluencias) — la afluencia ES la oportunid describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => { it('la whitelist solo permite las transiciones del vendedor', () => { - expect(ESTADOS_LEAD_PERMITIDOS).toEqual(['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO']) + expect(ESTADOS_LEAD_PERMITIDOS).toEqual(['CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO']) // El descarte tiene su propio endpoint (lleva motivo) expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('DESCARTADO') expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('NUEVO') @@ -329,6 +329,20 @@ describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => { ]) }) + it('INTERESADO escribe el label en el tablero Leads (444)', async () => { + const monday = mondayMock() + await cambiarEstadoLead(monday, 'lead-1', 'INTERESADO', undefined, 'lead') + expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'Interesado' }]) + }) + + it('INTERESADO no aplica al tablero Oportunidades (no es etapa del funnel)', async () => { + const monday = mondayMock() + await expect(cambiarEstadoLead(monday, 'op-1', 'INTERESADO', undefined, 'oportunidad')).rejects.toThrow( + /Interesado solo aplica a leads/, + ) + expect(monday.cambios).toEqual([]) + }) + it('AGENDA CITA sin fecha no toca la columna Fecha', async () => { const monday = mondayMock() await cambiarEstadoLead(monday, 'op-1', 'AGENDA CITA') @@ -401,6 +415,20 @@ describe('descartarLead (POST /api/leads/:itemId/descartar)', () => { expect(monday.motivos[0]).toEqual({ nombre: 'Timing — Lead Prueba', categoria: 'Timing' }) }) + it('con subcategoría: va en el nombre y en la columna Subcategoria del catálogo', async () => { + const monday = mondayMock() + await descartarLead(monday, 'lead-1', 'Precio', undefined, 'lead', 'Precio Total') + expect(monday.motivos).toEqual([ + { nombre: 'Precio / Precio Total — Lead Prueba', categoria: 'Precio', subcategoria: 'Precio Total' }, + ]) + }) + + it('sin subcategoría el nombre del catálogo queda como antes', async () => { + const monday = mondayMock() + await descartarLead(monday, 'lead-1', 'Timing', undefined, 'lead') + expect(monday.motivos[0]).toEqual({ nombre: 'Timing — Lead Prueba', categoria: 'Timing' }) + }) + it('propaga el error si Monday falla (el error handler responde 5xx, no truena el server)', async () => { const monday = mondayMock() vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído')) diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index 6e27b91..454b738 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -5,7 +5,7 @@ import { mondayService, type LeadMonday, type MondayService, type OportunidadMon import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js' import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js' import { prisma } from '../services/prisma.js' -import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js' +import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js' import { AppError } from '../utils/errors.js' /** @@ -23,19 +23,21 @@ import { AppError } from '../utils/errors.js' */ // Transiciones que el vendedor puede hacer desde la app Mi día -export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO'] as const +export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO'] as const // Traducción de la whitelist (valores que mandan los frontends) a los labels // reales de la columna status 'Estado' de cada board const LABEL_ESTADO_LEADS: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = { CONTACTADO: 'Contactado', 'Intento de contacto': 'Intento de contacto', + INTERESADO: 'Interesado', 'AGENDA CITA': 'Agenda Cita', CALIFICADO: 'Calificado', } const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = { CONTACTADO: 'Contactado', 'Intento de contacto': 'Intento de contacto', + INTERESADO: 'Interesado', // no existe en el board Oportunidades: se rechaza abajo 'AGENDA CITA': 'Agendo Cita', CALIFICADO: 'Calificado', } @@ -93,9 +95,14 @@ const cambiarEstadoSchema = z.object({ const descartarSchema = z.object({ motivo: z.enum(MOTIVOS_NO_COMPRA), + // Subcategoría del catálogo de Monday (debe pertenecer a la categoría elegida) + subcategoria: z.string().trim().min(1).optional(), comentario: z.string().trim().min(1).optional(), fuente: fuenteSchema, -}) +}).refine( + (b) => b.subcategoria === undefined || SUBCATEGORIAS_NO_COMPRA[b.motivo].includes(b.subcategoria), + { message: 'La subcategoría no pertenece a la categoría elegida', path: ['subcategoria'] }, +) // ---------- Búsqueda dual (prellenado del alta de clientes) ---------- @@ -333,6 +340,10 @@ export async function cambiarEstadoLead( fuente: FuenteLead = 'oportunidad', ): Promise { const esLead = fuente === 'lead' + // 'Interesado' solo existe en el tablero Leads (no es etapa del funnel) + if (estado === 'INTERESADO' && !esLead) { + throw new AppError(400, 'La etapa Interesado solo aplica a leads del tablero Leads') + } const boardId = esLead ? config.monday.boards.leads : config.monday.boards.oportunidades const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado] await monday.cambiarValorColumna(boardId, itemId, 'Estado', label) @@ -346,7 +357,8 @@ export async function cambiarEstadoLead( * - 'oportunidad': Estado 'Perdido' + dropdown Motivo No Compra (label ya * sembrado; si no existiera se omite) + comentario en COMENTARIOS. * - 'lead': Estado 'Descartado' + comentario en Notas. - * Ambos registran el motivo en el tablero catálogo Motivos No Compra. + * Ambos registran el motivo (y la subcategoría si viene) en el tablero + * catálogo Motivos No Compra. */ export async function descartarLead( monday: MondayLeads, @@ -354,6 +366,7 @@ export async function descartarLead( motivo: string, comentario?: string, fuente: FuenteLead = 'oportunidad', + subcategoria?: string, ): Promise { const nombreItem = await monday.obtenerNombreItem(itemId) if (fuente === 'lead') { @@ -377,8 +390,9 @@ export async function descartarLead( } } return monday.crearMotivoNoCompra({ - nombre: `${motivo} — ${nombreItem}`, + nombre: `${motivo}${subcategoria !== undefined ? ` / ${subcategoria}` : ''} — ${nombreItem}`, categoria: motivo, + ...(subcategoria !== undefined ? { subcategoria } : {}), ...(comentario !== undefined ? { descripcion: comentario } : {}), }) } @@ -513,11 +527,11 @@ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Pr res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) }) } -/** POST /api/leads/:itemId/descartar { motivo, comentario?, fuente? } */ +/** POST /api/leads/:itemId/descartar { motivo, subcategoria?, comentario?, fuente? } */ export async function descartarLeadEndpoint(req: Request, res: Response): Promise { const body = descartarSchema.parse(req.body) const itemId = String(req.params['itemId']) - const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente) + const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente, body.subcategoria) res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId }) } diff --git a/api-gateway/src/controllers/oportunidades.controller.ts b/api-gateway/src/controllers/oportunidades.controller.ts index 9cd5219..9e12f07 100644 --- a/api-gateway/src/controllers/oportunidades.controller.ts +++ b/api-gateway/src/controllers/oportunidades.controller.ts @@ -15,6 +15,23 @@ export const MOTIVOS_NO_COMPRA = [ 'Timing', ] as const +/** + * Subcategorías por categoría, tomadas del tablero de referencia "Motivos No + * Compra" del workspace CRM FRASA (los items de cada grupo son el catálogo). + * Sembradas como labels de la columna status 'Subcategoria' del tablero + * Motivos No Compra de Frasa-CAS. NOTA: 'Mesualidad' y 'Cambo de Planes' + * llevan las faltas de ortografía originales del tablero del cliente. + */ +export const SUBCATEGORIAS_NO_COMPRA: Record<(typeof MOTIVOS_NO_COMPRA)[number], string[]> = { + Precio: ['Precio Total', 'Enganche', 'Mesualidad'], + Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'], + Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'], + Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'], + Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'], + Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'], + Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'], +} + const perderSchema = z.object({ motivo: z.enum(MOTIVOS_NO_COMPRA), comentario: z.string().optional(), diff --git a/api-gateway/src/services/grupos-estado.service.ts b/api-gateway/src/services/grupos-estado.service.ts index 1a3b11d..37900cc 100644 --- a/api-gateway/src/services/grupos-estado.service.ts +++ b/api-gateway/src/services/grupos-estado.service.ts @@ -24,6 +24,7 @@ export const GRUPOS_POR_ESTADO: Record — ' categoria: string // una de MOTIVOS_NO_COMPRA + /** Label de la columna status 'Subcategoria' (catálogo del board de referencia) */ + subcategoria?: string descripcion?: string } diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx index 17cec08..7acdd19 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx @@ -48,7 +48,7 @@ describe('AltaAfluenciaForm', () => { fetchMock.mockResolvedValueOnce(respuesta({ id: 'l-1', estado: 'NUEVO', oportunidadId: 'op-1' })) render( undefined} onPasarAAltaCliente={() => undefined} />) llenarRequeridos() - fireEvent.change(screen.getByLabelText(/¿Cómo se enteró de nosotros\?/), { target: { value: 'Web' } }) + fireEvent.change(screen.getByLabelText(/¿Cómo se enteró de nosotros\?/), { target: { value: 'PASE' } }) fireEvent.change(screen.getByLabelText(/Desarrollo de interés/), { target: { value: 'SENDA' } }) fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ })) @@ -59,8 +59,8 @@ describe('AltaAfluenciaForm', () => { expect(url).toContain('/afluencias') expect(JSON.parse(String(init.body))).toMatchObject({ nombre: 'Ana Demo', - telefono: '6641234567', - origen: 'Web', + telefono: '+52 6641234567', + origen: 'PASE', desarrollo: 'SENDA', }) }) @@ -158,7 +158,7 @@ describe('AltaAfluenciaForm', () => { await screen.findByRole('status') const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] const cuerpo = JSON.parse(String(init.body)) as Record - expect(cuerpo).toEqual({ nombre: 'Ana Demo', telefono: '6641234567' }) + expect(cuerpo).toEqual({ nombre: 'Ana Demo', telefono: '+52 6641234567' }) }) it('con 409 muestra el duplicado con el nombre del lead existente', async () => { @@ -218,7 +218,7 @@ describe('AltaAfluenciaForm', () => { expect(onPasar).toHaveBeenCalledWith({ id: 'op-1', nombre: 'Ana Demo', - telefono: '6641234567', + telefono: '+52 6641234567', email: 'ana@correo.com', estado: 'Afluencia', origen: '', diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx index c41c854..8354f2b 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx @@ -64,6 +64,9 @@ const FORM_INICIAL = { */ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valoresIniciales, oportunidadId }: Props) { const [form, setForm] = useState({ ...FORM_INICIAL, ...valoresIniciales }) + const [lada, setLada] = useState<'MX' | 'US'>( + (valoresIniciales?.telefono ?? '').trim().startsWith('+1') ? 'US' : 'MX', + ) const [errores, setErrores] = useState({}) const [enviando, setEnviando] = useState(false) const [leadCreado, setLeadCreado] = useState(null) @@ -89,6 +92,11 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor * lead existe entre las opciones del formulario. */ const prellenarDesdeLead = (lead: LeadResumen) => { + // Teléfono con prefijo internacional: separa lada y número para el selector + const tel = lead.telefono.trim() + const esUsa = tel.startsWith('+1') + const numero = tel.startsWith('+1') ? tel.slice(2).trim() : tel.startsWith('+52') ? tel.slice(3).trim() : tel + setLada(esUsa ? 'US' : 'MX') setForm((prev) => { const desarrollo = lead.desarrollo !== undefined && (DESARROLLOS_LEAD as readonly string[]).includes(lead.desarrollo) @@ -102,7 +110,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor return { ...prev, nombre: lead.nombre, - telefono: lead.telefono, + telefono: numero, email: lead.email, origen: (ORIGENES_LEAD as readonly string[]).includes(lead.origen) ? lead.origen : prev.origen, desarrollo, @@ -129,9 +137,13 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor setAviso('') if (!validar()) return setEnviando(true) + // Teléfono con lada: " " (si ya trae +, se respeta tal cual) + const numeroTel = form.telefono.trim() + const telefono = + numeroTel === '' ? '' : numeroTel.startsWith('+') ? numeroTel : `${lada === 'US' ? '+1' : '+52'} ${numeroTel}` const res = await crearAfluencia({ nombre: form.nombre.trim(), - telefono: form.telefono.trim(), + telefono, ...(oportunidadId !== undefined ? { oportunidadId } : {}), ...(form.email.trim() ? { email: form.email.trim() } : {}), ...(form.origen ? { origen: form.origen } : {}), @@ -154,7 +166,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor setLeadCreado({ id: res.lead.id, nombre: form.nombre.trim(), - telefono: form.telefono.trim(), + telefono, email: form.email.trim(), estado: res.lead.estado, origen: form.origen, @@ -170,6 +182,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor const capturarOtro = () => { setForm(FORM_INICIAL) + setLada('MX') setErrores({}) setAviso('') setLeadCreado(null) @@ -251,19 +264,31 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor {errores.nombre &&

{errores.nombre}

} -