From 5d7c6f58cd819bb53d00922583b27dc0feeaa873 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Fri, 4 Sep 2026 22:53:21 +0000 Subject: [PATCH] =?UTF-8?q?feat(mi-dia):=20bot=C3=B3n=20Afluencia=20abre?= =?UTF-8?q?=20el=20formulario=20prellenado=20y=20actualiza=20la=20oportuni?= =?UTF-8?q?dad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mis oportunidades: 'Afluencia →' navega a /client/?afluencia= con nombre, contacto, desarrollo y modelo prellenados - Alta Afluencia: con oportunidadId el envío ACTUALIZA la oportunidad en Monday (perfil completo + etapa Afluencia) en vez de crear duplicado; sin oportunidadId el flujo de captura nueva no cambia - monday.service: actualizarOportunidadAfluencia (mismo builder de la creación + name); moverAGrupoSegunEstado tras actualizar - Tests: 457 API + 16 mi-dia + 116 alta-clientes, verde --- .../src/controllers/leads.controller.test.ts | 31 +++++++++++ .../src/controllers/leads.controller.ts | 44 ++++++++++++++- api-gateway/src/services/monday.service.ts | 42 ++++++++++++++ .../src/components/AltaAfluenciaForm.tsx | 55 +++++++++++++------ .../src/pages/AltaClientePage.tsx | 39 ++++++++++++- apps/alta-clientes/src/services/api.ts | 4 ++ apps/mi-dia/src/lib/mi-dia.test.ts | 38 +++++++++++++ apps/mi-dia/src/lib/mi-dia.ts | 16 ++++++ apps/mi-dia/src/pages/MiDiaPage.tsx | 19 ++++++- 9 files changed, 266 insertions(+), 22 deletions(-) diff --git a/api-gateway/src/controllers/leads.controller.test.ts b/api-gateway/src/controllers/leads.controller.test.ts index e50fa13..ef355f9 100644 --- a/api-gateway/src/controllers/leads.controller.test.ts +++ b/api-gateway/src/controllers/leads.controller.test.ts @@ -65,6 +65,10 @@ function mondayMock( this.oportunidadesCreadas.push(datos) return 'op-nueva-1' }, + oportunidadesActualizadas: [] as { id: string; datos: Record }[], + async actualizarOportunidadAfluencia(id: string, datos: Record) { + this.oportunidadesActualizadas.push({ id, datos }) + }, async buscarLeadPorTelefono(_telefono: string) { return opts.duplicadoLead ?? null }, @@ -260,6 +264,33 @@ describe('crearAfluencia (POST /api/afluencias) — la afluencia ES la oportunid espia.mockRestore() }) + it('con oportunidadId (botón "Afluencia" de Mi día): ACTUALIZA la oportunidad, sin duplicado ni validación de teléfono', async () => { + const monday = mondayMock() + const r = await crearAfluencia(monday, { + ...datosBase, + oportunidadId: 'op-existente-3', + desarrollo: 'SENDA', + modelo: 'SOHO', + edad: 34, + notas: 'Perfil completado en punto de venta', + }) + expect(r).toEqual({ id: 'op-existente-3', oportunidadId: 'op-existente-3', actualizada: true }) + expect(monday.oportunidadesCreadas).toEqual([]) // no se duplica + expect(monday.oportunidadesActualizadas).toEqual([ + { + id: 'op-existente-3', + datos: expect.objectContaining({ + nombre: 'Lead Prueba', + telefono: '4771234567', + desarrolloLabel: 'SENDA', + modeloLabel: 'SOHO', + edad: 34, + notas: 'Perfil completado en punto de venta', + }), + }, + ]) + }) + it('rechaza un desarrollo que no sea label real de la columna Desarrollo (board Oportunidades)', async () => { const monday = mondayMock() await expect(crearAfluencia(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow( diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index 8cd4a84..6e27b91 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -62,6 +62,9 @@ const crearAfluenciaSchema = z.object({ origen: z.string().trim().min(1).optional(), desarrollo: z.string().trim().min(1).optional(), notas: z.string().trim().min(1).optional(), + // Con oportunidadId (botón "Afluencia" de Mis oportunidades en Mi día): se + // ACTUALIZA esa oportunidad con el perfil capturado en vez de crear una nueva + oportunidadId: z.string().trim().min(1).optional(), // Campos de calificación de la afluencia (opcionales; los labels fijos del // front coinciden con los sembrados en Monday, no se validan contra el board) modelo: z.string().trim().min(1).optional(), @@ -165,6 +168,7 @@ type MondayLeads = Pick< | 'cambiarValorColumna' | 'crearLead' | 'crearOportunidadAfluencia' + | 'actualizarOportunidadAfluencia' | 'crearMotivoNoCompra' | 'buscarLeadPorTelefono' | 'buscarOportunidadPorTelefono' @@ -176,7 +180,7 @@ type MondayLeads = Pick< > export type ResultadoCrearLead = - | { id: string; oportunidadId: string | null } + | { id: string; oportunidadId: string | null; actualizada?: boolean } | { duplicado: { id: string; nombre: string } } /** @@ -237,6 +241,38 @@ export async function crearAfluencia( ): Promise { const boardOportunidades = config.monday.boards.oportunidades + // Con oportunidadId: el formulario venía prellenado desde Mis oportunidades + // (Mi día) → se ACTUALIZA esa oportunidad con el perfil completo y queda en + // etapa 'Afluencia'; no se crea duplicado ni se valida teléfono. + if (datos.oportunidadId !== undefined) { + await monday.actualizarOportunidadAfluencia(datos.oportunidadId, { + nombre: datos.nombre, + telefono: datos.telefono, + email: datos.email, + desarrolloLabel: datos.desarrollo, + origenLabel: datos.origen, + modeloLabel: datos.modelo, + medioContacto: datos.medioContacto, + edad: datos.edad, + estadoCivilLabel: datos.estadoCivil, + tipoEmpleoLabel: datos.tipoEmpleo, + ocupacion: datos.ocupacion, + rangoSalarial: datos.rangoSalarial, + creditoLabel: datos.credito, + tipoEconomiaLabel: datos.tipoEconomia, + calificaLabel: datos.califica, + loQuierePara: datos.loQuierePara, + notas: datos.notas, + }) + try { + await moverAGrupoSegunEstado(monday, boardOportunidades, datos.oportunidadId) + } catch (err) { + // eslint-disable-next-line no-console + console.error(`No se pudo mover al grupo correcto la oportunidad ${datos.oportunidadId}:`, err) + } + return { id: datos.oportunidadId, oportunidadId: datos.oportunidadId, actualizada: true } + } + if (datos.telefono !== undefined) { const existente = await monday.buscarOportunidadPorTelefono(datos.telefono) if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } } @@ -429,6 +465,12 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi }) return } + // Actualización de oportunidad existente (botón "Afluencia" de Mi día): no + // hay captura nueva que registrar ni round-robin que correr + if (resultado.actualizada === true) { + res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true }) + return + } // Registro local del capturista para "Mis capturas" del panel Mi día. // leadId = el id de la oportunidad (la afluencia ES la oportunidad). Un // fallo aquí no rompe la respuesta: la afluencia ya quedó creada en Monday. diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts index d82e0d9..1cd2179 100644 --- a/api-gateway/src/services/monday.service.ts +++ b/api-gateway/src/services/monday.service.ts @@ -520,6 +520,48 @@ export class MondayService { return this.crearItem(boardId, datos.nombre, columnValues) } + /** + * Actualiza una oportunidad existente con el perfil completo de la afluencia + * (mismo builder que crearOportunidadAfluencia) y la pone en etapa + * 'Afluencia'. La usa el botón "Afluencia" de Mis oportunidades en Mi día: + * el formulario se manda prellenado y el envío enriquece el item en vez de + * duplicarlo. El nombre del item se actualiza por el column id 'name'. + */ + async actualizarOportunidadAfluencia(oportunidadId: string, 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 valores = JSON.parse( + await this.construirColumnValues(boardId, { + Estado: 'Afluencia', + Fecha: fechaHoy, + 'Cliente teléfono': datos.telefono, + 'Cliente email': datos.email, + Desarrollo: datos.desarrolloLabel, + 'Modelo de Interés': datos.modeloLabel, + 'Origen del Lead': datos.origenLabel, + 'Medio de Contacto': datos.medioContacto, + Edad: datos.edad, + 'Estado Civil': datos.estadoCivilLabel, + 'Tipo de Empleo': datos.tipoEmpleoLabel, + Ocupación: datos.ocupacion, + 'Rango Salarial': datos.rangoSalarial, + Crédito: datos.creditoLabel, + 'Tipo de Econimia': datos.tipoEconomiaLabel, // SIC: typo en el título real + 'Lead Califica': datos.calificaLabel, + 'Proposito de Compra': datos.loQuierePara, + COMENTARIOS: datos.notas, + }), + ) as Record + if (datos.nombre !== '') valores['name'] = datos.nombre + 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: oportunidadId, columnValues: JSON.stringify(valores) }, + ) + } + /** * Actualiza una oportunidad existente con los datos de una cotización * (mismo builder de valores que crearOportunidad) y la mueve a 'Cotizador'. diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx index 07cc5e4..c41c854 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx @@ -18,6 +18,10 @@ interface Props { /** Conversión directa: tras registrar la afluencia, abrir el alta completa * prellenada con los datos recién capturados (sin rebuscar el lead) */ onPasarAAltaCliente: (lead: LeadResumen) => void + /** Prefill desde Mis oportunidades (Mi día): valores iniciales del formulario */ + valoresIniciales?: Partial + /** Oportunidad de Monday a ACTUALIZAR al enviar (sin duplicar el item) */ + oportunidadId?: string } const emailSchema = z.string().trim().email() @@ -58,8 +62,8 @@ const FORM_INICIAL = { * lead en Monday y su oportunidad en etapa Afluencia. El alta completa de * cliente sigue siendo el wizard del picker. */ -export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Props) { - const [form, setForm] = useState(FORM_INICIAL) +export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valoresIniciales, oportunidadId }: Props) { + const [form, setForm] = useState({ ...FORM_INICIAL, ...valoresIniciales }) const [errores, setErrores] = useState({}) const [enviando, setEnviando] = useState(false) const [leadCreado, setLeadCreado] = useState(null) @@ -128,6 +132,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro const res = await crearAfluencia({ nombre: form.nombre.trim(), telefono: form.telefono.trim(), + ...(oportunidadId !== undefined ? { oportunidadId } : {}), ...(form.email.trim() ? { email: form.email.trim() } : {}), ...(form.origen ? { origen: form.origen } : {}), ...(form.desarrollo ? { desarrollo: form.desarrollo } : {}), @@ -178,9 +183,11 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro role="status" className="rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700" > - {asignacion === 'capturista' - ? 'Registrado en afluencia — asignado a ti' - : 'Registrado en afluencia — se asignará por round-robin'} + {oportunidadId !== undefined + ? 'Afluencia actualizada — el perfil completo quedó guardado en la oportunidad' + : asignacion === 'capturista' + ? 'Registrado en afluencia — asignado a ti' + : 'Registrado en afluencia — se asignará por round-robin'}

- + {oportunidadId === undefined && ( + + )} + {/* Afluencia: abre el formulario de alta afluencia prellenado */} + {fases.map((f) => f === 'Agendo Cita' ? ( @@ -771,6 +785,9 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones void ejecutar(() => cambiarEtapaOportunidad(op.id, etapa, fecha), 'No se pudo actualizar la oportunidad') } onCotizar={() => void cotizarOportunidad(op)} + onAfluencia={() => { + window.location.href = urlAfluenciaParaOportunidad(op) + }} onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })} /> ))}