fix(auditoria): afluencia (validacion, update sin perder datos ni retroceder etapa, dedup cruzado, notas), alta cliente (registro tras firma, documentos por cliente, aviso de fallos) y reporte de clientes aplanado

This commit is contained in:
2026-09-18 16:05:18 +00:00
parent 4965a4488a
commit 656116c4e5
13 changed files with 295 additions and 59 deletions

View File

@@ -120,32 +120,32 @@ describe('AltaAfluenciaForm', () => {
llenarRequeridos()
fireEvent.change(screen.getByLabelText(/Desarrollo de interés/), { target: { value: 'SENDA' } })
fireEvent.change(screen.getByLabelText(/Departamento o casa de interés/), { target: { value: 'HIMALAYA' } })
fireEvent.change(screen.getByLabelText(/Medio de contacto/), { target: { value: 'Llamada' } })
fireEvent.change(screen.getByLabelText(/Número de dependientes/), { target: { value: '3' } })
fireEvent.change(screen.getByLabelText(/^Edad/), { target: { value: '35' } })
fireEvent.change(screen.getByLabelText(/^Estado civil/), { target: { value: 'Casado(a)' } })
fireEvent.change(screen.getByLabelText(/Tipo de empleo/), { target: { value: 'Asalariado' } })
fireEvent.change(screen.getByLabelText(/^Ocupación/), { target: { value: 'Ingeniero' } })
fireEvent.change(screen.getByLabelText(/Rango salarial/), { target: { value: '$20,000 - $30,000' } })
fireEvent.change(screen.getByLabelText(/Rango salarial/), { target: { value: 'F-25001.00-30000.00' } })
fireEvent.change(screen.getByLabelText(/^Crédito/), { target: { value: 'Infonavit' } })
fireEvent.change(screen.getByLabelText(/Tipo de economía/), { target: { value: 'Economía mexicana' } })
fireEvent.change(screen.getByLabelText(/^Califica/), { target: { value: 'Sí' } })
fireEvent.change(screen.getByLabelText(/Lo quiere para/), { target: { value: 'Vivir ahí' } })
fireEvent.change(screen.getByLabelText(/Lo quiere para/), { target: { value: 'Para vivir' } })
fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ }))
await screen.findByRole('status')
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(JSON.parse(String(init.body))).toMatchObject({
modelo: 'HIMALAYA',
medioContacto: 'Llamada',
numDependientes: '3',
edad: '35',
estadoCivil: 'Casado(a)',
tipoEmpleo: 'Asalariado',
ocupacion: 'Ingeniero',
rangoSalarial: '$20,000 - $30,000',
rangoSalarial: 'F-25001.00-30000.00',
credito: 'Infonavit',
tipoEconomia: 'Economía mexicana',
califica: 'Sí',
loQuierePara: 'Vivir ahí',
loQuierePara: 'Para vivir',
})
})
@@ -170,7 +170,7 @@ describe('AltaAfluenciaForm', () => {
fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ }))
expect((await screen.findByRole('alert')).textContent).toContain(
'Ya existe un lead con ese teléfono: ANA DEMO.',
'Ya existe un registro con ese teléfono: ANA DEMO.',
)
})

View File

