F4 WEB: add public secure Act signature page
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user