301 lines
13 KiB
React
301 lines
13 KiB
React
import { useEffect, useState } from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import { listDocuments, downloadDocument } from '../../api/documents';
|
|
import { listCertificates } from '../../api/certificates';
|
|
import { signDocument, getSignatures, downloadSigned } from '../../api/signatures';
|
|
import { PenTool, FileText, Shield, CheckCircle, MapPin, Download } from 'lucide-react';
|
|
import toast from 'react-hot-toast';
|
|
import PasswordModal from '../../components/ui/PasswordModal';
|
|
import PdfSignaturePlacer from '../../components/ui/PdfSignaturePlacer';
|
|
import { useAuth } from '../../contexts/AuthContext';
|
|
|
|
const STEPS = ['Selección', 'Posición', 'Firmar'];
|
|
|
|
export default function SignaturesPage() {
|
|
const { user } = useAuth();
|
|
const [searchParams] = useSearchParams();
|
|
const [docs, setDocs] = useState([]);
|
|
const [certs, setCerts] = useState([]);
|
|
const [form, setForm] = useState({
|
|
documentId: searchParams.get('docId') || '',
|
|
certificateId: '',
|
|
reason: 'Firmado digitalmente',
|
|
location: '',
|
|
signatureStyle: user?.signaturePreferences?.signatureStyle || 'qr',
|
|
qrPosition: user?.signaturePreferences?.qrPosition || 'left',
|
|
qrBackground: user?.signaturePreferences?.qrBackground || 'transparent',
|
|
qrBorder: user?.signaturePreferences?.qrBorder || 'none',
|
|
signatureImageBase64: user?.signaturePreferences?.signatureImageBase64 || null,
|
|
});
|
|
const [step, setStep] = useState(0); // 0=select, 1=place, 2=sign
|
|
const [pdfBlob, setPdfBlob] = useState(null);
|
|
const [sigPlacement, setSigPlacement] = useState(null); // { page, rect }
|
|
const [signing, setSigning] = useState(false);
|
|
const [result, setResult] = useState(null);
|
|
const [sigs, setSigs] = useState([]);
|
|
const [pendingSign, setPendingSign] = useState(null);
|
|
|
|
useEffect(() => {
|
|
Promise.all([
|
|
listDocuments({ limit: 100 }).then(({ data }) => setDocs(data.data.rows.filter(d => d.status !== 'signed'))),
|
|
listCertificates().then(({ data }) => setCerts(data.data.filter(c => c.is_active))),
|
|
]).catch(() => toast.error('Error al cargar datos'));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (form.documentId) {
|
|
getSignatures(form.documentId).then(({ data }) => setSigs(data.data || [])).catch(() => setSigs([]));
|
|
}
|
|
}, [form.documentId]);
|
|
|
|
useEffect(() => {
|
|
if (!user?.signaturePreferences) return;
|
|
setForm((currentForm) => ({
|
|
...currentForm,
|
|
signatureStyle: user.signaturePreferences.signatureStyle || 'qr',
|
|
qrPosition: user.signaturePreferences.qrPosition || 'left',
|
|
qrBackground: user.signaturePreferences.qrBackground || 'transparent',
|
|
qrBorder: user.signaturePreferences.qrBorder || 'none',
|
|
signatureImageBase64: user.signaturePreferences.signatureImageBase64 || null,
|
|
}));
|
|
}, [user?.signaturePreferences]);
|
|
|
|
const handleChange = (e) => setForm(f => ({ ...f, [e.target.name]: e.target.value }));
|
|
|
|
// Step 0 → 1: load PDF for placement
|
|
const handleGoToPlace = async () => {
|
|
if (!form.documentId || !form.certificateId) {
|
|
toast.error('Selecciona documento y certificado'); return;
|
|
}
|
|
try {
|
|
const { data } = await downloadDocument(form.documentId);
|
|
setPdfBlob(data);
|
|
setSigPlacement(null);
|
|
setStep(1);
|
|
} catch { toast.error('No se pudo cargar el documento'); }
|
|
};
|
|
|
|
// Step 1 → 2: placement confirmed
|
|
const handlePlaced = (placement) => {
|
|
setSigPlacement(placement);
|
|
setStep(2);
|
|
};
|
|
|
|
const doSign = async (certPassword = '', fromModal = false) => {
|
|
if (!form.documentId || !form.certificateId) {
|
|
toast.error('Faltan datos de documento o certificado. Vuelve al paso 1.');
|
|
setStep(0);
|
|
return;
|
|
}
|
|
setSigning(true);
|
|
const payload = {
|
|
documentId: Number(form.documentId),
|
|
certificateId: Number(form.certificateId),
|
|
certPassword,
|
|
reason: form.reason,
|
|
location: form.location,
|
|
page: sigPlacement?.page ?? 0,
|
|
rect: sigPlacement?.rect ?? null,
|
|
signatureStyle: form.signatureStyle,
|
|
qrPosition: form.qrPosition,
|
|
qrBackground: form.qrBackground,
|
|
qrBorder: form.qrBorder,
|
|
signatureImageBase64: form.signatureImageBase64,
|
|
};
|
|
try {
|
|
const { data } = await signDocument(payload);
|
|
setResult(data.data);
|
|
toast.success('¡Documento firmado exitosamente!');
|
|
setDocs(prev => prev.filter(d => d.id !== payload.documentId));
|
|
setForm(f => ({ ...f, documentId: '', certificateId: '' }));
|
|
setSigPlacement(null);
|
|
setPdfBlob(null);
|
|
setStep(0);
|
|
setPendingSign(null);
|
|
} catch (err) {
|
|
const code = err.response?.data?.code;
|
|
if (code === 'CERT_PASSWORD_REQUIRED' && !fromModal) {
|
|
setPendingSign(payload);
|
|
} else if (code === 'CERT_PASSWORD_REQUIRED' && fromModal) {
|
|
toast.error('Contraseña incorrecta. Intenta de nuevo.');
|
|
setPendingSign(payload);
|
|
} else {
|
|
toast.error(err.response?.data?.error || 'Error al firmar');
|
|
}
|
|
} finally { setSigning(false); }
|
|
};
|
|
|
|
const selectedCert = certs.find(c => c.id === Number(form.certificateId));
|
|
const selectedDoc = docs.find(d => d.id === Number(form.documentId));
|
|
|
|
return (
|
|
<div className="page">
|
|
{pendingSign && (
|
|
<PasswordModal
|
|
title="Contraseña del certificado"
|
|
description={`El certificado "${selectedCert?.alias || ''}" requiere contraseña para firmar. No se almacenará.`}
|
|
onConfirm={(pw) => doSign(pw, true)}
|
|
onCancel={() => setPendingSign(null)}
|
|
/>
|
|
)}
|
|
|
|
<div className="page-header">
|
|
<h2>Firmar Documento</h2>
|
|
<p>Firma digital con certificado P12 — firma visible en el PDF</p>
|
|
</div>
|
|
|
|
{/* Steps indicator */}
|
|
<div className="steps-bar">
|
|
{STEPS.map((label, i) => (
|
|
<div key={i} className={`step ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`}>
|
|
<div className="step-dot">{i < step ? '✓' : i + 1}</div>
|
|
<span>{label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── Step 0: Selección ── */}
|
|
{step === 0 && (
|
|
<div className="sign-layout">
|
|
<div className="sign-form-card">
|
|
<div className="field">
|
|
<label><FileText size={14} /> Documento</label>
|
|
<select name="documentId" value={form.documentId} onChange={handleChange} required>
|
|
<option value="">Selecciona un documento...</option>
|
|
{docs.map(d => <option key={d.id} value={d.id}>{d.title}</option>)}
|
|
</select>
|
|
{docs.length === 0 && <small className="field-hint">No hay documentos pendientes.</small>}
|
|
</div>
|
|
<div className="field">
|
|
<label><Shield size={14} /> Certificado digital</label>
|
|
<select name="certificateId" value={form.certificateId} onChange={handleChange} required>
|
|
<option value="">Selecciona un certificado...</option>
|
|
{certs.map(c => <option key={c.id} value={c.id}>{c.alias}{c.subject_cn ? ` (${c.subject_cn})` : ''}</option>)}
|
|
</select>
|
|
{certs.length === 0 && <small className="field-hint">No tienes certificados activos.</small>}
|
|
</div>
|
|
<div className="field">
|
|
<label>Razón de firma</label>
|
|
<input type="text" name="reason" value={form.reason} onChange={handleChange} />
|
|
</div>
|
|
<div className="field">
|
|
<label>Ubicación</label>
|
|
<input type="text" name="location" value={form.location} onChange={handleChange} placeholder="Ciudad, País..." />
|
|
</div>
|
|
<div className="info-box">
|
|
<h4>Estilo aplicado</h4>
|
|
<p>La firma usará tu configuración guardada en el menú <strong>Estilo Firma</strong>.</p>
|
|
<p><strong>Tipo:</strong> {form.signatureStyle}</p>
|
|
{form.signatureStyle === 'qr' && <p><strong>Posición QR:</strong> {form.qrPosition === 'left' ? 'Izquierda' : 'Derecha'}</p>}
|
|
{form.signatureStyle === 'qr' && <p><strong>Fondo:</strong> {form.qrBackground === 'transparent' ? 'Transparente' : 'Suave'}</p>}
|
|
{form.signatureStyle === 'qr' && <p><strong>Borde:</strong> {form.qrBorder === 'none' ? 'Sin borde' : 'Con borde'}</p>}
|
|
</div>
|
|
<button className="btn btn-primary btn-full" onClick={handleGoToPlace}>
|
|
<MapPin size={16} /> Elegir posición de firma →
|
|
</button>
|
|
</div>
|
|
|
|
<div className="sign-info-panel">
|
|
{result && (
|
|
<div className="result-card success">
|
|
<CheckCircle size={32} />
|
|
<h3>¡Firma aplicada!</h3>
|
|
<p>Firma visible + firma criptográfica válida.</p>
|
|
<code>{result.signedFilename}</code>
|
|
<button
|
|
className="btn btn-primary"
|
|
style={{ marginTop: 12, width: '100%', justifyContent: 'center' }}
|
|
onClick={async () => {
|
|
try {
|
|
const { data } = await downloadSigned(result.sigId);
|
|
const url = URL.createObjectURL(data);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = result.signedFilename; a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch { toast.error('Error al descargar'); }
|
|
}}
|
|
>
|
|
<Download size={15} /> Descargar PDF firmado
|
|
</button>
|
|
</div>
|
|
)}
|
|
{sigs.length > 0 && (
|
|
<div className="sigs-list">
|
|
<h3>Firmas del documento</h3>
|
|
{sigs.map(sig => (
|
|
<div key={sig.id} className="sig-item">
|
|
<CheckCircle size={16} className="sig-icon" />
|
|
<div>
|
|
<strong>{sig.signer_name}</strong>
|
|
<p>{sig.signer_email}</p>
|
|
<small>{new Date(sig.signed_at).toLocaleString('es-EC')}</small>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="info-box">
|
|
<h4>Proceso de firma</h4>
|
|
<ol>
|
|
<li>Selecciona documento y certificado</li>
|
|
<li>Elige dónde colocar la firma en el PDF</li>
|
|
<li>Confirma — se pedirá contraseña si es necesario</li>
|
|
<li>Descarga el PDF firmado con firma visible</li>
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Step 1: Posicionamiento ── */}
|
|
{step === 1 && pdfBlob && (
|
|
<div>
|
|
<div className="step-nav">
|
|
<button className="btn btn-ghost" onClick={() => setStep(0)}>← Volver</button>
|
|
<p className="step-desc">
|
|
Haz clic donde quieres que aparezca la firma en <strong>{selectedDoc?.title}</strong>
|
|
</p>
|
|
</div>
|
|
<PdfSignaturePlacer
|
|
pdfBlob={pdfBlob}
|
|
signerName={selectedCert?.subject_cn || user?.name || 'Firmante'}
|
|
signatureStyle={form.signatureStyle}
|
|
qrPosition={form.qrPosition}
|
|
qrBackground={form.qrBackground}
|
|
qrBorder={form.qrBorder}
|
|
signatureImage={form.signatureImageBase64}
|
|
onPlaced={handlePlaced}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Step 2: Confirmar y firmar ── */}
|
|
{step === 2 && (
|
|
<div className="sign-confirm-card">
|
|
<div className="sign-confirm-header">
|
|
<CheckCircle size={24} color="var(--success)" />
|
|
<h3>Posición confirmada — listo para firmar</h3>
|
|
</div>
|
|
<div className="sign-confirm-details">
|
|
<p><strong>Documento:</strong> {selectedDoc?.title}</p>
|
|
<p><strong>Certificado:</strong> {selectedCert?.alias}</p>
|
|
<p><strong>Razón:</strong> {form.reason}</p>
|
|
{form.location && <p><strong>Ubicación:</strong> {form.location}</p>}
|
|
<p><strong>Estilo visual:</strong> {form.signatureStyle}</p>
|
|
{form.signatureStyle === 'qr' && <p><strong>Posición QR:</strong> {form.qrPosition === 'left' ? 'Izquierda' : 'Derecha'}</p>}
|
|
{form.signatureStyle === 'qr' && <p><strong>Fondo:</strong> {form.qrBackground === 'transparent' ? 'Transparente' : 'Suave'}</p>}
|
|
{form.signatureStyle === 'qr' && <p><strong>Borde:</strong> {form.qrBorder === 'none' ? 'Sin borde' : 'Con borde'}</p>}
|
|
<p><strong>Página:</strong> {(sigPlacement?.page ?? 0) + 1}</p>
|
|
</div>
|
|
<div className="sign-confirm-actions">
|
|
<button className="btn btn-ghost" onClick={() => setStep(1)}>← Cambiar posición</button>
|
|
<button className="btn btn-primary" onClick={() => doSign('')} disabled={signing}>
|
|
<PenTool size={16} /> {signing ? 'Firmando...' : 'Firmar documento'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|