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:
@@ -134,6 +134,18 @@ export function useAltaCliente() {
|
||||
const anterior = () => setPaso((p) => Math.max(p - 1, 0))
|
||||
const irAPaso = (p: number) => setPaso(p)
|
||||
|
||||
/** Restaura el estado completo (p. ej. desde un borrador) y salta a un paso */
|
||||
const restaurar = (nuevosDatos: ReturnType<typeof estadoInicial>, pasoDestino = 0) => {
|
||||
setDatos(nuevosDatos)
|
||||
setPaso(pasoDestino)
|
||||
}
|
||||
|
||||
/** Vuelve al estado inicial (alta nueva) */
|
||||
const reiniciar = () => {
|
||||
setDatos(estadoInicial())
|
||||
setPaso(0)
|
||||
}
|
||||
|
||||
return {
|
||||
paso,
|
||||
siguiente,
|
||||
@@ -142,6 +154,8 @@ export function useAltaCliente() {
|
||||
datos,
|
||||
alta,
|
||||
actualizar,
|
||||
restaurar,
|
||||
reiniciar,
|
||||
agregarDocumento,
|
||||
quitarDocumento,
|
||||
}
|
||||
|
||||
54
apps/alta-clientes/src/lib/cotizacionAlta.test.ts
Normal file
54
apps/alta-clientes/src/lib/cotizacionAlta.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cotizacionAPrellenado, type CotizacionParaAlta } from './cotizacionAlta'
|
||||
import { PROYECTOS } from '../data/constantes'
|
||||
|
||||
const base: CotizacionParaAlta = {
|
||||
clienteNombre: 'María Elena López García',
|
||||
clienteEmail: 'maria@example.com',
|
||||
clienteTelefono: '4421234567',
|
||||
desarrollo: 'DIAGONAL',
|
||||
unidadCodigo: 'U-101',
|
||||
precioFinal: 1450000,
|
||||
enganche: 217500,
|
||||
}
|
||||
|
||||
describe('cotizacionAPrellenado', () => {
|
||||
it('prellena nombre y contacto del cliente', () => {
|
||||
const pre = cotizacionAPrellenado(base)
|
||||
expect(pre.datosGenerales.nombreCompleto).toBe('MARÍA ELENA LÓPEZ GARCÍA')
|
||||
expect(pre.datosGenerales.email).toBe('maria@example.com')
|
||||
expect(pre.datosGenerales.celular).toBe('4421234567')
|
||||
})
|
||||
|
||||
it('omite email/celular cuando la cotización no los trae', () => {
|
||||
const pre = cotizacionAPrellenado({ ...base, clienteEmail: null, clienteTelefono: null })
|
||||
expect(pre.datosGenerales).not.toHaveProperty('email')
|
||||
expect(pre.datosGenerales).not.toHaveProperty('celular')
|
||||
})
|
||||
|
||||
it('mapea un desarrollo conocido al dropdown de proyecto', () => {
|
||||
const pre = cotizacionAPrellenado(base)
|
||||
expect(PROYECTOS).toContain('DIAGONAL')
|
||||
expect(pre.viviendaCredito.proyecto).toBe('DIAGONAL')
|
||||
expect(pre.viviendaCredito.proyectoOtro).toBe('')
|
||||
})
|
||||
|
||||
it('un desarrollo fuera de PROYECTOS cae en OTRO con el nombre en texto libre', () => {
|
||||
const pre = cotizacionAPrellenado({ ...base, desarrollo: 'JURIQUILLA' })
|
||||
expect(pre.viviendaCredito.proyecto).toBe('OTRO')
|
||||
expect(pre.viviendaCredito.proyectoOtro).toBe('JURIQUILLA')
|
||||
})
|
||||
|
||||
it('prellena unidad, valor de venta y enganche', () => {
|
||||
const pre = cotizacionAPrellenado(base)
|
||||
expect(pre.viviendaCredito.ubicacion).toBe('U-101')
|
||||
expect(pre.viviendaCredito.valorVenta).toBe('1450000')
|
||||
expect(pre.viviendaCredito.enganche).toBe('217500')
|
||||
})
|
||||
|
||||
it('montos en cero quedan vacíos (no inventa "0")', () => {
|
||||
const pre = cotizacionAPrellenado({ ...base, precioFinal: 0, enganche: 0 })
|
||||
expect(pre.viviendaCredito.valorVenta).toBe('')
|
||||
expect(pre.viviendaCredito.enganche).toBe('')
|
||||
})
|
||||
})
|
||||
44
apps/alta-clientes/src/lib/cotizacionAlta.ts
Normal file
44
apps/alta-clientes/src/lib/cotizacionAlta.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { DatosGenerales, ViviendaCredito } from '../types'
|
||||
import { PROYECTOS } from '../data/constantes'
|
||||
|
||||
/** Shape mínimo de GET /api/cotizaciones/:id que usa el pre-llenado del alta */
|
||||
export interface CotizacionParaAlta {
|
||||
clienteNombre: string
|
||||
clienteEmail: string | null
|
||||
clienteTelefono: string | null
|
||||
desarrollo: string
|
||||
unidadCodigo: string
|
||||
precioFinal: number
|
||||
enganche: number
|
||||
}
|
||||
|
||||
export interface PrellenadoAlta {
|
||||
datosGenerales: Partial<DatosGenerales>
|
||||
viviendaCredito: Partial<ViviendaCredito>
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformación pura cotización → campos del wizard de alta.
|
||||
* El desarrollo se mapea al dropdown de proyecto; si no está en PROYECTOS cae
|
||||
* en OTRO con el nombre en texto libre. La "ubicación" del formato es el
|
||||
* código de unidad (U-101, etc.). Los montos se prellenan como texto.
|
||||
*/
|
||||
export function cotizacionAPrellenado(c: CotizacionParaAlta): PrellenadoAlta {
|
||||
const desarrollo = c.desarrollo.trim().toUpperCase()
|
||||
const conocido = (PROYECTOS as readonly string[]).includes(desarrollo)
|
||||
|
||||
return {
|
||||
datosGenerales: {
|
||||
nombreCompleto: c.clienteNombre.trim().toUpperCase(),
|
||||
...(c.clienteEmail ? { email: c.clienteEmail.trim() } : {}),
|
||||
...(c.clienteTelefono ? { celular: c.clienteTelefono.trim() } : {}),
|
||||
},
|
||||
viviendaCredito: {
|
||||
proyecto: conocido ? desarrollo : 'OTRO',
|
||||
proyectoOtro: conocido ? '' : desarrollo,
|
||||
ubicacion: c.unidadCodigo,
|
||||
valorVenta: c.precioFinal > 0 ? String(c.precioFinal) : '',
|
||||
enganche: c.enganche > 0 ? String(c.enganche) : '',
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,24 @@ import '@fontsource/inter/500.css'
|
||||
import '@fontsource/inter/700.css'
|
||||
import './index.css'
|
||||
import AltaClientePage from './pages/AltaClientePage'
|
||||
import FirmaRemotaPage from './pages/FirmaRemotaPage'
|
||||
import AuthGate from './components/AuthGate'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename="/client">
|
||||
<AuthGate>
|
||||
<Routes>
|
||||
<Route path="/" element={<AltaClientePage />} />
|
||||
<Route path="*" element={<AltaClientePage />} />
|
||||
</Routes>
|
||||
</AuthGate>
|
||||
<Routes>
|
||||
{/* Ruta pública de la liga remota: sin login (el token va en la URL) */}
|
||||
<Route path="/firma/:token" element={<FirmaRemotaPage />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<AuthGate>
|
||||
<AltaClientePage />
|
||||
</AuthGate>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
197
apps/alta-clientes/src/pages/FirmaRemotaPage.tsx
Normal file
197
apps/alta-clientes/src/pages/FirmaRemotaPage.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import SignaturePad from '../components/SignaturePad'
|
||||
import { SLOTS_DOCUMENTOS, TAMANO_MAX_DOCUMENTO } from '../data/constantes'
|
||||
import {
|
||||
enviarFirmaRemota,
|
||||
ErrorLiga,
|
||||
obtenerClientePublico,
|
||||
subirDocumentoRemoto,
|
||||
type ClientePublico,
|
||||
} from '../services/api'
|
||||
|
||||
const ACEPTADOS = '.pdf,.jpg,.jpeg,.png'
|
||||
|
||||
/**
|
||||
* Pantalla PÚBLICA de firma en remoto (/client/firma/:token): sin login.
|
||||
* El cliente final firma y sube sus documentos; el token (scope firma-remota,
|
||||
* 72 h) va en la URL y lo verifica el API en cada llamada.
|
||||
*/
|
||||
export default function FirmaRemotaPage() {
|
||||
const { token = '' } = useParams()
|
||||
const [cliente, setCliente] = useState<ClientePublico | null>(null)
|
||||
const [errorCarga, setErrorCarga] = useState('')
|
||||
|
||||
const [firmaDataUrl, setFirmaDataUrl] = useState('')
|
||||
const [enviandoFirma, setEnviandoFirma] = useState(false)
|
||||
const [firmaOk, setFirmaOk] = useState(false)
|
||||
const [errorFirma, setErrorFirma] = useState('')
|
||||
|
||||
const [subiendoSlot, setSubiendoSlot] = useState('')
|
||||
const [erroresDoc, setErroresDoc] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
obtenerClientePublico(token)
|
||||
.then((c) => {
|
||||
setCliente(c)
|
||||
setFirmaOk(c.firma)
|
||||
})
|
||||
.catch((err) =>
|
||||
setErrorCarga(err instanceof ErrorLiga ? err.message : 'No se pudo cargar la información. Intenta más tarde.'),
|
||||
)
|
||||
}, [token])
|
||||
|
||||
const enviarFirma = async () => {
|
||||
if (!firmaDataUrl) {
|
||||
setErrorFirma('Primero dibuja tu firma en el recuadro.')
|
||||
return
|
||||
}
|
||||
setEnviandoFirma(true)
|
||||
setErrorFirma('')
|
||||
try {
|
||||
await enviarFirmaRemota(token, firmaDataUrl)
|
||||
setFirmaOk(true)
|
||||
} catch (err) {
|
||||
setErrorFirma(err instanceof ErrorLiga ? err.message : 'No se pudo guardar la firma. Intenta de nuevo.')
|
||||
} finally {
|
||||
setEnviandoFirma(false)
|
||||
}
|
||||
}
|
||||
|
||||
const subirDocumento = async (slot: string, archivo: File | undefined) => {
|
||||
if (!archivo) return
|
||||
if (archivo.size > TAMANO_MAX_DOCUMENTO) {
|
||||
setErroresDoc((prev) => ({ ...prev, [slot]: 'El archivo supera 10 MB' }))
|
||||
return
|
||||
}
|
||||
setSubiendoSlot(slot)
|
||||
setErroresDoc((prev) => ({ ...prev, [slot]: '' }))
|
||||
try {
|
||||
await subirDocumentoRemoto(token, slot, archivo)
|
||||
// Refresca el estado de documentos desde el servidor
|
||||
setCliente(await obtenerClientePublico(token))
|
||||
} catch (err) {
|
||||
setErroresDoc((prev) => ({
|
||||
...prev,
|
||||
[slot]: err instanceof ErrorLiga ? err.message : 'No se pudo subir el archivo. Intenta de nuevo.',
|
||||
}))
|
||||
} finally {
|
||||
setSubiendoSlot('')
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCarga) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-neutral-900">
|
||||
<main className="mx-auto max-w-xl px-4 py-24 text-center">
|
||||
<span className="text-sm font-bold uppercase tracking-widest">FRASA</span>
|
||||
<p role="alert" className="mt-8 border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{errorCarga}
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!cliente) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<main className="mx-auto max-w-xl px-4 py-24">
|
||||
<p className="text-sm text-neutral-500">Cargando…</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-neutral-900">
|
||||
<header className="border-b border-neutral-200 px-4 py-6">
|
||||
<div className="mx-auto flex max-w-xl items-baseline gap-3">
|
||||
<span className="text-sm font-bold uppercase tracking-widest">FRASA</span>
|
||||
<h1 className="text-2xl font-light tracking-wide">Expediente digital</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-xl space-y-12 px-4 py-12">
|
||||
<section>
|
||||
<h2 className="text-lg font-light">
|
||||
Hola, <span className="font-medium">{cliente.nombre}</span>
|
||||
</h2>
|
||||
<p className="mt-2 text-sm font-light text-neutral-500">
|
||||
Proyecto {cliente.proyecto}
|
||||
{cliente.unidad ? ` · Unidad ${cliente.unidad}` : ''}. Firma y sube los documentos de tu
|
||||
expediente desde aquí.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="firma-titulo">
|
||||
<h3 id="firma-titulo" className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Tu firma
|
||||
</h3>
|
||||
{firmaOk ? (
|
||||
<p role="status" className="mt-4 border border-accent bg-white px-4 py-3 text-xs font-bold uppercase tracking-widest text-accent">
|
||||
Firma registrada correctamente
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<SignaturePad onFirma={setFirmaDataUrl} />
|
||||
{errorFirma && <p className="mt-2 text-xs text-red-700">{errorFirma}</p>}
|
||||
<button
|
||||
type="button"
|
||||
disabled={enviandoFirma}
|
||||
onClick={() => void enviarFirma()}
|
||||
className="mt-4 bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:bg-neutral-900 disabled:opacity-40"
|
||||
>
|
||||
{enviandoFirma ? 'Enviando…' : 'Enviar firma'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="docs-titulo">
|
||||
<h3 id="docs-titulo" className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Tus documentos
|
||||
</h3>
|
||||
<ul className="mt-4 space-y-4">
|
||||
{SLOTS_DOCUMENTOS.map(({ slot, etiqueta }) => {
|
||||
const subido = cliente.documentos.find((d) => d.tipo === slot)
|
||||
return (
|
||||
<li key={slot} className="border border-neutral-200 bg-white px-4 py-3">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">{etiqueta}</p>
|
||||
{subido ? (
|
||||
<p role="status" className="mt-2 text-sm text-neutral-900">
|
||||
✓ {subido.nombreArchivo}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2">
|
||||
<label className="inline-block cursor-pointer 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">
|
||||
{subiendoSlot === slot ? 'Subiendo…' : 'Seleccionar archivo'}
|
||||
<input
|
||||
type="file"
|
||||
accept={ACEPTADOS}
|
||||
disabled={subiendoSlot !== ''}
|
||||
aria-label={`Seleccionar archivo para ${etiqueta}`}
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
void subirDocumento(slot, e.target.files?.[0])
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-2 text-xs font-light text-neutral-500">PDF/JPG/PNG · máx 10 MB</p>
|
||||
</div>
|
||||
)}
|
||||
{erroresDoc[slot] && (
|
||||
<p role="alert" className="mt-2 text-xs text-red-700">
|
||||
{erroresDoc[slot]}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -97,3 +97,143 @@ export async function subirDocumentos(clienteId: string, payload: AltaCliente):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Borradores del alta (auth) ----------
|
||||
|
||||
export interface BorradorResumen {
|
||||
id: string
|
||||
nombre: string
|
||||
paso: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Borrador extends BorradorResumen {
|
||||
datosJson: string
|
||||
}
|
||||
|
||||
/** GET /borradores — los míos, sin datosJson */
|
||||
export async function listarBorradores(): Promise<BorradorResumen[]> {
|
||||
const res = await fetch(`${API_URL}/borradores`, conAuth())
|
||||
manejar401(res)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as BorradorResumen[]
|
||||
}
|
||||
|
||||
/** POST /borradores — upsert: con `id` actualiza, sin `id` crea */
|
||||
export async function guardarBorrador(payload: {
|
||||
id?: string
|
||||
paso: number
|
||||
datos: unknown
|
||||
}): Promise<Borrador> {
|
||||
const res = await fetch(
|
||||
`${API_URL}/borradores`,
|
||||
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 Borrador
|
||||
}
|
||||
|
||||
/** GET /borradores/:id — borrador completo */
|
||||
export async function obtenerBorrador(id: string): Promise<Borrador> {
|
||||
const res = await fetch(`${API_URL}/borradores/${id}`, conAuth())
|
||||
manejar401(res)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as Borrador
|
||||
}
|
||||
|
||||
/** DELETE /borradores/:id */
|
||||
export async function eliminarBorrador(id: string): Promise<void> {
|
||||
const res = await fetch(`${API_URL}/borradores/${id}`, conAuth({ method: 'DELETE' }))
|
||||
manejar401(res)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
// ---------- Pre-llenado desde cotización (auth) ----------
|
||||
|
||||
/** GET /cotizaciones/:id — datos para prellenar el alta (?cotizacion=<id>) */
|
||||
export async function obtenerCotizacion(id: string): Promise<import('../lib/cotizacionAlta').CotizacionParaAlta> {
|
||||
const res = await fetch(`${API_URL}/cotizaciones/${id}`, conAuth())
|
||||
manejar401(res)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as import('../lib/cotizacionAlta').CotizacionParaAlta
|
||||
}
|
||||
|
||||
// ---------- Liga remota de firma (auth) ----------
|
||||
|
||||
export interface LigaRemota {
|
||||
token: string
|
||||
url: string // ruta pública relativa: /client/firma/<token>
|
||||
expiraEn: string
|
||||
}
|
||||
|
||||
/** POST /clientes/:id/liga — genera la liga pública de firma/documentos */
|
||||
export async function generarLigaRemota(clienteId: string): Promise<LigaRemota> {
|
||||
const res = await fetch(`${API_URL}/clientes/${clienteId}/liga`, conAuth({ method: 'POST' }))
|
||||
manejar401(res)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as LigaRemota
|
||||
}
|
||||
|
||||
// ---------- Endpoints públicos de la liga (SIN auth; el token va en la URL) ----------
|
||||
|
||||
export interface DocumentoPublico {
|
||||
tipo: string
|
||||
nombreArchivo: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ClientePublico {
|
||||
nombre: string
|
||||
proyecto: string
|
||||
unidad: string | null
|
||||
firma: boolean
|
||||
documentos: DocumentoPublico[]
|
||||
}
|
||||
|
||||
/** Error de liga inválida/expirada con mensaje para el cliente final */
|
||||
export class ErrorLiga extends Error {
|
||||
constructor(message = 'La liga es inválida o ya expiró. Pide una nueva a tu asesor.') {
|
||||
super(message)
|
||||
this.name = 'ErrorLiga'
|
||||
}
|
||||
}
|
||||
|
||||
const manejarErrorLiga = (res: Response): void => {
|
||||
if (res.status === 401 || res.status === 404) throw new ErrorLiga()
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
/** GET /clientes/publico/:token — solo nombre, proyecto, unidad y estado */
|
||||
export async function obtenerClientePublico(token: string): Promise<ClientePublico> {
|
||||
const res = await fetch(`${API_URL}/clientes/publico/${token}`)
|
||||
manejarErrorLiga(res)
|
||||
return (await res.json()) as ClientePublico
|
||||
}
|
||||
|
||||
/** POST /clientes/publico/:token/firma — guarda la firma (PNG data URL) */
|
||||
export async function enviarFirmaRemota(token: string, firmaDataUrl: string): Promise<void> {
|
||||
const res = await fetch(`${API_URL}/clientes/publico/${token}/firma`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ firmaDataUrl }),
|
||||
})
|
||||
manejarErrorLiga(res)
|
||||
}
|
||||
|
||||
/** POST /clientes/publico/:token/documentos — adjunta UN documento por slot */
|
||||
export async function subirDocumentoRemoto(token: string, tipo: string, archivo: File): Promise<void> {
|
||||
const form = new FormData()
|
||||
form.append('tipo', tipo)
|
||||
form.append('archivos', archivo, archivo.name)
|
||||
const res = await fetch(`${API_URL}/clientes/publico/${token}/documentos`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
manejarErrorLiga(res)
|
||||
}
|
||||
|
||||
@@ -99,13 +99,21 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
|
||||
{formatoMoneda(c.precioFinal)}
|
||||
</td>
|
||||
<td className="border-t border-neutral-200 p-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetomar(c)}
|
||||
className="border border-accent bg-white px-3 py-1.5 text-xs font-bold tracking-widest text-accent uppercase transition-colors duration-150 hover:bg-accent hover:text-white"
|
||||
>
|
||||
Retomar
|
||||
</button>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetomar(c)}
|
||||
className="border border-accent bg-white px-3 py-1.5 text-xs font-bold tracking-widest text-accent uppercase transition-colors duration-150 hover:bg-accent hover:text-white"
|
||||
>
|
||||
Retomar
|
||||
</button>
|
||||
<a
|
||||
href={`/client/?cotizacion=${c.id}`}
|
||||
className="border border-neutral-900 bg-white px-3 py-1.5 text-xs font-bold tracking-widest text-neutral-900 uppercase transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||
>
|
||||
Iniciar alta
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user