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 { 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(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) => { 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) => { 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) => { 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 (
{hasInk ? 'Firma capturada.' : 'Firmá dentro del recuadro.'}
); } export function CompanySignaturePage() { const [params] = useSearchParams(); const token = params.get('token')?.trim() ?? ''; const [view, setView] = useState(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(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((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

DH Inspección

Validando enlace seguro…

; } if (done) { return

Manifestación registrada

{done}

Podés cerrar esta ventana.

; } if (!view) { return

Enlace no disponible

{error || 'No se pudo abrir el Acta.'}

; } return (
DH
Dirección de Hidrocarburos
Manifestación sobre Acta de Inspección

{view.act.code}

Inspección {view.act.inspectionCode}

Estado
BLOQUEADA
Urgencia
{view.act.urgency === 'URGENT' ? 'Urgente' : 'No urgente'}
Vigencia del enlace
{new Date(view.invitation.expiresAt).toLocaleString('es-AR')}
Hash del contenido bloqueado
{view.act.lockedSha256}

Contenido del Acta

{view.act.summary && <>Resumen

{view.act.summary}

} {view.act.observations && <>Observaciones

{view.act.observations}

}

Inventario inspeccionado

{view.inventories.length === 0 ?

Sin Inventario detallado.

: view.inventories.map((item) => (
{item.code} · {item.name}{item.typeName &&
{item.typeName}
}
))}

Hallazgos

{view.findings.length === 0 ?

El Acta no contiene Hallazgos.

: view.findings.map((finding) => (
{finding.code} · {finding.title} {finding.isRecurrence && REINCIDENCIA}

Descripción: {finding.description}

{finding.legalBasis &&

Base legal: {finding.legalBasis}

} {finding.severity != null && Gravedad: {finding.severity}/10}
))}

Identificación del responsable

Manifestación

{action === 'DISSENT' &&