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:
@@ -56,7 +56,7 @@ describe('AltaAfluenciaForm', () => {
|
||||
'Registrado en afluencia — se asignará por round-robin',
|
||||
)
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/leads')
|
||||
expect(url).toContain('/afluencias')
|
||||
expect(JSON.parse(String(init.body))).toMatchObject({
|
||||
nombre: 'Ana Demo',
|
||||
telefono: '6641234567',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { crearLead, type LeadResumen } from '../services/api'
|
||||
import { crearAfluencia, type LeadResumen } from '../services/api'
|
||||
import {
|
||||
DESARROLLOS_LEAD,
|
||||
ESTADOS_CIVILES,
|
||||
@@ -94,7 +94,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
|
||||
setAviso('')
|
||||
if (!validar()) return
|
||||
setEnviando(true)
|
||||
const res = await crearLead({
|
||||
const res = await crearAfluencia({
|
||||
nombre: form.nombre.trim(),
|
||||
telefono: form.telefono.trim(),
|
||||
...(form.email.trim() ? { email: form.email.trim() } : {}),
|
||||
|
||||
@@ -253,12 +253,12 @@ export type ResultadoCrearLead =
|
||||
| { ok: false; motivo: 'conexion' }
|
||||
|
||||
/**
|
||||
* POST /leads — captura ligera de afluencia: crea UN item en el tablero
|
||||
* Oportunidades (etapa Afluencia, con todo el perfil; la asigna el capturista
|
||||
* o el round-robin). Si el teléfono ya existe el API responde 409 con la
|
||||
* oportunidad existente (motivo 'duplicado').
|
||||
* POST /afluencias — Alta Afluencia (forma completa): crea UN item en el
|
||||
* tablero Oportunidades (etapa Afluencia, con todo el perfil; la asigna el
|
||||
* capturista o el round-robin). Si el teléfono ya existe el API responde 409
|
||||
* con la oportunidad existente (motivo 'duplicado').
|
||||
*/
|
||||
export async function crearLead(payload: {
|
||||
export async function crearAfluencia(payload: {
|
||||
nombre: string
|
||||
telefono: string
|
||||
email?: string
|
||||
@@ -280,7 +280,7 @@ export async function crearLead(payload: {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(
|
||||
`${API_URL}/leads`,
|
||||
`${API_URL}/afluencias`,
|
||||
conAuth({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -288,7 +288,7 @@ export async function crearLead(payload: {
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn('[api] crearLead: sin conexión al servidor', err)
|
||||
console.warn('[api] crearAfluencia: sin conexión al servidor', err)
|
||||
return { ok: false, motivo: 'conexion' }
|
||||
}
|
||||
manejar401(res)
|
||||
@@ -299,7 +299,7 @@ export async function crearLead(payload: {
|
||||
const cuerpo = (await res.json()) as { leadId: string; nombre: string }
|
||||
return { ok: false, motivo: 'duplicado', leadId: cuerpo.leadId, nombre: cuerpo.nombre }
|
||||
}
|
||||
console.warn(`[api] crearLead: HTTP ${res.status}`)
|
||||
console.warn(`[api] crearAfluencia: HTTP ${res.status}`)
|
||||
return { ok: false, motivo: 'conexion' }
|
||||
}
|
||||
|
||||
|
||||
@@ -115,19 +115,19 @@ describe('unidadesEnEtapa', () => {
|
||||
})
|
||||
|
||||
describe('datosFunnel', () => {
|
||||
it('ordena Afluencia → Agendo Cita → Cotizador → Aparto o más → Ganado (todo desde porEtapa)', () => {
|
||||
it('ordena Leads → Afluencia → Agendo Cita → Cotizador → Aparto o más → Ganado', () => {
|
||||
const funnel = datosFunnel(resumen())
|
||||
expect(funnel.map((e) => e.etapa)).toEqual(['Afluencia', 'Agendo Cita', 'Cotizador', 'Aparto o más', 'Ganado'])
|
||||
expect(funnel.map((e) => e.valor)).toEqual([8, 0, 5, 6, 2]) // Aparto(4) + Ganado(2) acumulados
|
||||
expect(funnel.map((e) => e.etapa)).toEqual(['Leads', 'Afluencia', 'Agendo Cita', 'Cotizador', 'Aparto o más', 'Ganado'])
|
||||
expect(funnel.map((e) => e.valor)).toEqual([40, 8, 0, 5, 6, 2]) // Aparto(4) + Ganado(2) acumulados
|
||||
})
|
||||
|
||||
it('tolera etiquetas con otra capitalización', () => {
|
||||
const r = resumen()
|
||||
r.oportunidades.porEtapa = { afluencia: 3, ' GANADO ': 3, aparto: 1 }
|
||||
const funnel = datosFunnel(r)
|
||||
expect(funnel[0]?.valor).toBe(3)
|
||||
expect(funnel[3]?.valor).toBe(4)
|
||||
expect(funnel[4]?.valor).toBe(3)
|
||||
expect(funnel[1]?.valor).toBe(3)
|
||||
expect(funnel[4]?.valor).toBe(4)
|
||||
expect(funnel[5]?.valor).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -104,9 +104,9 @@ export function unidadesEnEtapa(resumen: ResumenReportes, etapa: string): number
|
||||
}
|
||||
|
||||
/**
|
||||
* Datos del funnel comercial, todo desde el tablero Oportunidades (la
|
||||
* afluencia ES la oportunidad en etapa 'Afluencia'): Afluencia → Agendo Cita
|
||||
* → Cotizador → Aparto o más → Ganado. "Aparto o más" es acumulado
|
||||
* Datos del funnel comercial: Leads (total del tablero Leads, captura ligera)
|
||||
* → Afluencia → Agendo Cita → Cotizador → Aparto o más → Ganado (las etapas,
|
||||
* del tablero Oportunidades vía porEtapa). "Aparto o más" es acumulado
|
||||
* (Aparto + Ganado) porque Aparto es subconjunto del camino a Ganado.
|
||||
*/
|
||||
export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] {
|
||||
@@ -117,6 +117,7 @@ export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] {
|
||||
.reduce((acc, [, v]) => acc + v, 0)
|
||||
const apartoOMas = conteo('APARTO') + conteo('GANADO')
|
||||
return [
|
||||
{ etapa: 'Leads', valor: resumen.leads.total },
|
||||
{ etapa: 'Afluencia', valor: conteo('AFLUENCIA') },
|
||||
{ etapa: 'Agendo Cita', valor: conteo('AGENDO CITA') },
|
||||
{ etapa: 'Cotizador', valor: conteo('COTIZADOR') },
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface LeadMiDia {
|
||||
estado: string
|
||||
origen: string
|
||||
updatedAt: string
|
||||
/** Tablero del item: 'lead' (tablero Leads) u 'oportunidad' (Oportunidades) */
|
||||
fuente: 'lead' | 'oportunidad'
|
||||
}
|
||||
|
||||
export interface ActividadMiDia {
|
||||
|
||||
@@ -133,7 +133,19 @@ function FilaLead({
|
||||
<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>
|
||||
<p className="text-sm font-medium">{lead.nombre}</p>
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
{lead.nombre}
|
||||
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
|
||||
<span
|
||||
className={
|
||||
lead.fuente === 'oportunidad'
|
||||
? 'rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-800'
|
||||
: 'rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600'
|
||||
}
|
||||
>
|
||||
{lead.fuente === 'oportunidad' ? 'Oportunidad' : 'Lead'}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#64748B]">
|
||||
{lead.telefono || 'Sin teléfono'}
|
||||
{lead.origen ? ` · ${lead.origen}` : ''}
|
||||
@@ -366,10 +378,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
<ul className="space-y-2">
|
||||
{resumen.misLeads.map((lead) => (
|
||||
<FilaLead
|
||||
key={lead.id}
|
||||
key={`${lead.fuente}-${lead.id}`}
|
||||
lead={lead}
|
||||
enviando={enviando}
|
||||
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha), 'No se pudo actualizar el lead')}
|
||||
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
|
||||
onDescartar={() => setDescartando(lead)}
|
||||
/>
|
||||
))}
|
||||
@@ -656,7 +668,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
const lead = descartando
|
||||
setDescartando(null)
|
||||
void ejecutar(
|
||||
() => descartarLead(lead.id, motivo, comentario === '' ? undefined : comentario),
|
||||
() => descartarLead(lead.id, motivo, comentario === '' ? undefined : comentario, lead.fuente),
|
||||
'No se pudo descartar el lead',
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -41,18 +41,26 @@ export async function crearLead(datos: {
|
||||
})
|
||||
}
|
||||
|
||||
export function cambiarEstadoLead(itemId: string, estado: string, fecha?: string): Promise<unknown> {
|
||||
export function cambiarEstadoLead(itemId: string, estado: string, fecha?: string, fuente?: 'lead' | 'oportunidad'): Promise<unknown> {
|
||||
return fetchJson(`/leads/${itemId}/estado`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fecha !== undefined && fecha !== '' ? { estado, fecha } : { estado }),
|
||||
body: JSON.stringify({
|
||||
estado,
|
||||
...(fecha !== undefined && fecha !== '' ? { fecha } : {}),
|
||||
...(fuente !== undefined ? { fuente } : {}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function descartarLead(itemId: string, motivo: string, comentario?: string): Promise<unknown> {
|
||||
export function descartarLead(itemId: string, motivo: string, comentario?: string, fuente?: 'lead' | 'oportunidad'): Promise<unknown> {
|
||||
return fetchJson(`/leads/${itemId}/descartar`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(comentario !== undefined && comentario !== '' ? { motivo, comentario } : { motivo }),
|
||||
body: JSON.stringify({
|
||||
motivo,
|
||||
...(comentario !== undefined && comentario !== '' ? { comentario } : {}),
|
||||
...(fuente !== undefined ? { fuente } : {}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user