feat: conversión lead→oportunidad (Agendo) y alta ligada a apartado
- Mis leads: botón 'Agendo →' convierte el lead en oportunidad (etapa Agendo Cita, con sus datos, asesor = vendedor del lead o quien convierte), marca el lead Calificado y guarda el mapping LeadOportunidad; 409 si ya fue convertido - Mis oportunidades: se quitan No Asistio y Abandono (redundantes con Descartar); quedan Cotizar, Afluencia, Agendo Cita y Aparto - Mis apartados: 'Firma cliente (venta) →' abre el alta de cliente con TODO lo recolectado prellenado (ficha del apartado + contacto + oportunidad + cotización vinculada vía GET /altas/prefill-apartado/:id) - El alta con apartadoMondayId se LIGA al apartado existente y lo enriquece (curp, teléfono, email, precio final, enganche, forma de pago, unidad) en vez de crear un duplicado - monday.service: actualizarApartado + ApartadoMonday con curp, forma de pago, enganche y monto apartado - Tests: 470 API + 120 alta-clientes + 16 mi-dia, verde
This commit is contained in:
@@ -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<string | null> {
|
||||||
|
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) */
|
/** POST /api/clientes — persiste el alta y la replica en Monday (Contactos y Apartado) */
|
||||||
export async function crearCliente(req: Request, res: Response): Promise<void> {
|
export async function crearCliente(req: Request, res: Response): Promise<void> {
|
||||||
const body = crearClienteSchema.parse(req.body)
|
const body = crearClienteSchema.parse(req.body)
|
||||||
@@ -290,7 +324,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
|
|||||||
...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) },
|
...{ 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({
|
await prisma.cliente.update({
|
||||||
where: { id: cliente.id },
|
where: { id: cliente.id },
|
||||||
@@ -493,8 +527,10 @@ export async function completarProgreso(req: Request, res: Response): Promise<vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mondayApartadoId === null) {
|
if (mondayApartadoId === null) {
|
||||||
// El capturista original del alta progresiva es quien queda como Vendedor
|
// Con apartadoMondayId (alta desde un apartado existente) se liga y
|
||||||
mondayApartadoId = await crearApartadoSiAplica(dg, vc, cliente.capturadoPorId)
|
// enriquece ese item; si no, se crea. El capturista original del alta
|
||||||
|
// progresiva es quien queda como Vendedor.
|
||||||
|
mondayApartadoId = await apartadoDeAlta(dg, vc, cliente.capturadoPorId, body.apartadoMondayId)
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.cliente.update({
|
await prisma.cliente.update({
|
||||||
@@ -607,3 +643,65 @@ export async function listarDocumentos(req: Request, res: Response): Promise<voi
|
|||||||
})
|
})
|
||||||
res.json(documentos)
|
res.json(documentos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/altas/prefill-apartado/:itemId — todo lo ya recolectado de un
|
||||||
|
* apartado de Monday para prellenar el alta de cliente (botón "Firma Cliente
|
||||||
|
* (Venta)" de Mis apartados): ficha del apartado, contacto relacionado
|
||||||
|
* (perfil completo), oportunidad relacionada (perfil de la afluencia) y la
|
||||||
|
* cotización más reciente vinculada. Las relaciones se arman por teléfono
|
||||||
|
* (últimos 10 dígitos) o email.
|
||||||
|
*/
|
||||||
|
export async function prefillApartadoEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buscarLeads,
|
buscarLeads,
|
||||||
buscarLeadsEndpoint,
|
buscarLeadsEndpoint,
|
||||||
cambiarEstadoLead,
|
cambiarEstadoLead,
|
||||||
|
convertirLead,
|
||||||
crearAfluencia,
|
crearAfluencia,
|
||||||
crearAfluenciaEndpoint,
|
crearAfluenciaEndpoint,
|
||||||
crearLead,
|
crearLead,
|
||||||
@@ -22,6 +23,7 @@ const prismaMock = vi.hoisted(() => ({
|
|||||||
},
|
},
|
||||||
leadOportunidad: {
|
leadOportunidad: {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
},
|
},
|
||||||
usuario: {
|
usuario: {
|
||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
@@ -37,6 +39,7 @@ vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
|||||||
const LABELS_DESARROLLO = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA']
|
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_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_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 */
|
/** Monday mockeado: registra escrituras y devuelve catálogos controlados */
|
||||||
function mondayMock(
|
function mondayMock(
|
||||||
@@ -46,6 +49,7 @@ function mondayMock(
|
|||||||
labelsMotivo?: string[]
|
labelsMotivo?: string[]
|
||||||
duplicadoLead?: LeadMonday | null
|
duplicadoLead?: LeadMonday | null
|
||||||
duplicadoOportunidad?: OportunidadMonday | null
|
duplicadoOportunidad?: OportunidadMonday | null
|
||||||
|
leads?: LeadMonday[]
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
@@ -65,6 +69,9 @@ function mondayMock(
|
|||||||
this.oportunidadesCreadas.push(datos)
|
this.oportunidadesCreadas.push(datos)
|
||||||
return 'op-nueva-1'
|
return 'op-nueva-1'
|
||||||
},
|
},
|
||||||
|
async getLeads() {
|
||||||
|
return opts.leads ?? []
|
||||||
|
},
|
||||||
oportunidadesActualizadas: [] as { id: string; datos: Record<string, unknown> }[],
|
oportunidadesActualizadas: [] as { id: string; datos: Record<string, unknown> }[],
|
||||||
async actualizarOportunidadAfluencia(id: string, datos: Record<string, unknown>) {
|
async actualizarOportunidadAfluencia(id: string, datos: Record<string, unknown>) {
|
||||||
this.oportunidadesActualizadas.push({ id, datos })
|
this.oportunidadesActualizadas.push({ id, datos })
|
||||||
@@ -78,6 +85,8 @@ function mondayMock(
|
|||||||
async labelsDeColumna(_boardId: string, titulo: string) {
|
async labelsDeColumna(_boardId: string, titulo: string) {
|
||||||
if (titulo === 'Desarrollo') return opts.labelsDesarrollo ?? LABELS_DESARROLLO
|
if (titulo === 'Desarrollo') return opts.labelsDesarrollo ?? LABELS_DESARROLLO
|
||||||
if (titulo === 'Motivo No Compra') return opts.labelsMotivo ?? LABELS_MOTIVO
|
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
|
return opts.labelsOrigen ?? LABELS_ORIGEN
|
||||||
},
|
},
|
||||||
async obtenerNombreItem(_itemId: string) {
|
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', () => {
|
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 () => {
|
it('crea UN item en Oportunidades (Estado Afluencia) y devuelve su id como id y oportunidadId', async () => {
|
||||||
const monday = mondayMock()
|
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(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
|
||||||
expect(monday.oportunidadesCreadas).toEqual([
|
expect(monday.oportunidadesCreadas).toEqual([
|
||||||
{
|
{
|
||||||
@@ -194,7 +203,7 @@ describe('crearAfluencia (POST /api/afluencias) — la afluencia ES la oportunid
|
|||||||
telefono: '4771234567',
|
telefono: '4771234567',
|
||||||
email: undefined,
|
email: undefined,
|
||||||
desarrolloLabel: undefined,
|
desarrolloLabel: undefined,
|
||||||
origenLabel: 'Facebook',
|
origenLabel: 'PASE',
|
||||||
modeloLabel: undefined,
|
modeloLabel: undefined,
|
||||||
medioContacto: undefined,
|
medioContacto: undefined,
|
||||||
edad: undefined,
|
edad: undefined,
|
||||||
@@ -707,3 +716,74 @@ describe('buscarLeads (GET /api/leads/buscar) — búsqueda dual Leads + Oportun
|
|||||||
).rejects.toMatchObject({ status: 400 })
|
).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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../ser
|
|||||||
import { prisma } from '../services/prisma.js'
|
import { prisma } from '../services/prisma.js'
|
||||||
import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
|
import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
|
||||||
import { AppError } from '../utils/errors.js'
|
import { AppError } from '../utils/errors.js'
|
||||||
|
import { NotFoundError } from '../utils/errors.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Captura y acciones del CRM ligero, sobre DOS tableros:
|
* Captura y acciones del CRM ligero, sobre DOS tableros:
|
||||||
@@ -179,6 +180,7 @@ type MondayLeads = Pick<
|
|||||||
| 'crearMotivoNoCompra'
|
| 'crearMotivoNoCompra'
|
||||||
| 'buscarLeadPorTelefono'
|
| 'buscarLeadPorTelefono'
|
||||||
| 'buscarOportunidadPorTelefono'
|
| 'buscarOportunidadPorTelefono'
|
||||||
|
| 'getLeads'
|
||||||
| 'labelsDeColumna'
|
| 'labelsDeColumna'
|
||||||
| 'obtenerNombreItem'
|
| 'obtenerNombreItem'
|
||||||
| 'columnaIdPorTitulo'
|
| '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 })
|
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<void> {
|
||||||
|
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
|
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
|
||||||
* clientes. Busca en AMBOS tableros (Leads + Oportunidades); q mínimo 2
|
* clientes. Busca en AMBOS tableros (Leads + Oportunidades); q mínimo 2
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
iniciarProgreso,
|
iniciarProgreso,
|
||||||
listarDocumentos,
|
listarDocumentos,
|
||||||
obtenerCliente,
|
obtenerCliente,
|
||||||
|
prefillApartadoEndpoint,
|
||||||
subirDocumentos,
|
subirDocumentos,
|
||||||
uploadDocumentos,
|
uploadDocumentos,
|
||||||
} from '../controllers/clientes.controller.js'
|
} from '../controllers/clientes.controller.js'
|
||||||
@@ -54,6 +55,7 @@ import {
|
|||||||
crearAfluenciaEndpoint,
|
crearAfluenciaEndpoint,
|
||||||
crearLeadEndpoint,
|
crearLeadEndpoint,
|
||||||
buscarLeadsEndpoint,
|
buscarLeadsEndpoint,
|
||||||
|
convertirLeadEndpoint,
|
||||||
descartarLeadEndpoint,
|
descartarLeadEndpoint,
|
||||||
} from '../controllers/leads.controller.js'
|
} from '../controllers/leads.controller.js'
|
||||||
import {
|
import {
|
||||||
@@ -100,6 +102,9 @@ apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica)
|
|||||||
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
|
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
|
||||||
|
|
||||||
apiRouter.post('/clientes', crearCliente)
|
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).
|
// Alta progresiva: guardado etapa por etapa del wizard (Contacto PROSPECTO → CLIENTE).
|
||||||
// Van ANTES de /clientes/:id para no colisionar con el parámetro.
|
// Van ANTES de /clientes/:id para no colisionar con el parámetro.
|
||||||
apiRouter.post('/clientes/progreso/iniciar', iniciarProgreso)
|
apiRouter.post('/clientes/progreso/iniciar', iniciarProgreso)
|
||||||
@@ -157,6 +162,8 @@ apiRouter.get('/leads/buscar', buscarLeadsEndpoint)
|
|||||||
apiRouter.post('/leads', crearLeadEndpoint)
|
apiRouter.post('/leads', crearLeadEndpoint)
|
||||||
apiRouter.post('/leads/:itemId/estado', cambiarEstadoLeadEndpoint)
|
apiRouter.post('/leads/:itemId/estado', cambiarEstadoLeadEndpoint)
|
||||||
apiRouter.post('/leads/:itemId/descartar', descartarLeadEndpoint)
|
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
|
// Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades
|
||||||
apiRouter.post('/afluencias', crearAfluenciaEndpoint)
|
apiRouter.post('/afluencias', crearAfluenciaEndpoint)
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ export interface ApartadoMonday {
|
|||||||
email?: string // columna email 'Correo electrónico' ('' si no tiene)
|
email?: string // columna email 'Correo electrónico' ('' si no tiene)
|
||||||
precioFinal?: number // columna numbers 'Precio Final' (0 si no tiene)
|
precioFinal?: number // columna numbers 'Precio Final' (0 si no tiene)
|
||||||
fechaCompra?: string // 'YYYY-MM-DD' de 'Fecha Compra' ('' 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) */
|
/** 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
|
* Oportunidad en etapa 'Afluencia' con todo el perfil de captura (la
|
||||||
* afluencia ES la oportunidad: no hay item aparte en el tablero Leads).
|
* afluencia ES la oportunidad: no hay item aparte en el tablero Leads).
|
||||||
* La columna date 'Fecha' lleva la fecha de captura (hoy, hora local).
|
* 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<string> {
|
async crearOportunidadAfluencia(datos: DatosOportunidadAfluencia): Promise<string> {
|
||||||
const boardId = config.monday.boards.oportunidades
|
const boardId = config.monday.boards.oportunidades
|
||||||
const hoy = new Date()
|
const hoy = new Date()
|
||||||
const fechaHoy = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(hoy.getDate()).padStart(2, '0')}`
|
const fechaHoy = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(hoy.getDate()).padStart(2, '0')}`
|
||||||
const columnValues = await this.construirColumnValues(boardId, {
|
const columnValues = await this.construirColumnValues(boardId, {
|
||||||
Estado: 'Afluencia',
|
Estado: datos.estadoLabel ?? 'Afluencia',
|
||||||
Fecha: fechaHoy,
|
Fecha: fechaHoy,
|
||||||
'Cliente teléfono': datos.telefono,
|
'Cliente teléfono': datos.telefono,
|
||||||
'Cliente email': datos.email,
|
'Cliente email': datos.email,
|
||||||
@@ -682,6 +688,41 @@ export class MondayService {
|
|||||||
return this.crearItem(boardId, datos.clienteNombre, columnValues)
|
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<DatosApartado>): Promise<void> {
|
||||||
|
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<string, unknown>
|
||||||
|
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<void> {
|
async actualizarDisponibilidadUnidad(codigo: string, status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'): Promise<void> {
|
||||||
const boardId = config.monday.boards.unidades
|
const boardId = config.monday.boards.unidades
|
||||||
// Asegura cache fresco para resolver el itemId
|
// Asegura cache fresco para resolver el itemId
|
||||||
@@ -1190,6 +1231,10 @@ export class MondayService {
|
|||||||
const colEmail = cols.get('Correo electrónico')
|
const colEmail = cols.get('Correo electrónico')
|
||||||
const colPrecioFinal = cols.get('Precio Final')
|
const colPrecioFinal = cols.get('Precio Final')
|
||||||
const colFechaCompra = cols.get('Fecha Compra')
|
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[] = []
|
const items: ItemMonday[] = []
|
||||||
let cursor: string | null = null
|
let cursor: string | null = null
|
||||||
@@ -1241,6 +1286,10 @@ export class MondayService {
|
|||||||
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
|
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
|
||||||
precioFinal: colPrecioFinal ? Number(porId.get(colPrecioFinal.id)?.text ?? 0) || 0 : 0,
|
precioFinal: colPrecioFinal ? Number(porId.get(colPrecioFinal.id)?.text ?? 0) || 0 : 0,
|
||||||
fechaCompra: colFechaCompra ? (porId.get(colFechaCompra.id)?.text ?? '') : '',
|
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 {
|
export interface DatosOportunidadAfluencia {
|
||||||
nombre: string // name del item = nombre del interesado
|
nombre: string // name del item = nombre del interesado
|
||||||
|
/** Etapa inicial (default 'Afluencia'; 'Agendo Cita' al convertir un lead) */
|
||||||
|
estadoLabel?: string | undefined
|
||||||
telefono?: string | undefined
|
telefono?: string | undefined
|
||||||
email?: string | undefined
|
email?: string | undefined
|
||||||
desarrolloLabel?: string | undefined // label tal cual de la columna status 'Desarrollo'
|
desarrolloLabel?: string | undefined // label tal cual de la columna status 'Desarrollo'
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const economicosVacios = (): AltaCliente['economicos'] => ({
|
|||||||
|
|
||||||
const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
|
const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
|
||||||
leadMondayId: null,
|
leadMondayId: null,
|
||||||
|
apartadoMondayId: null,
|
||||||
progresoId: null,
|
progresoId: null,
|
||||||
datosGenerales: datosGeneralesVacios(),
|
datosGenerales: datosGeneralesVacios(),
|
||||||
empleo: empleoVacio(),
|
empleo: empleoVacio(),
|
||||||
|
|||||||
79
apps/alta-clientes/src/lib/apartadoAlta.test.ts
Normal file
79
apps/alta-clientes/src/lib/apartadoAlta.test.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
138
apps/alta-clientes/src/lib/apartadoAlta.ts
Normal file
138
apps/alta-clientes/src/lib/apartadoAlta.ts
Normal file
@@ -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<string, string> | 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<DatosGenerales>
|
||||||
|
viviendaCredito: Partial<ViviendaCredito>
|
||||||
|
/** 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<string, EstadoCivil> = {
|
||||||
|
'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<string, TipoCredito> = {
|
||||||
|
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<string, TipoCredito> = {
|
||||||
|
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<DatosGenerales> = {
|
||||||
|
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<ViviendaCredito> = {
|
||||||
|
...(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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export const FIRMA_PRUEBA =
|
|||||||
*/
|
*/
|
||||||
export const fixtureAlta: AltaCliente = {
|
export const fixtureAlta: AltaCliente = {
|
||||||
leadMondayId: null,
|
leadMondayId: null,
|
||||||
|
apartadoMondayId: null,
|
||||||
progresoId: null,
|
progresoId: null,
|
||||||
datosGenerales: {
|
datosGenerales: {
|
||||||
campaniaVenta: '',
|
campaniaVenta: '',
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ import {
|
|||||||
listarBorradores,
|
listarBorradores,
|
||||||
obtenerBorrador,
|
obtenerBorrador,
|
||||||
obtenerCotizacion,
|
obtenerCotizacion,
|
||||||
|
obtenerPrefillApartado,
|
||||||
subirDocumentos,
|
subirDocumentos,
|
||||||
type BorradorResumen,
|
type BorradorResumen,
|
||||||
type LeadResumen,
|
type LeadResumen,
|
||||||
} from '../services/api'
|
} from '../services/api'
|
||||||
import { cotizacionAPrellenado } from '../lib/cotizacionAlta'
|
import { cotizacionAPrellenado } from '../lib/cotizacionAlta'
|
||||||
|
import { apartadoAPrellenado } from '../lib/apartadoAlta'
|
||||||
import { leadAPrellenado } from '../lib/leadAlta'
|
import { leadAPrellenado } from '../lib/leadAlta'
|
||||||
import BusquedaLead from '../components/BusquedaLead'
|
import BusquedaLead from '../components/BusquedaLead'
|
||||||
import AltaAfluenciaForm from '../components/AltaAfluenciaForm'
|
import AltaAfluenciaForm from '../components/AltaAfluenciaForm'
|
||||||
@@ -54,6 +56,11 @@ function paramsAfluencia(): { oportunidadId: string; valores: Record<string, str
|
|||||||
return { oportunidadId: id, valores }
|
return { oportunidadId: id, valores }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lee ?apartado=<itemId> — 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 =>
|
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' })
|
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<string, string>
|
valores: Record<string, string>
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
// Al entrar: ?cotizacion=<id> prellena el alta y salta directo al wizard;
|
// Al entrar: ?apartado=<id> prellena el alta con todo lo recolectado del
|
||||||
// ?afluencia=<id> abre el formulario de afluencia prellenado; si no, la
|
// apartado y salta al wizard ligado a ese item; ?afluencia=<id> abre el
|
||||||
// entrada ofrece Alta Afluencia (captura ligera) y Alta de Cliente
|
// formulario de afluencia prellenado; ?cotizacion=<id> prellena el alta;
|
||||||
|
// si no, la entrada ofrece Alta Afluencia (captura ligera) y Alta de Cliente
|
||||||
useEffect(() => {
|
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()
|
const afl = paramsAfluencia()
|
||||||
if (afl) {
|
if (afl) {
|
||||||
setAfluenciaPrevia(afl)
|
setAfluenciaPrevia(afl)
|
||||||
@@ -487,6 +509,11 @@ export default function AltaClientePage() {
|
|||||||
{errorCarga}
|
{errorCarga}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{datos.apartadoMondayId && (
|
||||||
|
<p role="note" className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-xs font-medium text-blue-800">
|
||||||
|
Alta ligada al apartado del cliente en Monday — al completarla se actualiza ese item (no se duplica).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{paso === 0 && (
|
{paso === 0 && (
|
||||||
<TarjetaPaso>
|
<TarjetaPaso>
|
||||||
|
|||||||
@@ -216,6 +216,14 @@ export async function obtenerCotizacion(id: string): Promise<import('../lib/coti
|
|||||||
return (await res.json()) as import('../lib/cotizacionAlta').CotizacionParaAlta
|
return (await res.json()) as import('../lib/cotizacionAlta').CotizacionParaAlta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET /altas/prefill-apartado/:itemId — todo lo recolectado de un apartado de Monday (?apartado=<id>) */
|
||||||
|
export async function obtenerPrefillApartado(itemId: string): Promise<import('../lib/apartadoAlta').PrefillApartado> {
|
||||||
|
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) ----------
|
// ---------- Pre-llenado desde lead de Monday (auth) ----------
|
||||||
|
|
||||||
export interface LeadResumen {
|
export interface LeadResumen {
|
||||||
|
|||||||
@@ -149,6 +149,10 @@ export interface AltaCliente {
|
|||||||
// ID del item del tablero Leads de Monday cuando el alta se prellenó desde
|
// ID del item del tablero Leads de Monday cuando el alta se prellenó desde
|
||||||
// un lead (null = captura manual). Passthrough hasta payloadJson del API.
|
// un lead (null = captura manual). Passthrough hasta payloadJson del API.
|
||||||
leadMondayId: string | null
|
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/
|
// ID del Cliente creado por el guardado progresivo (POST /clientes/progreso/
|
||||||
// iniciar); viaja con el borrador (datosJson) y no se captura en el formato.
|
// iniciar); viaja con el borrador (datosJson) y no se captura en el formato.
|
||||||
progresoId: string | null
|
progresoId: string | null
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
cambiarEstadoApartado,
|
cambiarEstadoApartado,
|
||||||
cambiarEstadoLead,
|
cambiarEstadoLead,
|
||||||
cambiarEtapaOportunidad,
|
cambiarEtapaOportunidad,
|
||||||
|
convertirLead,
|
||||||
crearLead,
|
crearLead,
|
||||||
descartarLead,
|
descartarLead,
|
||||||
fetchResumenMiDia,
|
fetchResumenMiDia,
|
||||||
@@ -180,9 +181,8 @@ function FilaOportunidad({
|
|||||||
}) {
|
}) {
|
||||||
const [conCita, setConCita] = useState(false)
|
const [conCita, setConCita] = useState(false)
|
||||||
const [fechaCita, setFechaCita] = useState('')
|
const [fechaCita, setFechaCita] = useState('')
|
||||||
// 'Afluencia' no es un cambio de etapa directo: abre el formulario de
|
// Sin 'No Asistio' ni 'Abandono': son redundantes con Descartar
|
||||||
// afluencia prellenado para capturar/actualizar el perfil completo
|
const fases = ['Agendo Cita', 'Aparto'] as const
|
||||||
const fases = ['Agendo Cita', 'Aparto', 'No Asistio', 'Abandono'] as const
|
|
||||||
return (
|
return (
|
||||||
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -311,16 +311,26 @@ function FilaApartado({
|
|||||||
key={fase}
|
key={fase}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={enviando}
|
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={
|
className={
|
||||||
fase === 'Cancelacion Apartado'
|
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'
|
? '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
|
: fase === 'Firma Cliente (Venta)'
|
||||||
? 'rounded-lg border border-accent bg-accent/5 px-3 py-1.5 text-xs font-medium text-accent shadow-sm disabled:opacity-50'
|
? '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'
|
||||||
: CLASE_BOTON_ACCION
|
: 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]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
@@ -484,11 +494,13 @@ function TabsCrm({
|
|||||||
function FilaLead({
|
function FilaLead({
|
||||||
lead,
|
lead,
|
||||||
onAccion,
|
onAccion,
|
||||||
|
onConvertir,
|
||||||
onDescartar,
|
onDescartar,
|
||||||
enviando,
|
enviando,
|
||||||
}: {
|
}: {
|
||||||
lead: LeadMiDia
|
lead: LeadMiDia
|
||||||
onAccion: (estado: string, fecha?: string) => void
|
onAccion: (estado: string, fecha?: string) => void
|
||||||
|
onConvertir: () => void
|
||||||
onDescartar: () => void
|
onDescartar: () => void
|
||||||
enviando: boolean
|
enviando: boolean
|
||||||
}) {
|
}) {
|
||||||
@@ -534,6 +546,15 @@ function FilaLead({
|
|||||||
<button type="button" disabled={enviando} onClick={() => onAccion('INTERESADO')} className={CLASE_BOTON_ACCION}>
|
<button type="button" disabled={enviando} onClick={() => onAccion('INTERESADO')} className={CLASE_BOTON_ACCION}>
|
||||||
Interesado
|
Interesado
|
||||||
</button>
|
</button>
|
||||||
|
{/* Agendo: convierte el lead en oportunidad (etapa Agendo Cita) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={enviando}
|
||||||
|
onClick={onConvertir}
|
||||||
|
className="rounded-lg border border-accent bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Agendo →
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={enviando}
|
disabled={enviando}
|
||||||
@@ -561,6 +582,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
fuente: 'lead' | 'oportunidad'
|
fuente: 'lead' | 'oportunidad'
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [tabCrm, setTabCrm] = useState<TabCrm>('leads')
|
const [tabCrm, setTabCrm] = useState<TabCrm>('leads')
|
||||||
|
const [nota, setNota] = useState<string | null>(null)
|
||||||
|
|
||||||
// Captura rápida
|
// Captura rápida
|
||||||
const [capNombre, setCapNombre] = useState('')
|
const [capNombre, setCapNombre] = useState('')
|
||||||
@@ -603,6 +625,25 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Botón "Agendo" de Mis leads: convierte el lead en oportunidad (etapa
|
||||||
|
* Agendo Cita); el lead sale de la lista y aparece en Mis oportunidades.
|
||||||
|
*/
|
||||||
|
async function convertir(lead: LeadMiDia) {
|
||||||
|
setEnviando(true)
|
||||||
|
setAviso(null)
|
||||||
|
setNota(null)
|
||||||
|
try {
|
||||||
|
await convertirLead(lead.id)
|
||||||
|
await cargar()
|
||||||
|
setNota(`${lead.nombre} pasó a Mis oportunidades (Agendo Cita).`)
|
||||||
|
} catch (err) {
|
||||||
|
setAviso(err instanceof Error ? err.message : 'No se pudo convertir el lead')
|
||||||
|
} finally {
|
||||||
|
setEnviando(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Botón "Cotizar": pasa la oportunidad a etapa Cotizador y abre el cotizador
|
* Botón "Cotizar": pasa la oportunidad a etapa Cotizador y abre el cotizador
|
||||||
* con los datos del cliente prellenados y la oportunidad vinculada (al
|
* con los datos del cliente prellenados y la oportunidad vinculada (al
|
||||||
@@ -732,6 +773,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
{aviso}
|
{aviso}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{nota && (
|
||||||
|
<p role="status" className="mb-6 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
|
||||||
|
{nota}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{resumen?.sinMapeoMonday && (
|
{resumen?.sinMapeoMonday && (
|
||||||
<p className="mb-6 rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-sm font-medium text-[#64748B] shadow-sm">
|
<p className="mb-6 rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-sm font-medium text-[#64748B] shadow-sm">
|
||||||
Tu usuario aún no está vinculado a Monday; las acciones siguen funcionando
|
Tu usuario aún no está vinculado a Monday; las acciones siguen funcionando
|
||||||
@@ -779,6 +825,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
lead={lead}
|
lead={lead}
|
||||||
enviando={enviando}
|
enviando={enviando}
|
||||||
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
|
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
|
||||||
|
onConvertir={() => void convertir(lead)}
|
||||||
onDescartar={() => setDescartando(lead)}
|
onDescartar={() => setDescartando(lead)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ export function cambiarEtapaOportunidad(itemId: string, etapa: string, fecha?: s
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** POST /leads/:itemId/convertir — botón "Agendo": convierte el lead en oportunidad */
|
||||||
|
export function convertirLead(itemId: string): Promise<{ oportunidadId: string }> {
|
||||||
|
return fetchJson(`/leads/${itemId}/convertir`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** POST /apartados/:itemId/estado — botones de fase de "Mis apartados" */
|
/** POST /apartados/:itemId/estado — botones de fase de "Mis apartados" */
|
||||||
export function cambiarEstadoApartado(itemId: string, estado: string): Promise<unknown> {
|
export function cambiarEstadoApartado(itemId: string, estado: string): Promise<unknown> {
|
||||||
return fetchJson(`/apartados/${itemId}/estado`, {
|
return fetchJson(`/apartados/${itemId}/estado`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user