feat: Interesado en leads, lada MX/USA, orígenes FRASA y subcategoría de descarte

- Mis leads: botón Interesado (label sembrado en Monday, mueve a Leads
  Progreso); el botón Agenda cita ahora solo vive en Mis oportunidades
- Descarte: además de la categoría, subcategoría del catálogo de Monday
  (workspace de referencia CRM FRASA); se guarda en la columna
  Subcategoria del tablero Motivos No Compra y el item queda en el grupo
  de su categoría
- Teléfonos con selector de lada 🇲🇽 +52 / 🇺🇸 +1 en captura rápida (Mi
  día), Alta Afluencia y Alta de Cliente (tel. casa y celular); Monday
  escribe countryShortName MX/US y el número compacto (rechaza espacios);
  el dedup sigue comparando por los últimos 10 dígitos
- Origen nuevo en captura rápida y afluencia: PASE, PROSPECCION ASESOR,
  ACTIVACION, FACEBOOK FRASA, REFERIDO, OTRO (sembrados en Leads.Origen y
  Oportunidades.Origen del Lead)
- Tests: 461 API + 16 mi-dia + 116 alta-clientes, verde
This commit is contained in:
2026-09-04 23:28:56 +00:00
parent 5d7c6f58cd
commit 27ba9ea8fa
14 changed files with 279 additions and 72 deletions

View File

