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:
@@ -58,6 +58,7 @@ const economicosVacios = (): AltaCliente['economicos'] => ({
|
||||
|
||||
const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
|
||||
leadMondayId: null,
|
||||
apartadoMondayId: null,
|
||||
progresoId: null,
|
||||
datosGenerales: datosGeneralesVacios(),
|
||||
empleo: empleoVacio(),
|
||||
|
||||
79
apps/alta-clientes/src/lib/apartadoAlta.test.ts
Normal file
79
apps/alta-clientes/src/lib/apartadoAlta.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apartadoAPrellenado, type PrefillApartado } from './apartadoAlta'
|
||||
|
||||
const base: PrefillApartado = {
|
||||
apartado: {
|
||||
id: 'a1',
|
||||
nombre: 'Ana Demo',
|
||||
telefono: '+52 6641234567',
|
||||
email: 'ana@correo.com',
|
||||
curp: '',
|
||||
unidadCodigo: 'SENDA-409-1-2',
|
||||
precioFinal: 2646000,
|
||||
enganche: 132300,
|
||||
formaPago: 'Cofinavit',
|
||||
},
|
||||
contacto: {
|
||||
nombre: 'Ana Demo',
|
||||
CURP: 'AAXX010101XXXXXXX0',
|
||||
RFC: 'AAXX010101XXX',
|
||||
Dirección: 'Calle 1, Col. Centro, CP 38000',
|
||||
'Estado Civil': 'Casado(a)',
|
||||
'Fecha Nacimiento': '1990-05-12',
|
||||
},
|
||||
oportunidad: { id: 'op-1', desarrollo: 'SENDA', modeloInteres: 'SOHO', credito: 'Cofinavit' },
|
||||
cotizacion: { tipoPropuesta: 'INFONAVIT', acabados: true, precioFinal: 2646000, enganche: 132300 },
|
||||
}
|
||||
|
||||
describe('apartadoAPrellenado (alta desde apartado de Monday)', () => {
|
||||
it('prellena datos generales (contacto manda) y vivienda/crédito (apartado > oportunidad > cotización)', () => {
|
||||
const p = apartadoAPrellenado(base)
|
||||
expect(p.datosGenerales).toMatchObject({
|
||||
nombreCompleto: 'ANA DEMO',
|
||||
email: 'ana@correo.com',
|
||||
celular: '+52 6641234567',
|
||||
curp: 'AAXX010101XXXXXXX0',
|
||||
rfc: 'AAXX010101XXX',
|
||||
direccion: 'CALLE 1, COL. CENTRO, CP 38000',
|
||||
fechaNacimiento: '1990-05-12',
|
||||
estadoCivil: 'CASADO',
|
||||
})
|
||||
expect(p.viviendaCredito).toMatchObject({
|
||||
proyecto: 'SENDA',
|
||||
modelo: 'SOHO',
|
||||
ubicacion: 'SENDA-409-1-2',
|
||||
valorVenta: '2646000',
|
||||
enganche: '132300',
|
||||
tiposCredito: ['COFINAVIT'], // formaPago del apartado manda sobre la cotización
|
||||
equipamientoAdicional: 'COCINA Y CLOSET', // cotización con acabados
|
||||
})
|
||||
expect(p.leadMondayId).toBe('op-1')
|
||||
})
|
||||
|
||||
it('desarrollo desconocido cae en OTRO con el nombre en texto libre', () => {
|
||||
const p = apartadoAPrellenado({ ...base, oportunidad: { id: 'op-1', desarrollo: 'MONTEBELLO' } })
|
||||
expect(p.viviendaCredito.proyecto).toBe('OTRO')
|
||||
expect(p.viviendaCredito.proyectoOtro).toBe('MONTEBELLO')
|
||||
})
|
||||
|
||||
it('sin contacto usa los datos del apartado; sin créditos no prellena tiposCredito', () => {
|
||||
const p = apartadoAPrellenado({
|
||||
...base,
|
||||
contacto: null,
|
||||
apartado: { ...base.apartado, formaPago: '' },
|
||||
oportunidad: { id: 'op-1', desarrollo: 'SENDA' },
|
||||
cotizacion: null,
|
||||
})
|
||||
expect(p.datosGenerales).toMatchObject({ nombreCompleto: 'ANA DEMO', email: 'ana@correo.com', celular: '+52 6641234567' })
|
||||
expect(p.datosGenerales.curp).toBeUndefined()
|
||||
expect(p.datosGenerales.estadoCivil).toBeUndefined()
|
||||
expect(p.viviendaCredito.tiposCredito).toBeUndefined()
|
||||
expect(p.viviendaCredito.equipamientoAdicional).toBeUndefined()
|
||||
expect(p.leadMondayId).toBe('op-1')
|
||||
})
|
||||
|
||||
it('sin oportunidad relacionada el alta queda sin lead ligado', () => {
|
||||
const p = apartadoAPrellenado({ ...base, oportunidad: null })
|
||||
expect(p.leadMondayId).toBeNull()
|
||||
})
|
||||
})
|
||||
138
apps/alta-clientes/src/lib/apartadoAlta.ts
Normal file
138
apps/alta-clientes/src/lib/apartadoAlta.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { DatosGenerales, EstadoCivil, TipoCredito, ViviendaCredito } from '../types'
|
||||
import { PROYECTOS } from '../data/constantes'
|
||||
|
||||
/**
|
||||
* Transformación pura: todo lo recolectado de un apartado de Monday (ficha del
|
||||
* apartado + contacto + oportunidad + cotización vinculada) → campos del
|
||||
* wizard de alta. Prellena solo lo que exista (nunca inventa datos).
|
||||
*/
|
||||
|
||||
/** Shape de GET /api/altas/prefill-apartado/:itemId que usa el pre-llenado */
|
||||
export interface PrefillApartado {
|
||||
apartado: {
|
||||
id: string
|
||||
nombre: string
|
||||
telefono?: string
|
||||
email?: string
|
||||
curp?: string
|
||||
unidadCodigo: string
|
||||
precioFinal?: number
|
||||
enganche?: number
|
||||
formaPago?: string
|
||||
}
|
||||
contacto: Record<string, string> | null
|
||||
oportunidad: {
|
||||
id: string
|
||||
desarrollo?: string
|
||||
modeloInteres?: string
|
||||
credito?: string
|
||||
} | null
|
||||
cotizacion: {
|
||||
tipoPropuesta: string
|
||||
acabados: boolean
|
||||
precioFinal: number
|
||||
enganche: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface PrellenadoDesdeApartado {
|
||||
datosGenerales: Partial<DatosGenerales>
|
||||
viviendaCredito: Partial<ViviendaCredito>
|
||||
/** Oportunidad relacionada (para que el alta mueva el funnel como siempre) */
|
||||
leadMondayId: string | null
|
||||
}
|
||||
|
||||
// Label de Monday (Contacto/Oportunidad) → enum del wizard
|
||||
const ESTADO_CIVIL_ENUM: Record<string, EstadoCivil> = {
|
||||
'SOLTERO(A)': 'SOLTERO',
|
||||
SOLTERO: 'SOLTERO',
|
||||
'CASADO(A)': 'CASADO',
|
||||
CASADO: 'CASADO',
|
||||
'UNION LIBRE': 'UNION_LIBRE',
|
||||
'UNIÓN LIBRE': 'UNION_LIBRE',
|
||||
'DIVORCIADO(A)': 'DIVORCIADO',
|
||||
DIVORCIADO: 'DIVORCIADO',
|
||||
'VIUDO(A)': 'VIUDO',
|
||||
VIUDO: 'VIUDO',
|
||||
}
|
||||
|
||||
// Label del dropdown 'Forma de Pago' (Apartado) o 'Crédito' (Oportunidades) → enum
|
||||
const CREDITO_ENUM: Record<string, TipoCredito> = {
|
||||
INFONAVIT: 'INFONAVIT',
|
||||
'APOYO INFONAVIT': 'APOYO_INFONAVIT',
|
||||
COFINAVIT: 'COFINAVIT',
|
||||
'SEGUNDO CRÉDITO': 'SEGUNDO_CREDITO',
|
||||
'SEGUNDO CREDITO': 'SEGUNDO_CREDITO',
|
||||
CONTADO: 'CONTADO',
|
||||
FOVISSSTE: 'FOVISSSTE',
|
||||
'COFI-FOVISSSTE': 'COFI_FOVISSSTE',
|
||||
BANCARIO: 'BANCARIO',
|
||||
}
|
||||
|
||||
// Tipo de propuesta del cotizador → enum de crédito del wizard
|
||||
const PROPUESTA_ENUM: Record<string, TipoCredito> = {
|
||||
BANCARIO: 'BANCARIO',
|
||||
INFONAVIT: 'INFONAVIT',
|
||||
COFINAVIT: 'COFINAVIT',
|
||||
CONTADO: 'CONTADO',
|
||||
}
|
||||
|
||||
const texto = (v: string | undefined | null): string | undefined => {
|
||||
const t = (v ?? '').trim()
|
||||
return t === '' ? undefined : t
|
||||
}
|
||||
|
||||
const monto = (n: number | undefined | null): string | undefined =>
|
||||
n !== undefined && n !== null && n > 0 ? String(n) : undefined
|
||||
|
||||
/** apartado/contacto/oportunidad/cotización → campos del wizard de alta */
|
||||
export function apartadoAPrellenado(pre: PrefillApartado): PrellenadoDesdeApartado {
|
||||
const { apartado, contacto, oportunidad, cotizacion } = pre
|
||||
const c = contacto ?? {}
|
||||
|
||||
// Datos generales: el contacto (perfil del alta progresiva) manda; el
|
||||
// apartado cubre lo que falte
|
||||
const estadoCivilLabel = (texto(c['Estado Civil']) ?? '').toUpperCase()
|
||||
const datosGenerales: Partial<DatosGenerales> = {
|
||||
nombreCompleto: (texto(c['nombre']) ?? apartado.nombre).toUpperCase(),
|
||||
...(texto(c['Email']) ?? texto(apartado.email) ? { email: (texto(c['Email']) ?? texto(apartado.email))! } : {}),
|
||||
...(texto(c['Teléfono']) ?? texto(apartado.telefono) ? { celular: (texto(c['Teléfono']) ?? texto(apartado.telefono))! } : {}),
|
||||
...(texto(c['CURP']) ?? texto(apartado.curp) ? { curp: (texto(c['CURP']) ?? texto(apartado.curp))!.toUpperCase() } : {}),
|
||||
...(texto(c['RFC']) ? { rfc: texto(c['RFC'])!.toUpperCase() } : {}),
|
||||
...(texto(c['Dirección']) ? { direccion: texto(c['Dirección'])!.toUpperCase() } : {}),
|
||||
...(texto(c['Fecha Nacimiento']) ? { fechaNacimiento: texto(c['Fecha Nacimiento'])!.slice(0, 10) } : {}),
|
||||
...(ESTADO_CIVIL_ENUM[estadoCivilLabel] ? { estadoCivil: ESTADO_CIVIL_ENUM[estadoCivilLabel] } : {}),
|
||||
}
|
||||
|
||||
// Vivienda/crédito: desarrollo → proyecto del dropdown; la "ubicación" del
|
||||
// formato es el código de unidad. Forma de pago: apartado > crédito de la
|
||||
// oportunidad > tipo de propuesta de la cotización.
|
||||
const desarrollo = (oportunidad?.desarrollo ?? '').trim().toUpperCase()
|
||||
const conocido = (PROYECTOS as readonly string[]).includes(desarrollo)
|
||||
const formaPago = CREDITO_ENUM[(apartado.formaPago ?? '').trim().toUpperCase()]
|
||||
const creditoOp = CREDITO_ENUM[(oportunidad?.credito ?? '').trim().toUpperCase()]
|
||||
const creditoCot = cotizacion ? PROPUESTA_ENUM[cotizacion.tipoPropuesta] : undefined
|
||||
const tipoCredito = formaPago ?? creditoOp ?? creditoCot
|
||||
|
||||
const viviendaCredito: Partial<ViviendaCredito> = {
|
||||
...(desarrollo !== ''
|
||||
? { proyecto: conocido ? desarrollo : 'OTRO', proyectoOtro: conocido ? '' : desarrollo }
|
||||
: {}),
|
||||
...(texto(oportunidad?.modeloInteres) ? { modelo: texto(oportunidad?.modeloInteres)!.toUpperCase() } : {}),
|
||||
...(texto(apartado.unidadCodigo) ? { ubicacion: apartado.unidadCodigo } : {}),
|
||||
...(monto(apartado.precioFinal) ?? monto(cotizacion?.precioFinal)
|
||||
? { valorVenta: (monto(apartado.precioFinal) ?? monto(cotizacion?.precioFinal))! }
|
||||
: {}),
|
||||
...(monto(apartado.enganche) ?? monto(cotizacion?.enganche)
|
||||
? { enganche: (monto(apartado.enganche) ?? monto(cotizacion?.enganche))! }
|
||||
: {}),
|
||||
...(tipoCredito ? { tiposCredito: [tipoCredito] } : {}),
|
||||
...(cotizacion?.acabados ? { equipamientoAdicional: 'COCINA Y CLOSET' } : {}),
|
||||
}
|
||||
|
||||
return {
|
||||
datosGenerales,
|
||||
viviendaCredito,
|
||||
leadMondayId: oportunidad?.id ?? null,
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const FIRMA_PRUEBA =
|
||||
*/
|
||||
export const fixtureAlta: AltaCliente = {
|
||||
leadMondayId: null,
|
||||
apartadoMondayId: null,
|
||||
progresoId: null,
|
||||
datosGenerales: {
|
||||
campaniaVenta: '',
|
||||
|
||||
@@ -21,11 +21,13 @@ import {
|
||||
listarBorradores,
|
||||
obtenerBorrador,
|
||||
obtenerCotizacion,
|
||||
obtenerPrefillApartado,
|
||||
subirDocumentos,
|
||||
type BorradorResumen,
|
||||
type LeadResumen,
|
||||
} from '../services/api'
|
||||
import { cotizacionAPrellenado } from '../lib/cotizacionAlta'
|
||||
import { apartadoAPrellenado } from '../lib/apartadoAlta'
|
||||
import { leadAPrellenado } from '../lib/leadAlta'
|
||||
import BusquedaLead from '../components/BusquedaLead'
|
||||
import AltaAfluenciaForm from '../components/AltaAfluenciaForm'
|
||||
@@ -54,6 +56,11 @@ function paramsAfluencia(): { oportunidadId: string; valores: Record<string, str
|
||||
return { oportunidadId: id, valores }
|
||||
}
|
||||
|
||||
/** Lee ?apartado=<itemId> — botón "Firma Cliente (Venta)" de Mis apartados en Mi día */
|
||||
function paramApartado(): string | null {
|
||||
return new URLSearchParams(window.location.search).get('apartado')
|
||||
}
|
||||
|
||||
const formatoFechaHora = (iso: string): string =>
|
||||
new Date(iso).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
@@ -127,10 +134,25 @@ export default function AltaClientePage() {
|
||||
valores: Record<string, string>
|
||||
} | null>(null)
|
||||
|
||||
// Al entrar: ?cotizacion=<id> prellena el alta y salta directo al wizard;
|
||||
// ?afluencia=<id> abre el formulario de afluencia prellenado; si no, la
|
||||
// entrada ofrece Alta Afluencia (captura ligera) y Alta de Cliente
|
||||
// Al entrar: ?apartado=<id> prellena el alta con todo lo recolectado del
|
||||
// apartado y salta al wizard ligado a ese item; ?afluencia=<id> abre el
|
||||
// formulario de afluencia prellenado; ?cotizacion=<id> prellena el alta;
|
||||
// si no, la entrada ofrece Alta Afluencia (captura ligera) y Alta de Cliente
|
||||
useEffect(() => {
|
||||
const apartadoId = paramApartado()
|
||||
if (apartadoId) {
|
||||
obtenerPrefillApartado(apartadoId)
|
||||
.then((pre) => {
|
||||
const p = apartadoAPrellenado(pre)
|
||||
actualizar('datosGenerales', { ...datos.datosGenerales, ...p.datosGenerales })
|
||||
actualizar('viviendaCredito', { ...datos.viviendaCredito, ...p.viviendaCredito })
|
||||
actualizar('apartadoMondayId', apartadoId)
|
||||
if (p.leadMondayId) actualizar('leadMondayId', p.leadMondayId)
|
||||
})
|
||||
.catch(() => setErrorCarga('No se pudo cargar el apartado para prellenar el alta.'))
|
||||
.finally(() => setPantalla('wizard'))
|
||||
return
|
||||
}
|
||||
const afl = paramsAfluencia()
|
||||
if (afl) {
|
||||
setAfluenciaPrevia(afl)
|
||||
@@ -487,6 +509,11 @@ export default function AltaClientePage() {
|
||||
{errorCarga}
|
||||
</p>
|
||||
)}
|
||||
{datos.apartadoMondayId && (
|
||||
<p role="note" className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-xs font-medium text-blue-800">
|
||||
Alta ligada al apartado del cliente en Monday — al completarla se actualiza ese item (no se duplica).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{paso === 0 && (
|
||||
<TarjetaPaso>
|
||||
|
||||
@@ -216,6 +216,14 @@ export async function obtenerCotizacion(id: string): Promise<import('../lib/coti
|
||||
return (await res.json()) as import('../lib/cotizacionAlta').CotizacionParaAlta
|
||||
}
|
||||
|
||||
/** GET /altas/prefill-apartado/:itemId — todo lo recolectado de un apartado de Monday (?apartado=<id>) */
|
||||
export async function obtenerPrefillApartado(itemId: string): Promise<import('../lib/apartadoAlta').PrefillApartado> {
|
||||
const res = await fetch(`${API_URL}/altas/prefill-apartado/${itemId}`, conAuth())
|
||||
manejar401(res)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as import('../lib/apartadoAlta').PrefillApartado
|
||||
}
|
||||
|
||||
// ---------- Pre-llenado desde lead de Monday (auth) ----------
|
||||
|
||||
export interface LeadResumen {
|
||||
|
||||
@@ -149,6 +149,10 @@ export interface AltaCliente {
|
||||
// ID del item del tablero Leads de Monday cuando el alta se prellenó desde
|
||||
// un lead (null = captura manual). Passthrough hasta payloadJson del API.
|
||||
leadMondayId: string | null
|
||||
// ID del item del tablero Apartado de Monday cuando el alta arrancó desde un
|
||||
// apartado existente (botón "Firma Cliente (Venta)" de Mis apartados): el
|
||||
// API lo LIGA y enriquece en vez de crear un apartado duplicado.
|
||||
apartadoMondayId: string | null
|
||||
// ID del Cliente creado por el guardado progresivo (POST /clientes/progreso/
|
||||
// iniciar); viaja con el borrador (datosJson) y no se captura en el formato.
|
||||
progresoId: string | null
|
||||
|
||||
@@ -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