espace-paie-odentas/app/(app)/signatures-electroniques/page.tsx
2025-10-12 17:05:46 +02:00

476 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { FileSignature, BellRing, XCircle } from 'lucide-react';
import Script from 'next/script';
import { usePageTitle } from '@/hooks/usePageTitle';
type AirtableRecord = {
id: string;
fields: Record<string, any>;
};
type ContractsResponse = {
records: AirtableRecord[];
};
type ContratWithSignatures = AirtableRecord & {
fields: {
Reference?: string;
embed_src_employeur?: string;
docuseal_template_id?: string;
[key: string]: any;
};
};
function classNames(...arr: Array<string | false | null | undefined>) {
return arr.filter(Boolean).join(' ');
}
export default function SignaturesElectroniques() {
usePageTitle("Signatures électroniques");
const [contrats, setContrats] = useState<ContratWithSignatures[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isOnline, setIsOnline] = useState(true);
// États pour les contrats
const [recordsEmployeur, setRecordsEmployeur] = useState<AirtableRecord[]>([]);
const [recordsSalarie, setRecordsSalarie] = useState<AirtableRecord[]>([]);
// États pour les modales
const [modalTitle, setModalTitle] = useState('');
const [embedSrc, setEmbedSrc] = useState<string | null>(null);
const [pageEmbedSrc, setPageEmbedSrc] = useState<string | null>(null);
const [pageEmbedTitle, setPageEmbedTitle] = useState('');
const pageIframeRef = useRef<HTMLIFrameElement>(null);
// État pour les relances
const [loadingRelance, setLoadingRelance] = useState<Record<string, boolean>>({});
// Suppression de pollActive et pollTimer car le polling a été retiré
// Load current contracts to sign (server-side API fetches Airtable)
async function load() {
try {
setError(null);
const [rEmp, rSal] = await Promise.all([
fetch('/api/signatures-electroniques/contrats?scope=employeur', { cache: 'no-store' }),
fetch('/api/signatures-electroniques/contrats?scope=salarie', { cache: 'no-store' }),
]);
if (!rEmp.ok) throw new Error(`HTTP employeur ${rEmp.status}`);
if (!rSal.ok) throw new Error(`HTTP salarie ${rSal.status}`);
const emp: ContractsResponse = await rEmp.json();
const sal: ContractsResponse = await rSal.json();
setRecordsEmployeur(emp.records || []);
setRecordsSalarie(sal.records || []);
} catch (e: any) {
console.error('Load contracts error', e);
setError(e?.message || 'Erreur de chargement');
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
// Polling supprimé pour éviter l'interférence avec les signatures
// Les données seront rechargées manuellement ou au refresh de la page
const stats = useMemo(() => ({
total: recordsEmployeur.length + recordsSalarie.length,
ready: recordsEmployeur.length,
salarieTodo: recordsSalarie.length,
}), [recordsEmployeur, recordsSalarie]);
async function openSignature(rec: AirtableRecord) {
const f = rec.fields || {};
let embed: string | null = null;
const title = `Signature (Employeur) · ${f.Reference || rec.id}`;
setModalTitle(title);
console.log('🔍 [SIGNATURES] Debug - record fields:', f);
console.log('🔍 [SIGNATURES] Debug - embed_src_employeur:', f.embed_src_employeur);
console.log('🔍 [SIGNATURES] Debug - docuseal_template_id:', f.docuseal_template_id);
// 1) Si l'URL d'embed est déjà en base (signature_link)
if (typeof f.embed_src_employeur === 'string' && f.embed_src_employeur.trim()) {
const signatureLink = f.embed_src_employeur.trim();
console.log('🔗 [SIGNATURES] Signature link trouvé:', signatureLink);
// Extraire le docuseal_id du lien de signature
const signatureLinkMatch = signatureLink.match(/docuseal_id=([^&]+)/);
if (signatureLinkMatch) {
const docusealId = signatureLinkMatch[1];
embed = `https://docuseal.eu/s/${docusealId}`;
console.log('🔗 [SIGNATURES] URL embed depuis signature_link:', embed);
} else {
// Si c'est déjà une URL DocuSeal directe
if (signatureLink.includes('docuseal.eu/s/')) {
embed = signatureLink;
console.log('🔗 [SIGNATURES] URL embed directe DocuSeal:', embed);
}
}
}
// 2) Sinon, récupérer via le proxy DocuSeal à partir du template
if (!embed && f.docuseal_template_id) {
console.log('🔍 [SIGNATURES] Template ID trouvé, tentative récupération DocuSeal:', f.docuseal_template_id);
try {
const tId = String(f.docuseal_template_id);
const subRes = await fetch(`/api/docuseal/templates/${encodeURIComponent(tId)}/submissions`, { cache: 'no-store' });
const subData = await subRes.json();
console.log('📋 [SIGNATURES] Submissions DocuSeal:', subData);
const first = Array.isArray(subData?.data) ? subData.data[0] : (Array.isArray(subData) ? subData[0] : subData);
const subId = first?.id;
if (subId) {
console.log('🔍 [SIGNATURES] Submission ID trouvé:', subId);
const detRes = await fetch(`/api/docuseal/submissions/${encodeURIComponent(subId)}`, { cache: 'no-store' });
const detData = await detRes.json();
console.log('📋 [SIGNATURES] Détails submission DocuSeal:', detData);
const roles = detData?.submitters || detData?.roles || [];
const employer = roles.find((r: any) => (r.role || r.name) === 'Employeur') || {};
if (employer?.slug) {
embed = `https://docuseal.eu/s/${employer.slug}`;
console.log('🔗 [SIGNATURES] URL embed depuis DocuSeal API (slug):', embed);
} else {
embed = employer?.embed_src || employer?.sign_src || detData?.embed_src || null;
console.log('🔗 [SIGNATURES] URL embed alternative:', embed);
}
} else {
console.warn('❌ [SIGNATURES] Aucun submission ID trouvé');
}
} catch (e) {
console.warn('❌ [SIGNATURES] DocuSeal fetch (template->submission) failed', e);
}
}
console.log('🎯 [SIGNATURES] URL embed finale:', embed);
if (embed) {
setEmbedSrc(embed);
// show modal
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
if (dlg) {
// Ajouter un listener pour rafraîchir les données quand le modal se ferme
const handleClose = () => {
load(); // Recharger les données
dlg.removeEventListener('close', handleClose);
};
dlg.addEventListener('close', handleClose);
if (typeof dlg.showModal === 'function') dlg.showModal();
else dlg.setAttribute('open', '');
}
} else {
console.error('❌ [SIGNATURES] Aucune URL d\'embed disponible');
alert("Lien d'embed indisponible pour ce contrat.");
}
}
function openEmbed(url: string, title: string) {
try {
const u = new URL(url, location.origin);
u.searchParams.set('embed', '1');
setPageEmbedSrc(u.toString());
setPageEmbedTitle(title);
const dlg = document.getElementById('dlg-embed') as HTMLDialogElement | null;
if (dlg) {
if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open','');
}
// small delay then try to inject CSS once iframe loads
setTimeout(() => {
const iframe = pageIframeRef.current;
if (!iframe) return;
const inject = () => {
try {
const doc = iframe.contentDocument || iframe.ownerDocument;
if (!doc) return;
const style = doc.createElement('style');
style.textContent = `
aside, header { display:none !important }
.grid { grid-template-columns: 1fr !important }
main { padding: 0 !important }
body { background:#fff !important }
`;
doc.head.appendChild(style);
} catch {}
};
if (iframe.contentWindow) {
iframe.addEventListener('load', inject, { once: true });
}
}, 50);
} catch {}
}
async function relancerSalarie(rec: AirtableRecord) {
const contractId = rec.id;
const f = rec.fields || {};
const ref = f.Reference || contractId;
const nom = f['Nom salarié'] || f['Nom Salarié'] || f['Nom'] || f['Salarié'] || f['Nom complet'] || f['Prénom Nom'] || f['Prenom Nom'] || f['Nom et Prénom'] || 'Salarié';
setLoadingRelance(prev => ({ ...prev, [contractId]: true }));
try {
const response = await fetch('/api/signatures-electroniques/relance', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ contractId }),
});
const result = await response.json();
if (response.ok) {
alert(`✅ Email de relance envoyé avec succès à ${nom} pour le contrat ${ref}`);
// Optionnel: recharger la liste pour mettre à jour les données
load();
} else {
alert(`❌ Erreur lors de l'envoi de la relance: ${result.error || result.message || 'Erreur inconnue'}`);
}
} catch (error: any) {
console.error('Erreur relance salarié:', error);
alert(`❌ Erreur lors de l'envoi de la relance: ${error.message || 'Erreur de réseau'}`);
} finally {
setLoadingRelance(prev => ({ ...prev, [contractId]: false }));
}
}
return (
<div className="mx-auto max-w-5xl px-4 py-6">
{/* DocuSeal web component script */}
<Script src="https://cdn.docuseal.com/js/form.js" strategy="lazyOnload" />
<h1 className="text-2xl font-semibold">Signatures électroniques</h1>
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 gap-3">
<div className="rounded-lg border p-3">
<div className="text-xs text-slate-500">Contrats dans la liste</div>
<div className="text-xl font-bold">{stats.total}</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-slate-500">À signer par lemployeur</div>
<div className="text-xl font-bold">{stats.ready}</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-slate-500">À signer par les salariés</div>
<div className="text-xl font-bold">{stats.salarieTodo}</div>
</div>
</div>
<div className="mt-6">
{loading ? (
<div className="text-slate-500">Chargement</div>
) : error ? (
<div className="text-red-600">Erreur: {error}</div>
) : (recordsEmployeur.length + recordsSalarie.length) === 0 ? (
<div className="text-slate-500">Aucun contrat à signer.</div>
) : (
<>
{/* Table 1: employeur pending */}
<div className="rounded-xl border overflow-hidden shadow-sm">
<div className="flex items-center justify-between gap-3 px-3 py-2 border-b bg-white/60 backdrop-blur supports-[backdrop-filter]:bg-white/60[backdrop-filter]:bg-slate-900/60">
<div className="text-sm font-medium text-slate-700">Contrats en attente de signature employeur</div>
<div className="text-xs text-slate-500">{recordsEmployeur.length} élément(s)</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-slate-500 border-b bg-slate-50/60">
<th className="px-3 py-2 font-medium whitespace-nowrap">Référence</th>
<th className="px-3 py-2 font-medium whitespace-nowrap">Salarié</th>
<th className="px-3 py-2 font-medium whitespace-nowrap">Matricule</th>
<th className="px-3 py-2 font-medium whitespace-nowrap">Statut</th>
<th className="px-3 py-2 font-medium whitespace-nowrap text-right">Action</th>
</tr>
</thead>
<tbody>
{recordsEmployeur.map((rec, idx) => {
const f = rec.fields || {};
const isSigned = String(f['Contrat signé par employeur'] || '').toLowerCase() === 'oui';
const mat = f['Matricule API'] || f.Matricule || '—';
const ref = f.Reference || '—';
const nom = f['Nom salarié'] || f['Nom Salarié'] || f['Nom'] || f['Salarié'] || f['Nom complet'] || f['Prénom Nom'] || f['Prenom Nom'] || f['Nom et Prénom'] || '—';
return (
<tr key={rec.id} className={classNames(idx % 2 ? 'bg-white' : 'bg-slate-50/30', 'hover:bg-slate-50 transition-colors')}>
<td className="px-3 py-2 font-medium text-slate-900 whitespace-nowrap">
<a
href={`/contrats/${encodeURIComponent(rec.id)}`}
onClick={(e)=>{ e.preventDefault(); openEmbed(`/contrats/${rec.id}`, `Contrat · ${ref}`); }}
className="hover:underline"
>{ref}</a>
</td>
<td className="px-3 py-2 text-slate-700 max-w-[260px] truncate" title={nom}>
<a
href={mat ? `/salaries/${encodeURIComponent(mat)}` : '#'}
onClick={(e)=>{ if (!mat) return; e.preventDefault(); openEmbed(`/salaries/${mat}`, `Salarié · ${nom}`); }}
className={classNames('hover:underline', !mat && 'pointer-events-none opacity-60')}
>{nom}</a>
</td>
<td className="px-3 py-2 text-slate-700 whitespace-nowrap">{mat}</td>
<td className="px-3 py-2 whitespace-nowrap">
<span className={classNames(
'inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border',
isSigned ? 'border-emerald-200 text-emerald-700 bg-emerald-50' : 'border-amber-200 text-amber-700 bg-amber-50'
)}>
{isSigned ? 'Signé employeur' : 'En attente employeur'}
</span>
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
<button
className="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium hover:bg-slate-50 disabled:opacity-60"
disabled={isSigned}
onClick={() => openSignature(rec)}
aria-label={`Signer le contrat ${ref}`}
>
<FileSignature className="w-3.5 h-3.5" aria-hidden="true" />
Signer
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Table 2: salarie pending */}
<div className="rounded-xl border overflow-hidden shadow-sm mt-8">
<div className="flex items-center justify-between gap-3 px-3 py-2 border-b bg-white/60 backdrop-blur supports-[backdrop-filter]:bg-white/60[backdrop-filter]:bg-slate-900/60">
<div className="text-sm font-medium text-slate-700">Contrats en attente de signature salarié</div>
<div className="text-xs text-slate-500">{recordsSalarie.length} élément(s)</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-slate-500 border-b bg-slate-50/60">
<th className="px-3 py-2 font-medium whitespace-nowrap">Référence</th>
<th className="px-3 py-2 font-medium whitespace-nowrap">Salarié</th>
<th className="px-3 py-2 font-medium whitespace-nowrap">Matricule</th>
<th className="px-3 py-2 font-medium whitespace-nowrap">Statut</th>
<th className="px-3 py-2 font-medium whitespace-nowrap text-right">Action</th>
</tr>
</thead>
<tbody>
{recordsSalarie.map((rec, idx) => {
const f = rec.fields || {};
const mat = f['Matricule API'] || f.Matricule || '—';
const ref = f.Reference || '—';
const nom = f['Nom salarié'] || f['Nom Salarié'] || f['Nom'] || f['Salarié'] || f['Nom complet'] || f['Prénom Nom'] || f['Prenom Nom'] || f['Nom et Prénom'] || '—';
return (
<tr key={rec.id} className={classNames(idx % 2 ? 'bg-white' : 'bg-slate-50/30', 'hover:bg-slate-50 transition-colors')}>
<td className="px-3 py-2 font-medium text-slate-900 whitespace-nowrap">
<a
href={`/contrats/${encodeURIComponent(rec.id)}`}
onClick={(e)=>{ e.preventDefault(); openEmbed(`/contrats/${rec.id}`, `Contrat · ${ref}`); }}
className="hover:underline"
>{ref}</a>
</td>
<td className="px-3 py-2 text-slate-700 max-w-[260px] truncate" title={nom}>
<a
href={mat ? `/salaries/${encodeURIComponent(mat)}` : '#'}
onClick={(e)=>{ if (!mat) return; e.preventDefault(); openEmbed(`/salaries/${mat}`, `Salarié · ${nom}`); }}
className={classNames('hover:underline', !mat && 'pointer-events-none opacity-60')}
>{nom}</a>
</td>
<td className="px-3 py-2 text-slate-700 whitespace-nowrap">{mat}</td>
<td className="px-3 py-2 whitespace-nowrap">
<span className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-sky-200 text-sky-700 bg-sky-50">
En attente salarié
</span>
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
<button
className="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium hover:bg-slate-50 disabled:opacity-60"
onClick={() => relancerSalarie(rec)}
disabled={loadingRelance[rec.id]}
aria-label={`Relancer le salarié pour ${ref}`}
>
<BellRing className="w-3.5 h-3.5" aria-hidden="true" />
{loadingRelance[rec.id] ? 'Envoi...' : 'Relancer salarié'}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
{/* Modal signature with docuseal-form */}
<dialog id="dlg-signature" className="rounded-lg border max-w-4xl w-[92vw] max-h-[90vh] overflow-hidden">
<div className="sticky top-0 z-10 flex items-center justify-between px-4 py-3 border-b bg-white">
<strong className="text-slate-900">{modalTitle}</strong>
<button
onClick={() => {
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
if (dlg) dlg.close();
}}
className="p-1.5 rounded hover:bg-slate-50"
aria-label="Fermer"
title="Fermer"
>
<XCircle className="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div className="overflow-auto" style={{ height: 'calc(90vh - 60px)', minHeight: 480 }}>
{embedSrc ? (
<div dangerouslySetInnerHTML={{
__html: `<docuseal-form
data-src="${embedSrc}"
data-language="fr"
data-with-title="false"
data-background-color="#fff">
</docuseal-form>`
}} />
) : (
<div className="p-4 text-slate-500">Préparation du formulaire</div>
)}
</div>
</dialog>
{/* Modal embed page */}
<dialog id="dlg-embed" className="rounded-lg border max-w-5xl w-[96vw]">
<div className="flex items-center justify-between px-4 py-3 border-b">
<strong>{pageEmbedTitle || 'Aperçu'}</strong>
<button
onClick={() => {
const dlg = document.getElementById('dlg-embed') as HTMLDialogElement | null;
if (dlg) dlg.close();
}}
className="p-1.5 rounded hover:bg-slate-50"
aria-label="Fermer"
title="Fermer"
>
<XCircle className="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div className="p-0" style={{ height: '80vh', minHeight: 520 }}>
{pageEmbedSrc ? (
<iframe ref={pageIframeRef} src={pageEmbedSrc} className="w-full h-full border-0" />
) : (
<div className="p-4 text-slate-500">Chargement</div>
)}
</div>
</dialog>
</div>
);
}