diff --git a/web-v2/src/pages/CompanySignaturePage.tsx b/web-v2/src/pages/CompanySignaturePage.tsx new file mode 100644 index 0000000..edbb826 --- /dev/null +++ b/web-v2/src/pages/CompanySignaturePage.tsx @@ -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 { + 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' &&