- 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
198 lines
7.4 KiB
TypeScript
198 lines
7.4 KiB
TypeScript
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>
|
|
)
|
|
}
|