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:
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -78,6 +78,15 @@ export function cambiarEtapaOportunidad(itemId: string, etapa: string, fecha?: s
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /leads/:itemId/convertir — botón "Agendo": convierte el lead en oportunidad */
|
||||
export function convertirLead(itemId: string): Promise<{ oportunidadId: string }> {
|
||||
return fetchJson(`/leads/${itemId}/convertir`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /apartados/:itemId/estado — botones de fase de "Mis apartados" */
|
||||
export function cambiarEstadoApartado(itemId: string, estado: string): Promise<unknown> {
|
||||
return fetchJson(`/apartados/${itemId}/estado`, {
|
||||
|
||||
Reference in New Issue
Block a user