feat(alta): borrador, pre-llenado desde cotización y liga remota de firma

- Borradores por vendedor (guardar/continuar/eliminar), se limpia al registrar
- Pre-llenado con ?cotizacion=<id> (cliente, contacto, proyecto, montos);
  botón 'Iniciar alta' en Mis cotizaciones del cotizador
- Liga remota pública /client/firma/<token> (JWT scope firma-remota, 72h,
  rate limit, solo datos mínimos sin PLD): firma digital + subida de
  documentos que prende checkboxes en Monday Apartado
- Tokens de liga no sirven como sesión y viceversa
- Tests: api 228, alta 82, cotizador 82
This commit is contained in:
2026-08-28 07:29:22 +00:00
parent 4f31d1c171
commit 59944668a8
18 changed files with 1589 additions and 33 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import Stepper from '../components/Stepper'
import DatosGeneralesForm from '../forms/DatosGeneralesForm'
import EmpleoForm from '../forms/EmpleoForm'
@@ -9,15 +9,129 @@ import ViviendaCreditoForm from '../forms/ViviendaCreditoForm'
import DocumentosForm from '../forms/DocumentosForm'
import FirmasForm from '../forms/FirmasForm'
import { useAltaCliente, PASOS } from '../hooks/useAltaCliente'
import { crearAltaCliente, subirDocumentos } from '../services/api'
import {
crearAltaCliente,
eliminarBorrador,
generarLigaRemota,
guardarBorrador,
listarBorradores,
obtenerBorrador,
obtenerCotizacion,
subirDocumentos,
type BorradorResumen,
} from '../services/api'
import { cotizacionAPrellenado } from '../lib/cotizacionAlta'
/** Lee ?cotizacion=<id> del query string o del hash (#cotizacion=<id>) */
function paramCotizacion(): string | null {
const query = new URLSearchParams(window.location.search).get('cotizacion')
if (query) return query
const hash = window.location.hash.replace(/^#/, '')
return new URLSearchParams(hash).get('cotizacion')
}
const formatoFechaHora = (iso: string): string =>
new Date(iso).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
export default function AltaClientePage() {
const { paso, siguiente, anterior, irAPaso, datos, alta, actualizar, agregarDocumento, quitarDocumento } =
useAltaCliente()
const {
paso,
siguiente,
anterior,
irAPaso,
datos,
alta,
actualizar,
restaurar,
reiniciar,
agregarDocumento,
quitarDocumento,
} = useAltaCliente()
const [registro, setRegistro] = useState<string>('')
const [sinConexion, setSinConexion] = useState(false)
const [errorValidacion, setErrorValidacion] = useState<string>('')
// ---------- Borradores ----------
type Pantalla = 'cargando' | 'picker' | 'wizard'
const [pantalla, setPantalla] = useState<Pantalla>('cargando')
const [borradores, setBorradores] = useState<BorradorResumen[]>([])
const [borradorId, setBorradorId] = useState<string | null>(null)
const [avisoBorrador, setAvisoBorrador] = useState('')
const [cargoDeBorrador, setCargoDeBorrador] = useState(false)
const [errorCarga, setErrorCarga] = useState('')
// ---------- Liga remota ----------
const [ligaUrl, setLigaUrl] = useState('')
const [ligaCopiada, setLigaCopiada] = useState(false)
const [errorLiga, setErrorLiga] = useState('')
// Al entrar: ?cotizacion=<id> prellena el alta; si no, ofrece borradores
useEffect(() => {
const cotizacionId = paramCotizacion()
if (cotizacionId) {
obtenerCotizacion(cotizacionId)
.then((c) => {
const pre = cotizacionAPrellenado(c)
actualizar('datosGenerales', { ...datos.datosGenerales, ...pre.datosGenerales })
actualizar('viviendaCredito', { ...datos.viviendaCredito, ...pre.viviendaCredito })
})
.catch(() => setErrorCarga('No se pudo cargar la cotización para prellenar el alta.'))
.finally(() => setPantalla('wizard'))
return
}
listarBorradores()
.then((lista) => {
setBorradores(lista)
setPantalla(lista.length > 0 ? 'picker' : 'wizard')
})
.catch(() => setPantalla('wizard')) // sin conexión: el wizard funciona igual
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const continuarBorrador = async (id: string) => {
try {
const b = await obtenerBorrador(id)
restaurar(JSON.parse(b.datosJson) as typeof datos, b.paso)
setBorradorId(b.id)
setCargoDeBorrador(true)
setPantalla('wizard')
} catch {
setErrorCarga('No se pudo abrir el borrador.')
}
}
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
})
} catch {
setErrorCarga('No se pudo eliminar el borrador.')
}
}
const empezarNuevo = () => {
reiniciar()
setBorradorId(null)
setCargoDeBorrador(false)
setPantalla('wizard')
}
const guardar = async () => {
try {
const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso, datos })
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')
}
}
// 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)
const registrarAlta = async () => {
@@ -27,6 +141,11 @@ export default function AltaClientePage() {
setRegistro(res.id)
setSinConexion(false)
setErrorValidacion('')
// El alta ya quedó registrada: el borrador usado ya no hace falta
if (borradorId) {
eliminarBorrador(borradorId).catch(() => undefined)
setBorradorId(null)
}
} else if (res.motivo === 'validacion') {
setRegistro('')
setSinConexion(false)
@@ -38,18 +157,119 @@ export default function AltaClientePage() {
}
}
// Genera la liga pública y copia al portapapeles el texto listo para WhatsApp
const generarLiga = async () => {
setErrorLiga('')
setLigaCopiada(false)
try {
const liga = await generarLigaRemota(registro)
const urlAbsoluta = `${window.location.origin}${liga.url}`
setLigaUrl(urlAbsoluta)
const texto = `Hola ${alta.datosGenerales.nombreCompleto}, te comparto la liga para firmar tu expediente y subir tus documentos (vigencia 72 h): ${urlAbsoluta}`
await navigator.clipboard.writeText(texto)
setLigaCopiada(true)
} catch {
setErrorLiga('No se pudo generar la liga. Intenta de nuevo.')
}
}
if (pantalla === 'cargando') {
return (
<div className="min-h-screen bg-white">
<main className="mx-auto max-w-6xl px-4 py-16">
<p className="text-sm text-neutral-500">Cargando…</p>
</main>
</div>
)
}
if (pantalla === 'picker') {
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">
<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>
{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}
className="mt-8 bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:bg-neutral-900"
>
Empezar nuevo
</button>
</main>
</div>
)
}
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">
<div className="mx-auto flex max-w-6xl flex-wrap 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 className="ml-auto flex items-center gap-3">
{avisoBorrador && (
<span role="status" className="text-xs font-light text-neutral-500">
{avisoBorrador}
</span>
)}
<button
type="button"
onClick={() => void guardar()}
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
Guardar borrador
</button>
</div>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-16">
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPaso} />
{errorCarga && (
<p role="alert" className="mt-8 border border-amber-300 bg-amber-50 px-4 py-3 text-xs text-amber-700">
{errorCarga}
</p>
)}
<div className="mt-16">
{paso === 0 && (
<DatosGeneralesForm
@@ -111,20 +331,31 @@ export default function AltaClientePage() {
/>
)}
{paso === 6 && (
<DocumentosForm
documentos={alta.documentos}
firmaDataUrl={alta.firma.firmaDataUrl}
onAgregar={agregarDocumento}
onQuitar={quitarDocumento}
onAplicarDatosGenerales={(campos) =>
actualizar('datosGenerales', { ...datos.datosGenerales, ...campos })
}
onAnterior={anterior}
onSiguiente={() => {
void registrarAlta()
siguiente()
}}
/>
<>
{cargoDeBorrador && (
<p
role="note"
className="mb-8 border border-amber-300 bg-amber-50 px-4 py-3 text-xs text-amber-700"
>
Retomaste un borrador: los archivos de los documentos no se guardan en el borrador,
vuelve a adjuntarlos.
</p>
)}
<DocumentosForm
documentos={alta.documentos}
firmaDataUrl={alta.firma.firmaDataUrl}
onAgregar={agregarDocumento}
onQuitar={quitarDocumento}
onAplicarDatosGenerales={(campos) =>
actualizar('datosGenerales', { ...datos.datosGenerales, ...campos })
}
onAnterior={anterior}
onSiguiente={() => {
void registrarAlta()
siguiente()
}}
/>
</>
)}
{paso === 7 && (
<>
@@ -149,6 +380,35 @@ export default function AltaClientePage() {
El servidor rechazó el alta: {errorValidacion}
</p>
)}
{registro && (
<div className="mb-8 border border-neutral-200 bg-white px-4 py-4">
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">
Firma en remoto
</p>
<p className="mt-2 text-xs font-light text-neutral-500">
Genera una liga pública (vigencia 72 h) para que el cliente firme y suba sus
documentos desde su celular, sin usuario ni contraseña.
</p>
<button
type="button"
onClick={() => void generarLiga()}
className="mt-4 border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
Generar liga para firmar en remoto
</button>
{ligaUrl && (
<p className="mt-4 break-all text-xs text-neutral-900">
{ligaUrl}
{ligaCopiada && (
<span role="status" className="ml-2 font-bold uppercase tracking-widest text-accent">
Liga y mensaje de WhatsApp copiados
</span>
)}
</p>
)}
{errorLiga && <p className="mt-2 text-xs text-red-700">{errorLiga}</p>}
</div>
)}
<FirmasForm
alta={alta}
onCambiarFirma={(f) => actualizar('firma', f)}