1279 lines
54 KiB
TypeScript
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>
|
|
);
|
|
}
|