refactor: las afluencias viven en Oportunidades (no en Leads)
- POST /api/leads crea UN solo item en Oportunidades (Estado Afluencia) con todo el perfil (desarrollo, modelo, estado civil, credito, etc.) en las columnas del board; dedup por telefono contra Oportunidades; asignacion al capturista via columna Asesor o round-robin del equipo. - Round-robin, escalado e inactividad operan sobre Oportunidades (solo etapa Afluencia sin asesor entra al reparto). - Acciones del vendedor (contactado/cita/descartar) escriben directo en la oportunidad; descarte = Perdido + Motivo No Compra. - Mi dia y reportes leen Oportunidades; el funnel de /dash arranca en Afluencia; misOportunidades.porEtapa ya es real (columna Asesor). - El picker del alta busca en Leads (historico) Y Oportunidades, con insignia de origen; moverAAparto tolera ambos ids. - Lectura de status con value null = vacio (quirk del default Monday). - Script de migracion del historial de agosto Leads -> Oportunidades.
This commit is contained in:
@@ -206,7 +206,7 @@ describe('AltaAfluenciaForm', () => {
|
||||
})
|
||||
|
||||
it('tras el éxito, "Pasar a Alta de Cliente" entrega el lead recién creado', async () => {
|
||||
fetchMock.mockResolvedValueOnce(respuesta({ id: 'l-1', estado: 'NUEVO', oportunidadId: 'op-1' }))
|
||||
fetchMock.mockResolvedValueOnce(respuesta({ id: 'op-1', estado: 'Afluencia', oportunidadId: 'op-1' }))
|
||||
const onPasar = vi.fn()
|
||||
render(<AltaAfluenciaForm onVolver={() => undefined} onPasarAAltaCliente={onPasar} />)
|
||||
llenarRequeridos()
|
||||
@@ -216,12 +216,13 @@ describe('AltaAfluenciaForm', () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Pasar a Alta de Cliente/ }))
|
||||
|
||||
expect(onPasar).toHaveBeenCalledWith({
|
||||
id: 'l-1',
|
||||
id: 'op-1',
|
||||
nombre: 'Ana Demo',
|
||||
telefono: '6641234567',
|
||||
email: 'ana@correo.com',
|
||||
estado: 'NUEVO',
|
||||
estado: 'Afluencia',
|
||||
origen: '',
|
||||
fuente: 'oportunidad', // la captura nueva vive en Oportunidades
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -122,6 +122,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
|
||||
email: form.email.trim(),
|
||||
estado: res.lead.estado,
|
||||
origen: form.origen,
|
||||
fuente: 'oportunidad', // la captura nueva vive en el tablero Oportunidades
|
||||
})
|
||||
setAsignacion(res.lead.asignacion ?? 'round-robin')
|
||||
} else if (res.motivo === 'duplicado') {
|
||||
|
||||
@@ -6,8 +6,8 @@ import type { LeadResumen } from '../services/api'
|
||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
const LEADS: LeadResumen[] = [
|
||||
{ id: 'lead-1', nombre: 'MARÍA FERNANDA LÓPEZ', telefono: '4771234567', email: 'maria@correo.com', estado: 'NUEVO', origen: 'Web' },
|
||||
{ id: 'lead-2', nombre: 'Juan Pérez', telefono: '', email: 'juan@correo.com', estado: 'CONTACTADO', origen: '' },
|
||||
{ id: 'lead-1', nombre: 'MARÍA FERNANDA LÓPEZ', telefono: '4771234567', email: 'maria@correo.com', estado: 'NUEVO', origen: 'Web', fuente: 'lead' },
|
||||
{ id: 'op-2', nombre: 'Juan Pérez', telefono: '', email: 'juan@correo.com', estado: 'Afluencia', origen: '', fuente: 'oportunidad' },
|
||||
]
|
||||
|
||||
const respuestaOk = (data: unknown) =>
|
||||
@@ -35,13 +35,16 @@ describe('BusquedaLead', () => {
|
||||
expect(llamadasBusqueda()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renderiza los resultados de la búsqueda (nombre, teléfono, estado)', async () => {
|
||||
it('renderiza los resultados de la búsqueda (nombre, teléfono, estado) con insignia de fuente', async () => {
|
||||
render(<BusquedaLead onSeleccion={() => undefined} />)
|
||||
fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'maria' } })
|
||||
|
||||
expect(await screen.findByText('MARÍA FERNANDA LÓPEZ')).toBeTruthy()
|
||||
expect(screen.getByText(/4771234567 · NUEVO/)).toBeTruthy()
|
||||
expect(screen.getByText('Juan Pérez')).toBeTruthy()
|
||||
// Insignias: gris "Lead" (histórico) y azul "Oportunidad" (modelo nuevo)
|
||||
expect(screen.getByText('Lead')).toBeTruthy()
|
||||
expect(screen.getByText('Oportunidad')).toBeTruthy()
|
||||
const [url] = llamadasBusqueda()[0] as [string]
|
||||
expect(url).toContain('/leads/buscar?q=maria')
|
||||
})
|
||||
|
||||
@@ -8,8 +8,9 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscador con debounce de leads de Monday (GET /leads/buscar). No llama al
|
||||
* API con menos de 2 caracteres; al elegir un resultado avisa con onSeleccion.
|
||||
* Buscador con debounce de leads/afluencias de Monday (GET /leads/buscar,
|
||||
* búsqueda dual: tablero Leads histórico + Oportunidades). No llama al API
|
||||
* con menos de 2 caracteres; al elegir un resultado avisa con onSeleccion.
|
||||
*/
|
||||
export default function BusquedaLead({ onSeleccion }: Props) {
|
||||
const [q, setQ] = useState('')
|
||||
@@ -71,13 +72,25 @@ export default function BusquedaLead({ onSeleccion }: Props) {
|
||||
{resultados !== null && resultados.length > 0 && (
|
||||
<ul className="mt-3 max-w-md divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
{resultados.map((lead) => (
|
||||
<li key={lead.id}>
|
||||
<li key={`${lead.fuente}-${lead.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSeleccion(lead)}
|
||||
className="flex w-full flex-wrap items-baseline justify-between gap-2 px-4 py-3 text-left transition-colors duration-100 hover:bg-[#F8FAFC]"
|
||||
>
|
||||
<span className="text-sm text-[#111827]">{lead.nombre}</span>
|
||||
<span className="flex items-baseline gap-2 text-sm text-[#111827]">
|
||||
{lead.nombre}
|
||||
{/* Insignia de procedencia: gris = archivo histórico, azul = modelo nuevo */}
|
||||
<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>
|
||||
</span>
|
||||
<span className="text-xs text-[#64748B]">
|
||||
{lead.telefono || 'sin teléfono'}
|
||||
{lead.estado ? ` · ${lead.estado}` : ''}
|
||||
|
||||
@@ -12,6 +12,7 @@ const LEAD: LeadResumen = {
|
||||
email: 'maria@correo.com',
|
||||
estado: 'NUEVO',
|
||||
origen: 'Web',
|
||||
fuente: 'lead',
|
||||
}
|
||||
|
||||
const respuestaOk = (data: unknown) =>
|
||||
|
||||
@@ -225,9 +225,11 @@ export interface LeadResumen {
|
||||
email: string
|
||||
estado: string
|
||||
origen: string
|
||||
/** 'lead' = archivo histórico (tablero Leads); 'oportunidad' = modelo nuevo (tablero Oportunidades) */
|
||||
fuente: 'lead' | 'oportunidad'
|
||||
}
|
||||
|
||||
/** GET /leads/buscar?q= — autocompletado para prellenar el alta desde un lead */
|
||||
/** GET /leads/buscar?q= — autocompletado para prellenar el alta (busca en AMBOS tableros) */
|
||||
export async function buscarLeads(q: string): Promise<LeadResumen[]> {
|
||||
const res = await fetch(`${API_URL}/leads/buscar?q=${encodeURIComponent(q)}`, conAuth())
|
||||
manejar401(res)
|
||||
@@ -236,8 +238,9 @@ export async function buscarLeads(q: string): Promise<LeadResumen[]> {
|
||||
}
|
||||
|
||||
export interface LeadCreado {
|
||||
/** id de la oportunidad creada (la afluencia ES la oportunidad: id === oportunidadId) */
|
||||
id: string
|
||||
estado: string
|
||||
estado: string // 'Afluencia'
|
||||
oportunidadId: string | null
|
||||
/** 'capturista' = asignado a quien lo registró; 'round-robin' = entra al reparto */
|
||||
asignacion?: 'capturista' | 'round-robin'
|
||||
@@ -250,9 +253,10 @@ export type ResultadoCrearLead =
|
||||
| { ok: false; motivo: 'conexion' }
|
||||
|
||||
/**
|
||||
* POST /leads — captura ligera de afluencia: crea el lead en Monday y su
|
||||
* oportunidad en etapa Afluencia (round-robin). Si el teléfono ya existe el
|
||||
* API responde 409 con el lead existente (motivo 'duplicado').
|
||||
* 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').
|
||||
*/
|
||||
export async function crearLead(payload: {
|
||||
nombre: string
|
||||
|
||||
@@ -115,20 +115,19 @@ describe('unidadesEnEtapa', () => {
|
||||
})
|
||||
|
||||
describe('datosFunnel', () => {
|
||||
it('ordena Leads → Oportunidades → Aparto o más → Ganado sin subir', () => {
|
||||
it('ordena Afluencia → Agendo Cita → Cotizador → Aparto o más → Ganado (todo desde porEtapa)', () => {
|
||||
const funnel = datosFunnel(resumen())
|
||||
expect(funnel.map((e) => e.etapa)).toEqual(['Leads', 'Oportunidades', 'Aparto o más', 'Ganado'])
|
||||
expect(funnel.map((e) => e.valor)).toEqual([40, 20, 6, 2]) // Aparto(4) + Ganado(2) acumulados
|
||||
const valores = funnel.map((e) => e.valor)
|
||||
for (let i = 1; i < valores.length; i++) expect(valores[i]!).toBeLessThanOrEqual(valores[i - 1]!)
|
||||
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
|
||||
})
|
||||
|
||||
it('tolera etiquetas con otra capitalización', () => {
|
||||
const r = resumen()
|
||||
r.oportunidades.porEtapa = { aparto: 1, ' GANADO ': 3 }
|
||||
r.oportunidades.porEtapa = { afluencia: 3, ' GANADO ': 3, aparto: 1 }
|
||||
const funnel = datosFunnel(r)
|
||||
expect(funnel[2]?.valor).toBe(4)
|
||||
expect(funnel[3]?.valor).toBe(3)
|
||||
expect(funnel[0]?.valor).toBe(3)
|
||||
expect(funnel[3]?.valor).toBe(4)
|
||||
expect(funnel[4]?.valor).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -104,8 +104,10 @@ export function unidadesEnEtapa(resumen: ResumenReportes, etapa: string): number
|
||||
}
|
||||
|
||||
/**
|
||||
* Datos del funnel comercial: Leads → Oportunidades → Aparto o más → Ganado.
|
||||
* "Aparto o más" es acumulado (Aparto + Ganado) para que el funnel no suba.
|
||||
* 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
|
||||
* (Aparto + Ganado) porque Aparto es subconjunto del camino a Ganado.
|
||||
*/
|
||||
export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] {
|
||||
const porEtapa = resumen.oportunidades.porEtapa
|
||||
@@ -115,8 +117,9 @@ 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: 'Oportunidades', valor: resumen.oportunidades.total },
|
||||
{ etapa: 'Afluencia', valor: conteo('AFLUENCIA') },
|
||||
{ etapa: 'Agendo Cita', valor: conteo('AGENDO CITA') },
|
||||
{ etapa: 'Cotizador', valor: conteo('COTIZADOR') },
|
||||
{ etapa: 'Aparto o más', valor: apartoOMas },
|
||||
{ etapa: 'Ganado', valor: conteo('GANADO') },
|
||||
]
|
||||
|
||||
@@ -53,8 +53,7 @@ export interface ResumenMiDia {
|
||||
misClientes: ClienteMiDia[]
|
||||
misActividadesHoy: ActividadMiDia[]
|
||||
apartadosPorVencer: ApartadoPorVencer[]
|
||||
misOportunidades: { porEtapa: Record<string, number> } | null
|
||||
notaOportunidades: string | null
|
||||
misOportunidades: { porEtapa: Record<string, number> } // conteo de MIS oportunidades por etapa
|
||||
conteos: {
|
||||
leadsNuevosSemana: number
|
||||
actividadesAbiertas: number
|
||||
|
||||
Reference in New Issue
Block a user