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 && (
+
+ )}