@@ -33,6 +33,7 @@ const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CB
interface Errores {
nombre?: string
nombrePila?: string
telefono?: string
email?: string
}
@@ -65,8 +66,33 @@ const FORM_INICIAL = {
* lead y su oportunidad en etapa Afluencia. El alta completa de
* cliente sigue siendo el wizard del picker.
*/
/**
* Reparte un nombre completo en nombre(s) + apellido paterno + materno para
* prellenar los tres campos del formulario (el nombre viene de un solo campo
* en el lead/oportunidad de origen).
*/
function repartirNombre(nombre: string): { nombrePila: string; apellidoPaterno: string; apellidoMaterno: string } {
const partes = nombre.trim().split(/\s+/).filter(Boolean)
if (partes.length === 0) return { nombrePila: '', apellidoPaterno: '', apellidoMaterno: '' }
if (partes.length === 1) return { nombrePila: partes[0]!, apellidoPaterno: '', apellidoMaterno: '' }
if (partes.length === 2) return { nombrePila: partes[0]!, apellidoPaterno: partes[1]!, apellidoMaterno: '' }
return {
nombrePila: partes[0]!,
apellidoPaterno: partes[1]!,
apellidoMaterno: partes.slice(2).join(' '),
}
}
export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valoresIniciales, oportunidadId }: Props) {
const [form, setForm] = useState({ ...FORM_INICIAL, ...valoresIniciales })
const [form, setForm] = useState(() => {
const base = { ...FORM_INICIAL, ...valoresIniciales }
// Si el origen trae el nombre completo en un solo campo, se reparte en
// nombre(s) + apellidos para que los tres inputs se vean llenos.
if (base.nombrePila.trim() === '' && base.nombre.trim() !== '') {
return { ...base, ...repartirNombre(base.nombre) }
}
return base
})
const [lada, setLada] = useState<'MX' | 'US'>(
(valoresIniciales?.telefono ?? '').trim().startsWith('+1') ? 'US' : 'MX',
)
@@ -142,7 +168,10 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
const validar = (): boolean => {
const nuevos: Errores = {}
if (!form.nombre.trim()) nuevos.nombre = 'El nombre es obligatorio'
// El nombre capturado se compone de nombre(s) + apellidos (el campo `nombre`
// sólo se usa cuando viene prellenado de un lead existente)
const nombreCompuesto = nombreCompleto()
if (nombreCompuesto === '') nuevos.nombrePila = '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'
@@ -184,6 +213,11 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
setAviso('La afluencia se actualizó, pero hubo un problema de conexión. Verifica en Mi día.')
}
/** Nombre completo capturado (nombre(s) + apellidos), con respaldo en `nombre` */
const nombreCompleto = () =>
[form.nombrePila, form.apellidoPaterno, form.apellidoMaterno].filter(Boolean).join(' ').trim() ||
form.nombre.trim()
/** Teléfono con lada: "<prefijo> <número>" (si ya trae +, se respeta tal cual) */
const telefonoConLada = () => {
const numeroTel = form.telefono.trim()
@@ -223,7 +257,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
if (res.ok) {
setLeadCreado({
id: res.lead.id,
nombre: [form.nombrePila, form.apellidoPaterno, form.apellidoMaterno].filter(Boolean).join(' ').trim() || form.nombre.trim(),
nombre: nombreCompleto(),
telefono: telefonoConLada(),
email: form.email.trim(),
estado: res.lead.estado,
@@ -239,6 +273,11 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
setAviso(`Ya existe un registro con ese teléfono: ${res.nombre}.`)
return
}
if (res.motivo === 'validacion') {
setAvisoTipo('error')
setAviso(`El servidor rechazó la captura: ${res.detalle ?? 'revisa los datos.'}`)
return
}
if (oportunidadIdElegido !== undefined) {
// El backend es local: la actualización YA se guardó aunque el fetch se cortara
setAvisoTipo('advertencia')
@@ -340,9 +379,9 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
type="text"
value={form.nombrePila}
onChange={cambiar('nombrePila')}
className={`${inputClase} ${errores.nombre ? 'border-red-400' : ''}`}
className={`${inputClase} ${errores.nombrePila ? 'border-red-400' : ''}`}
/>
{errores.nombre && <p className="mt-1 text-xs text-red-700">{errores.nombre}</p>}
{errores.nombrePila && <p className="mt-1 text-xs text-red-700">{errores.nombrePila}</p>}
</label>
<label htmlFor="afluencia-apellido-paterno" className="block">

View File

@@ -17,6 +17,10 @@ interface Props {
alta: AltaCliente
onCambiarFirma: (firma: DatosFirma) => void
onAnterior: () => void
/** Registra el alta en el servidor (después de firmar y revisar el expediente) */
onRegistrar: () => void
/** true mientras el alta se está registrando */
registrando?: boolean
}
const GENERADORES: { tipo: string; descargar: (alta: AltaCliente) => void }[] = [
@@ -28,7 +32,7 @@ const GENERADORES: { tipo: string; descargar: (alta: AltaCliente) => void }[] =
]
/** Paso 8: firma del cliente, nombre del asesor, resumen y generación de PDFs */
export default function FirmasForm({ alta, onCambiarFirma, onAnterior }: Props) {
export default function FirmasForm({ alta, onCambiarFirma, onAnterior, onRegistrar, registrando = false }: Props) {
const [error, setError] = useState('')
const [avisoExpediente, setAvisoExpediente] = useState('')
const { datosGenerales: g, viviendaCredito: v, economicos, firma } = alta
@@ -158,14 +162,31 @@ export default function FirmasForm({ alta, onCambiarFirma, onAnterior }: Props)
</div>
</SectionCard>
<div className="sticky bottom-0 flex items-center rounded-b-xl border-t border-[#E5E7EB] bg-white px-6 py-4">
<div className="sticky bottom-0 flex flex-wrap items-center gap-3 rounded-b-xl border-t border-[#E5E7EB] bg-white px-6 py-4">
<button
type="button"
onClick={onAnterior}
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]"
disabled={registrando}
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] disabled:opacity-50"
>
Anterior
</button>
<button
type="button"
onClick={() => {
// La firma es obligatoria para registrar el alta
if (!validar()) return
avisarExpediente()
onRegistrar()
}}
disabled={registrando}
className="rounded-lg bg-accent px-6 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
>
{registrando ? 'Registrando…' : 'Registrar alta'}
</button>
<p className="text-xs text-[#64748B]">
El expediente se registra y los PDFs se generan después de firmar.
</p>
</div>
</div>
)

View File

@@ -128,7 +128,7 @@ export const PASOS = [
export function useAltaCliente() {
const [paso, setPaso] = useState(0)
const [datos, setDatos] = useState(estadoInicial)
const { documentos, agregarDocumento, quitarDocumento } = useDocumentos()
const { documentos, agregarDocumento, quitarDocumento, limpiarDocumentos } = useDocumentos()
const alta: AltaCliente = { ...datos, documentos }
@@ -146,9 +146,10 @@ export function useAltaCliente() {
setPaso(pasoDestino)
}
/** Vuelve al estado inicial (alta nueva) */
/** Vuelve al estado inicial (alta nueva), incluido el expediente de documentos */
const reiniciar = () => {
setDatos(estadoInicial())
limpiarDocumentos()
setPaso(0)
}

View File

@@ -13,5 +13,8 @@ export function useDocumentos() {
setDocumentos((prev) => prev.filter((d) => d.slot !== slot))
}, [])
return { documentos, agregarDocumento, quitarDocumento }
/** Limpia el expediente (alta nueva): evita heredar documentos del cliente anterior */
const limpiarDocumentos = useCallback(() => setDocumentos([]), [])
return { documentos, agregarDocumento, quitarDocumento, limpiarDocumentos }
}

View File

@@ -4,7 +4,18 @@ import AltaClientePage from './AltaClientePage'
// El lienzo de firma necesita canvas 2d (no existe en jsdom); el paso 7 se
// prueba solo hasta la orquestación de la página, no el trazo de la firma.
vi.mock('../forms/FirmasForm', () => ({ default: () => <div>Paso de firmas</div> }))
// Stub del paso de firmas: expone el botón de registro (el registro del alta
// ahora se dispara desde este paso, no al salir de Documentos)
vi.mock('../forms/FirmasForm', () => ({
default: ({ onRegistrar }: { onRegistrar?: () => void }) => (
<div>
<p>Paso de firmas</p>
<button type="button" onClick={onRegistrar}>
Registrar alta
</button>
</div>
),
}))
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
@@ -136,8 +147,11 @@ describe('AltaClientePage — guardado progresivo (etapa por etapa)', () => {
fireEvent.change(await screen.findByLabelText(/^Proyecto/), { target: { value: 'DIAGONAL' } })
await avanzar() // Vivienda → Documentos
// Documentos → Firmas: dispara el submit final del alta
// Documentos → Firmas: ya NO registra el alta (se registra tras firmar)
await avanzar()
expect(llamadasA('/clientes/progreso/prog-1/completar', 'POST')).toHaveLength(0)
// El registro se dispara con el botón del paso de firmas
fireEvent.click(await screen.findByRole('button', { name: /Registrar alta/ }))
await waitFor(() => expect(llamadasA('/clientes/progreso/prog-1/completar', 'POST')).toHaveLength(1))
// NO se usó el endpoint legado
expect(

View File

@@ -115,6 +115,12 @@ export default function AltaClientePage() {
const [registro, setRegistro] = useState<string>('')
const [sinConexion, setSinConexion] = useState(false)
const [errorValidacion, setErrorValidacion] = useState<string>('')
/** Falta un documento obligatorio (p. ej. INE) al avanzar del paso Documentos */
const [errorDocumentos, setErrorDocumentos] = useState('')
/** Documentos que no se pudieron subir tras registrar el alta */
const [avisoDocumentos, setAvisoDocumentos] = useState('')
/** El alta se está registrando en el servidor */
const [registrandoAlta, setRegistrandoAlta] = useState(false)
// ---------- Borradores ----------
type Pantalla = 'cargando' | 'entrada' | 'picker' | 'afluencia' | 'wizard'
@@ -327,15 +333,26 @@ export default function AltaClientePage() {
// responde, el flujo continúa solo local (los PDFs se generan igual). Con
// guardado progresivo activo se cierra por el endpoint de progreso.
const registrarAlta = async () => {
setRegistrandoAlta(true)
const res = datos.progresoId
? await completarProgreso(datos.progresoId, alta)
: await crearAltaCliente(alta)
toast.success("Alta de cliente completada")
if (res.ok) {
await subirDocumentos(res.id, alta)
const { fallidos } = await subirDocumentos(res.id, alta)
setRegistro(res.id)
setSinConexion(false)
setErrorValidacion('')
// El alta quedó registrada, pero algunos archivos no subieron: se avisa
// con el detalle para que el asesor los vuelva a adjuntar.
if (fallidos.length > 0) {
setAvisoDocumentos(
`El alta se registró, pero no se pudieron subir ${fallidos.length} documento(s): ` +
`${fallidos.map((f) => f.nombre).join(', ')}. Vuelve a adjuntarlos desde el expediente del cliente.`,
)
} else {
setAvisoDocumentos('')
}
// El alta ya quedó registrada: el borrador usado ya no hace falta
if (borradorId) {
eliminarBorrador(borradorId).catch(() => undefined)
@@ -350,6 +367,7 @@ export default function AltaClientePage() {
setSinConexion(true)
setErrorValidacion('')
}
setRegistrandoAlta(false)
}
// Pantalla de éxito tras registrar el alta (en lugar del redirect automático a Mi día)
@@ -559,6 +577,15 @@ export default function AltaClientePage() {
</div>
</div>
{avisoDocumentos !== '' && (
<p
role="alert"
className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800"
>
{avisoDocumentos}
</p>
)}
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<button
type="button"
@@ -747,6 +774,14 @@ export default function AltaClientePage() {
)}
{paso === 6 && (
<>
{errorDocumentos !== '' && (
<p
role="alert"
className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
>
{errorDocumentos}
</p>
)}
{cargoDeBorrador && (
<p
role="note"
@@ -767,7 +802,14 @@ export default function AltaClientePage() {
}
onAnterior={anterior}
onSiguiente={() => {
void registrarAlta()
// La INE es obligatoria en el expediente: se avisa al pasar
// de paso (el asesor puede adjuntarla después o por la liga
// de firma), y el paso de firmas muestra lo que falte.
setErrorDocumentos(
alta.documentos.some((d) => d.slot === 'INE')
? ''
: 'Recuerda adjuntar la INE del cliente: es un documento obligatorio del expediente.',
)
siguiente()
guardarAvance(datos, paso + 1)
}}
@@ -804,6 +846,8 @@ export default function AltaClientePage() {
alta={alta}
onCambiarFirma={(f) => actualizar('firma', f)}
onAnterior={anterior}
onRegistrar={() => void registrarAlta()}
registrando={registrandoAlta}
/>
</TarjetaPaso>
</>

View File

@@ -54,7 +54,7 @@ describe('services/api', () => {
expect(resultado).toEqual({ ok: true, id: 'srv-123' })
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('http://192.168.10.123:3000/api/clientes')
expect(url).toBe('/api/clientes')
expect(init.method).toBe('POST')
expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
@@ -119,7 +119,7 @@ describe('services/api', () => {
expect(fetchMock).toHaveBeenCalledTimes(2)
const [url1, init1] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url1).toBe('http://192.168.10.123:3000/api/clientes/srv-123/documentos')
expect(url1).toBe('/api/clientes/srv-123/documentos')
expect(init1.method).toBe('POST')
// Sin Content-Type manual: el navegador pone el boundary del multipart
expect(init1.headers).toBeUndefined()
@@ -143,11 +143,13 @@ describe('services/api', () => {
expect(fetchMock).not.toHaveBeenCalled()
})
it('ante un fallo de red avisa y continúa sin lanzar', async () => {
it('ante un fallo de red avisa y devuelve los documentos que no subieron', async () => {
fetchMock.mockRejectedValue(new TypeError('fetch failed'))
await expect(subirDocumentos('srv-123', altaConDocumentos())).resolves.toBeUndefined()
expect(warnSpy).toHaveBeenCalledTimes(2)
// No lanza: el alta ya quedó registrada; se reportan los fallos al usuario
const { fallidos } = await subirDocumentos('srv-123', altaConDocumentos())
expect(fallidos.length).toBeGreaterThanOrEqual(1)
expect(warnSpy).toHaveBeenCalled()
})
})
})

View File

@@ -126,10 +126,17 @@ export async function completarProgreso(id: string, payload: AltaCliente): Promi
* Sube los documentos reales del expediente (POST multipart
* /clientes/:id/documentos, campo 'archivos'). El backend acepta un solo
* `tipo` por petición, así que se envía una petición por documento con su
* slot como tipo. Los fallos se reportan con console.warn sin interrumpir
* el flujo local.
* slot como tipo.
*
* Devuelve los documentos que NO se pudieron subir: el alta ya quedó
* registrada, así que el usuario debe enterarse para reintentar (antes se
* perdían en silencio).
*/
export async function subirDocumentos(clienteId: string, payload: AltaCliente): Promise<void> {
export async function subirDocumentos(
clienteId: string,
payload: AltaCliente,
): Promise<{ fallidos: { slot: string; nombre: string }[] }> {
const fallidos: { slot: string; nombre: string }[] = []
for (const doc of payload.documentos) {
try {
const form = new FormData()
@@ -146,8 +153,10 @@ export async function subirDocumentos(clienteId: string, payload: AltaCliente):
if (!res.ok) throw new Error(`HTTP ${res.status}`)
} catch (err) {
console.warn(`[api] subirDocumentos: no se pudo subir ${doc.nombre} (${doc.slot})`, err)
fallidos.push({ slot: doc.slot, nombre: doc.nombre })
}
}
return { fallidos }
}
// ---------- Borradores del alta (auth) ----------
@@ -264,6 +273,7 @@ export interface LeadCreado {
export type ResultadoCrearLead =
| { ok: true; lead: LeadCreado }
| { ok: false; motivo: 'duplicado'; leadId: string; nombre: string }
| { ok: false; motivo: 'validacion'; detalle: string }
| { ok: false; motivo: 'conexion' }
/**
@@ -308,13 +318,25 @@ export async function crearAfluencia(payload: {
return { ok: false, motivo: 'conexion' }
}
manejar401(res)
if (res.status === 201) {
// 201 = afluencia nueva; 200 = actualización de una afluencia existente
if (res.status === 201 || res.status === 200) {
return { ok: true, lead: (await res.json()) as LeadCreado }
}
if (res.status === 409) {
const cuerpo = (await res.json()) as { leadId: string; nombre: string }
return { ok: false, motivo: 'duplicado', leadId: cuerpo.leadId, nombre: cuerpo.nombre }
}
if (res.status === 400) {
// Validación del servidor: se muestra el motivo real, no "sin conexión"
const cuerpo = (await res.json().catch(() => null)) as
| { error?: string; issues?: { message?: string; path?: (string | number)[] }[] }
| null
const detalle =
cuerpo?.issues?.map((i) => `${i.path?.join('.') ?? ''}: ${i.message ?? ''}`).filter(Boolean).join(' · ') ||
cuerpo?.error ||
'Datos incompletos o inválidos'
return { ok: false, motivo: 'validacion', detalle }
}
console.warn(`[api] crearAfluencia: HTTP ${res.status}`)
return { ok: false, motivo: 'conexion' }
}