feat(leads): doble via de captura — lead rapido a Leads, afluencia a Oportunidades
- POST /api/leads: captura ligera al tablero Leads (sin oportunidad), self-assign a Vendedor o round-robin; tambien cubre la captura manual desde la UI de Monday (webhook create_item re-registrado). - POST /api/afluencias (nuevo): la forma completa sigue cayendo en Oportunidades con todo el perfil (etapa Afluencia, columna Asesor). - Round-robin dual (ambos tableros), Mi dia mezcla leads+oportunidades con insignia de origen, acciones con parametro fuente, reportes: leads desde Leads, perfilamiento desde Oportunidades, funnel Leads->Afluencia->...->Ganado.
This commit is contained in:
@@ -9,20 +9,30 @@ import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
|
||||
/**
|
||||
* Captura y acciones del CRM ligero sobre el tablero Oportunidades: la
|
||||
* afluencia ES la oportunidad en etapa 'Afluencia' (UN solo registro con todo
|
||||
* el perfil). El tablero Leads queda como archivo histórico: solo se consulta
|
||||
* en la búsqueda dual del picker del alta de clientes.
|
||||
* Los labels de estado/origen son los reales del board (verificados contra
|
||||
* Monday); la whitelist evita escribir labels que el status no tenga dados de
|
||||
* alta, y cada valor de la whitelist se traduce al label real del board.
|
||||
* Captura y acciones del CRM ligero, sobre DOS tableros:
|
||||
* - Tablero Leads (POST /api/leads): captura rápida de /hoy y captura manual
|
||||
* desde la UI de Monday. Item ligero (nombre, teléfono, email, origen,
|
||||
* desarrollo, notas). Los leads NO crean oportunidad.
|
||||
* - Tablero Oportunidades (POST /api/afluencias): el Alta Afluencia (forma
|
||||
* completa de /client) — la afluencia ES la oportunidad en etapa
|
||||
* 'Afluencia', con todo el perfil.
|
||||
* Las acciones del vendedor (/estado, /descartar) y la búsqueda del picker del
|
||||
* alta conviven con ambos tableros vía el parámetro `fuente`.
|
||||
* Los labels se validan/resuelven contra los reales de cada board (los
|
||||
* dropdown y status de Monday no auto-crean labels al escribir).
|
||||
*/
|
||||
|
||||
// Transiciones que el vendedor puede hacer desde la app Mi día
|
||||
export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO'] as const
|
||||
|
||||
// Traducción de la whitelist (valores que mandan los frontends) a los labels
|
||||
// reales de la columna status 'Estado' del board Oportunidades
|
||||
// reales de la columna status 'Estado' de cada board
|
||||
const LABEL_ESTADO_LEADS: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
|
||||
CONTACTADO: 'Contactado',
|
||||
'Intento de contacto': 'Intento de contacto',
|
||||
'AGENDA CITA': 'Agenda Cita',
|
||||
CALIFICADO: 'Calificado',
|
||||
}
|
||||
const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
|
||||
CONTACTADO: 'Contactado',
|
||||
'Intento de contacto': 'Intento de contacto',
|
||||
@@ -30,6 +40,11 @@ const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number],
|
||||
CALIFICADO: 'Calificado',
|
||||
}
|
||||
|
||||
export type FuenteLead = 'lead' | 'oportunidad'
|
||||
|
||||
const fuenteSchema = z.enum(['lead', 'oportunidad']).optional()
|
||||
|
||||
/** Captura rápida de lead (tablero Leads): solo los campos ligeros */
|
||||
const crearLeadSchema = z.object({
|
||||
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
|
||||
telefono: z.string().trim().min(1).optional(),
|
||||
@@ -37,6 +52,16 @@ const crearLeadSchema = z.object({
|
||||
origen: z.string().trim().min(1).optional(),
|
||||
desarrollo: z.string().trim().min(1).optional(),
|
||||
notas: z.string().trim().min(1).optional(),
|
||||
})
|
||||
|
||||
/** Alta Afluencia (tablero Oportunidades): captura con perfil completo */
|
||||
const crearAfluenciaSchema = z.object({
|
||||
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
|
||||
telefono: z.string().trim().min(1).optional(),
|
||||
email: z.string().trim().email('Email inválido').optional(),
|
||||
origen: z.string().trim().min(1).optional(),
|
||||
desarrollo: z.string().trim().min(1).optional(),
|
||||
notas: z.string().trim().min(1).optional(),
|
||||
// Campos de calificación de la afluencia (opcionales; los labels fijos del
|
||||
// front coinciden con los sembrados en Monday, no se validan contra el board)
|
||||
modelo: z.string().trim().min(1).optional(),
|
||||
@@ -58,11 +83,15 @@ const cambiarEstadoSchema = z.object({
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe ser YYYY-MM-DD')
|
||||
.optional(),
|
||||
// Tablero del item: 'lead' = tablero Leads; 'oportunidad' = Oportunidades
|
||||
// (default por compat: las afluencias nuevas viven ahí)
|
||||
fuente: fuenteSchema,
|
||||
})
|
||||
|
||||
const descartarSchema = z.object({
|
||||
motivo: z.enum(MOTIVOS_NO_COMPRA),
|
||||
comentario: z.string().trim().min(1).optional(),
|
||||
fuente: fuenteSchema,
|
||||
})
|
||||
|
||||
// ---------- Búsqueda dual (prellenado del alta de clientes) ----------
|
||||
@@ -76,7 +105,7 @@ const sinAcentos = (s: string): string =>
|
||||
.replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F
|
||||
.toLowerCase()
|
||||
|
||||
/** Resultado del picker del alta: conviven el archivo histórico (Leads) y el modelo nuevo (Oportunidades) */
|
||||
/** Resultado del picker del alta: conviven Leads (captura ligera) y Oportunidades (alta afluencia) */
|
||||
export interface ResultadoBusquedaLead {
|
||||
id: string
|
||||
nombre: string
|
||||
@@ -84,13 +113,13 @@ export interface ResultadoBusquedaLead {
|
||||
email: string
|
||||
estado: string
|
||||
origen: string
|
||||
fuente: 'lead' | 'oportunidad'
|
||||
fuente: FuenteLead
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtra por q en nombre/teléfono/email (case-insensitive, sin acentos) en
|
||||
* AMBOS tableros — Leads (histórico) y Oportunidades (modelo nuevo) —, ordena
|
||||
* por actualización reciente y limita a 15 resultados combinados.
|
||||
* AMBOS tableros — Leads y Oportunidades —, ordena por actualización reciente
|
||||
* y limita a 15 resultados combinados.
|
||||
*/
|
||||
export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonday[], q: string): ResultadoBusquedaLead[] {
|
||||
const needle = sinAcentos(q)
|
||||
@@ -126,8 +155,10 @@ export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonda
|
||||
type MondayLeads = Pick<
|
||||
MondayService,
|
||||
| 'cambiarValorColumna'
|
||||
| 'crearLead'
|
||||
| 'crearOportunidadAfluencia'
|
||||
| 'crearMotivoNoCompra'
|
||||
| 'buscarLeadPorTelefono'
|
||||
| 'buscarOportunidadPorTelefono'
|
||||
| 'labelsDeColumna'
|
||||
| 'obtenerNombreItem'
|
||||
@@ -136,7 +167,51 @@ type MondayLeads = Pick<
|
||||
| 'moverItemAGrupo'
|
||||
>
|
||||
|
||||
export type ResultadoCrearLead = { id: string; oportunidadId: string } | { duplicado: OportunidadMonday }
|
||||
export type ResultadoCrearLead =
|
||||
| { id: string; oportunidadId: string | null }
|
||||
| { duplicado: { id: string; nombre: string } }
|
||||
|
||||
/**
|
||||
* Crea un lead en el tablero Leads (Estado NUEVO + Fecha de hoy; captura
|
||||
* rápida ligera, SIN oportunidad). Si viene teléfono y ya existe un lead con
|
||||
* ese número, no crea nada y devuelve { duplicado } (el endpoint → 409).
|
||||
* Origen y desarrollo se validan contra los labels reales del board Leads.
|
||||
*/
|
||||
export async function crearLead(
|
||||
monday: MondayLeads,
|
||||
datos: z.infer<typeof crearLeadSchema>,
|
||||
): Promise<ResultadoCrearLead> {
|
||||
const boardLeads = config.monday.boards.leads
|
||||
|
||||
if (datos.telefono !== undefined) {
|
||||
const existente = await monday.buscarLeadPorTelefono(datos.telefono)
|
||||
if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } }
|
||||
}
|
||||
|
||||
if (datos.origen !== undefined) {
|
||||
const labels = await monday.labelsDeColumna(boardLeads, 'Origen')
|
||||
if (!labels.includes(datos.origen)) {
|
||||
throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (datos.desarrollo !== undefined) {
|
||||
const labels = await monday.labelsDeColumna(boardLeads, 'Desarrollo')
|
||||
if (!labels.includes(datos.desarrollo)) {
|
||||
throw new AppError(400, `Desarrollo inválido: "${datos.desarrollo}". Labels válidos: ${labels.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
const id = await monday.crearLead({
|
||||
nombre: datos.nombre,
|
||||
telefono: datos.telefono,
|
||||
email: datos.email,
|
||||
origenLabel: datos.origen,
|
||||
desarrolloLabel: datos.desarrollo,
|
||||
notas: datos.notas,
|
||||
})
|
||||
return { id, oportunidadId: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea la afluencia en Monday: UN item en el tablero Oportunidades con Estado
|
||||
@@ -148,15 +223,15 @@ export type ResultadoCrearLead = { id: string; oportunidadId: string } | { dupli
|
||||
* item en el grupo top del tablero, hoy "Perdidos", y el webhook de change no
|
||||
* dispara en la creación); un fallo ahí solo se loguea.
|
||||
*/
|
||||
export async function crearLead(
|
||||
export async function crearAfluencia(
|
||||
monday: MondayLeads,
|
||||
datos: z.infer<typeof crearLeadSchema>,
|
||||
datos: z.infer<typeof crearAfluenciaSchema>,
|
||||
): Promise<ResultadoCrearLead> {
|
||||
const boardOportunidades = config.monday.boards.oportunidades
|
||||
|
||||
if (datos.telefono !== undefined) {
|
||||
const existente = await monday.buscarOportunidadPorTelefono(datos.telefono)
|
||||
if (existente) return { duplicado: existente }
|
||||
if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } }
|
||||
}
|
||||
|
||||
if (datos.origen !== undefined) {
|
||||
@@ -202,43 +277,60 @@ export async function crearLead(
|
||||
return { id, oportunidadId: id }
|
||||
}
|
||||
|
||||
/** Cambia el estado de la afluencia (whitelist → label real del board); en AGENDA CITA puede setear la Fecha */
|
||||
/**
|
||||
* Cambia el estado del item (whitelist → label real del board correspondiente
|
||||
* según `fuente`); en AGENDA CITA puede setear la columna Fecha.
|
||||
*/
|
||||
export async function cambiarEstadoLead(
|
||||
monday: MondayLeads,
|
||||
itemId: string,
|
||||
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
|
||||
fecha?: string,
|
||||
fuente: FuenteLead = 'oportunidad',
|
||||
): Promise<void> {
|
||||
const boardOportunidades = config.monday.boards.oportunidades
|
||||
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', LABEL_ESTADO_OPORTUNIDAD[estado])
|
||||
const esLead = fuente === 'lead'
|
||||
const boardId = esLead ? config.monday.boards.leads : config.monday.boards.oportunidades
|
||||
const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado]
|
||||
await monday.cambiarValorColumna(boardId, itemId, 'Estado', label)
|
||||
if (estado === 'AGENDA CITA' && fecha !== undefined) {
|
||||
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Fecha', fecha)
|
||||
await monday.cambiarValorColumna(boardId, itemId, 'Fecha', fecha)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estado 'Perdido' + dropdown Motivo No Compra (label ya sembrado; si no
|
||||
* existiera se omite) + registro en el catálogo Motivos No Compra. El
|
||||
* comentario opcional va a la columna long_text COMENTARIOS.
|
||||
* Descarte del item según `fuente`:
|
||||
* - 'oportunidad': Estado 'Perdido' + dropdown Motivo No Compra (label ya
|
||||
* sembrado; si no existiera se omite) + comentario en COMENTARIOS.
|
||||
* - 'lead': Estado 'Descartado' + comentario en Notas.
|
||||
* Ambos registran el motivo en el tablero catálogo Motivos No Compra.
|
||||
*/
|
||||
export async function descartarLead(
|
||||
monday: MondayLeads,
|
||||
itemId: string,
|
||||
motivo: string,
|
||||
comentario?: string,
|
||||
fuente: FuenteLead = 'oportunidad',
|
||||
): Promise<string> {
|
||||
const boardOportunidades = config.monday.boards.oportunidades
|
||||
const nombreItem = await monday.obtenerNombreItem(itemId)
|
||||
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', 'Perdido')
|
||||
if (comentario !== undefined) {
|
||||
await monday.cambiarValorColumna(boardOportunidades, itemId, 'COMENTARIOS', comentario)
|
||||
}
|
||||
const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra')
|
||||
if (labels.includes(motivo)) {
|
||||
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Motivo No Compra', motivo)
|
||||
if (fuente === 'lead') {
|
||||
const boardLeads = config.monday.boards.leads
|
||||
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', 'Descartado')
|
||||
if (comentario !== undefined) {
|
||||
await monday.cambiarValorColumna(boardLeads, itemId, 'Notas', comentario)
|
||||
}
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`)
|
||||
const boardOportunidades = config.monday.boards.oportunidades
|
||||
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', 'Perdido')
|
||||
if (comentario !== undefined) {
|
||||
await monday.cambiarValorColumna(boardOportunidades, itemId, 'COMENTARIOS', comentario)
|
||||
}
|
||||
const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra')
|
||||
if (labels.includes(motivo)) {
|
||||
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Motivo No Compra', motivo)
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`)
|
||||
}
|
||||
}
|
||||
return monday.crearMotivoNoCompra({
|
||||
nombre: `${motivo} — ${nombreItem}`,
|
||||
@@ -247,7 +339,42 @@ export async function descartarLead(
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /api/leads { nombre, telefono?, email?, origen?, notas? } */
|
||||
/**
|
||||
* Self-assign del capturista con bandera anti-carrera rr:skip (para que el
|
||||
* webhook create_item no lo reparta por round-robin mientras Monday propaga la
|
||||
* escritura; si la asignación falla, se apaga y cae al reparto).
|
||||
* Devuelve cómo quedó la asignación.
|
||||
*/
|
||||
async function asignarCapturistaSiAplica(
|
||||
usuarioId: string | undefined,
|
||||
itemId: string,
|
||||
nombre: string,
|
||||
board: 'leads' | 'oportunidades',
|
||||
): Promise<'capturista' | 'round-robin'> {
|
||||
if (usuarioId === undefined) return 'round-robin'
|
||||
try {
|
||||
const capturista = await prisma.usuario.findUnique({
|
||||
where: { id: usuarioId },
|
||||
select: { mondayUserId: true },
|
||||
})
|
||||
if (capturista?.mondayUserId != null) {
|
||||
await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'capturista')
|
||||
try {
|
||||
await automatizacionesService.asignarACapturista(itemId, nombre, capturista.mondayUserId, board)
|
||||
return 'capturista'
|
||||
} catch (err) {
|
||||
await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'round-robin')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo asignar el item ${itemId} al capturista:`, err)
|
||||
}
|
||||
return 'round-robin'
|
||||
}
|
||||
|
||||
/** POST /api/leads { nombre, telefono?, email?, origen?, desarrollo?, notas? } — captura rápida (tablero Leads) */
|
||||
export async function crearLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = crearLeadSchema.parse(req.body)
|
||||
const resultado = await crearLead(mondayService, body)
|
||||
@@ -260,9 +387,43 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
|
||||
return
|
||||
}
|
||||
// Registro local del capturista para "Mis capturas" del panel Mi día (Monday
|
||||
// no guarda quién dio de alta el lead). leadId = el id de la oportunidad
|
||||
// (modelo nuevo: la afluencia ES la oportunidad). Un fallo aquí no rompe la
|
||||
// respuesta: la afluencia ya quedó creada en Monday.
|
||||
// no guarda quién dio de alta el lead). Un fallo aquí no rompe la respuesta:
|
||||
// el lead ya quedó creado en Monday.
|
||||
if (req.usuarioToken?.sub) {
|
||||
try {
|
||||
await prisma.leadCaptura.create({
|
||||
data: {
|
||||
leadId: resultado.id,
|
||||
oportunidadId: null, // los leads ya no generan oportunidad
|
||||
usuarioId: req.usuarioToken.sub,
|
||||
nombreLead: body.nombre,
|
||||
telefono: body.telefono ?? null,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
|
||||
}
|
||||
}
|
||||
const asignacion = await asignarCapturistaSiAplica(req.usuarioToken?.sub, resultado.id, body.nombre, 'leads')
|
||||
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: null, asignacion })
|
||||
}
|
||||
|
||||
/** POST /api/afluencias { nombre, telefono?, ..., perfil completo } — Alta Afluencia (tablero Oportunidades) */
|
||||
export async function crearAfluenciaEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = crearAfluenciaSchema.parse(req.body)
|
||||
const resultado = await crearAfluencia(mondayService, body)
|
||||
if ('duplicado' in resultado) {
|
||||
res.status(409).json({
|
||||
error: 'Ya existe un lead con ese teléfono',
|
||||
leadId: resultado.duplicado.id,
|
||||
nombre: resultado.duplicado.nombre,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Registro local del capturista para "Mis capturas" del panel Mi día.
|
||||
// leadId = el id de la oportunidad (la afluencia ES la oportunidad). Un
|
||||
// fallo aquí no rompe la respuesta: la afluencia ya quedó creada en Monday.
|
||||
if (req.usuarioToken?.sub) {
|
||||
try {
|
||||
await prisma.leadCaptura.create({
|
||||
@@ -276,7 +437,7 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
|
||||
})
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
|
||||
console.error(`No se pudo registrar la captura de la afluencia ${resultado.id}:`, err)
|
||||
}
|
||||
}
|
||||
// Auto-mapping leadId = oportunidadId en LeadOportunidad: el alta de
|
||||
@@ -284,62 +445,36 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
|
||||
// 'Aparto' tanto en ligas históricas como en el modelo nuevo.
|
||||
try {
|
||||
await prisma.leadOportunidad.create({
|
||||
data: { leadId: resultado.id, oportunidadId: resultado.oportunidadId },
|
||||
data: { leadId: resultado.id, oportunidadId: resultado.oportunidadId ?? resultado.id },
|
||||
})
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo guardar el mapping de la oportunidad ${resultado.id}:`, err)
|
||||
}
|
||||
// Asignación: si el capturista tiene su usuario Monday vinculado, la
|
||||
// oportunidad queda asignada a él (columna Asesor). La bandera rr:skip se
|
||||
// prende ANTES de asignar para que el webhook create_item no la reparta por
|
||||
// round-robin mientras Monday propaga la escritura (si la asignación falla,
|
||||
// se apaga y cae al reparto).
|
||||
let asignacion: 'capturista' | 'round-robin' = 'round-robin'
|
||||
if (req.usuarioToken?.sub) {
|
||||
try {
|
||||
const capturista = await prisma.usuario.findUnique({
|
||||
where: { id: req.usuarioToken.sub },
|
||||
select: { mondayUserId: true },
|
||||
})
|
||||
if (capturista?.mondayUserId != null) {
|
||||
await prismaStateStore.set(`${CLAVE_RR_SKIP}${resultado.id}`, 'capturista')
|
||||
try {
|
||||
await automatizacionesService.asignarACapturista(resultado.id, body.nombre, capturista.mondayUserId)
|
||||
asignacion = 'capturista'
|
||||
} catch (err) {
|
||||
await prismaStateStore.set(`${CLAVE_RR_SKIP}${resultado.id}`, 'round-robin')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo asignar la afluencia ${resultado.id} al capturista:`, err)
|
||||
}
|
||||
}
|
||||
const asignacion = await asignarCapturistaSiAplica(req.usuarioToken?.sub, resultado.id, body.nombre, 'oportunidades')
|
||||
res.status(201).json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, asignacion })
|
||||
}
|
||||
|
||||
/** POST /api/leads/:itemId/estado { estado, fecha? } */
|
||||
/** POST /api/leads/:itemId/estado { estado, fecha?, fuente? } */
|
||||
export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = cambiarEstadoSchema.parse(req.body)
|
||||
const itemId = String(req.params['itemId'])
|
||||
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha)
|
||||
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, body.fuente)
|
||||
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
|
||||
}
|
||||
|
||||
/** POST /api/leads/:itemId/descartar { motivo, comentario? } */
|
||||
/** POST /api/leads/:itemId/descartar { motivo, comentario?, fuente? } */
|
||||
export async function descartarLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = descartarSchema.parse(req.body)
|
||||
const itemId = String(req.params['itemId'])
|
||||
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario)
|
||||
res.json({ itemId, estado: 'Perdido', motivo: body.motivo, motivoItemId })
|
||||
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente)
|
||||
res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
|
||||
* clientes. Busca en AMBOS tableros (Leads histórico + Oportunidades); q
|
||||
* mínimo 2 caracteres; devuelve máximo 15, cada uno con su `fuente`.
|
||||
* clientes. Busca en AMBOS tableros (Leads + Oportunidades); q mínimo 2
|
||||
* caracteres; devuelve máximo 15, cada uno con su `fuente`.
|
||||
*/
|
||||
export async function buscarLeadsEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const q = String(req.query['q'] ?? '').trim()
|
||||
|
||||
Reference in New Issue
Block a user