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 (