Files
FRASA-CAS/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx

680 lines
26 KiB
TypeScript

import { useEffect, useState, type FormEvent } from 'react'
import { z } from 'zod'
import { crearAfluencia, type LeadResumen } from '../services/api'
import BusquedaLead from './BusquedaLead'
import {
DESARROLLOS_LEAD,
ESTADOS_CIVILES,
MODELOS_POR_PROYECTO,
OPCIONES_CALIFICA,
ORIGENES_LEAD,
TIPOS_CREDITO,
TIPOS_ECONOMIA,
TIPOS_EMPLEO,
} from '../data/constantes'
interface Props {
onVolver: () => void
/** Conversión directa: tras registrar la afluencia, abrir el alta completa
* prellenada con los datos recién capturados (sin rebuscar el lead) */
onPasarAAltaCliente: (lead: LeadResumen) => void
/** Prefill desde Mis oportunidades (Mi día): valores iniciales del formulario */
valoresIniciales?: Partial<typeof FORM_INICIAL>
/** Oportunidad a ACTUALIZAR al enviar (sin duplicar el registro) */
oportunidadId?: string
}
const emailSchema = z.string().trim().email()
const inputClase =
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
interface Errores {
nombre?: string
telefono?: string
email?: string
}
const FORM_INICIAL = {
nombre: '',
nombrePila: '',
apellidoPaterno: '',
apellidoMaterno: '',
telefono: '',
email: '',
origen: '',
desarrollo: '',
modelo: '',
numDependientes: '',
edad: '',
estadoCivil: '',
tipoEmpleo: '',
ocupacion: '',
rangoSalarial: '',
credito: '',
tipoEconomia: '',
califica: '',
loQuierePara: '',
notas: '',
}
/**
* Captura ligera del interesado al entrar al embudo (POST /leads): crea el
* lead y su oportunidad en etapa Afluencia. El alta completa de
* cliente sigue siendo el wizard del picker.
*/
export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valoresIniciales, oportunidadId }: Props) {
const [form, setForm] = useState({ ...FORM_INICIAL, ...valoresIniciales })
const [lada, setLada] = useState<'MX' | 'US'>(
(valoresIniciales?.telefono ?? '').trim().startsWith('+1') ? 'US' : 'MX',
)
const [errores, setErrores] = useState<Errores>({})
const [enviando, setEnviando] = useState(false)
const [leadCreado, setLeadCreado] = useState<LeadResumen | null>(null)
const [asignacion, setAsignacion] = useState<'capturista' | 'round-robin' | null>(null)
const [aviso, setAviso] = useState('')
const [avisoTipo, setAvisoTipo] = useState<'error' | 'advertencia'>('error')
// Registro existente con ese teléfono: permite actualizarlo en vez de fallar
const [duplicado, setDuplicado] = useState<{ id: string; nombre: string } | null>(null)
const cambiar = (campo: keyof typeof FORM_INICIAL) => (e: { target: { value: string } }) => {
setForm((prev) => ({ ...prev, [campo]: e.target.value }))
setErrores((prev) => ({ ...prev, [campo]: undefined }))
}
// Al cambiar de desarrollo se resetea el modelo (las opciones dependen de él)
const cambiarDesarrollo = (e: { target: { value: string } }) => {
setForm((prev) => ({ ...prev, desarrollo: e.target.value, modelo: '' }))
}
const modelosDelDesarrollo =
MODELOS_POR_PROYECTO[form.desarrollo as keyof typeof MODELOS_POR_PROYECTO] ?? []
/**
* Jala los datos de un lead u oportunidad ya capturados:
* nombre/contacto siempre; origen, desarrollo y modelo solo si el valor del
* lead existe entre las opciones del formulario.
*/
const prellenarDesdeLead = (lead: LeadResumen) => {
// Teléfono con prefijo internacional: separa lada y número para el selector
const tel = lead.telefono.trim()
const esUsa = tel.startsWith('+1')
// Strip lada: +52 or +1 prefix
let numero = tel
if (tel.startsWith('+52 ')) numero = tel.slice(4).trim()
else if (tel.startsWith('+52')) numero = tel.slice(3).trim()
else if (tel.startsWith('+1 ')) numero = tel.slice(3).trim()
else if (tel.startsWith('+1')) numero = tel.slice(2).trim()
setLada(esUsa ? 'US' : 'MX')
setForm((prev) => {
const desarrollo =
lead.desarrollo !== undefined && (DESARROLLOS_LEAD as readonly string[]).includes(lead.desarrollo)
? lead.desarrollo
: prev.desarrollo
const modelos = MODELOS_POR_PROYECTO[desarrollo as keyof typeof MODELOS_POR_PROYECTO] ?? []
const modelo =
lead.modeloInteres !== undefined && (modelos as readonly string[]).includes(lead.modeloInteres)
? lead.modeloInteres
: ''
// Split name: lead.nombre may contain full name or just first name
const partes = lead.nombre.trim().split(/\s+/)
const nombrePila = partes[0] || ''
const apellidoPaterno = partes.length > 1 ? partes[1] : ''
const apellidoMaterno = partes.length > 2 ? partes.slice(2).join(' ') : ''
return {
...prev,
nombre: lead.nombre,
nombrePila,
apellidoPaterno,
apellidoMaterno,
telefono: numero,
email: lead.email,
origen: (ORIGENES_LEAD as readonly string[]).includes(lead.origen) ? lead.origen : prev.origen,
desarrollo,
modelo,
}
})
setErrores({})
setAviso('')
}
const validar = (): boolean => {
const nuevos: Errores = {}
if (!form.nombre.trim()) nuevos.nombre = 'El nombre es obligatorio'
if (!form.telefono.trim()) nuevos.telefono = 'El teléfono es obligatorio'
if (form.email.trim() && !emailSchema.safeParse(form.email).success) {
nuevos.email = 'Correo electrónico inválido'
}
setErrores(nuevos)
return Object.keys(nuevos).length === 0
}
const enviar = async (e: FormEvent) => {
e.preventDefault()
setAviso('')
setDuplicado(null)
if (!validar()) return
setEnviando(true)
const res = await crearAfluencia(construirPayload(oportunidadId))
setEnviando(false)
procesarResultado(res, oportunidadId)
}
/** Reenvía la captura como ACTUALIZACIÓN del registro existente (duplicado) */
const actualizarExistente = async () => {
if (!duplicado) return
setAviso('')
setEnviando(true)
const res = await crearAfluencia(construirPayload(duplicado.id))
setEnviando(false)
if (res.ok) {
setDuplicado(null)
procesarResultado(res, duplicado.id)
return
}
if (res.motivo === 'duplicado') {
setDuplicado({ id: res.leadId, nombre: res.nombre })
setAvisoTipo('error')
setAviso(`No se pudo actualizar: el teléfono pertenece a ${res.nombre}.`)
return
}
setAvisoTipo('advertencia')
setAviso('La afluencia se actualizó, pero hubo un problema de conexión. Verifica en Mi día.')
}
/** Teléfono con lada: "<prefijo> <número>" (si ya trae +, se respeta tal cual) */
const telefonoConLada = () => {
const numeroTel = form.telefono.trim()
return numeroTel === '' ? '' : numeroTel.startsWith('+') ? numeroTel : `${lada === 'US' ? '+1' : '+52'} ${numeroTel}`
}
/** Payload del alta/actualización de afluencia (teléfono normalizado con lada) */
const construirPayload = (oportunidadIdElegido?: string) => {
const telefono = telefonoConLada()
return {
nombre: [form.nombrePila, form.apellidoPaterno, form.apellidoMaterno].filter(Boolean).join(' ').trim() || form.nombre.trim(),
telefono,
...(oportunidadIdElegido !== undefined ? { oportunidadId: oportunidadIdElegido } : {}),
...(form.email.trim() ? { email: form.email.trim() } : {}),
...(form.origen ? { origen: form.origen } : {}),
...(form.desarrollo ? { desarrollo: form.desarrollo } : {}),
...(form.modelo ? { modelo: form.modelo } : {}),
...(form.numDependientes ? { numDependientes: form.numDependientes } : {}),
...(form.edad.trim() ? { edad: form.edad.trim() } : {}),
...(form.estadoCivil ? { estadoCivil: form.estadoCivil } : {}),
...(form.tipoEmpleo ? { tipoEmpleo: form.tipoEmpleo } : {}),
...(form.ocupacion.trim() ? { ocupacion: form.ocupacion.trim() } : {}),
...(form.rangoSalarial.trim() ? { rangoSalarial: form.rangoSalarial.trim() } : {}),
...(form.credito ? { credito: form.credito } : {}),
...(form.tipoEconomia ? { tipoEconomia: form.tipoEconomia } : {}),
...(form.califica ? { califica: form.califica } : {}),
...(form.loQuierePara.trim() ? { loQuierePara: form.loQuierePara.trim() } : {}),
...(form.notas.trim() ? { notas: form.notas.trim() } : {}),
}
}
/** Resultado del API: éxito, duplicado (ofrece actualizar) o problema de red */
const procesarResultado = (
res: Awaited<ReturnType<typeof crearAfluencia>>,
oportunidadIdElegido?: string,
) => {
if (res.ok) {
setLeadCreado({
id: res.lead.id,
nombre: [form.nombrePila, form.apellidoPaterno, form.apellidoMaterno].filter(Boolean).join(' ').trim() || form.nombre.trim(),
telefono: telefonoConLada(),
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')
return
}
if (res.motivo === 'duplicado') {
setDuplicado({ id: res.leadId, nombre: res.nombre })
setAvisoTipo('error')
setAviso(`Ya existe un registro con ese teléfono: ${res.nombre}.`)
return
}
if (oportunidadIdElegido !== undefined) {
// El backend es local: la actualización YA se guardó aunque el fetch se cortara
setAvisoTipo('advertencia')
setAviso('La afluencia se actualizó, pero hubo un problema de conexión. Verifica en Mi día.')
return
}
setAvisoTipo('advertencia')
setAviso('La afluencia se registró, pero hubo un problema de conexión. Verifica en Mi día.')
}
const capturarOtro = () => {
setForm(FORM_INICIAL)
setLada('MX')
setErrores({})
setAviso('')
setLeadCreado(null)
setAsignacion(null)
}
// Redirigir a Mi día 2 s después de registrar la afluencia
useEffect(() => {
if (leadCreado === null) return
const timer = setTimeout(() => {
window.location.href = '/hoy/'
}, 2000)
return () => clearTimeout(timer)
}, [leadCreado])
if (leadCreado !== null) {
return (
<div className="max-w-md">
<p
role="status"
className="rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700"
>
{oportunidadId !== undefined
? 'Afluencia actualizada — el perfil completo quedó guardado en la oportunidad'
: asignacion === 'capturista'
? 'Registrado en afluencia — asignado a ti'
: 'Registrado en afluencia — se asignará por round-robin'}
</p>
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">
Redirigiendo a Mi día en unos segundos…
</p>
<div className="mt-6 flex flex-wrap gap-3">
<button
type="button"
onClick={() => onPasarAAltaCliente(leadCreado)}
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
>
Pasar a Alta de Cliente
</button>
{oportunidadId === undefined && (
<button
type="button"
onClick={capturarOtro}
className="rounded-lg border border-accent bg-white px-5 py-2.5 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white"
>
Capturar otro
</button>
)}
<button
type="button"
onClick={onVolver}
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Volver
</button>
</div>
</div>
)
}
return (
<form onSubmit={(e) => void enviar(e)} noValidate>
{oportunidadId !== undefined ? (
<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"
>
Estás completando la afluencia de {form.nombre || 'esta oportunidad'} — al enviar se actualiza su
oportunidad ya capturada (no se duplica).
</p>
) : (
<div className="mb-6 border-b border-[#E5E7EB] pb-6">
<BusquedaLead onSeleccion={prellenarDesdeLead} />
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">
Si el interesado ya está capturado (lead u oportunidad), sus datos se prellenan solos.
</p>
</div>
)}
<div className="grid gap-6 sm:grid-cols-2">
<label htmlFor="afluencia-nombre-pila" className="block">
<span className={labelClase}>
Nombre(s)<span className="text-red-700"> *</span>
</span>
<input
id="afluencia-nombre-pila"
type="text"
value={form.nombrePila}
onChange={cambiar('nombrePila')}
className={`${inputClase} ${errores.nombre ? 'border-red-400' : ''}`}
/>
{errores.nombre && <p className="mt-1 text-xs text-red-700">{errores.nombre}</p>}
</label>
<label htmlFor="afluencia-apellido-paterno" className="block">
<span className={labelClase}>Apellido paterno</span>
<input
id="afluencia-apellido-paterno"
type="text"
value={form.apellidoPaterno}
onChange={cambiar('apellidoPaterno')}
className={inputClase}
/>
</label>
<label htmlFor="afluencia-apellido-materno" className="block">
<span className={labelClase}>Apellido materno</span>
<input
id="afluencia-apellido-materno"
type="text"
value={form.apellidoMaterno}
onChange={cambiar('apellidoMaterno')}
className={inputClase}
/>
</label>
<div>
<label htmlFor="afluencia-telefono" className={labelClase}>
Teléfono<span className="text-red-700"> *</span>
</label>
<div className="flex gap-2">
<select
aria-label="Lada del teléfono"
value={lada}
onChange={(e) => setLada(e.target.value as 'MX' | 'US')}
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
>
<option value="MX">🇲🇽 +52</option>
<option value="US">🇺🇸 +1</option>
</select>
<input
id="afluencia-telefono"
type="tel"
placeholder="664 123 4567"
value={form.telefono}
onChange={cambiar('telefono')}
className={`${inputClase} ${errores.telefono ? 'border-red-400' : ''}`}
/>
</div>
{errores.telefono && <p className="mt-1 text-xs text-red-700">{errores.telefono}</p>}
</div>
<label htmlFor="afluencia-email" className="block">
<span className={labelClase}>Correo</span>
<input
id="afluencia-email"
type="email"
value={form.email}
onChange={cambiar('email')}
className={`${inputClase} ${errores.email ? 'border-red-400' : ''}`}
/>
{errores.email && <p className="mt-1 text-xs text-red-700">{errores.email}</p>}
</label>
<label htmlFor="afluencia-desarrollo" className="block">
<span className={labelClase}>Desarrollo de interés</span>
<select
id="afluencia-desarrollo"
value={form.desarrollo}
onChange={cambiarDesarrollo}
className={inputClase}
>
<option value="">Sin especificar</option>
{DESARROLLOS_LEAD.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
</label>
<label htmlFor="afluencia-modelo" className="block">
<span className={labelClase}>Departamento o casa de interés</span>
<select
id="afluencia-modelo"
value={form.modelo}
onChange={cambiar('modelo')}
disabled={form.desarrollo === ''}
className={`${inputClase} disabled:bg-[#F1F5F9] disabled:text-[#94A3B8]`}
>
<option value="">
{form.desarrollo === '' ? 'Selecciona primero el desarrollo' : '— Selecciona —'}
</option>
{modelosDelDesarrollo.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</label>
<label htmlFor="afluencia-num-dependientes" className="block">
<span className={labelClase}>Número de dependientes</span>
<select
id="afluencia-num-dependientes"
value={form.numDependientes}
onChange={cambiar('numDependientes')}
className={inputClase}
>
<option value="">Seleccionar...</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5 o más</option>
</select>
</label>
<label htmlFor="afluencia-origen" className="block">
<span className={labelClase}>¿Cómo se enteró de nosotros?</span>
<select
id="afluencia-origen"
value={form.origen}
onChange={cambiar('origen')}
className={inputClase}
>
<option value="">— Selecciona —</option>
{ORIGENES_LEAD.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</label>
<label htmlFor="afluencia-edad" className="block">
<span className={labelClase}>Edad</span>
<input
id="afluencia-edad"
type="number"
min={15}
max={120}
value={form.edad}
onChange={cambiar('edad')}
className={inputClase}
/>
</label>
<label htmlFor="afluencia-estado-civil" className="block">
<span className={labelClase}>Estado civil</span>
<select
id="afluencia-estado-civil"
value={form.estadoCivil}
onChange={cambiar('estadoCivil')}
className={inputClase}
>
<option value="">— Selecciona —</option>
{ESTADOS_CIVILES.map((e) => (
<option key={e.valor} value={e.etiqueta}>
{e.etiqueta}
</option>
))}
</select>
</label>
<label htmlFor="afluencia-tipo-empleo" className="block">
<span className={labelClase}>Tipo de empleo</span>
<select
id="afluencia-tipo-empleo"
value={form.tipoEmpleo}
onChange={cambiar('tipoEmpleo')}
className={inputClase}
>
<option value="">— Selecciona —</option>
{TIPOS_EMPLEO.map((t) => (
<option key={t.valor} value={t.etiqueta}>
{t.etiqueta}
</option>
))}
</select>
</label>
<label htmlFor="afluencia-ocupacion" className="block">
<span className={labelClase}>Ocupación</span>
<input
id="afluencia-ocupacion"
type="text"
value={form.ocupacion}
onChange={cambiar('ocupacion')}
className={inputClase}
/>
</label>
<label htmlFor="afluencia-rango-salarial" className="block">
<span className={labelClase}>Rango salarial</span>
<select
id="afluencia-rango-salarial"
value={form.rangoSalarial}
onChange={cambiar('rangoSalarial')}
className={inputClase}
>
<option value="">— Selecciona —</option>
<option value="A-0-10000.00">A-0-10000.00</option>
<option value="B-10000.01-15000.00">B-10000.01-15000.00</option>
<option value="C-15000.01-20000.00">C-15000.01-20000.00</option>
<option value="D-17000.01-22000.00">D-17000.01-22000.00</option>
<option value="E-20001.00-25000.00">E-20001.00-25000.00</option>
<option value="F-25001.00-30000.00">F-25001.00-30000.00</option>
<option value="G-30001.00-35000.00">G-30001.00-35000.00</option>
<option value="H-35001.00-40000.00">H-35001.00-40000.00</option>
<option value="I-40001.00-45000.00">I-40001.00-45000.00</option>
<option value="J-45001.00-50000.00">J-45001.00-50000.00</option>
<option value="K-50001.00-75000.00">K-50001.00-75000.00</option>
<option value="L-75001.00-100000.00">L-75001.00-100000.00</option>
<option value="M-100001.00-500000.00">M-100001.00-500000.00</option>
</select>
</label>
<label htmlFor="afluencia-credito" className="block">
<span className={labelClase}>Crédito</span>
<select
id="afluencia-credito"
value={form.credito}
onChange={cambiar('credito')}
className={inputClase}
>
<option value="">— Selecciona —</option>
{TIPOS_CREDITO.map((c) => (
<option key={c.valor} value={c.etiqueta}>
{c.etiqueta}
</option>
))}
</select>
</label>
<label htmlFor="afluencia-tipo-economia" className="block">
<span className={labelClase}>Tipo de economía</span>
<select
id="afluencia-tipo-economia"
value={form.tipoEconomia}
onChange={cambiar('tipoEconomia')}
className={inputClase}
>
<option value="">— Selecciona —</option>
{TIPOS_ECONOMIA.map((t) => (
<option key={t.valor} value={t.etiqueta}>
{t.etiqueta}
</option>
))}
</select>
</label>
<label htmlFor="afluencia-califica" className="block">
<span className={labelClase}>Califica</span>
<select
id="afluencia-califica"
value={form.califica}
onChange={cambiar('califica')}
className={inputClase}
>
<option value="">— Selecciona —</option>
{OPCIONES_CALIFICA.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
<label htmlFor="afluencia-lo-quiere-para" className="block">
<span className={labelClase}>Lo quiere para</span>
<select
id="afluencia-lo-quiere-para"
value={form.loQuierePara}
onChange={cambiar('loQuierePara')}
className={inputClase}
>
<option value="">— Selecciona —</option>
<option value="Para vivir">Para vivir</option>
<option value="Para inversión">Para inversión</option>
</select>
</label>
<label htmlFor="afluencia-notas" className="block sm:col-span-2">
<span className={labelClase}>Notas</span>
<input
id="afluencia-notas"
type="text"
value={form.notas}
onChange={cambiar('notas')}
className={inputClase}
/>
</label>
</div>
{aviso && avisoTipo === 'advertencia' && (
<p role="status" className="mt-6 rounded-lg border border-yellow-200 bg-yellow-50 px-4 py-3 text-xs font-medium text-yellow-800">
{aviso}
</p>
)}
{aviso && avisoTipo === 'error' && (
<div role="alert" className="mt-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
<p>{aviso}</p>
{duplicado && (
<button
type="button"
onClick={() => void actualizarExistente()}
disabled={enviando}
className="mt-3 rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{enviando ? 'Actualizando…' : 'Actualizar este registro con los datos capturados'}
</button>
)}
</div>
)}
<div className="mt-8 flex gap-3">
<button
type="submit"
disabled={enviando}
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
>
{enviando ? 'Registrando…' : oportunidadId !== undefined ? 'Actualizar afluencia' : 'Registrar en afluencia'}
</button>
<button
type="button"
onClick={onVolver}
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Volver
</button>
</div>
</form>
)
}