Improve web signing flow and locale handling
This commit is contained in:
+7
-1
@@ -2,10 +2,16 @@
|
|||||||
|
|
||||||
## Versión actual
|
## Versión actual
|
||||||
|
|
||||||
- Versión base: `1.2.8`
|
- Versión base: `1.2.9`
|
||||||
|
|
||||||
## Historial
|
## Historial
|
||||||
|
|
||||||
|
### 1.2.9 - 2026-04-23
|
||||||
|
|
||||||
|
- Web evita el refresco manual después de subir PDFs y actualiza la lista de documentos en caliente.
|
||||||
|
- El sello visible de firma usa el huso horario y locale detectados en el navegador del usuario.
|
||||||
|
- `Firmar ahora` agrega selector con botón para elegir documento y botón `+` para cargar un PDF desde el mismo flujo.
|
||||||
|
|
||||||
### 1.2.8 - 2026-04-23
|
### 1.2.8 - 2026-04-23
|
||||||
|
|
||||||
- Android simplifica la pantalla biométrica: se ocultan `Face ID`, `PIN` y `SMS OTP`.
|
- Android simplifica la pantalla biométrica: se ocultan `Face ID`, `PIN` y `SMS OTP`.
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ android {
|
|||||||
applicationId "net.linkedcorp.linkedsign"
|
applicationId "net.linkedcorp.linkedsign"
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 35
|
targetSdk 35
|
||||||
versionCode 10208
|
versionCode 10209
|
||||||
versionName "1.2.8"
|
versionName "1.2.9"
|
||||||
|
|
||||||
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
|
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkedsign-backend",
|
"name": "linkedsign-backend",
|
||||||
"version": "1.2.8",
|
"version": "1.2.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Initial backend for LinkedSign",
|
"description": "Initial backend for LinkedSign",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -8,18 +8,21 @@ const getCtx = (req) => ({
|
|||||||
userName: req.user?.name || req.user?.email,
|
userName: req.user?.name || req.user?.email,
|
||||||
userEmail: req.user?.email || null,
|
userEmail: req.user?.email || null,
|
||||||
frontendUrl: req.headers.origin || null,
|
frontendUrl: req.headers.origin || null,
|
||||||
|
clientLocale: req.body?.clientLocale || null,
|
||||||
|
clientTimeZone: req.body?.clientTimeZone || null,
|
||||||
|
clientCountry: req.body?.clientCountry || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sign = async (req, res, next) => {
|
const sign = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 } = req.body;
|
const { documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64, clientLocale, clientTimeZone, clientCountry } = req.body;
|
||||||
const docId = Number(documentId);
|
const docId = Number(documentId);
|
||||||
const certId = Number(certificateId);
|
const certId = Number(certificateId);
|
||||||
if (!docId || !certId || isNaN(docId) || isNaN(certId)) {
|
if (!docId || !certId || isNaN(docId) || isNaN(certId)) {
|
||||||
const e = new Error('documentId and certificateId are required.'); e.statusCode = 400; throw e;
|
const e = new Error('documentId and certificateId are required.'); e.statusCode = 400; throw e;
|
||||||
}
|
}
|
||||||
const result = await signaturesService.sign(
|
const result = await signaturesService.sign(
|
||||||
{ documentId: docId, certificateId: certId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 },
|
{ documentId: docId, certificateId: certId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64, clientLocale, clientTimeZone, clientCountry },
|
||||||
req.user.id,
|
req.user.id,
|
||||||
getCtx(req)
|
getCtx(req)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const { signPdf } = require('../signing/signing.service');
|
|||||||
const auditService = require('../audit/audit.service');
|
const auditService = require('../audit/audit.service');
|
||||||
const env = require('../../config/env');
|
const env = require('../../config/env');
|
||||||
|
|
||||||
const sign = async ({ documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 }, userId, context = {}) => {
|
const sign = async ({ documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64, clientLocale, clientTimeZone, clientCountry }, userId, context = {}) => {
|
||||||
const doc = await documentsRepository.findById(documentId);
|
const doc = await documentsRepository.findById(documentId);
|
||||||
if (!doc) { const e = new Error('Document not found.'); e.statusCode = 404; throw e; }
|
if (!doc) { const e = new Error('Document not found.'); e.statusCode = 404; throw e; }
|
||||||
if (doc.user_id !== userId) { const e = new Error('Access denied.'); e.statusCode = 403; throw e; }
|
if (doc.user_id !== userId) { const e = new Error('Access denied.'); e.statusCode = 403; throw e; }
|
||||||
@@ -50,6 +50,9 @@ const sign = async ({ documentId, certificateId, certPassword, reason, location,
|
|||||||
qrBorder: qrBorder || 'none',
|
qrBorder: qrBorder || 'none',
|
||||||
signatureImageBase64: signatureImageBase64 || null,
|
signatureImageBase64: signatureImageBase64 || null,
|
||||||
verificationUrl,
|
verificationUrl,
|
||||||
|
clientLocale: clientLocale || context.clientLocale || null,
|
||||||
|
clientTimeZone: clientTimeZone || context.clientTimeZone || null,
|
||||||
|
clientCountry: clientCountry || context.clientCountry || null,
|
||||||
}));
|
}));
|
||||||
} catch (signErr) {
|
} catch (signErr) {
|
||||||
const msg = signErr.message || '';
|
const msg = signErr.message || '';
|
||||||
|
|||||||
@@ -39,6 +39,34 @@ function getSignatureWidth(style, signerName = '') {
|
|||||||
return Math.max(170, Math.min(270, Math.round(calculatedWidth)));
|
return Math.max(170, Math.min(270, Math.round(calculatedWidth)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildLocalizedSignatureTimestamp({ locale, timeZone } = {}) {
|
||||||
|
const now = new Date();
|
||||||
|
const normalizedLocale = locale || 'es-EC';
|
||||||
|
const options = timeZone ? { timeZone } : {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
dateStr: now.toLocaleDateString(normalizedLocale, {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
...options,
|
||||||
|
}),
|
||||||
|
timeStr: now.toLocaleTimeString(normalizedLocale, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
...options,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
dateStr: now.toLocaleDateString('es-EC', { day: '2-digit', month: '2-digit', year: 'numeric' }),
|
||||||
|
timeStr: now.toLocaleTimeString('es-EC', { hour: '2-digit', minute: '2-digit', hour12: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Style: CLASSIC (blue bar + DS badge) ──────────────────────────────────────
|
// ── Style: CLASSIC (blue bar + DS badge) ──────────────────────────────────────
|
||||||
async function drawStyleClassic(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location }) {
|
async function drawStyleClassic(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location }) {
|
||||||
const f1 = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
const f1 = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||||
@@ -222,6 +250,8 @@ const signPdf = async ({
|
|||||||
qrBackground = 'transparent',
|
qrBackground = 'transparent',
|
||||||
qrBorder = 'none',
|
qrBorder = 'none',
|
||||||
verificationUrl = null,
|
verificationUrl = null,
|
||||||
|
clientLocale = null,
|
||||||
|
clientTimeZone = null,
|
||||||
outputDir = null,
|
outputDir = null,
|
||||||
outputBaseName = null,
|
outputBaseName = null,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -258,9 +288,10 @@ const signPdf = async ({
|
|||||||
};
|
};
|
||||||
const { x: sx, y: sy, width: sw, height: sh } = sigRect;
|
const { x: sx, y: sy, width: sw, height: sh } = sigRect;
|
||||||
|
|
||||||
const now = new Date();
|
const { dateStr, timeStr } = buildLocalizedSignatureTimestamp({
|
||||||
const dateStr = now.toLocaleDateString('es-EC', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
locale: clientLocale,
|
||||||
const timeStr = now.toLocaleTimeString('es-EC', { hour: '2-digit', minute: '2-digit', hour12: true });
|
timeZone: clientTimeZone,
|
||||||
|
});
|
||||||
|
|
||||||
const info = { signerName, reason, dateStr, timeStr, location, signatureImageBase64, verificationUrl, qrPosition, qrBackground, qrBorder };
|
const info = { signerName, reason, dateStr, timeStr, location, signatureImageBase64, verificationUrl, qrPosition, qrBackground, qrBorder };
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.8",
|
"version": "1.2.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { Document, Page, pdfjs } from 'react-pdf';
|
import { Document, Page, pdfjs } from 'react-pdf';
|
||||||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||||
import 'react-pdf/dist/Page/TextLayer.css';
|
import 'react-pdf/dist/Page/TextLayer.css';
|
||||||
|
import { detectClientSignatureContext } from '../../utils/clientSignatureContext';
|
||||||
|
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||||
@@ -28,6 +29,7 @@ export default function PdfSignaturePlacer({
|
|||||||
signatureImage = null,
|
signatureImage = null,
|
||||||
onPlaced,
|
onPlaced,
|
||||||
}) {
|
}) {
|
||||||
|
const clientSignatureContext = detectClientSignatureContext();
|
||||||
const [numPages, setNumPages] = useState(null);
|
const [numPages, setNumPages] = useState(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [sigBox, setSigBox] = useState(null); // { x, y } in container px
|
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 = () => {
|
const renderPreview = () => {
|
||||||
if (signatureStyle === 'qr') {
|
if (signatureStyle === 'qr') {
|
||||||
|
|||||||
@@ -1039,6 +1039,26 @@ select {
|
|||||||
padding: 24px;
|
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-form-card form { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
|
||||||
.sign-info-panel { 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;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.select-action-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-action-buttons {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-action-buttons .btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.steps-bar {
|
.steps-bar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
@@ -48,19 +48,21 @@ export default function DocumentsPage() {
|
|||||||
const [creatingFlow, setCreatingFlow] = useState(false);
|
const [creatingFlow, setCreatingFlow] = useState(false);
|
||||||
const [deletingInvitationId, setDeletingInvitationId] = useState(null);
|
const [deletingInvitationId, setDeletingInvitationId] = useState(null);
|
||||||
|
|
||||||
const loadDocs = () => {
|
const loadDocs = async ({ silent = false } = {}) => {
|
||||||
setLoading(true);
|
if (!silent) setLoading(true);
|
||||||
Promise.all([
|
try {
|
||||||
listDocuments({ limit: 50 }),
|
const [documentsResponse, signedResponse] = await Promise.all([
|
||||||
listSignedDocuments(),
|
listDocuments({ limit: 50 }),
|
||||||
])
|
listSignedDocuments(),
|
||||||
.then(([documentsResponse, signedResponse]) => {
|
]);
|
||||||
setDocs(documentsResponse.data.data.rows);
|
setDocs(documentsResponse.data.data.rows);
|
||||||
setTotal(documentsResponse.data.data.total);
|
setTotal(documentsResponse.data.data.total);
|
||||||
setSignedDocs(signedResponse.data.data || []);
|
setSignedDocs(signedResponse.data.data || []);
|
||||||
})
|
} catch {
|
||||||
.catch(() => toast.error('Error al cargar documentos'))
|
toast.error('Error al cargar documentos');
|
||||||
.finally(() => setLoading(false));
|
} finally {
|
||||||
|
if (!silent) setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { loadDocs(); }, []);
|
useEffect(() => { loadDocs(); }, []);
|
||||||
@@ -79,9 +81,12 @@ export default function DocumentsPage() {
|
|||||||
if (file.type !== 'application/pdf') { toast.error('Solo se permiten archivos PDF'); return; }
|
if (file.type !== 'application/pdf') { toast.error('Solo se permiten archivos PDF'); return; }
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
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');
|
toast.success('Documento subido correctamente');
|
||||||
loadDocs();
|
void loadDocs({ silent: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.response?.data?.error || 'Error al subir documento');
|
toast.error(err.response?.data?.error || 'Error al subir documento');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
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 { listCertificates } from '../../api/certificates';
|
||||||
import { signDocument, getSignatures, downloadSigned } from '../../api/signatures';
|
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 toast from 'react-hot-toast';
|
||||||
import PasswordModal from '../../components/ui/PasswordModal';
|
import PasswordModal from '../../components/ui/PasswordModal';
|
||||||
import PdfSignaturePlacer from '../../components/ui/PdfSignaturePlacer';
|
import PdfSignaturePlacer from '../../components/ui/PdfSignaturePlacer';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { detectClientSignatureContext } from '../../utils/clientSignatureContext';
|
||||||
|
|
||||||
const STEPS = ['Selección', 'Posición', 'Firmar'];
|
const STEPS = ['Selección', 'Posición', 'Firmar'];
|
||||||
const normalizeSignatureStyle = (value) => (value === 'image' ? 'qr' : (value || 'qr'));
|
const normalizeSignatureStyle = (value) => (value === 'image' ? 'qr' : (value || 'qr'));
|
||||||
@@ -15,13 +16,16 @@ const normalizeSignatureStyle = (value) => (value === 'image' ? 'qr' : (value ||
|
|||||||
export default function SignaturesPage() {
|
export default function SignaturesPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const clientSignatureContext = detectClientSignatureContext();
|
||||||
|
const initialDocumentId = searchParams.get('docId') || '';
|
||||||
const [docs, setDocs] = useState([]);
|
const [docs, setDocs] = useState([]);
|
||||||
const [certs, setCerts] = useState([]);
|
const [certs, setCerts] = useState([]);
|
||||||
|
const [documentPickerId, setDocumentPickerId] = useState(initialDocumentId);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
documentId: searchParams.get('docId') || '',
|
documentId: initialDocumentId,
|
||||||
certificateId: '',
|
certificateId: '',
|
||||||
reason: 'Firmado digitalmente',
|
reason: 'Firmado digitalmente',
|
||||||
location: '',
|
location: clientSignatureContext.defaultLocation,
|
||||||
signatureStyle: normalizeSignatureStyle(user?.signaturePreferences?.signatureStyle),
|
signatureStyle: normalizeSignatureStyle(user?.signaturePreferences?.signatureStyle),
|
||||||
qrPosition: user?.signaturePreferences?.qrPosition || 'left',
|
qrPosition: user?.signaturePreferences?.qrPosition || 'left',
|
||||||
qrBackground: user?.signaturePreferences?.qrBackground || 'transparent',
|
qrBackground: user?.signaturePreferences?.qrBackground || 'transparent',
|
||||||
@@ -34,6 +38,9 @@ export default function SignaturesPage() {
|
|||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [sigs, setSigs] = useState([]);
|
const [sigs, setSigs] = useState([]);
|
||||||
const [pendingSign, setPendingSign] = useState(null);
|
const [pendingSign, setPendingSign] = useState(null);
|
||||||
|
const [uploadingDocument, setUploadingDocument] = useState(false);
|
||||||
|
const [pendingLocalDocument, setPendingLocalDocument] = useState(null);
|
||||||
|
const documentInputRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -59,16 +66,76 @@ export default function SignaturesPage() {
|
|||||||
}));
|
}));
|
||||||
}, [user?.signaturePreferences]);
|
}, [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 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
|
// Step 0 → 1: load PDF for placement
|
||||||
const handleGoToPlace = async () => {
|
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;
|
toast.error('Selecciona documento y certificado'); return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { data } = await downloadDocument(form.documentId);
|
if (usingLocalDocument) {
|
||||||
setPdfBlob(data);
|
setPdfBlob(pendingLocalDocument.file);
|
||||||
|
} else {
|
||||||
|
const { data } = await downloadDocument(form.documentId);
|
||||||
|
setPdfBlob(data);
|
||||||
|
}
|
||||||
setSigPlacement(null);
|
setSigPlacement(null);
|
||||||
setStep(1);
|
setStep(1);
|
||||||
} catch { toast.error('No se pudo cargar el documento'); }
|
} catch { toast.error('No se pudo cargar el documento'); }
|
||||||
@@ -81,18 +148,37 @@ export default function SignaturesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const doSign = async (certPassword = '', fromModal = false) => {
|
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.');
|
toast.error('Faltan datos de documento o certificado. Vuelve al paso 1.');
|
||||||
setStep(0);
|
setStep(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSigning(true);
|
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 = {
|
const payload = {
|
||||||
documentId: Number(form.documentId),
|
documentId: workingDocumentId,
|
||||||
certificateId: Number(form.certificateId),
|
certificateId: Number(form.certificateId),
|
||||||
certPassword,
|
certPassword,
|
||||||
reason: form.reason,
|
reason: form.reason,
|
||||||
location: form.location,
|
location: form.location,
|
||||||
|
clientLocale: clientSignatureContext.locale,
|
||||||
|
clientTimeZone: clientSignatureContext.timeZone,
|
||||||
|
clientCountry: clientSignatureContext.countryCode,
|
||||||
page: sigPlacement?.page ?? 0,
|
page: sigPlacement?.page ?? 0,
|
||||||
rect: sigPlacement?.rect ?? null,
|
rect: sigPlacement?.rect ?? null,
|
||||||
signatureStyle: form.signatureStyle,
|
signatureStyle: form.signatureStyle,
|
||||||
@@ -105,6 +191,8 @@ export default function SignaturesPage() {
|
|||||||
setResult(data.data);
|
setResult(data.data);
|
||||||
toast.success('¡Documento firmado exitosamente!');
|
toast.success('¡Documento firmado exitosamente!');
|
||||||
setDocs(prev => prev.filter(d => d.id !== payload.documentId));
|
setDocs(prev => prev.filter(d => d.id !== payload.documentId));
|
||||||
|
setPendingLocalDocument(null);
|
||||||
|
setDocumentPickerId('');
|
||||||
setForm(f => ({ ...f, documentId: '', certificateId: '' }));
|
setForm(f => ({ ...f, documentId: '', certificateId: '' }));
|
||||||
setSigPlacement(null);
|
setSigPlacement(null);
|
||||||
setPdfBlob(null);
|
setPdfBlob(null);
|
||||||
@@ -125,6 +213,9 @@ export default function SignaturesPage() {
|
|||||||
|
|
||||||
const selectedCert = certs.find(c => c.id === Number(form.certificateId));
|
const selectedCert = certs.find(c => c.id === Number(form.certificateId));
|
||||||
const selectedDoc = docs.find(d => d.id === Number(form.documentId));
|
const selectedDoc = docs.find(d => d.id === Number(form.documentId));
|
||||||
|
const selectedDocumentTitle = documentPickerId === '__local__'
|
||||||
|
? pendingLocalDocument?.title
|
||||||
|
: selectedDoc?.title;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
@@ -158,11 +249,38 @@ export default function SignaturesPage() {
|
|||||||
<div className="sign-form-card">
|
<div className="sign-form-card">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label><FileText size={14} /> Documento</label>
|
<label><FileText size={14} /> Documento</label>
|
||||||
<select name="documentId" value={form.documentId} onChange={handleChange} required>
|
<div className="select-action-row">
|
||||||
<option value="">Selecciona un documento...</option>
|
<select name="documentPickerId" value={documentPickerId} onChange={(event) => setDocumentPickerId(event.target.value)} required>
|
||||||
{docs.map(d => <option key={d.id} value={d.id}>{d.title}</option>)}
|
<option value="">Selecciona un documento...</option>
|
||||||
</select>
|
{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>}
|
{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>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label><Shield size={14} /> Certificado digital</label>
|
<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