feat(alta): guardado progresivo por etapa con réplica a Monday
- Al avanzar cada paso del wizard: upsert silencioso del borrador local + sync a Monday (iniciar → Contacto en PROSPECTO; pasos siguientes → PATCH con los campos mapeables: datos generales, dirección armada, actividad económica) - Al completar: Contacto pasa a CLIENTE + Apartado + funnel (dedup) - progresoId viaja con el borrador (se restaura solo); fallos de sync no bloquean el avance (aviso ámbar no bloqueante); submit final con fallback al endpoint legado si nunca hubo sync - API: POST /clientes/progreso/iniciar, PATCH /clientes/progreso/:id, POST /clientes/progreso/:id/completar; POST /clientes legado intacto - Tests: API 338 (+16), alta-clientes 112 (+4); smoke en vivo verificado (PROSPECTO → patch → CLIENTE + Apartado) y limpiado
This commit is contained in:
@@ -58,6 +58,7 @@ const economicosVacios = (): AltaCliente['economicos'] => ({
|
||||
|
||||
const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
|
||||
leadMondayId: null,
|
||||
progresoId: null,
|
||||
datosGenerales: datosGeneralesVacios(),
|
||||
empleo: empleoVacio(),
|
||||
economicos: economicosVacios(),
|
||||
|
||||
@@ -10,6 +10,7 @@ export const FIRMA_PRUEBA =
|
||||
*/
|
||||
export const fixtureAlta: AltaCliente = {
|
||||
leadMondayId: null,
|
||||
progresoId: null,
|
||||
datosGenerales: {
|
||||
campaniaVenta: '',
|
||||
fecha: '2026-07-19',
|
||||
|
||||
151
apps/alta-clientes/src/pages/AltaClientePage.progreso.test.tsx
Normal file
151
apps/alta-clientes/src/pages/AltaClientePage.progreso.test.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
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> }))
|
||||
|
||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
const respuesta = (data: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
|
||||
|
||||
const llamadasA = (fragmento: string, metodo?: string) =>
|
||||
fetchMock.mock.calls.filter(
|
||||
([input, init]) => String(input).includes(fragmento) && (metodo === undefined || (init?.method ?? 'GET') === metodo),
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<FetchFn>().mockImplementation((input, init) => {
|
||||
const url = String(input)
|
||||
const metodo = init?.method ?? 'GET'
|
||||
if (url.includes('/borradores') && metodo === 'GET') return Promise.resolve(respuesta([]))
|
||||
if (url.includes('/borradores') && metodo === 'POST') {
|
||||
return Promise.resolve(respuesta({ id: 'b-1', nombre: 'B', paso: 1, datosJson: '{}' }), )
|
||||
}
|
||||
if (url.includes('/borradores') && metodo === 'DELETE') return Promise.resolve(respuesta({}))
|
||||
if (url.endsWith('/clientes/progreso/iniciar')) {
|
||||
return Promise.resolve(respuesta({ id: 'prog-1', mondayContactoId: 'cont-1' }, 201))
|
||||
}
|
||||
if (url.includes('/clientes/progreso/prog-1/completar')) return Promise.resolve(respuesta({ id: 'prog-1' }, 201))
|
||||
if (url.includes('/clientes/progreso/prog-1') && metodo === 'PATCH') {
|
||||
return Promise.resolve(respuesta({ id: 'prog-1', mondaySync: true }))
|
||||
}
|
||||
if (url.endsWith('/clientes') && metodo === 'POST') return Promise.resolve(respuesta({ id: 'cli-directo' }, 201))
|
||||
return Promise.reject(new Error(`fetch inesperado: ${metodo} ${url}`))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const entrarAlWizard = async () => {
|
||||
render(<AltaClientePage />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Alta de Cliente/ }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Empezar nuevo' }))
|
||||
}
|
||||
|
||||
const llenarPaso0 = async () => {
|
||||
fireEvent.change(await screen.findByLabelText(/Nombre completo/), { target: { value: 'Ana Demo Progreso' } })
|
||||
fireEvent.change(screen.getByLabelText(/^CURP/), { target: { value: 'AEDA900101MBCXXX00' } })
|
||||
fireEvent.change(screen.getByLabelText(/^RFC/), { target: { value: 'AEDA900101XX0' } })
|
||||
fireEvent.change(screen.getByLabelText(/^Email/), { target: { value: 'ana@correo.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/Celular/), { target: { value: '6649990003' } })
|
||||
fireEvent.change(screen.getByLabelText(/Fecha de nacimiento/), { target: { value: '1990-01-01' } })
|
||||
}
|
||||
|
||||
const avanzar = async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Siguiente' }))
|
||||
await act(async () => {})
|
||||
}
|
||||
|
||||
describe('AltaClientePage — guardado progresivo (etapa por etapa)', () => {
|
||||
it('al avanzar del paso 0 con nombre inicia el progreso (Contacto PROSPECTO)', async () => {
|
||||
await entrarAlWizard()
|
||||
await llenarPaso0()
|
||||
await avanzar()
|
||||
|
||||
// Avanzó al paso Empleo y subió el inicio del progreso con datosGenerales
|
||||
expect(await screen.findByLabelText(/Tipo de empleo/)).toBeTruthy()
|
||||
await waitFor(() => expect(llamadasA('/clientes/progreso/iniciar', 'POST')).toHaveLength(1))
|
||||
const cuerpo = JSON.parse(String(llamadasA('/clientes/progreso/iniciar', 'POST')[0]?.[1]?.body)) as {
|
||||
datosGenerales: { nombreCompleto: string }
|
||||
}
|
||||
expect(cuerpo.datosGenerales.nombreCompleto).toBe('Ana Demo Progreso')
|
||||
})
|
||||
|
||||
it('los pasos siguientes hacen PATCH del progreso ya iniciado', async () => {
|
||||
await entrarAlWizard()
|
||||
await llenarPaso0()
|
||||
await avanzar()
|
||||
await screen.findByLabelText(/Tipo de empleo/)
|
||||
await waitFor(() => expect(llamadasA('/clientes/progreso/iniciar', 'POST')).toHaveLength(1))
|
||||
|
||||
await avanzar() // Empleo → Económicos
|
||||
await waitFor(() => expect(llamadasA('/clientes/progreso/prog-1', 'PATCH')).toHaveLength(1))
|
||||
// No se duplica el inicio
|
||||
expect(llamadasA('/clientes/progreso/iniciar', 'POST')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('si el sync falla, el avance no se bloquea y solo queda un aviso', async () => {
|
||||
fetchMock.mockImplementation((input, init) => {
|
||||
const url = String(input)
|
||||
const metodo = init?.method ?? 'GET'
|
||||
if (url.includes('/borradores') && metodo === 'GET') return Promise.resolve(respuesta([]))
|
||||
if (url.includes('/borradores') && metodo === 'POST') {
|
||||
return Promise.resolve(respuesta({ id: 'b-1', nombre: 'B', paso: 1, datosJson: '{}' }))
|
||||
}
|
||||
if (url.endsWith('/clientes/progreso/iniciar')) return Promise.reject(new Error('API caída'))
|
||||
return Promise.reject(new Error(`fetch inesperado: ${metodo} ${url}`))
|
||||
})
|
||||
|
||||
await entrarAlWizard()
|
||||
await llenarPaso0()
|
||||
await avanzar()
|
||||
|
||||
// Avanzó igual al paso Empleo y solo queda el aviso sutil
|
||||
expect(await screen.findByLabelText(/Tipo de empleo/)).toBeTruthy()
|
||||
await waitFor(() => {
|
||||
const avisos = screen.getAllByRole('status').map((el) => el.textContent ?? '')
|
||||
expect(avisos.some((a) => a.includes('El avance quedó guardado local'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('el submit final usa el endpoint progresivo cuando hay progresoId', async () => {
|
||||
await entrarAlWizard()
|
||||
await llenarPaso0()
|
||||
await avanzar()
|
||||
await screen.findByLabelText(/Tipo de empleo/)
|
||||
await waitFor(() => expect(llamadasA('/clientes/progreso/iniciar', 'POST')).toHaveLength(1))
|
||||
|
||||
await avanzar() // Empleo → Económicos
|
||||
await screen.findByLabelText(/Ingresos mensuales/)
|
||||
await avanzar() // Económicos → Co-acreditado
|
||||
await screen.findByLabelText(/co-acreditado o cónyuge/)
|
||||
await avanzar() // Co-acreditado → Referencias
|
||||
// Referencias exige al menos 1 con nombre y teléfono
|
||||
fireEvent.change((await screen.findAllByLabelText(/^Nombre completo/))[0]!, { target: { value: 'Ref Uno' } })
|
||||
fireEvent.change(screen.getAllByLabelText(/^Teléfono/)[0]!, { target: { value: '6641112233' } })
|
||||
await avanzar() // Referencias → Vivienda y crédito
|
||||
fireEvent.change(await screen.findByLabelText(/^Proyecto/), { target: { value: 'DIAGONAL' } })
|
||||
await avanzar() // Vivienda → Documentos
|
||||
|
||||
// Documentos → Firmas: dispara el submit final del alta
|
||||
await avanzar()
|
||||
await waitFor(() => expect(llamadasA('/clientes/progreso/prog-1/completar', 'POST')).toHaveLength(1))
|
||||
// NO se usó el endpoint legado
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
([input, init]) => String(input).endsWith('/clientes') && (init?.method ?? '') === 'POST',
|
||||
),
|
||||
).toHaveLength(0)
|
||||
// El flujo posterior queda idéntico: alta registrada con el id del progreso
|
||||
expect(await screen.findByText(/Alta registrada en el servidor: prog-1/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Stepper from '../components/Stepper'
|
||||
import DatosGeneralesForm from '../forms/DatosGeneralesForm'
|
||||
import EmpleoForm from '../forms/EmpleoForm'
|
||||
@@ -10,10 +10,13 @@ import DocumentosForm from '../forms/DocumentosForm'
|
||||
import FirmasForm from '../forms/FirmasForm'
|
||||
import { useAltaCliente, PASOS } from '../hooks/useAltaCliente'
|
||||
import {
|
||||
actualizarProgreso,
|
||||
completarProgreso,
|
||||
crearAltaCliente,
|
||||
eliminarBorrador,
|
||||
generarLigaRemota,
|
||||
guardarBorrador,
|
||||
iniciarProgreso,
|
||||
listarBorradores,
|
||||
obtenerBorrador,
|
||||
obtenerCotizacion,
|
||||
@@ -61,6 +64,7 @@ export default function AltaClientePage() {
|
||||
const [borradores, setBorradores] = useState<BorradorResumen[]>([])
|
||||
const [borradorId, setBorradorId] = useState<string | null>(null)
|
||||
const [avisoBorrador, setAvisoBorrador] = useState('')
|
||||
const [avisoProgreso, setAvisoProgreso] = useState('')
|
||||
const [cargoDeBorrador, setCargoDeBorrador] = useState(false)
|
||||
const [errorCarga, setErrorCarga] = useState('')
|
||||
|
||||
@@ -147,10 +151,54 @@ export default function AltaClientePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Guardado progresivo (borrador local + Contacto en Monday) ----------
|
||||
// Guard in-flight: con clicks rápidos se omite el sync en curso; el siguiente
|
||||
// avance manda el estado completo (el PATCH es idempotente).
|
||||
const syncEnCurso = useRef(false)
|
||||
|
||||
const syncProgreso = async (datosSync: typeof datos) => {
|
||||
if (syncEnCurso.current) return
|
||||
if (datosSync.datosGenerales.nombreCompleto.trim() === '') return
|
||||
syncEnCurso.current = true
|
||||
try {
|
||||
if (datosSync.progresoId) {
|
||||
await actualizarProgreso(datosSync.progresoId, datosSync)
|
||||
} else {
|
||||
const r = await iniciarProgreso({
|
||||
datosGenerales: datosSync.datosGenerales,
|
||||
...(datosSync.leadMondayId ? { leadMondayId: datosSync.leadMondayId } : {}),
|
||||
})
|
||||
actualizar('progresoId', r.id)
|
||||
}
|
||||
setAvisoProgreso('')
|
||||
} catch (err) {
|
||||
console.warn('[alta] no se pudo sincronizar el avance con el servidor', err)
|
||||
setAvisoProgreso('El avance quedó guardado local; no se pudo subir a Monday')
|
||||
} finally {
|
||||
syncEnCurso.current = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Al avanzar de paso: guarda el borrador en silencio y sube el avance a Monday */
|
||||
const guardarAvance = (datosNuevos: typeof datos, pasoDestino: number) => {
|
||||
guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso: pasoDestino, datos: datosNuevos })
|
||||
.then((b) => {
|
||||
setBorradorId(b.id)
|
||||
setAvisoBorrador(
|
||||
`Borrador guardado ${new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })}`,
|
||||
)
|
||||
})
|
||||
.catch(() => setAvisoBorrador('No se pudo guardar el borrador'))
|
||||
void syncProgreso(datosNuevos)
|
||||
}
|
||||
|
||||
// Registra el alta en el API al llegar al paso de firmas; si el API no
|
||||
// responde, el flujo continúa solo local (los PDFs se generan igual)
|
||||
// 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 () => {
|
||||
const res = await crearAltaCliente(alta)
|
||||
const res = datos.progresoId
|
||||
? await completarProgreso(datos.progresoId, alta)
|
||||
: await crearAltaCliente(alta)
|
||||
if (res.ok) {
|
||||
await subirDocumentos(res.id, alta)
|
||||
setRegistro(res.id)
|
||||
@@ -337,6 +385,11 @@ export default function AltaClientePage() {
|
||||
{avisoBorrador}
|
||||
</span>
|
||||
)}
|
||||
{avisoProgreso && (
|
||||
<span role="status" className="text-xs font-light text-amber-700">
|
||||
{avisoProgreso}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void guardar()}
|
||||
@@ -364,6 +417,7 @@ export default function AltaClientePage() {
|
||||
onSiguiente={(v) => {
|
||||
actualizar('datosGenerales', v)
|
||||
siguiente()
|
||||
guardarAvance({ ...datos, datosGenerales: v }, paso + 1)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -374,6 +428,7 @@ export default function AltaClientePage() {
|
||||
onSiguiente={(v) => {
|
||||
actualizar('empleo', v)
|
||||
siguiente()
|
||||
guardarAvance({ ...datos, empleo: v }, paso + 1)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -384,6 +439,7 @@ export default function AltaClientePage() {
|
||||
onSiguiente={(v) => {
|
||||
actualizar('economicos', v)
|
||||
siguiente()
|
||||
guardarAvance({ ...datos, economicos: v }, paso + 1)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -394,6 +450,7 @@ export default function AltaClientePage() {
|
||||
onSiguiente={(v) => {
|
||||
actualizar('coacreditado', v)
|
||||
siguiente()
|
||||
guardarAvance({ ...datos, coacreditado: v }, paso + 1)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -404,6 +461,7 @@ export default function AltaClientePage() {
|
||||
onSiguiente={(v) => {
|
||||
actualizar('referencias', v)
|
||||
siguiente()
|
||||
guardarAvance({ ...datos, referencias: v }, paso + 1)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -414,6 +472,7 @@ export default function AltaClientePage() {
|
||||
onSiguiente={(v) => {
|
||||
actualizar('viviendaCredito', v)
|
||||
siguiente()
|
||||
guardarAvance({ ...datos, viviendaCredito: v }, paso + 1)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -440,6 +499,7 @@ export default function AltaClientePage() {
|
||||
onSiguiente={() => {
|
||||
void registrarAlta()
|
||||
siguiente()
|
||||
guardarAvance(datos, paso + 1)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -32,16 +32,16 @@ const serializarAlta = (payload: AltaCliente) => ({
|
||||
})
|
||||
|
||||
/**
|
||||
* Registra el alta en el API Gateway (POST /clientes). El backend persiste en
|
||||
* SQLite y replica en Monday. Si el API no responde devuelve motivo 'conexion'
|
||||
* y el flujo continúa solo local: los PDFs se generan igual. Si el API rechaza
|
||||
* por validación (400) devuelve motivo 'validacion' con el detalle.
|
||||
* POST JSON del alta contra `ruta` (`/clientes` o `/clientes/progreso/:id/completar`).
|
||||
* Si el API no responde devuelve motivo 'conexion' y el flujo continúa solo
|
||||
* local: los PDFs se generan igual. Si el API rechaza por validación (400)
|
||||
* devuelve motivo 'validacion' con el detalle.
|
||||
*/
|
||||
export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoAlta> {
|
||||
async function enviarAlta(ruta: string, payload: AltaCliente): Promise<ResultadoAlta> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(
|
||||
`${API_URL}/clientes`,
|
||||
`${API_URL}${ruta}`,
|
||||
conAuth({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -49,7 +49,7 @@ export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoA
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn('[api] crearAltaCliente: sin conexión al servidor, el expediente se genera solo localmente', err)
|
||||
console.warn('[api] alta: sin conexión al servidor, el expediente se genera solo localmente', err)
|
||||
return { ok: false, motivo: 'conexion' }
|
||||
}
|
||||
manejar401(res)
|
||||
@@ -63,13 +63,65 @@ export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoA
|
||||
issues?: { path: (string | number)[]; message: string }[]
|
||||
} | null
|
||||
const detalle = cuerpo?.issues?.map((i) => `${i.path.join('.')}: ${i.message}`).join(' · ')
|
||||
console.warn('[api] crearAltaCliente: validación rechazada', detalle)
|
||||
console.warn('[api] alta: validación rechazada', detalle)
|
||||
return { ok: false, motivo: 'validacion', detalle: detalle ?? 'Datos incompletos o inválidos' }
|
||||
}
|
||||
console.warn(`[api] crearAltaCliente: HTTP ${res.status}`)
|
||||
console.warn(`[api] alta: HTTP ${res.status}`)
|
||||
return { ok: false, motivo: 'conexion' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra el alta en el API Gateway (POST /clientes). El backend persiste en
|
||||
* SQLite y replica en Monday.
|
||||
*/
|
||||
export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoAlta> {
|
||||
return enviarAlta('/clientes', payload)
|
||||
}
|
||||
|
||||
// ---------- Progreso del alta etapa por etapa (auth) ----------
|
||||
|
||||
export interface ProgresoIniciado {
|
||||
id: string
|
||||
mondayContactoId: string | null
|
||||
}
|
||||
|
||||
/** POST /clientes/progreso/iniciar — crea el alta en progreso (Contacto PROSPECTO en Monday) */
|
||||
export async function iniciarProgreso(payload: {
|
||||
datosGenerales: unknown
|
||||
leadMondayId?: string
|
||||
}): Promise<ProgresoIniciado> {
|
||||
const res = await fetch(
|
||||
`${API_URL}/clientes/progreso/iniciar`,
|
||||
conAuth({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
)
|
||||
manejar401(res)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as ProgresoIniciado
|
||||
}
|
||||
|
||||
/** PATCH /clientes/progreso/:id — guarda el avance del wizard y lo replica en el Contacto */
|
||||
export async function actualizarProgreso(id: string, datos: unknown): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${API_URL}/clientes/progreso/${id}`,
|
||||
conAuth({
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(datos),
|
||||
}),
|
||||
)
|
||||
manejar401(res)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
/** POST /clientes/progreso/:id/completar — cierra el alta (mismo contrato que POST /clientes) */
|
||||
export async function completarProgreso(id: string, payload: AltaCliente): Promise<ResultadoAlta> {
|
||||
return enviarAlta(`/clientes/progreso/${id}/completar`, payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sube los documentos reales del expediente (POST multipart
|
||||
* /clientes/:id/documentos, campo 'archivos'). El backend acepta un solo
|
||||
|
||||
@@ -149,6 +149,9 @@ export interface AltaCliente {
|
||||
// ID del item del tablero Leads de Monday cuando el alta se prellenó desde
|
||||
// un lead (null = captura manual). Passthrough hasta payloadJson del API.
|
||||
leadMondayId: string | null
|
||||
// ID del Cliente creado por el guardado progresivo (POST /clientes/progreso/
|
||||
// iniciar); viaja con el borrador (datosJson) y no se captura en el formato.
|
||||
progresoId: string | null
|
||||
datosGenerales: DatosGenerales
|
||||
empleo: DatosEmpleo
|
||||
economicos: DatosEconomicos
|
||||
|
||||
Reference in New Issue
Block a user