600 lines
26 KiB
TypeScript
600 lines
26 KiB
TypeScript
'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';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
|
||
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(' ');
|
||
}
|
||
|
||
// Hook pour récupérer les infos utilisateur
|
||
function useUserInfo() {
|
||
return useQuery({
|
||
queryKey: ["user-info"],
|
||
queryFn: async () => {
|
||
try {
|
||
const res = await fetch("/api/me", {
|
||
cache: "no-store",
|
||
headers: { Accept: "application/json" },
|
||
credentials: "include"
|
||
});
|
||
if (!res.ok) return null;
|
||
const me = await res.json();
|
||
|
||
return {
|
||
isStaff: Boolean(me.is_staff || me.isStaff),
|
||
orgId: me.orgId || me.active_org_id || null,
|
||
orgName: me.orgName || me.active_org_name || "Organisation",
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
},
|
||
staleTime: 30_000,
|
||
});
|
||
}
|
||
|
||
// Hook pour récupérer les organisations (staff uniquement)
|
||
function useOrganizations() {
|
||
const { data: userInfo, isSuccess: userInfoLoaded } = useUserInfo();
|
||
|
||
return useQuery({
|
||
queryKey: ["organizations"],
|
||
queryFn: async () => {
|
||
try {
|
||
const res = await fetch("/api/staff/organizations", {
|
||
cache: "no-store",
|
||
headers: { Accept: "application/json" },
|
||
credentials: "include"
|
||
});
|
||
if (!res.ok) return [];
|
||
const data = await res.json();
|
||
return data.organizations || [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
},
|
||
enabled: userInfoLoaded && !!userInfo?.isStaff,
|
||
staleTime: 60_000,
|
||
});
|
||
}
|
||
|
||
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>>({});
|
||
|
||
// État pour le sélecteur d'organisation (staff uniquement)
|
||
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
|
||
|
||
// Charger les infos utilisateur et organisations
|
||
const { data: userInfo } = useUserInfo();
|
||
const { data: organizations } = useOrganizations();
|
||
|
||
// 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);
|
||
|
||
// Ajouter le paramètre org_id si sélectionné (staff uniquement)
|
||
const orgParam = selectedOrgId ? `&org_id=${selectedOrgId}` : '';
|
||
|
||
const [rEmp, rSal] = await Promise.all([
|
||
fetch(`/api/signatures-electroniques/contrats?scope=employeur${orgParam}`, { cache: 'no-store' }),
|
||
fetch(`/api/signatures-electroniques/contrats?scope=salarie${orgParam}`, { 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();
|
||
}, [selectedOrgId]); // Recharger quand l'organisation change
|
||
|
||
// Ajouter des écouteurs pour recharger les données quand un modal se ferme
|
||
useEffect(() => {
|
||
const dlgSignature = document.getElementById('dlg-signature') as HTMLDialogElement | null;
|
||
const dlgEmbed = document.getElementById('dlg-embed') as HTMLDialogElement | null;
|
||
|
||
const handleSignatureClose = () => {
|
||
console.log('🔄 Modal signature fermé, rechargement des données...');
|
||
load();
|
||
};
|
||
|
||
const handleEmbedClose = () => {
|
||
console.log('🔄 Modal embed fermé, rechargement des données...');
|
||
load();
|
||
};
|
||
|
||
if (dlgSignature) {
|
||
dlgSignature.addEventListener('close', handleSignatureClose);
|
||
}
|
||
|
||
if (dlgEmbed) {
|
||
dlgEmbed.addEventListener('close', handleEmbedClose);
|
||
}
|
||
|
||
return () => {
|
||
if (dlgSignature) {
|
||
dlgSignature.removeEventListener('close', handleSignatureClose);
|
||
}
|
||
if (dlgEmbed) {
|
||
dlgEmbed.removeEventListener('close', handleEmbedClose);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
// 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" />
|
||
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
|
||
<h1 className="text-2xl font-semibold">Signatures électroniques</h1>
|
||
|
||
{/* Sélecteur d'organisation (visible uniquement par le staff) */}
|
||
{userInfo?.isStaff && (
|
||
<div className="flex items-center gap-2">
|
||
<label className="text-sm font-medium text-slate-700">Organisation :</label>
|
||
<select
|
||
className="px-3 py-2 rounded-lg border bg-white text-sm min-w-[200px]"
|
||
value={selectedOrgId}
|
||
onChange={(e) => setSelectedOrgId(e.target.value)}
|
||
disabled={!organizations || organizations.length === 0}
|
||
>
|
||
<option value="">
|
||
{!organizations || organizations.length === 0
|
||
? "Chargement..."
|
||
: "Toutes les organisations"}
|
||
</option>
|
||
{organizations && organizations.map((org: any) => (
|
||
<option key={org.id} value={org.id}>{org.name}</option>
|
||
))}
|
||
</select>
|
||
{organizations && organizations.length > 0 && (
|
||
<span className="text-xs text-slate-500">({organizations.length})</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="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 l’employeur</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>
|
||
);
|
||
}
|