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:
2026-09-04 04:46:34 +00:00
parent d4fc9ee892
commit 845f314560
26 changed files with 1496 additions and 985 deletions

View File

@@ -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
})
})
})

View File

@@ -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') {

View File

@@ -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')
})

View File

@@ -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}` : ''}