@@ -48,7 +48,7 @@ describe('AltaAfluenciaForm', () => {
fetchMock.mockResolvedValueOnce(respuesta({ id: 'l-1', estado: 'NUEVO', oportunidadId: 'op-1' }))
render(<AltaAfluenciaForm onVolver={() => undefined} onPasarAAltaCliente={() => undefined} />)
llenarRequeridos()
fireEvent.change(screen.getByLabelText(/¿Cómo se enteró de nosotros\?/), { target: { value: 'Web' } })
fireEvent.change(screen.getByLabelText(/¿Cómo se enteró de nosotros\?/), { target: { value: 'PASE' } })
fireEvent.change(screen.getByLabelText(/Desarrollo de interés/), { target: { value: 'SENDA' } })
fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ }))
@@ -59,8 +59,8 @@ describe('AltaAfluenciaForm', () => {
expect(url).toContain('/afluencias')
expect(JSON.parse(String(init.body))).toMatchObject({
nombre: 'Ana Demo',
telefono: '6641234567',
origen: 'Web',
telefono: '+52 6641234567',
origen: 'PASE',
desarrollo: 'SENDA',
})
})
@@ -158,7 +158,7 @@ describe('AltaAfluenciaForm', () => {
await screen.findByRole('status')
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
const cuerpo = JSON.parse(String(init.body)) as Record<string, unknown>
expect(cuerpo).toEqual({ nombre: 'Ana Demo', telefono: '6641234567' })
expect(cuerpo).toEqual({ nombre: 'Ana Demo', telefono: '+52 6641234567' })
})
it('con 409 muestra el duplicado con el nombre del lead existente', async () => {
@@ -218,7 +218,7 @@ describe('AltaAfluenciaForm', () => {
expect(onPasar).toHaveBeenCalledWith({
id: 'op-1',
nombre: 'Ana Demo',
telefono: '6641234567',
telefono: '+52 6641234567',
email: 'ana@correo.com',
estado: 'Afluencia',
origen: '',

View File

@@ -64,6 +64,9 @@ const FORM_INICIAL = {
*/
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)
@@ -89,6 +92,11 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
* 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')
const numero = tel.startsWith('+1') ? tel.slice(2).trim() : tel.startsWith('+52') ? tel.slice(3).trim() : tel
setLada(esUsa ? 'US' : 'MX')
setForm((prev) => {
const desarrollo =
lead.desarrollo !== undefined && (DESARROLLOS_LEAD as readonly string[]).includes(lead.desarrollo)
@@ -102,7 +110,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
return {
...prev,
nombre: lead.nombre,
telefono: lead.telefono,
telefono: numero,
email: lead.email,
origen: (ORIGENES_LEAD as readonly string[]).includes(lead.origen) ? lead.origen : prev.origen,
desarrollo,
@@ -129,9 +137,13 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
setAviso('')
if (!validar()) return
setEnviando(true)
// Teléfono con lada: "<prefijo> <número>" (si ya trae +, se respeta tal cual)
const numeroTel = form.telefono.trim()
const telefono =
numeroTel === '' ? '' : numeroTel.startsWith('+') ? numeroTel : `${lada === 'US' ? '+1' : '+52'} ${numeroTel}`
const res = await crearAfluencia({
nombre: form.nombre.trim(),
telefono: form.telefono.trim(),
telefono,
...(oportunidadId !== undefined ? { oportunidadId } : {}),
...(form.email.trim() ? { email: form.email.trim() } : {}),
...(form.origen ? { origen: form.origen } : {}),
@@ -154,7 +166,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
setLeadCreado({
id: res.lead.id,
nombre: form.nombre.trim(),
telefono: form.telefono.trim(),
telefono,
email: form.email.trim(),
estado: res.lead.estado,
origen: form.origen,
@@ -170,6 +182,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
const capturarOtro = () => {
setForm(FORM_INICIAL)
setLada('MX')
setErrores({})
setAviso('')
setLeadCreado(null)
@@ -251,19 +264,31 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
{errores.nombre && <p className="mt-1 text-xs text-red-700">{errores.nombre}</p>}
</label>
<label htmlFor="afluencia-telefono" className="block">
<span className={labelClase}>
<div>
<label htmlFor="afluencia-telefono" 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' : ''}`}
/>
</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 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>}
</label>
</div>
<label htmlFor="afluencia-email" className="block">
<span className={labelClase}>Correo</span>

View File

@@ -89,6 +89,7 @@ interface CampoSelectProps extends CampoBaseProps {
opciones: { valor: string; etiqueta: string }[]
}
/** CampoSelect (dropdown) conectado a react-hook-form */
export function CampoSelect({ nombre, etiqueta, opciones, requerido, className }: CampoSelectProps) {
const { register, formState: { errors } } = useFormContext()
return (
@@ -106,3 +107,50 @@ export function CampoSelect({ nombre, etiqueta, opciones, requerido, className }
</label>
)
}
/**
* Teléfono con selector de lada (México +52 / USA +1). El campo del formulario
* guarda el valor compuesto "<prefijo> <número>" (p. ej. "+52 6641234567");
* el dedup del API compara por los últimos 10 dígitos, así que no afecta.
*/
export function CampoTelefono({ nombre, etiqueta, requerido, className }: CampoBaseProps) {
const { watch, setValue, formState: { errors } } = useFormContext()
const valor = ((watch(nombre) as string) ?? '').trim()
const lada = valor.startsWith('+1') ? '+1' : '+52'
const numero = valor.startsWith('+1') ? valor.slice(2).trim() : valor.startsWith('+52') ? valor.slice(3).trim() : valor
const actualizar = (nuevaLada: string, nuevoNumero: string) => {
const limpio = nuevoNumero.trim()
// Vacío se guarda vacío (sin prefijo huérfano); con número, compuesto
setValue(nombre, limpio === '' ? '' : `${nuevaLada} ${limpio}`, { shouldValidate: true })
}
return (
<div className={className ?? ''}>
<label htmlFor={nombre}>
<Etiqueta etiqueta={etiqueta} requerido={requerido} />
</label>
<div className="flex gap-2">
<select
id={`${nombre}-lada`}
aria-label="Lada"
value={lada}
onChange={(e) => actualizar(e.target.value, numero)}
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
>
<option value="+52">🇲🇽 +52</option>
<option value="+1">🇺🇸 +1</option>
</select>
<input
id={nombre}
type="tel"
placeholder="664 123 4567"
value={numero}
onChange={(e) => actualizar(lada, e.target.value)}
className={claseInput(nombre, errors)}
/>
</div>
<MensajeError nombre={nombre} />
</div>
)
}