feat(alta): separar en Alta Afluencia y Alta de Cliente
- Nueva pantalla de entrada en /client con dos opciones:
· Alta Afluencia: captura ligera (nombre, teléfono, email, origen,
desarrollo de interés, notas) → POST /api/leads (lead + oportunidad
Afluencia + round-robin, todo ya existente en el API)
· Alta de Cliente: formato completo actual (picker + wizard intactos)
- Manejo de duplicado por teléfono (409 con nombre del lead existente)
- ?cotizacion=<id> sigue saltando directo al wizard
- Tests: 107 verdes (11 nuevos); smoke en vivo OK y limpio
This commit is contained in:
225
apps/alta-clientes/src/components/AltaAfluenciaForm.tsx
Normal file
225
apps/alta-clientes/src/components/AltaAfluenciaForm.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { crearLead } from '../services/api'
|
||||
import { DESARROLLOS_LEAD, ORIGENES_LEAD } from '../data/constantes'
|
||||
|
||||
interface Props {
|
||||
onVolver: () => void
|
||||
}
|
||||
|
||||
const emailSchema = z.string().trim().email()
|
||||
|
||||
const inputClase =
|
||||
'w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none'
|
||||
|
||||
const labelClase = 'mb-1 block text-xs font-bold uppercase tracking-widest text-neutral-500'
|
||||
|
||||
interface Errores {
|
||||
nombre?: string
|
||||
telefono?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
const FORM_INICIAL = { nombre: '', telefono: '', email: '', origen: '', desarrollo: '', 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 }: Props) {
|
||||
const [form, setForm] = useState(FORM_INICIAL)
|
||||
const [errores, setErrores] = useState<Errores>({})
|
||||
const [enviando, setEnviando] = useState(false)
|
||||
const [exito, setExito] = useState(false)
|
||||
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 }))
|
||||
}
|
||||
|
||||
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.notas.trim() ? { notas: form.notas.trim() } : {}),
|
||||
})
|
||||
setEnviando(false)
|
||||
if (res.ok) {
|
||||
setExito(true)
|
||||
} 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('')
|
||||
setExito(false)
|
||||
}
|
||||
|
||||
if (exito) {
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<p
|
||||
role="status"
|
||||
className="border border-accent bg-white px-4 py-3 text-xs font-bold uppercase tracking-widest text-accent"
|
||||
>
|
||||
Registrado en afluencia — se asignará por round-robin
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={capturarOtro}
|
||||
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:bg-neutral-900"
|
||||
>
|
||||
Capturar otro
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onVolver}
|
||||
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||
>
|
||||
Volver
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => void enviar(e)} noValidate className="max-w-md">
|
||||
<div className="space-y-6">
|
||||
<label htmlFor="afluencia-nombre" className="block">
|
||||
<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}>Email</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-origen" className="block">
|
||||
<span className={labelClase}>Origen</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-desarrollo" className="block">
|
||||
<span className={labelClase}>Desarrollo de interés</span>
|
||||
<select
|
||||
id="afluencia-desarrollo"
|
||||
value={form.desarrollo}
|
||||
onChange={cambiar('desarrollo')}
|
||||
className={inputClase}
|
||||
>
|
||||
<option value="">Sin especificar</option>
|
||||
{DESARROLLOS_LEAD.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label htmlFor="afluencia-notas" className="block">
|
||||
<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 border border-red-300 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="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:bg-neutral-900 disabled:opacity-50"
|
||||
>
|
||||
{enviando ? 'Registrando…' : 'Registrar en afluencia'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onVolver}
|
||||
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||
>
|
||||
Volver
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user