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:
@@ -39,11 +39,40 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('AltaClientePage — pantalla de entrada', () => {
|
||||
it('muestra las dos opciones de alta', async () => {
|
||||
render(<AltaClientePage />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Alta Afluencia/ })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: /Alta de Cliente/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('"Alta de Cliente" navega al picker (borradores + búsqueda de lead)', async () => {
|
||||
render(<AltaClientePage />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Alta de Cliente/ }))
|
||||
|
||||
expect(await screen.findByLabelText(/Buscar lead/)).toBeTruthy()
|
||||
expect(screen.getByText('Borrador previo')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('"Alta Afluencia" muestra el formulario y "Volver" regresa a la entrada', async () => {
|
||||
render(<AltaClientePage />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Alta Afluencia/ }))
|
||||
expect(await screen.findByLabelText(/^Nombre/)).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Volver' }))
|
||||
expect(await screen.findByRole('button', { name: /Alta de Cliente/ })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AltaClientePage — alta desde lead de Monday', () => {
|
||||
it('al elegir un lead el wizard arranca con datosGenerales prellenados', async () => {
|
||||
render(<AltaClientePage />)
|
||||
|
||||
// Picker visible con la sección de búsqueda
|
||||
// Entrada → Alta de Cliente → picker con la sección de búsqueda
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Alta de Cliente/ }))
|
||||
const busqueda = await screen.findByLabelText(/Buscar lead/)
|
||||
fireEvent.change(busqueda, { target: { value: 'maria' } })
|
||||
fireEvent.click(await screen.findByText('María Fernanda López'))
|
||||
@@ -55,3 +84,36 @@ describe('AltaClientePage — alta desde lead de Monday', () => {
|
||||
expect((screen.getByLabelText(/Celular/i) as HTMLInputElement).value).toBe('4771234567')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AltaClientePage — alta desde cotización (?cotizacion=<id>)', () => {
|
||||
it('se salta la entrada y va directo al wizard prellenado', async () => {
|
||||
window.history.pushState({}, '', '/?cotizacion=cot-1')
|
||||
fetchMock.mockImplementation((input) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/cotizaciones/cot-1')) {
|
||||
return Promise.resolve(
|
||||
respuestaOk({
|
||||
clienteNombre: 'Cliente Prueba',
|
||||
clienteEmail: 'cliente@correo.com',
|
||||
clienteTelefono: '6645550000',
|
||||
desarrollo: 'SENDA',
|
||||
unidadCodigo: 'U-101',
|
||||
precioFinal: 1500000,
|
||||
enganche: 150000,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Promise.reject(new Error(`fetch inesperado: ${url}`))
|
||||
})
|
||||
try {
|
||||
render(<AltaClientePage />)
|
||||
|
||||
// Wizard directo, sin pasar por la entrada
|
||||
const nombre = (await screen.findByLabelText(/Nombre completo/)) as HTMLInputElement
|
||||
expect(nombre.value).toBe('CLIENTE PRUEBA')
|
||||
expect(screen.queryByRole('button', { name: /Alta Afluencia/ })).toBeNull()
|
||||
} finally {
|
||||
window.history.pushState({}, '', '/')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { cotizacionAPrellenado } from '../lib/cotizacionAlta'
|
||||
import { leadAPrellenado } from '../lib/leadAlta'
|
||||
import BusquedaLead from '../components/BusquedaLead'
|
||||
import AltaAfluenciaForm from '../components/AltaAfluenciaForm'
|
||||
|
||||
/** Lee ?cotizacion=<id> del query string o del hash (#cotizacion=<id>) */
|
||||
function paramCotizacion(): string | null {
|
||||
@@ -55,7 +56,7 @@ export default function AltaClientePage() {
|
||||
const [errorValidacion, setErrorValidacion] = useState<string>('')
|
||||
|
||||
// ---------- Borradores ----------
|
||||
type Pantalla = 'cargando' | 'picker' | 'wizard'
|
||||
type Pantalla = 'cargando' | 'entrada' | 'picker' | 'afluencia' | 'wizard'
|
||||
const [pantalla, setPantalla] = useState<Pantalla>('cargando')
|
||||
const [borradores, setBorradores] = useState<BorradorResumen[]>([])
|
||||
const [borradorId, setBorradorId] = useState<string | null>(null)
|
||||
@@ -68,7 +69,8 @@ export default function AltaClientePage() {
|
||||
const [ligaCopiada, setLigaCopiada] = useState(false)
|
||||
const [errorLiga, setErrorLiga] = useState('')
|
||||
|
||||
// Al entrar: ?cotizacion=<id> prellena el alta; si no, ofrece borradores
|
||||
// Al entrar: ?cotizacion=<id> prellena el alta y salta directo al wizard;
|
||||
// si no, la entrada ofrece Alta Afluencia (captura ligera) y Alta de Cliente
|
||||
useEffect(() => {
|
||||
const cotizacionId = paramCotizacion()
|
||||
if (cotizacionId) {
|
||||
@@ -85,11 +87,11 @@ export default function AltaClientePage() {
|
||||
listarBorradores()
|
||||
.then((lista) => {
|
||||
setBorradores(lista)
|
||||
// El picker es siempre la entrada: además de los borradores ofrece
|
||||
// "Empezar nuevo" y la búsqueda de leads de Monday.
|
||||
setPantalla('picker')
|
||||
setPantalla('entrada')
|
||||
})
|
||||
.catch(() => setPantalla('wizard')) // sin conexión: el wizard funciona igual
|
||||
// Sin conexión: la entrada no depende de los borradores y conserva la
|
||||
// opción de afluencia (el alta de cliente funcionará cuando vuelva la red)
|
||||
.catch(() => setPantalla('entrada'))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
@@ -196,6 +198,59 @@ export default function AltaClientePage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (pantalla === 'entrada') {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<header className="border-b border-neutral-200 px-4 py-6">
|
||||
<div className="mx-auto flex max-w-6xl items-baseline gap-3">
|
||||
<span className="text-sm font-bold uppercase tracking-widest">FRASA</span>
|
||||
<h1 className="text-2xl font-light tracking-wide">Alta de Clientes</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl px-4 py-16">
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPantalla('afluencia')}
|
||||
className="border border-neutral-200 bg-white p-8 text-left transition-colors duration-150 hover:border-accent"
|
||||
>
|
||||
<h2 className="text-lg font-light tracking-wide text-neutral-900">Alta Afluencia</h2>
|
||||
<p className="mt-2 text-xs font-light text-neutral-500">
|
||||
Captura rápida del interesado; entra al embudo de ventas.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPantalla('picker')}
|
||||
className="border border-neutral-200 bg-white p-8 text-left transition-colors duration-150 hover:border-accent"
|
||||
>
|
||||
<h2 className="text-lg font-light tracking-wide text-neutral-900">Alta de Cliente</h2>
|
||||
<p className="mt-2 text-xs font-light text-neutral-500">
|
||||
Formato completo para quien aparta.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pantalla === 'afluencia') {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<header className="border-b border-neutral-200 px-4 py-6">
|
||||
<div className="mx-auto flex max-w-6xl items-baseline gap-3">
|
||||
<span className="text-sm font-bold uppercase tracking-widest">FRASA</span>
|
||||
<h1 className="text-2xl font-light tracking-wide">Alta Afluencia</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl px-4 py-16">
|
||||
<AltaAfluenciaForm onVolver={() => setPantalla('entrada')} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pantalla === 'picker') {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
|
||||
Reference in New Issue
Block a user