Files
FRASA-CAS/apps/alta-clientes/src/pages/FirmaRemotaPage.tsx
Consult AS Technology 599dce2629 style: reskin NovaSpark en las 7 apps
Del look editorial (verde #1B4332, esquinas a 0, botones en mayusculas)
al sistema NovaSpark: primario azul #2563EB, detalle naranja #F97316,
fondo #F8FAFC con tarjetas blancas rounded-xl + shadow-sm, chips
rounded-full semanticos, focus ring azul, transiciones 100-200ms.
Incluye headers de tabla en los PDFs (cotizador y estado de cuenta).
2026-09-03 02:35:34 +00:00

198 lines
7.5 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-[#F8FAFC] text-[#111827]">
<main className="mx-auto max-w-xl px-4 py-24 text-center">
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
<p role="alert" className="mt-8 rounded-lg border border-red-200 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-[#F8FAFC]">
<main className="mx-auto max-w-xl px-4 py-24">
<p className="text-sm text-[#64748B]">Cargando…</p>
</main>
</div>
)
}
return (
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6">
<div className="mx-auto flex max-w-xl items-baseline gap-3">
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
<h1 className="text-2xl font-semibold text-[#111827]">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-semibold text-[#111827]">
Hola, <span className="font-semibold">{cliente.nombre}</span>
</h2>
<p className="mt-2 text-sm text-[#64748B]">
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-semibold uppercase tracking-wider text-[#64748B]">
Tu firma
</h3>
{firmaOk ? (
<p role="status" className="mt-4 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
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 rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-40"
>
{enviandoFirma ? 'Enviando…' : 'Enviar firma'}
</button>
</div>
)}
</section>
<section aria-labelledby="docs-titulo">
<h3 id="docs-titulo" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
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="rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{etiqueta}</p>
{subido ? (
<p role="status" className="mt-2 text-sm text-[#111827]">
✓ {subido.nombreArchivo}
</p>
) : (
<div className="mt-2">
<label className="inline-block cursor-pointer rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]">
{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 text-[#64748B]">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>
)
}