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:
2026-09-05 00:33:53 +00:00
parent a4575bb948
commit 5b348d21e5
14 changed files with 654 additions and 17 deletions

View File

@@ -32,6 +32,7 @@ import {
cambiarEstadoApartado,
cambiarEstadoLead,
cambiarEtapaOportunidad,
convertirLead,
crearLead,
descartarLead,
fetchResumenMiDia,
@@ -180,9 +181,8 @@ function FilaOportunidad({
}) {
const [conCita, setConCita] = useState(false)
const [fechaCita, setFechaCita] = useState('')
// 'Afluencia' no es un cambio de etapa directo: abre el formulario de
// afluencia prellenado para capturar/actualizar el perfil completo
const fases = ['Agendo Cita', 'Aparto', 'No Asistio', 'Abandono'] as const
// Sin 'No Asistio' ni 'Abandono': son redundantes con Descartar
const fases = ['Agendo Cita', 'Aparto'] as const
return (
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -311,16 +311,26 @@ function FilaApartado({
key={fase}
type="button"
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={
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'
: 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
: fase === 'Firma Cliente (Venta)'
? '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'
: 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
@@ -484,11 +494,13 @@ function TabsCrm({
function FilaLead({
lead,
onAccion,
onConvertir,
onDescartar,
enviando,
}: {
lead: LeadMiDia
onAccion: (estado: string, fecha?: string) => void
onConvertir: () => void
onDescartar: () => void
enviando: boolean
}) {
@@ -534,6 +546,15 @@ function FilaLead({
<button type="button" disabled={enviando} onClick={() => onAccion('INTERESADO')} className={CLASE_BOTON_ACCION}>
Interesado
</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
type="button"
disabled={enviando}
@@ -561,6 +582,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
fuente: 'lead' | 'oportunidad'
} | null>(null)
const [tabCrm, setTabCrm] = useState<TabCrm>('leads')
const [nota, setNota] = useState<string | null>(null)
// Captura rápida
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
* con los datos del cliente prellenados y la oportunidad vinculada (al
@@ -732,6 +773,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
{aviso}
</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 && (
<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
@@ -779,6 +825,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
lead={lead}
enviando={enviando}
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)}
/>
))}