Improve web signing flow and locale handling
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.2.8",
|
||||
"version": "1.2.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 || '',
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user