F4 WEB: add public secure Act signature page

This commit is contained in:
2026-09-07 21:42:26 -03:00
parent bdcf193a90
commit f4e5480ac8
+414
View File
@@ -0,0 +1,414 @@
import { useEffect, useRef, useState } from 'react';
import type { FormEvent, PointerEvent as ReactPointerEvent } from 'react';
import { useSearchParams } from 'react-router';
interface PublicFinding {
id: string;
code: string;
title: string;
description: string;
legalBasis: string | null;
severity: number | null;
isRecurrence: boolean;
recurrenceOfFindingId: string | null;
}
interface PublicInventory {
id: string;
code: string;
name: string;
typeName: string | null;
}
interface PublicSignatureView {
invitation: {
id: string;
recipientEmail: string;
expiresAt: string;
};
act: {
code: string;
inspectionCode: string;
lockedAt: string;
lockedSha256: string;
urgency: string | null;
summary: string | null;
observations: string | null;
};
responsibleDefaults: {
fullName: string | null;
documentType: string | null;
documentNumber: string | null;
position: string | null;
};
inventories: PublicInventory[];
findings: PublicFinding[];
consent: string;
allowedActions: string[];
}
interface ApiProblem {
message?: string;
code?: string;
}
const API_BASE = '/api/v3/public/company-signatures';
const shellStyle: React.CSSProperties = {
minHeight: '100vh',
background: '#f3f6f8',
padding: '32px 16px 56px',
color: '#17313f',
};
const cardStyle: React.CSSProperties = {
maxWidth: 900,
margin: '0 auto 16px',
background: '#fff',
border: '1px solid #d8e1e7',
borderRadius: 16,
padding: 24,
boxShadow: '0 8px 30px rgba(23,49,63,.06)',
};
const gridStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: 12,
};
const fieldStyle: React.CSSProperties = {
width: '100%',
border: '1px solid #b8c7d0',
borderRadius: 9,
padding: '11px 12px',
font: 'inherit',
boxSizing: 'border-box',
};
const primaryButtonStyle: React.CSSProperties = {
border: 0,
borderRadius: 10,
padding: '12px 16px',
fontWeight: 700,
cursor: 'pointer',
background: '#0d5f78',
color: '#fff',
};
const secondaryButtonStyle: React.CSSProperties = {
...primaryButtonStyle,
border: '1px solid #9fb3be',
background: '#fff',
color: '#17313f',
};
async function problem(response: Response): Promise<string> {
const body = await response.json().catch(() => ({} as ApiProblem)) as ApiProblem;
if (body.message) return body.message;
if (response.status === 410) return 'Este enlace ya fue utilizado o venció.';
if (response.status === 404) return 'El enlace de firma no es válido.';
return `No se pudo completar la operación (HTTP ${response.status}).`;
}
function SignaturePad({ disabled, onClearReady }: { disabled: boolean; onClearReady?: (clear: () => void) => void }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const drawing = useRef(false);
const [hasInk, setHasInk] = useState(false);
const clear = () => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height);
setHasInk(false);
};
useEffect(() => {
onClearReady?.(clear);
}, [onClearReady]);
const point = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
return {
x: (event.clientX - rect.left) * (canvas.width / rect.width),
y: (event.clientY - rect.top) * (canvas.height / rect.height),
};
};
const start = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (disabled) return;
const canvas = canvasRef.current;
const p = point(event);
if (!canvas || !p) return;
canvas.setPointerCapture(event.pointerId);
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.strokeStyle = '#102a36';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(p.x, p.y);
drawing.current = true;
};
const move = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (!drawing.current || disabled) return;
const p = point(event);
const ctx = canvasRef.current?.getContext('2d');
if (!p || !ctx) return;
ctx.lineTo(p.x, p.y);
ctx.stroke();
setHasInk(true);
};
const stop = () => {
drawing.current = false;
};
return (
<div>
<canvas
ref={canvasRef}
width={900}
height={260}
onPointerDown={start}
onPointerMove={move}
onPointerUp={stop}
onPointerCancel={stop}
style={{
width: '100%',
height: 210,
border: '1px solid #9fb3be',
borderRadius: 10,
background: '#fff',
touchAction: 'none',
opacity: disabled ? .55 : 1,
}}
aria-label="Área para firma manuscrita"
/>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginTop: 8 }}>
<small style={{ color: '#5c707b' }}>{hasInk ? 'Firma capturada.' : 'Firmá dentro del recuadro.'}</small>
<button type="button" style={secondaryButtonStyle} onClick={clear} disabled={disabled || !hasInk}>Limpiar firma</button>
</div>
</div>
);
}
export function CompanySignaturePage() {
const [params] = useSearchParams();
const token = params.get('token')?.trim() ?? '';
const [view, setView] = useState<PublicSignatureView | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [done, setDone] = useState('');
const [action, setAction] = useState<'CONFORMITY' | 'DISSENT' | 'REFUSE'>('CONFORMITY');
const [fullName, setFullName] = useState('');
const [documentType, setDocumentType] = useState('DNI');
const [documentNumber, setDocumentNumber] = useState('');
const [position, setPosition] = useState('');
const [statement, setStatement] = useState('');
const [refusalReason, setRefusalReason] = useState('');
const [consent, setConsent] = useState(false);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
if (!token) {
setError('El enlace no contiene un token de firma.');
setLoading(false);
return;
}
try {
const response = await fetch(`${API_BASE}/${encodeURIComponent(token)}`, {
credentials: 'omit',
headers: { Accept: 'application/json' },
});
if (!response.ok) throw new Error(await problem(response));
const data = await response.json() as PublicSignatureView;
if (cancelled) return;
setView(data);
setFullName(data.responsibleDefaults.fullName ?? '');
setDocumentType(data.responsibleDefaults.documentType ?? 'DNI');
setDocumentNumber(data.responsibleDefaults.documentNumber ?? '');
setPosition(data.responsibleDefaults.position ?? '');
} catch (loadError) {
if (!cancelled) setError(loadError instanceof Error ? loadError.message : 'No se pudo abrir el enlace.');
} finally {
if (!cancelled) setLoading(false);
}
};
void load();
return () => { cancelled = true; };
}, [token]);
const identityValid = fullName.trim().length >= 2
&& documentNumber.trim().length >= 3
&& position.trim().length >= 2;
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!view || !token || !identityValid) return;
setError('');
setSubmitting(true);
try {
if (action === 'REFUSE') {
if (refusalReason.trim().length < 10) {
throw new Error('Explicá el motivo de la negativa con al menos 10 caracteres.');
}
const response = await fetch(`${API_BASE}/${encodeURIComponent(token)}/refuse`, {
method: 'POST',
credentials: 'omit',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
fullName: fullName.trim(),
documentType,
documentNumber: documentNumber.trim(),
position: position.trim(),
reason: refusalReason.trim(),
}),
});
if (!response.ok) throw new Error(await problem(response));
setDone(`Negativa registrada para ${view.act.code}. Este enlace ya no puede volver a utilizarse.`);
return;
}
if (!consent) throw new Error('Debés aceptar la constancia antes de firmar.');
if (action === 'DISSENT' && statement.trim().length < 10) {
throw new Error('Escribí la manifestación de disidencia con al menos 10 caracteres.');
}
const canvas = document.querySelector('canvas[aria-label="Área para firma manuscrita"]') as HTMLCanvasElement | null;
canvasRef.current = canvas;
if (!canvas) throw new Error('No se encontró el área de firma.');
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
if (!blob) throw new Error('No se pudo capturar la firma.');
const imageData = canvas.getContext('2d')?.getImageData(0, 0, canvas.width, canvas.height).data;
const hasInk = imageData ? Array.from(imageData).some((value, index) => index % 4 === 3 && value > 0) : false;
if (!hasInk) throw new Error('Firmá dentro del recuadro antes de continuar.');
const form = new FormData();
form.append('fullName', fullName.trim());
form.append('documentType', documentType);
form.append('documentNumber', documentNumber.trim());
form.append('position', position.trim());
form.append('manifestation', action);
if (action === 'DISSENT') form.append('statement', statement.trim());
form.append('consentAccepted', 'true');
form.append('file', blob, 'firma.png');
const response = await fetch(`${API_BASE}/${encodeURIComponent(token)}/sign`, {
method: 'POST',
credentials: 'omit',
headers: { Accept: 'application/json' },
body: form,
});
if (!response.ok) throw new Error(await problem(response));
setDone(
action === 'DISSENT'
? `Firma en disidencia registrada para ${view.act.code}. Este enlace ya no puede volver a utilizarse.`
: `Firma registrada para ${view.act.code}. Este enlace ya no puede volver a utilizarse.`,
);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'No se pudo registrar la manifestación.');
} finally {
setSubmitting(false);
}
};
if (loading) {
return <main style={shellStyle}><section style={cardStyle}><h1>DH Inspección</h1><p>Validando enlace seguro</p></section></main>;
}
if (done) {
return <main style={shellStyle}><section style={cardStyle}><div style={{ fontSize: 44 }}></div><h1>Manifestación registrada</h1><p>{done}</p><p style={{ color: '#5c707b' }}>Podés cerrar esta ventana.</p></section></main>;
}
if (!view) {
return <main style={shellStyle}><section style={cardStyle}><h1>Enlace no disponible</h1><p>{error || 'No se pudo abrir el Acta.'}</p></section></main>;
}
return (
<main style={shellStyle}>
<section style={cardStyle}>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginBottom: 18 }}>
<span style={{ display: 'inline-grid', placeItems: 'center', width: 46, height: 46, borderRadius: 12, background: '#0d5f78', color: '#fff', fontWeight: 800 }}>DH</span>
<div><strong>Dirección de Hidrocarburos</strong><div style={{ color: '#5c707b' }}>Manifestación sobre Acta de Inspección</div></div>
</div>
<h1 style={{ marginBottom: 4 }}>{view.act.code}</h1>
<p style={{ marginTop: 0, color: '#5c707b' }}>Inspección {view.act.inspectionCode}</p>
<div style={gridStyle}>
<div><strong>Estado</strong><div>BLOQUEADA</div></div>
<div><strong>Urgencia</strong><div>{view.act.urgency === 'URGENT' ? 'Urgente' : 'No urgente'}</div></div>
<div><strong>Vigencia del enlace</strong><div>{new Date(view.invitation.expiresAt).toLocaleString('es-AR')}</div></div>
</div>
<div style={{ marginTop: 18, padding: 12, borderRadius: 10, background: '#eef5f7', fontSize: 13, wordBreak: 'break-all' }}>
<strong>Hash del contenido bloqueado</strong><br />{view.act.lockedSha256}
</div>
</section>
<section style={cardStyle}>
<h2>Contenido del Acta</h2>
{view.act.summary && <><strong>Resumen</strong><p>{view.act.summary}</p></>}
{view.act.observations && <><strong>Observaciones</strong><p>{view.act.observations}</p></>}
<h3>Inventario inspeccionado</h3>
{view.inventories.length === 0 ? <p>Sin Inventario detallado.</p> : view.inventories.map((item) => (
<div key={item.id} style={{ padding: '9px 0', borderBottom: '1px solid #e3e9ed' }}>
<strong>{item.code} · {item.name}</strong>{item.typeName && <div style={{ color: '#5c707b' }}>{item.typeName}</div>}
</div>
))}
<h3 style={{ marginTop: 24 }}>Hallazgos</h3>
{view.findings.length === 0 ? <p>El Acta no contiene Hallazgos.</p> : view.findings.map((finding) => (
<article key={finding.id} style={{ padding: 14, marginBottom: 10, border: '1px solid #d8e1e7', borderRadius: 10 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<strong>{finding.code} · {finding.title}</strong>
{finding.isRecurrence && <span style={{ borderRadius: 999, padding: '3px 8px', background: '#fff0d9', color: '#7a4a00', fontSize: 12, fontWeight: 700 }}>REINCIDENCIA</span>}
</div>
<p><strong>Descripción:</strong> {finding.description}</p>
{finding.legalBasis && <p><strong>Base legal:</strong> {finding.legalBasis}</p>}
{finding.severity != null && <small>Gravedad: {finding.severity}/10</small>}
</article>
))}
</section>
<form style={cardStyle} onSubmit={submit}>
<h2>Identificación del responsable</h2>
<div style={gridStyle}>
<label>Nombre y apellido<input style={fieldStyle} value={fullName} onChange={(event) => setFullName(event.target.value)} required /></label>
<label>Documento<select style={fieldStyle} value={documentType} onChange={(event) => setDocumentType(event.target.value)}><option value="DNI">DNI</option><option value="CUIL">CUIL</option><option value="PASSPORT">Pasaporte</option><option value="OTHER">Otro</option></select></label>
<label>Número<input style={fieldStyle} value={documentNumber} onChange={(event) => setDocumentNumber(event.target.value)} required /></label>
<label>Cargo<input style={fieldStyle} value={position} onChange={(event) => setPosition(event.target.value)} required /></label>
</div>
<h2 style={{ marginTop: 28 }}>Manifestación</h2>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button type="button" style={action === 'CONFORMITY' ? primaryButtonStyle : secondaryButtonStyle} onClick={() => setAction('CONFORMITY')}>Firmar en conformidad</button>
<button type="button" style={action === 'DISSENT' ? primaryButtonStyle : secondaryButtonStyle} onClick={() => setAction('DISSENT')}>Firmar en disidencia</button>
<button type="button" style={action === 'REFUSE' ? primaryButtonStyle : secondaryButtonStyle} onClick={() => setAction('REFUSE')}>Negarme a firmar</button>
</div>
{action === 'DISSENT' && <label style={{ display: 'block', marginTop: 16 }}>Manifestación de disidencia<textarea style={{ ...fieldStyle, minHeight: 110 }} value={statement} onChange={(event) => setStatement(event.target.value)} required minLength={10} /></label>}
{action === 'REFUSE' && <label style={{ display: 'block', marginTop: 16 }}>Motivo de negativa<textarea style={{ ...fieldStyle, minHeight: 110 }} value={refusalReason} onChange={(event) => setRefusalReason(event.target.value)} required minLength={10} /></label>}
{action !== 'REFUSE' && <div style={{ marginTop: 18 }}>
<h3>Firma manuscrita</h3>
<SignaturePad disabled={submitting} />
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 9, marginTop: 14 }}>
<input type="checkbox" checked={consent} onChange={(event) => setConsent(event.target.checked)} />
<span>{view.consent}</span>
</label>
</div>}
{error && <div role="alert" style={{ marginTop: 16, padding: 12, borderRadius: 9, background: '#fde7e7', color: '#8c2424' }}>{error}</div>}
<button
type="submit"
style={{ ...primaryButtonStyle, width: '100%', marginTop: 20, opacity: submitting || !identityValid ? .6 : 1 }}
disabled={submitting || !identityValid}
>
{submitting ? 'Registrando…' : action === 'REFUSE' ? 'Confirmar negativa' : action === 'DISSENT' ? 'Firmar en disidencia' : 'Firmar Acta'}
</button>
<p style={{ color: '#5c707b', fontSize: 13, marginBottom: 0 }}>Esta acción es de un solo uso. La manifestación quedará vinculada al hash del Acta bloqueada.</p>
</form>
</main>
);
}