Improve web signing flow and locale handling

This commit is contained in:
2026-04-23 21:45:24 -05:00
parent b24d17b98e
commit 24fe8d0a0b
13 changed files with 312 additions and 42 deletions
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
import { detectClientSignatureContext } from '../../utils/clientSignatureContext';
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
@@ -28,6 +29,7 @@ export default function PdfSignaturePlacer({
signatureImage = null,
onPlaced,
}) {
const clientSignatureContext = detectClientSignatureContext();
const [numPages, setNumPages] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
const [sigBox, setSigBox] = useState(null); // { x, y } in container px
@@ -120,7 +122,9 @@ export default function PdfSignaturePlacer({
});
};
const previewDate = new Date().toLocaleDateString('es-EC');
const previewDate = new Date().toLocaleDateString(clientSignatureContext.locale, {
timeZone: clientSignatureContext.timeZone,
});
const renderPreview = () => {
if (signatureStyle === 'qr') {
+32
View File
@@ -1039,6 +1039,26 @@ select {
padding: 24px;
}
.select-action-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: stretch;
}
.select-action-buttons {
display: flex;
gap: 8px;
}
.btn-square {
min-width: 44px;
width: 44px;
padding-left: 0;
padding-right: 0;
justify-content: center;
}
.sign-form-card form { display: flex; flex-direction: column; gap: 14px; }
.sign-info-panel { display: flex; flex-direction: column; gap: 14px; }
@@ -2282,6 +2302,18 @@ select {
grid-template-columns: 1fr;
}
.select-action-row {
grid-template-columns: 1fr;
}
.select-action-buttons {
width: 100%;
}
.select-action-buttons .btn {
flex: 1;
}
.steps-bar {
flex-direction: column;
align-items: stretch;
+20 -15
View File
@@ -48,19 +48,21 @@ export default function DocumentsPage() {
const [creatingFlow, setCreatingFlow] = useState(false);
const [deletingInvitationId, setDeletingInvitationId] = useState(null);
const loadDocs = () => {
setLoading(true);
Promise.all([
listDocuments({ limit: 50 }),
listSignedDocuments(),
])
.then(([documentsResponse, signedResponse]) => {
setDocs(documentsResponse.data.data.rows);
setTotal(documentsResponse.data.data.total);
setSignedDocs(signedResponse.data.data || []);
})
.catch(() => toast.error('Error al cargar documentos'))
.finally(() => setLoading(false));
const loadDocs = async ({ silent = false } = {}) => {
if (!silent) setLoading(true);
try {
const [documentsResponse, signedResponse] = await Promise.all([
listDocuments({ limit: 50 }),
listSignedDocuments(),
]);
setDocs(documentsResponse.data.data.rows);
setTotal(documentsResponse.data.data.total);
setSignedDocs(signedResponse.data.data || []);
} catch {
toast.error('Error al cargar documentos');
} finally {
if (!silent) setLoading(false);
}
};
useEffect(() => { loadDocs(); }, []);
@@ -79,9 +81,12 @@ export default function DocumentsPage() {
if (file.type !== 'application/pdf') { toast.error('Solo se permiten archivos PDF'); return; }
setUploading(true);
try {
await uploadDocument(file);
const { data } = await uploadDocument(file);
const createdDoc = data.data;
setDocs((currentDocs) => [createdDoc, ...currentDocs.filter((doc) => doc.id !== createdDoc.id)]);
setTotal((currentTotal) => currentTotal + 1);
toast.success('Documento subido correctamente');
loadDocs();
void loadDocs({ silent: true });
} catch (err) {
toast.error(err.response?.data?.error || 'Error al subir documento');
} finally {
+132 -14
View File
@@ -1,13 +1,14 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { listDocuments, downloadDocument } from '../../api/documents';
import { listDocuments, downloadDocument, uploadDocument } 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 { PenTool, FileText, Shield, CheckCircle, MapPin, Download, Check, Plus } 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';
import { detectClientSignatureContext } from '../../utils/clientSignatureContext';
const STEPS = ['Selección', 'Posición', 'Firmar'];
const normalizeSignatureStyle = (value) => (value === 'image' ? 'qr' : (value || 'qr'));
@@ -15,13 +16,16 @@ const normalizeSignatureStyle = (value) => (value === 'image' ? 'qr' : (value ||
export default function SignaturesPage() {
const { user } = useAuth();
const [searchParams] = useSearchParams();
const clientSignatureContext = detectClientSignatureContext();
const initialDocumentId = searchParams.get('docId') || '';
const [docs, setDocs] = useState([]);
const [certs, setCerts] = useState([]);
const [documentPickerId, setDocumentPickerId] = useState(initialDocumentId);
const [form, setForm] = useState({
documentId: searchParams.get('docId') || '',
documentId: initialDocumentId,
certificateId: '',
reason: 'Firmado digitalmente',
location: '',
location: clientSignatureContext.defaultLocation,
signatureStyle: normalizeSignatureStyle(user?.signaturePreferences?.signatureStyle),
qrPosition: user?.signaturePreferences?.qrPosition || 'left',
qrBackground: user?.signaturePreferences?.qrBackground || 'transparent',
@@ -34,6 +38,9 @@ export default function SignaturesPage() {
const [result, setResult] = useState(null);
const [sigs, setSigs] = useState([]);
const [pendingSign, setPendingSign] = useState(null);
const [uploadingDocument, setUploadingDocument] = useState(false);
const [pendingLocalDocument, setPendingLocalDocument] = useState(null);
const documentInputRef = useRef(null);
useEffect(() => {
Promise.all([
@@ -59,16 +66,76 @@ export default function SignaturesPage() {
}));
}, [user?.signaturePreferences]);
useEffect(() => {
if (!clientSignatureContext.defaultLocation) return;
setForm((currentForm) => (
currentForm.location
? currentForm
: { ...currentForm, location: clientSignatureContext.defaultLocation }
));
}, [clientSignatureContext.defaultLocation]);
const handleChange = (e) => setForm(f => ({ ...f, [e.target.name]: e.target.value }));
const handleChooseDocument = () => {
if (!documentPickerId) {
toast.error('Selecciona un documento del listado');
return;
}
if (documentPickerId === '__local__') {
if (!pendingLocalDocument) {
toast.error('Selecciona un PDF local válido');
return;
}
setForm((currentForm) => ({ ...currentForm, documentId: '' }));
toast.success('PDF local seleccionado');
return;
}
setForm((currentForm) => ({ ...currentForm, documentId: documentPickerId }));
toast.success('Documento seleccionado');
};
const handleUploadFromSignFlow = async (event) => {
const file = event.target.files?.[0];
if (!file) return;
if (file.type !== 'application/pdf') {
toast.error('Solo se permiten archivos PDF');
event.target.value = '';
return;
}
setUploadingDocument(true);
try {
setPendingLocalDocument({
file,
title: file.name.replace(/\.pdf$/i, ''),
});
setDocumentPickerId('__local__');
setForm((currentForm) => ({ ...currentForm, documentId: '' }));
toast.success('Documento cargado localmente. Se guardará cuando completes la firma.');
} catch {
toast.error('No se pudo preparar el documento');
} finally {
setUploadingDocument(false);
event.target.value = '';
}
};
// Step 0 → 1: load PDF for placement
const handleGoToPlace = async () => {
if (!form.documentId || !form.certificateId) {
const usingLocalDocument = documentPickerId === '__local__' && pendingLocalDocument;
if ((!form.documentId && !usingLocalDocument) || !form.certificateId) {
toast.error('Selecciona documento y certificado'); return;
}
try {
const { data } = await downloadDocument(form.documentId);
setPdfBlob(data);
if (usingLocalDocument) {
setPdfBlob(pendingLocalDocument.file);
} else {
const { data } = await downloadDocument(form.documentId);
setPdfBlob(data);
}
setSigPlacement(null);
setStep(1);
} catch { toast.error('No se pudo cargar el documento'); }
@@ -81,18 +148,37 @@ export default function SignaturesPage() {
};
const doSign = async (certPassword = '', fromModal = false) => {
if (!form.documentId || !form.certificateId) {
const usingLocalDocument = documentPickerId === '__local__' && pendingLocalDocument;
if ((!form.documentId && !usingLocalDocument) || !form.certificateId) {
toast.error('Faltan datos de documento o certificado. Vuelve al paso 1.');
setStep(0);
return;
}
setSigning(true);
let workingDocumentId = Number(form.documentId) || null;
try {
if (!workingDocumentId && usingLocalDocument) {
const { data: uploadResponse } = await uploadDocument(pendingLocalDocument.file);
const createdDoc = uploadResponse.data;
workingDocumentId = Number(createdDoc.id);
setDocs((currentDocs) => [createdDoc, ...currentDocs.filter((doc) => doc.id !== createdDoc.id)]);
}
} catch (error) {
setSigning(false);
toast.error(error.response?.data?.error || 'No se pudo guardar el documento antes de firmar');
return;
}
const payload = {
documentId: Number(form.documentId),
documentId: workingDocumentId,
certificateId: Number(form.certificateId),
certPassword,
reason: form.reason,
location: form.location,
clientLocale: clientSignatureContext.locale,
clientTimeZone: clientSignatureContext.timeZone,
clientCountry: clientSignatureContext.countryCode,
page: sigPlacement?.page ?? 0,
rect: sigPlacement?.rect ?? null,
signatureStyle: form.signatureStyle,
@@ -105,6 +191,8 @@ export default function SignaturesPage() {
setResult(data.data);
toast.success('¡Documento firmado exitosamente!');
setDocs(prev => prev.filter(d => d.id !== payload.documentId));
setPendingLocalDocument(null);
setDocumentPickerId('');
setForm(f => ({ ...f, documentId: '', certificateId: '' }));
setSigPlacement(null);
setPdfBlob(null);
@@ -125,6 +213,9 @@ export default function SignaturesPage() {
const selectedCert = certs.find(c => c.id === Number(form.certificateId));
const selectedDoc = docs.find(d => d.id === Number(form.documentId));
const selectedDocumentTitle = documentPickerId === '__local__'
? pendingLocalDocument?.title
: selectedDoc?.title;
return (
<div className="page">
@@ -158,11 +249,38 @@ export default function SignaturesPage() {
<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>
<div className="select-action-row">
<select name="documentPickerId" value={documentPickerId} onChange={(event) => setDocumentPickerId(event.target.value)} required>
<option value="">Selecciona un documento...</option>
{docs.map(d => <option key={d.id} value={d.id}>{d.title}</option>)}
</select>
<div className="select-action-buttons">
<button type="button" className="btn btn-secondary btn-square" title="Elegir documento" onClick={handleChooseDocument}>
<Check size={16} />
</button>
<button
type="button"
className="btn btn-primary btn-square"
title="Agregar PDF"
onClick={() => documentInputRef.current?.click()}
disabled={uploadingDocument}
>
<Plus size={16} />
</button>
</div>
</div>
<input
ref={documentInputRef}
type="file"
accept=".pdf,application/pdf"
style={{ display: 'none' }}
onChange={handleUploadFromSignFlow}
/>
{docs.length === 0 && <small className="field-hint">No hay documentos pendientes.</small>}
{selectedDocumentTitle && (
<small className="field-hint">Seleccionado: {selectedDocumentTitle}</small>
)}
{uploadingDocument && <small className="field-hint">Subiendo documento...</small>}
</div>
<div className="field">
<label><Shield size={14} /> Certificado digital</label>
@@ -0,0 +1,68 @@
const COUNTRY_BY_TIMEZONE = {
'America/Guayaquil': 'EC',
'America/Bogota': 'CO',
'America/Lima': 'PE',
'America/Caracas': 'VE',
'America/Santiago': 'CL',
'America/Buenos_Aires': 'AR',
'America/Sao_Paulo': 'BR',
'America/Mexico_City': 'MX',
'America/Panama': 'PA',
'America/Costa_Rica': 'CR',
'America/El_Salvador': 'SV',
'America/Managua': 'NI',
'America/Tegucigalpa': 'HN',
'America/Guatemala': 'GT',
'America/La_Paz': 'BO',
'America/Asuncion': 'PY',
'America/Montevideo': 'UY',
'America/Puerto_Rico': 'PR',
'America/New_York': 'US',
'America/Chicago': 'US',
'America/Denver': 'US',
'America/Los_Angeles': 'US',
'Europe/Madrid': 'ES',
};
const getClientLocale = () => {
if (typeof navigator === 'undefined') return 'es-EC';
return navigator.languages?.[0] || navigator.language || 'es-EC';
};
const getRegionFromLocale = (locale) => {
try {
const normalized = String(locale || '').replace('_', '-');
if (typeof Intl.Locale === 'function') {
return new Intl.Locale(normalized).region || null;
}
const parts = normalized.split('-');
return parts[1] || null;
} catch {
return null;
}
};
const getCountryName = (countryCode, locale) => {
if (!countryCode) return '';
try {
const display = new Intl.DisplayNames([locale || 'es-EC'], { type: 'region' });
return display.of(countryCode) || countryCode;
} catch {
return countryCode;
}
};
export const detectClientSignatureContext = () => {
const locale = getClientLocale();
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const countryCode = getRegionFromLocale(locale) || COUNTRY_BY_TIMEZONE[timeZone] || null;
const countryName = getCountryName(countryCode, locale);
return {
locale,
timeZone,
countryCode,
countryName,
defaultLocation: countryName || '',
};
};