feat: vínculo con usuario Monday en landing + alta prellenada desde leads
API: - GET /api/monday/usuarios: usuarios activos no-guest del workspace - PATCH /api/usuarios/me/monday: vincula/desvincula el mondayUserId del usuario autenticado (valida contra el workspace) - GET /api/leads/buscar?q=: búsqueda de leads por nombre/teléfono/email (sin acentos, orden updatedAt desc, límite 15) - aPublico/me ahora incluyen mondayUserId Landing (/): - Nueva sección 'Configuración de cuenta': login inline, selector del usuario de Monday, guardar/desvincular, cerrar sesión Alta de clientes (/client): - Búsqueda de leads de Monday con debounce en la pantalla de entrada; al elegir uno prellena nombre/email/celular y guarda leadMondayId - El picker (borradores + alta nueva + búsqueda de lead) es ahora siempre la pantalla de entrada Tests: api 294, landing 8, alta-clientes 96 — todos verdes
This commit is contained in:
69
apps/alta-clientes/src/components/BusquedaLead.test.tsx
Normal file
69
apps/alta-clientes/src/components/BusquedaLead.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import BusquedaLead from './BusquedaLead'
|
||||
import type { LeadResumen } from '../services/api'
|
||||
|
||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
const LEADS: LeadResumen[] = [
|
||||
{ id: 'lead-1', nombre: 'MARÍA FERNANDA LÓPEZ', telefono: '4771234567', email: 'maria@correo.com', estado: 'NUEVO', origen: 'Web' },
|
||||
{ id: 'lead-2', nombre: 'Juan Pérez', telefono: '', email: 'juan@correo.com', estado: 'CONTACTADO', origen: '' },
|
||||
]
|
||||
|
||||
const respuestaOk = (data: unknown) =>
|
||||
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<FetchFn>().mockResolvedValue(respuestaOk(LEADS))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const llamadasBusqueda = () => fetchMock.mock.calls.filter(([url]) => String(url).includes('/leads/buscar'))
|
||||
|
||||
describe('BusquedaLead', () => {
|
||||
it('con menos de 2 caracteres no llama al API', async () => {
|
||||
render(<BusquedaLead onSeleccion={() => undefined} />)
|
||||
fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'm' } })
|
||||
await new Promise((r) => setTimeout(r, 400)) // más que el debounce
|
||||
expect(llamadasBusqueda()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renderiza los resultados de la búsqueda (nombre, teléfono, estado)', async () => {
|
||||
render(<BusquedaLead onSeleccion={() => undefined} />)
|
||||
fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'maria' } })
|
||||
|
||||
expect(await screen.findByText('MARÍA FERNANDA LÓPEZ')).toBeTruthy()
|
||||
expect(screen.getByText(/4771234567 · NUEVO/)).toBeTruthy()
|
||||
expect(screen.getByText('Juan Pérez')).toBeTruthy()
|
||||
const [url] = llamadasBusqueda()[0] as [string]
|
||||
expect(url).toContain('/leads/buscar?q=maria')
|
||||
})
|
||||
|
||||
it('al elegir un resultado avisa con el lead completo', async () => {
|
||||
const onSeleccion = vi.fn()
|
||||
render(<BusquedaLead onSeleccion={onSeleccion} />)
|
||||
fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'maria' } })
|
||||
|
||||
fireEvent.click(await screen.findByText('MARÍA FERNANDA LÓPEZ'))
|
||||
expect(onSeleccion).toHaveBeenCalledWith(LEADS[0])
|
||||
})
|
||||
|
||||
it('muestra "sin resultados" y errores de red', async () => {
|
||||
render(<BusquedaLead onSeleccion={() => undefined} />)
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respuestaOk([]))
|
||||
fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'nadie' } })
|
||||
expect(await screen.findByText('Sin leads que coincidan.')).toBeTruthy()
|
||||
|
||||
fetchMock.mockRejectedValueOnce(new Error('red caída'))
|
||||
fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'otro' } })
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('No se pudo buscar en Monday')
|
||||
})
|
||||
})
|
||||
92
apps/alta-clientes/src/components/BusquedaLead.tsx
Normal file
92
apps/alta-clientes/src/components/BusquedaLead.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { buscarLeads, type LeadResumen } from '../services/api'
|
||||
|
||||
const DEBOUNCE_MS = 300
|
||||
|
||||
interface Props {
|
||||
onSeleccion: (lead: LeadResumen) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscador con debounce de leads de Monday (GET /leads/buscar). No llama al
|
||||
* API con menos de 2 caracteres; al elegir un resultado avisa con onSeleccion.
|
||||
*/
|
||||
export default function BusquedaLead({ onSeleccion }: Props) {
|
||||
const [q, setQ] = useState('')
|
||||
const [resultados, setResultados] = useState<LeadResumen[] | null>(null)
|
||||
const [buscando, setBuscando] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const texto = q.trim()
|
||||
if (texto.length < 2) {
|
||||
setResultados(null)
|
||||
setError('')
|
||||
setBuscando(false)
|
||||
return
|
||||
}
|
||||
setBuscando(true)
|
||||
const timer = setTimeout(() => {
|
||||
buscarLeads(texto)
|
||||
.then((lista) => {
|
||||
setResultados(lista)
|
||||
setError('')
|
||||
})
|
||||
.catch(() => {
|
||||
setResultados(null)
|
||||
setError('No se pudo buscar en Monday. Intenta de nuevo.')
|
||||
})
|
||||
.finally(() => setBuscando(false))
|
||||
}, DEBOUNCE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [q])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="busqueda-lead"
|
||||
className="mb-1 block text-xs font-bold uppercase tracking-widest text-neutral-500"
|
||||
>
|
||||
Buscar lead (nombre, teléfono o email)
|
||||
</label>
|
||||
<input
|
||||
id="busqueda-lead"
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Escribe al menos 2 caracteres…"
|
||||
className="w-full max-w-md border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-neutral-900"
|
||||
/>
|
||||
|
||||
{buscando && <p className="mt-3 text-xs font-light text-neutral-500">Buscando…</p>}
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 text-xs text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!buscando && !error && resultados !== null && resultados.length === 0 && (
|
||||
<p className="mt-3 text-xs font-light text-neutral-500">Sin leads que coincidan.</p>
|
||||
)}
|
||||
|
||||
{resultados !== null && resultados.length > 0 && (
|
||||
<ul className="mt-3 max-w-md divide-y divide-neutral-200 border border-neutral-200 bg-white">
|
||||
{resultados.map((lead) => (
|
||||
<li key={lead.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSeleccion(lead)}
|
||||
className="flex w-full flex-wrap items-baseline justify-between gap-2 px-4 py-3 text-left transition-colors duration-150 hover:bg-neutral-50"
|
||||
>
|
||||
<span className="text-sm text-neutral-900">{lead.nombre}</span>
|
||||
<span className="text-xs font-light text-neutral-500">
|
||||
{lead.telefono || 'sin teléfono'}
|
||||
{lead.estado ? ` · ${lead.estado}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -57,6 +57,7 @@ const economicosVacios = (): AltaCliente['economicos'] => ({
|
||||
})
|
||||
|
||||
const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
|
||||
leadMondayId: null,
|
||||
datosGenerales: datosGeneralesVacios(),
|
||||
empleo: empleoVacio(),
|
||||
economicos: economicosVacios(),
|
||||
|
||||
@@ -9,6 +9,7 @@ export const FIRMA_PRUEBA =
|
||||
* (PASCUAL MARTIN TELLEZ CHIGUINDO — pdf_formatos.txt)
|
||||
*/
|
||||
export const fixtureAlta: AltaCliente = {
|
||||
leadMondayId: null,
|
||||
datosGenerales: {
|
||||
campaniaVenta: '',
|
||||
fecha: '2026-07-19',
|
||||
|
||||
25
apps/alta-clientes/src/lib/leadAlta.test.ts
Normal file
25
apps/alta-clientes/src/lib/leadAlta.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { leadAPrellenado } from './leadAlta'
|
||||
|
||||
describe('leadAPrellenado', () => {
|
||||
it('prellena nombre (mayúsculas), email y celular cuando el lead los trae', () => {
|
||||
const pre = leadAPrellenado({ nombre: ' María Fernanda López ', email: 'maria@correo.com', telefono: '4771234567' })
|
||||
expect(pre).toEqual({
|
||||
nombreCompleto: 'MARÍA FERNANDA LÓPEZ',
|
||||
email: 'maria@correo.com',
|
||||
celular: '4771234567',
|
||||
})
|
||||
})
|
||||
|
||||
it('omite email y celular cuando el lead no los tiene (no inventa datos)', () => {
|
||||
expect(leadAPrellenado({ nombre: 'Lead Sin Contacto', email: '', telefono: '' })).toEqual({
|
||||
nombreCompleto: 'LEAD SIN CONTACTO',
|
||||
})
|
||||
})
|
||||
|
||||
it('prellena solo lo que haya (email sin teléfono)', () => {
|
||||
const pre = leadAPrellenado({ nombre: 'Solo Email', email: 'solo@correo.com', telefono: '' })
|
||||
expect(pre).toEqual({ nombreCompleto: 'SOLO EMAIL', email: 'solo@correo.com' })
|
||||
expect(pre.celular).toBeUndefined()
|
||||
})
|
||||
})
|
||||
16
apps/alta-clientes/src/lib/leadAlta.ts
Normal file
16
apps/alta-clientes/src/lib/leadAlta.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { DatosGenerales } from '../types'
|
||||
import type { LeadResumen } from '../services/api'
|
||||
|
||||
/**
|
||||
* Transformación pura lead de Monday → campos del wizard de alta.
|
||||
* Prellena solo lo que el lead tenga (nombre en mayúsculas, como el formato).
|
||||
*/
|
||||
export function leadAPrellenado(
|
||||
lead: Pick<LeadResumen, 'nombre' | 'email' | 'telefono'>,
|
||||
): Partial<DatosGenerales> {
|
||||
return {
|
||||
nombreCompleto: lead.nombre.trim().toUpperCase(),
|
||||
...(lead.email ? { email: lead.email.trim() } : {}),
|
||||
...(lead.telefono ? { celular: lead.telefono.trim() } : {}),
|
||||
}
|
||||
}
|
||||
57
apps/alta-clientes/src/pages/AltaClientePage.test.tsx
Normal file
57
apps/alta-clientes/src/pages/AltaClientePage.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import AltaClientePage from './AltaClientePage'
|
||||
import type { LeadResumen } from '../services/api'
|
||||
|
||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
const LEAD: LeadResumen = {
|
||||
id: 'lead-9',
|
||||
nombre: 'María Fernanda López',
|
||||
telefono: '4771234567',
|
||||
email: 'maria@correo.com',
|
||||
estado: 'NUEVO',
|
||||
origen: 'Web',
|
||||
}
|
||||
|
||||
const respuestaOk = (data: unknown) =>
|
||||
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<FetchFn>().mockImplementation((input) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/borradores')) {
|
||||
// Un borrador para que la pantalla inicial sea el picker
|
||||
return Promise.resolve(
|
||||
respuestaOk([{ id: 'b-1', nombre: 'Borrador previo', paso: 0, updatedAt: '2026-08-20T10:00:00Z' }]),
|
||||
)
|
||||
}
|
||||
if (url.includes('/leads/buscar')) return Promise.resolve(respuestaOk([LEAD]))
|
||||
return Promise.reject(new Error(`fetch inesperado: ${url}`))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
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
|
||||
const busqueda = await screen.findByLabelText(/Buscar lead/)
|
||||
fireEvent.change(busqueda, { target: { value: 'maria' } })
|
||||
fireEvent.click(await screen.findByText('María Fernanda López'))
|
||||
|
||||
// Wizard paso 1 con los campos del lead ya capturados
|
||||
const nombre = (await screen.findByLabelText(/Nombre completo/)) as HTMLInputElement
|
||||
expect(nombre.value).toBe('MARÍA FERNANDA LÓPEZ')
|
||||
expect((screen.getByLabelText(/Correo electrónico|Email/i) as HTMLInputElement).value).toBe('maria@correo.com')
|
||||
expect((screen.getByLabelText(/Celular/i) as HTMLInputElement).value).toBe('4771234567')
|
||||
})
|
||||
})
|
||||
@@ -19,8 +19,11 @@ import {
|
||||
obtenerCotizacion,
|
||||
subirDocumentos,
|
||||
type BorradorResumen,
|
||||
type LeadResumen,
|
||||
} from '../services/api'
|
||||
import { cotizacionAPrellenado } from '../lib/cotizacionAlta'
|
||||
import { leadAPrellenado } from '../lib/leadAlta'
|
||||
import BusquedaLead from '../components/BusquedaLead'
|
||||
|
||||
/** Lee ?cotizacion=<id> del query string o del hash (#cotizacion=<id>) */
|
||||
function paramCotizacion(): string | null {
|
||||
@@ -82,7 +85,9 @@ export default function AltaClientePage() {
|
||||
listarBorradores()
|
||||
.then((lista) => {
|
||||
setBorradores(lista)
|
||||
setPantalla(lista.length > 0 ? 'picker' : 'wizard')
|
||||
// El picker es siempre la entrada: además de los borradores ofrece
|
||||
// "Empezar nuevo" y la búsqueda de leads de Monday.
|
||||
setPantalla('picker')
|
||||
})
|
||||
.catch(() => setPantalla('wizard')) // sin conexión: el wizard funciona igual
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -103,11 +108,9 @@ export default function AltaClientePage() {
|
||||
const borrarBorrador = async (id: string) => {
|
||||
try {
|
||||
await eliminarBorrador(id)
|
||||
setBorradores((prev) => {
|
||||
const restantes = prev.filter((b) => b.id !== id)
|
||||
if (restantes.length === 0) setPantalla('wizard')
|
||||
return restantes
|
||||
})
|
||||
// Al borrar el último borrador el picker sigue siendo la pantalla de
|
||||
// entrada (ofrece "Empezar nuevo" y la búsqueda de leads).
|
||||
setBorradores((prev) => prev.filter((b) => b.id !== id))
|
||||
} catch {
|
||||
setErrorCarga('No se pudo eliminar el borrador.')
|
||||
}
|
||||
@@ -120,6 +123,16 @@ export default function AltaClientePage() {
|
||||
setPantalla('wizard')
|
||||
}
|
||||
|
||||
// Alta nueva prellenada desde un lead de Monday (nombre, email, celular)
|
||||
const empezarDesdeLead = (lead: LeadResumen) => {
|
||||
reiniciar()
|
||||
setBorradorId(null)
|
||||
setCargoDeBorrador(false)
|
||||
actualizar('datosGenerales', { ...datos.datosGenerales, ...leadAPrellenado(lead) })
|
||||
actualizar('leadMondayId', lead.id)
|
||||
setPantalla('wizard')
|
||||
}
|
||||
|
||||
const guardar = async () => {
|
||||
try {
|
||||
const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso, datos })
|
||||
@@ -193,39 +206,43 @@ export default function AltaClientePage() {
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl px-4 py-16">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado
|
||||
{borradores.length === 1 ? '' : 's'}
|
||||
</h2>
|
||||
{borradores.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado
|
||||
{borradores.length === 1 ? '' : 's'}
|
||||
</h2>
|
||||
<ul className="mt-6 divide-y divide-neutral-200 border border-neutral-200 bg-white">
|
||||
{borradores.map((b) => (
|
||||
<li key={b.id} className="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm text-neutral-900">{b.nombre}</p>
|
||||
<p className="mt-1 text-xs font-light text-neutral-500">
|
||||
Paso {b.paso + 1} de {PASOS.length} · {formatoFechaHora(b.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void continuarBorrador(b.id)}
|
||||
className="border border-accent bg-white px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:bg-accent hover:text-white"
|
||||
>
|
||||
Continuar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void borrarBorrador(b.id)}
|
||||
className="border border-red-300 bg-white px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-red-700 transition-colors duration-150 hover:bg-red-50"
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{errorCarga && <p className="mt-4 text-xs text-red-700">{errorCarga}</p>}
|
||||
<ul className="mt-6 divide-y divide-neutral-200 border border-neutral-200 bg-white">
|
||||
{borradores.map((b) => (
|
||||
<li key={b.id} className="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm text-neutral-900">{b.nombre}</p>
|
||||
<p className="mt-1 text-xs font-light text-neutral-500">
|
||||
Paso {b.paso + 1} de {PASOS.length} · {formatoFechaHora(b.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void continuarBorrador(b.id)}
|
||||
className="border border-accent bg-white px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:bg-accent hover:text-white"
|
||||
>
|
||||
Continuar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void borrarBorrador(b.id)}
|
||||
className="border border-red-300 bg-white px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-red-700 transition-colors duration-150 hover:bg-red-50"
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={empezarNuevo}
|
||||
@@ -233,6 +250,18 @@ export default function AltaClientePage() {
|
||||
>
|
||||
Empezar nuevo
|
||||
</button>
|
||||
|
||||
<section aria-labelledby="titulo-lead" className="mt-12 border-t border-neutral-200 pt-8">
|
||||
<h3 id="titulo-lead" className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Desde un lead de Monday
|
||||
</h3>
|
||||
<p className="mt-2 text-xs font-light text-neutral-500">
|
||||
Empieza un alta nueva con el nombre, email y celular del lead ya capturados.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<BusquedaLead onSeleccion={empezarDesdeLead} />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -164,6 +164,25 @@ export async function obtenerCotizacion(id: string): Promise<import('../lib/coti
|
||||
return (await res.json()) as import('../lib/cotizacionAlta').CotizacionParaAlta
|
||||
}
|
||||
|
||||
// ---------- Pre-llenado desde lead de Monday (auth) ----------
|
||||
|
||||
export interface LeadResumen {
|
||||
id: string
|
||||
nombre: string
|
||||
telefono: string
|
||||
email: string
|
||||
estado: string
|
||||
origen: string
|
||||
}
|
||||
|
||||
/** GET /leads/buscar?q= — autocompletado para prellenar el alta desde un lead */
|
||||
export async function buscarLeads(q: string): Promise<LeadResumen[]> {
|
||||
const res = await fetch(`${API_URL}/leads/buscar?q=${encodeURIComponent(q)}`, conAuth())
|
||||
manejar401(res)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as LeadResumen[]
|
||||
}
|
||||
|
||||
// ---------- Liga remota de firma (auth) ----------
|
||||
|
||||
export interface LigaRemota {
|
||||
|
||||
@@ -146,6 +146,9 @@ export interface DatosFirma {
|
||||
}
|
||||
|
||||
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
|
||||
datosGenerales: DatosGenerales
|
||||
empleo: DatosEmpleo
|
||||
economicos: DatosEconomicos
|
||||
|
||||
Reference in New Issue
Block a user