espace-paie-odentas/app/(app)/signatures-electroniques/page.tsx

1279 lines
54 KiB
TypeScript

'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { FileSignature, BellRing, XCircle, Sparkles, CheckCircle2, AlertCircle, Info, Lightbulb } from 'lucide-react';
import Script from 'next/script';
import { usePageTitle } from '@/hooks/usePageTitle';
import { useQuery } from '@tanstack/react-query';
import { useDemoMode } from '@/hooks/useDemoMode';
import { createPortal } from 'react-dom';
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(' ');
}
// Composant pour un bouton désactivé avec tooltip
function DisabledButton({ label, className }: { label: string; className?: string }) {
const btnRef = useRef<HTMLButtonElement | null>(null);
const [tipOpen, setTipOpen] = useState(false);
const [tipPos, setTipPos] = useState<{ top: number; left: number } | null>(null);
function computePos() {
const el = btnRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setTipPos({
top: r.top + window.scrollY - 10,
left: r.left + window.scrollX + r.width / 2
});
}
useEffect(() => {
if (!tipOpen) return;
const onScroll = () => computePos();
const onResize = () => computePos();
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
};
}, [tipOpen]);
return (
<>
<button
ref={btnRef}
onMouseEnter={() => { computePos(); setTipOpen(true); }}
onMouseLeave={() => setTipOpen(false)}
onFocus={() => { computePos(); setTipOpen(true); }}
onBlur={() => setTipOpen(false)}
disabled
className={`px-4 py-1.5 rounded-lg bg-slate-200 text-slate-400 text-sm font-medium cursor-not-allowed ${className || ''}`}
>
{label}
</button>
{tipOpen && tipPos && createPortal(
<div className="z-[1200] fixed" style={{ top: tipPos.top, left: tipPos.left, transform: 'translate(-50%, -100%)' }}>
<div className="inline-block max-w-[280px] rounded-lg bg-gray-900 text-white text-sm px-3 py-2 shadow-xl">
Désactivé en mode démo
</div>
<div className="mx-auto w-0 h-0" style={{
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderTop: '6px solid rgb(17, 24, 39)'
}} />
</div>,
document.body
)}
</>
);
}
// 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");
// 🎭 Détection du mode démo
const { isDemoMode } = useDemoMode();
const [contrats, setContrats] = useState<ContratWithSignatures[]>([]);
const [loading, setLoading] = useState(true);
const [reloadingAfterSignatureChange, setReloadingAfterSignatureChange] = useState(false);
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>("");
// Référence pour le conteneur DocuSeal
const docusealContainerRef = useRef<HTMLDivElement>(null);
// États pour la gestion de la signature
const [showSignatureModal, setShowSignatureModal] = useState(false);
const [currentSignature, setCurrentSignature] = useState<string | null>(null);
const [uploadingSignature, setUploadingSignature] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [uploadMessage, setUploadMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [activeSignature, setActiveSignature] = useState<string | null>(null); // Signature pour DocuSeal (remplace sessionStorage)
const fileInputRef = useRef<HTMLInputElement>(null);
// 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é
// Normaliser le format de la signature (ajouter le préfixe si nécessaire)
function normalizeSignatureFormat(signature: string | null): string | null {
if (!signature) return null;
// Si déjà au bon format (data:image/...), retourner tel quel
if (signature.startsWith('data:image/')) {
return signature;
}
// Sinon, ajouter le préfixe PNG
return `data:image/png;base64,${signature}`;
}
// Charger la signature de l'organisation
async function loadSignature() {
// Pour le staff, utiliser l'organisation sélectionnée, sinon l'organisation de l'utilisateur
const orgIdToUse = (userInfo?.isStaff && selectedOrgId) ? selectedOrgId : userInfo?.orgId;
console.log('🔍 [loadSignature] Debug:', {
isStaff: userInfo?.isStaff,
selectedOrgId,
userOrgId: userInfo?.orgId,
orgIdToUse
});
if (!orgIdToUse) {
console.log('⚠️ Aucune organisation sélectionnée pour charger la signature');
setCurrentSignature(null);
return;
}
console.log('🔍 Chargement signature pour org_id:', orgIdToUse);
try {
const res = await fetch(`/api/organization/signature?org_id=${orgIdToUse}`, { cache: 'no-store' });
console.log('📡 Response status:', res.status);
if (res.ok) {
const data = await res.json();
console.log('📦 Response data:', data);
// Vérifier le format de la signature
if (data.signature_b64) {
console.log('🖼️ Format signature:', {
length: data.signature_b64.length,
startsWithData: data.signature_b64.startsWith('data:'),
first50chars: data.signature_b64.substring(0, 50)
});
}
setCurrentSignature(data.signature_b64 || null);
console.log('✅ Signature chargée:', data.signature_b64 ? `présente (${data.signature_b64.substring(0, 50)}...)` : 'absente');
} else {
console.error('❌ Erreur HTTP:', res.status, await res.text());
}
} catch (e) {
console.error('❌ Erreur chargement signature:', e);
}
}
// Convertir une image (PNG ou JPEG) en base64 PNG
async function convertImageToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
// Créer une image temporaire
const img = new Image();
const reader = new FileReader();
reader.onload = (e) => {
img.src = e.target?.result as string;
};
img.onload = () => {
try {
// Créer un canvas pour convertir en PNG
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Impossible de créer le contexte canvas'));
return;
}
// Dessiner l'image sur le canvas
ctx.drawImage(img, 0, 0);
// Convertir en PNG avec le préfixe data URL complet
const pngDataUrl = canvas.toDataURL('image/png');
console.log('✅ Image convertie en PNG:', {
originalType: file.type,
originalSize: file.size,
dataUrlLength: pngDataUrl.length,
dimensions: `${img.width}x${img.height}`,
hasPrefix: pngDataUrl.startsWith('data:image/png;base64,')
});
// Retourner le data URL complet (data:image/png;base64,...)
resolve(pngDataUrl);
} catch (err) {
reject(err);
}
};
img.onerror = () => {
reject(new Error('Impossible de charger l\'image'));
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// Sauvegarder la signature
async function saveSignature(base64: string) {
// Pour le staff, utiliser l'organisation sélectionnée, sinon l'organisation de l'utilisateur
const orgIdToUse = (userInfo?.isStaff && selectedOrgId) ? selectedOrgId : userInfo?.orgId;
if (!orgIdToUse) {
setUploadMessage({ type: 'error', text: 'Organisation non identifiée' });
return;
}
console.log('💾 Sauvegarde signature pour org_id:', orgIdToUse);
setUploadingSignature(true);
setUploadMessage(null);
try {
const res = await fetch('/api/organization/signature', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
org_id: orgIdToUse,
signature_b64: base64
})
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || 'Erreur lors de la sauvegarde');
}
setCurrentSignature(base64);
console.log('✅ Signature sauvegardée avec succès');
setUploadMessage({ type: 'success', text: 'Signature enregistrée avec succès !' });
// Fermer le modal après 2 secondes et recharger les contrats
setTimeout(async () => {
setShowSignatureModal(false);
setUploadMessage(null);
// Recharger les contrats pour mettre à jour les badges de signature
console.log('🔄 Rechargement des contrats après sauvegarde signature...');
setReloadingAfterSignatureChange(true);
await load();
setReloadingAfterSignatureChange(false);
}, 2000);
} catch (e: any) {
console.error('Erreur sauvegarde signature:', e);
setUploadMessage({ type: 'error', text: e.message || 'Impossible de sauvegarder la signature' });
} finally {
setUploadingSignature(false);
}
}
// Supprimer la signature
async function deleteSignature() {
// Pour le staff, utiliser l'organisation sélectionnée, sinon l'organisation de l'utilisateur
const orgIdToUse = (userInfo?.isStaff && selectedOrgId) ? selectedOrgId : userInfo?.orgId;
if (!orgIdToUse) {
setUploadMessage({ type: 'error', text: 'Organisation non identifiée' });
return;
}
console.log('🗑️ Suppression signature pour org_id:', orgIdToUse);
setUploadingSignature(true);
setUploadMessage(null);
setShowDeleteConfirm(false);
try {
const res = await fetch('/api/organization/signature', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
org_id: orgIdToUse
})
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || 'Erreur lors de la suppression');
}
setCurrentSignature(null);
console.log('✅ Signature supprimée avec succès');
setUploadMessage({ type: 'success', text: 'Signature supprimée avec succès !' });
// Fermer le modal après 2 secondes et recharger les contrats
setTimeout(async () => {
setShowSignatureModal(false);
setUploadMessage(null);
// Recharger les contrats pour mettre à jour les badges de signature
console.log('🔄 Rechargement des contrats après suppression signature...');
setReloadingAfterSignatureChange(true);
await load();
setReloadingAfterSignatureChange(false);
}, 2000);
} catch (e: any) {
console.error('Erreur suppression signature:', e);
setUploadMessage({ type: 'error', text: e.message || 'Impossible de supprimer la signature' });
} finally {
setUploadingSignature(false);
}
}
// Gérer l'upload d'un fichier image
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
await processImageFile(file);
// Réinitialiser l'input pour permettre de ré-uploader le même fichier
if (fileInputRef.current) fileInputRef.current.value = '';
}
// Traiter un fichier image (validation + conversion + sauvegarde)
async function processImageFile(file: File) {
// Vérifier le type de fichier
if (!file.type.match(/^image\/(png|jpeg|jpg)$/i)) {
setUploadMessage({ type: 'error', text: 'Seuls les fichiers PNG et JPG sont acceptés' });
return;
}
// Vérifier la taille (max 5MB)
if (file.size > 5 * 1024 * 1024) {
setUploadMessage({ type: 'error', text: 'Le fichier est trop volumineux (max 5MB)' });
return;
}
try {
setUploadingSignature(true);
setUploadMessage(null);
const base64 = await convertImageToBase64(file);
await saveSignature(base64);
} catch (e: any) {
console.error('Erreur conversion image:', e);
setUploadMessage({ type: 'error', text: 'Erreur lors de la conversion de l\'image' });
setUploadingSignature(false);
}
}
// Gérer le drag over
function handleDragOver(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}
// Gérer le drag leave
function handleDragLeave(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}
// Gérer le drop
async function handleDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
await processImageFile(files[0]);
}
}
// 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(() => {
console.log('🔄 [useEffect] Déclenchement avec:', { selectedOrgId, userOrgId: userInfo?.orgId });
load();
loadSignature(); // Charger la signature au démarrage et quand l'organisation change
}, [selectedOrgId, userInfo?.orgId]); // Recharger quand l'organisation change (staff ou user)
// Réinitialiser le message quand le modal s'ouvre
useEffect(() => {
if (showSignatureModal) {
setUploadMessage(null);
}
}, [showSignatureModal]);
// useEffect pour injecter le composant DocuSeal uniquement quand embedSrc change
useEffect(() => {
if (!embedSrc || !docusealContainerRef.current) return;
const container = docusealContainerRef.current;
// Utiliser la signature depuis l'état React au lieu de sessionStorage
const signature = activeSignature;
// Construire les attributs
let dataSignatureAttr = '';
if (signature) {
console.log('✅ [SIGNATURES] Injection de la signature dans DocuSeal form');
dataSignatureAttr = `data-signature="${signature}"`;
} else {
console.log('⚠️ [SIGNATURES] Pas de signature à injecter');
}
// Injecter le HTML une seule fois
container.innerHTML = `<docuseal-form
data-src="${embedSrc}"
data-language="fr"
data-with-title="false"
data-background-color="#fff"
data-allow-typed-signature="false"
${dataSignatureAttr}>
</docuseal-form>`;
}, [embedSrc, activeSignature]); // Re-render quand embedSrc ou activeSignature 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);
console.log('🔍 [SIGNATURES] Debug - signature_b64:', f.signature_b64 ? 'présente' : 'absente');
// Gérer la signature pré-remplie si disponible
if (f.signature_b64) {
console.log('✅ [SIGNATURES] Signature trouvée, stockage dans l\'état React');
const normalizedSignature = normalizeSignatureFormat(f.signature_b64);
if (normalizedSignature) {
setActiveSignature(normalizedSignature);
console.log('✅ [SIGNATURES] Format normalisé:', normalizedSignature.substring(0, 50) + '...');
}
} else {
console.log('⚠️ [SIGNATURES] Pas de signature dans les données');
setActiveSignature(null);
}
// 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];
// URL propre sans paramètres
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) {
// URL propre sans paramètres
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) {
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>
{/* 🎭 Message informatif en mode démo */}
{isDemoMode && (
<div className="mb-6 rounded-xl border-2 border-amber-200 bg-gradient-to-r from-amber-50 to-orange-50 p-4 shadow-sm">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-amber-100 p-2">
<Info className="h-5 w-5 text-amber-700" />
</div>
<h3 className="font-semibold text-amber-900">Mode démonstration</h3>
</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>
{/* Card informative sur la signature pré-remplie - Visible uniquement si une organisation est identifiée */}
{((userInfo?.isStaff && selectedOrgId) || (!userInfo?.isStaff && userInfo?.orgId)) && (
<div className="mt-6 rounded-xl border-2 border-blue-100 bg-gradient-to-br from-blue-50/50 to-indigo-50/30 p-6 shadow-sm">
<div className="flex items-start gap-4">
{/* Icon */}
<div className="flex-shrink-0 mt-1">
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center">
<Sparkles className="w-6 h-6 text-blue-600" />
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-slate-900 mb-2 flex items-center gap-2">
<span>Nouveau : Signature pré-remplie automatiquement</span>
</h3>
<p className="text-sm text-slate-600 leading-relaxed mb-3">
La signature du signataire des contrats de travail, qui était déjà utilisée pour les <strong>AEM</strong> et les <strong>bordereaux Congés Spectacles</strong>, est maintenant <strong>pré-remplie automatiquement</strong> sur les contrats de travail.
</p>
{/* Statut de la signature */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-3">
{currentSignature ? (
<>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-100 border border-emerald-200 w-fit">
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
<span className="text-sm font-medium text-emerald-700">Signature connue</span>
</div>
{isDemoMode ? (
<DisabledButton label="Voir / modifier la signature" className="w-full sm:w-auto" />
) : (
<button
onClick={() => setShowSignatureModal(true)}
className="px-4 py-1.5 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors w-full sm:w-auto"
>
Voir / modifier la signature
</button>
)}
</>
) : (
<>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-amber-100 border border-amber-200 w-fit">
<AlertCircle className="w-4 h-4 text-amber-600" />
<span className="text-sm font-medium text-amber-700">Signature non connue</span>
</div>
{isDemoMode ? (
<DisabledButton label="Ajouter une signature" className="w-full sm:w-auto" />
) : (
<button
onClick={() => setShowSignatureModal(true)}
className="px-4 py-1.5 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors w-full sm:w-auto"
>
Ajouter une signature
</button>
)}
</>
)}
</div>
<div className="flex items-start gap-2 text-xs text-slate-500">
<Lightbulb className="w-4 h-4 text-slate-400 flex-shrink-0 mt-0.5" />
<span>Cette signature sera automatiquement proposée lors de la signature électronique des contrats, mais reste modifiable à chaque signature.</span>
</div>
</div>
</div>
</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="fixed inset-0 z-50 rounded-lg border max-w-4xl w-[92vw] max-h-[90vh] overflow-hidden backdrop:bg-black/50 m-auto p-0">
<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 ref={docusealContainerRef} />
) : (
<div className="p-4 text-slate-500">Préparation du formulaire…</div>
)}
</div>
</dialog>
{/* Modal embed page */}
<dialog id="dlg-embed" className="fixed inset-0 z-50 rounded-lg border max-w-5xl w-[96vw] backdrop:bg-black/50 m-auto p-0">
<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>
{/* Modal de gestion de la signature */}
{showSignatureModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b bg-gradient-to-r from-blue-50 to-indigo-50">
<h2 className="text-xl font-semibold text-slate-900">
{currentSignature ? 'Modifier la signature' : 'Ajouter une signature'}
</h2>
<button
onClick={() => {
setShowSignatureModal(false);
setShowDeleteConfirm(false);
}}
className="p-2 rounded-lg hover:bg-white/60 transition-colors"
aria-label="Fermer"
>
<XCircle className="w-5 h-5 text-slate-500" />
</button>
</div>
{/* Body */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
{/* Message de succès/erreur */}
{uploadMessage && (
<div className={`mb-6 p-4 rounded-lg border-2 ${
uploadMessage.type === 'success'
? 'bg-emerald-50 border-emerald-200'
: 'bg-red-50 border-red-200'
}`}>
<div className="flex items-center gap-3">
{uploadMessage.type === 'success' ? (
<CheckCircle2 className="w-5 h-5 text-emerald-600 flex-shrink-0" />
) : (
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
)}
<p className={`text-sm font-medium ${
uploadMessage.type === 'success' ? 'text-emerald-800' : 'text-red-800'
}`}>
{uploadMessage.text}
</p>
</div>
</div>
)}
{/* Affichage de la signature actuelle */}
{currentSignature && (
<div className="mb-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-2 gap-2">
<label className="text-sm font-medium text-slate-700">
Signature actuelle :
</label>
{!showDeleteConfirm ? (
<button
onClick={() => setShowDeleteConfirm(true)}
disabled={uploadingSignature}
className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto"
>
<XCircle className="w-4 h-4" />
Supprimer
</button>
) : (
<div className="flex flex-col sm:flex-row sm:items-center gap-2 w-full sm:w-auto">
<span className="text-xs text-slate-600 text-center sm:text-left">Confirmer ?</span>
<div className="flex items-center gap-2">
<button
onClick={deleteSignature}
disabled={uploadingSignature}
className="flex-1 sm:flex-none flex items-center justify-center gap-1 px-2 py-1 text-xs font-medium text-white bg-red-600 hover:bg-red-700 rounded transition-colors disabled:opacity-50"
>
Oui, supprimer
</button>
<button
onClick={() => setShowDeleteConfirm(false)}
disabled={uploadingSignature}
className="flex-1 sm:flex-none px-2 py-1 text-xs font-medium text-slate-600 hover:text-slate-800 hover:bg-slate-100 rounded transition-colors disabled:opacity-50"
>
Annuler
</button>
</div>
</div>
)}
</div>
<div className="rounded-lg border-2 border-slate-200 bg-slate-50 p-4 flex items-center justify-center">
<img
src={normalizeSignatureFormat(currentSignature) || ''}
alt="Signature actuelle"
className="max-h-32 max-w-full object-contain"
/>
</div>
</div>
)}
{/* Upload de nouvelle signature */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
{currentSignature ? 'Remplacer par une nouvelle signature :' : 'Charger une signature :'}
</label>
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-all cursor-pointer ${
isDragging
? 'border-blue-500 bg-blue-100/50 scale-[1.02]'
: 'border-slate-300 hover:border-blue-400 hover:bg-blue-50/30'
} ${uploadingSignature ? 'opacity-50 cursor-not-allowed' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !uploadingSignature && fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/jpg"
onChange={handleFileUpload}
disabled={uploadingSignature}
className="hidden"
id="signature-upload"
/>
<FileSignature className={`w-12 h-12 mx-auto mb-3 transition-colors ${
isDragging ? 'text-blue-500' : 'text-slate-400'
}`} />
<p className="text-sm font-medium text-slate-700 mb-1">
{isDragging ? 'Déposez l\'image ici' : 'Cliquez ou glissez-déposez une image'}
</p>
<p className="text-xs text-slate-500">
PNG ou JPG Max 5 MB
</p>
</div>
{uploadingSignature && (
<div className="mt-4 flex items-center justify-center gap-2 text-blue-600">
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span className="text-sm font-medium">Traitement en cours...</span>
</div>
)}
</div>
{/* Informations */}
<div className="mt-6 p-4 rounded-lg bg-blue-50 border border-blue-100">
<div className="flex gap-3">
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">À propos de la signature</p>
<ul className="text-xs space-y-1 text-blue-700">
<li> Elle sera automatiquement pré-remplie sur tous vos contrats</li>
<li> Vous pouvez la modifier à tout moment</li>
</ul>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t bg-slate-50">
<button
onClick={() => {
setShowSignatureModal(false);
setShowDeleteConfirm(false);
}}
className="px-4 py-2 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-200 transition-colors"
disabled={uploadingSignature}
>
Fermer
</button>
</div>
</div>
</div>
)}
{/* Overlay de rechargement après modification de signature */}
{reloadingAfterSignatureChange && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-white rounded-2xl shadow-2xl px-8 py-6 max-w-sm mx-4">
<div className="flex flex-col items-center gap-4">
<div className="relative">
<div className="w-16 h-16 border-4 border-blue-200 rounded-full"></div>
<div className="absolute top-0 left-0 w-16 h-16 border-4 border-blue-600 rounded-full border-t-transparent animate-spin"></div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-slate-900 mb-1">
Mise à jour en cours...
</h3>
<p className="text-sm text-slate-600">
Actualisation des contrats avec la nouvelle signature
</p>
</div>
</div>
</div>
</div>
)}
</div>
);
}