Del look editorial (verde #1B4332, esquinas a 0, botones en mayusculas) al sistema NovaSpark: primario azul #2563EB, detalle naranja #F97316, fondo #F8FAFC con tarjetas blancas rounded-xl + shadow-sm, chips rounded-full semanticos, focus ring azul, transiciones 100-200ms. Incluye headers de tabla en los PDFs (cotizador y estado de cuenta).
451 lines
15 KiB
TypeScript
451 lines
15 KiB
TypeScript
import { useState, type FormEvent } from 'react'
|
|
import { z } from 'zod'
|
|
import { crearLead, type LeadResumen } from '../services/api'
|
|
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
|
|
}
|
|
|
|
const emailSchema = z.string().trim().email()
|
|
|
|
const inputClase =
|
|
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 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]'
|
|
|
|
interface Errores {
|
|
nombre?: string
|
|
telefono?: string
|
|
email?: string
|
|
}
|
|
|
|
const FORM_INICIAL = {
|
|
nombre: '',
|
|
telefono: '',
|
|
email: '',
|
|
origen: '',
|
|
desarrollo: '',
|
|
modelo: '',
|
|
medioContacto: '',
|
|
edad: '',
|
|
estadoCivil: '',
|
|
tipoEmpleo: '',
|
|
ocupacion: '',
|
|
rangoSalarial: '',
|
|
credito: '',
|
|
tipoEconomia: '',
|
|
califica: '',
|
|
loQuierePara: '',
|
|
notas: '',
|
|
}
|
|
|
|
/**
|
|
* Captura ligera del interesado al entrar al embudo (POST /leads): crea el
|
|
* lead en Monday y su oportunidad en etapa Afluencia. El alta completa de
|
|
* cliente sigue siendo el wizard del picker.
|
|
*/
|
|
export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Props) {
|
|
const [form, setForm] = useState(FORM_INICIAL)
|
|
const [errores, setErrores] = useState<Errores>({})
|
|
const [enviando, setEnviando] = useState(false)
|
|
const [leadCreado, setLeadCreado] = useState<LeadResumen | null>(null)
|
|
const [aviso, setAviso] = useState('')
|
|
|
|
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] ?? []
|
|
|
|
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('')
|
|
if (!validar()) return
|
|
setEnviando(true)
|
|
const res = await crearLead({
|
|
nombre: form.nombre.trim(),
|
|
telefono: form.telefono.trim(),
|
|
...(form.email.trim() ? { email: form.email.trim() } : {}),
|
|
...(form.origen ? { origen: form.origen } : {}),
|
|
...(form.desarrollo ? { desarrollo: form.desarrollo } : {}),
|
|
...(form.modelo ? { modelo: form.modelo } : {}),
|
|
...(form.medioContacto.trim() ? { medioContacto: form.medioContacto.trim() } : {}),
|
|
...(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() } : {}),
|
|
})
|
|
setEnviando(false)
|
|
if (res.ok) {
|
|
setLeadCreado({
|
|
id: res.lead.id,
|
|
nombre: form.nombre.trim(),
|
|
telefono: form.telefono.trim(),
|
|
email: form.email.trim(),
|
|
estado: res.lead.estado,
|
|
origen: form.origen,
|
|
})
|
|
} else if (res.motivo === 'duplicado') {
|
|
setAviso(`Ya existe un lead con ese teléfono: ${res.nombre}.`)
|
|
} else {
|
|
setAviso('No se pudo registrar el lead. Revisa tu conexión e intenta de nuevo.')
|
|
}
|
|
}
|
|
|
|
const capturarOtro = () => {
|
|
setForm(FORM_INICIAL)
|
|
setErrores({})
|
|
setAviso('')
|
|
setLeadCreado(null)
|
|
}
|
|
|
|
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"
|
|
>
|
|
Registrado en afluencia — se asignará por round-robin
|
|
</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>
|
|
<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 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
|
>
|
|
Volver
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={(e) => void enviar(e)} noValidate className="max-w-2xl">
|
|
<div className="grid gap-6 sm:grid-cols-2">
|
|
<label htmlFor="afluencia-nombre" className="block sm:col-span-2">
|
|
<span className={labelClase}>
|
|
Nombre<span className="text-red-700"> *</span>
|
|
</span>
|
|
<input
|
|
id="afluencia-nombre"
|
|
type="text"
|
|
value={form.nombre}
|
|
onChange={cambiar('nombre')}
|
|
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-telefono" className="block">
|
|
<span className={labelClase}>
|
|
Teléfono<span className="text-red-700"> *</span>
|
|
</span>
|
|
<input
|
|
id="afluencia-telefono"
|
|
type="tel"
|
|
value={form.telefono}
|
|
onChange={cambiar('telefono')}
|
|
className={`${inputClase} ${errores.telefono ? 'border-red-400' : ''}`}
|
|
/>
|
|
{errores.telefono && <p className="mt-1 text-xs text-red-700">{errores.telefono}</p>}
|
|
</label>
|
|
|
|
<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-medio-contacto" className="block">
|
|
<span className={labelClase}>Medio de contacto</span>
|
|
<input
|
|
id="afluencia-medio-contacto"
|
|
type="text"
|
|
value={form.medioContacto}
|
|
onChange={cambiar('medioContacto')}
|
|
className={inputClase}
|
|
/>
|
|
</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>
|
|
<input
|
|
id="afluencia-rango-salarial"
|
|
type="text"
|
|
value={form.rangoSalarial}
|
|
onChange={cambiar('rangoSalarial')}
|
|
className={inputClase}
|
|
/>
|
|
</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>
|
|
<input
|
|
id="afluencia-lo-quiere-para"
|
|
type="text"
|
|
value={form.loQuierePara}
|
|
onChange={cambiar('loQuierePara')}
|
|
className={inputClase}
|
|
/>
|
|
</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 && (
|
|
<p role="alert" className="mt-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
|
|
{aviso}
|
|
</p>
|
|
)}
|
|
|
|
<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…' : 'Registrar en afluencia'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onVolver}
|
|
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
|
>
|
|
Volver
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)
|
|
}
|