- Créer hook useStaffOrgSelection avec persistence localStorage - Ajouter badge StaffOrgBadge dans Sidebar - Synchroniser filtres org dans toutes les pages (contrats, cotisations, facturation, etc.) - Fix calcul cachets: utiliser totalQuantities au lieu de dates.length - Fix structure field bug: ne plus écraser avec production_name - Ajouter création note lors modification contrat - Implémenter montants personnalisés pour virements salaires - Migrations SQL: custom_amount + fix_structure_field - Réorganiser boutons ContractEditor en carte flottante droite
1628 lines
70 KiB
TypeScript
1628 lines
70 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';
|
|
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
|
|
import { useStaffOrgSelection } from '@/hooks/useStaffOrgSelection';
|
|
|
|
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");
|
|
// Supabase client authentifié côté client (cookies) pour Realtime + RLS
|
|
const sb = useMemo(() => createClientComponentClient(), []);
|
|
|
|
// 🎭 Détection du mode démo
|
|
const { isDemoMode } = useDemoMode();
|
|
|
|
// Helper pour valider les UUIDs
|
|
const isValidUUID = (str: string | null): boolean => {
|
|
if (!str) return false;
|
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str);
|
|
};
|
|
|
|
// Zustand store pour la sélection d'organisation (staff)
|
|
const {
|
|
selectedOrgId: globalSelectedOrgId,
|
|
setSelectedOrg: setGlobalSelectedOrg
|
|
} = useStaffOrgSelection();
|
|
|
|
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);
|
|
// Indicateur Realtime (visuel + logs)
|
|
const [isRealtimeLive, setIsRealtimeLive] = useState(false);
|
|
|
|
// États pour les contrats
|
|
const [recordsEmployeur, setRecordsEmployeur] = useState<AirtableRecord[]>([]);
|
|
const [recordsSalarie, setRecordsSalarie] = useState<AirtableRecord[]>([]);
|
|
const recordsEmployeurRef = useRef<AirtableRecord[]>([]);
|
|
const recordsSalarieRef = useRef<AirtableRecord[]>([]);
|
|
|
|
// États pour les avenants
|
|
const [avenantsEmployeur, setAvenantsEmployeur] = useState<AirtableRecord[]>([]);
|
|
const [avenantsSalarie, setAvenantsSalarie] = useState<AirtableRecord[]>([]);
|
|
const avenantsEmployeurRef = useRef<AirtableRecord[]>([]);
|
|
const avenantsSalarieRef = useRef<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)
|
|
// Initialisé avec la valeur globale si elle est un UUID valide
|
|
const [selectedOrgId, setSelectedOrgId] = useState<string>(
|
|
isValidUUID(globalSelectedOrgId) ? globalSelectedOrgId : ""
|
|
);
|
|
|
|
// 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);
|
|
const isMountedRef = useRef(true);
|
|
const signingContractIdRef = useRef<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
isMountedRef.current = true;
|
|
return () => {
|
|
isMountedRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
recordsEmployeurRef.current = recordsEmployeur;
|
|
}, [recordsEmployeur]);
|
|
|
|
useEffect(() => {
|
|
recordsSalarieRef.current = recordsSalarie;
|
|
}, [recordsSalarie]);
|
|
|
|
// Charger les infos utilisateur et organisations
|
|
const { data: userInfo } = useUserInfo();
|
|
const { data: organizations } = useOrganizations();
|
|
|
|
// Synchronisation bidirectionnelle : global → local
|
|
useEffect(() => {
|
|
if (userInfo?.isStaff && isValidUUID(globalSelectedOrgId)) {
|
|
setSelectedOrgId(globalSelectedOrgId);
|
|
}
|
|
}, [globalSelectedOrgId, userInfo?.isStaff]);
|
|
|
|
// 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 and amendments to sign (server-side API fetches Supabase)
|
|
async function load() {
|
|
if (!isMountedRef.current) return;
|
|
try {
|
|
setError(null);
|
|
|
|
// Ajouter le paramètre org_id si sélectionné (staff uniquement)
|
|
const orgParam = selectedOrgId ? `&org_id=${selectedOrgId}` : '';
|
|
|
|
const [rEmp, rSal, rAveEmp, rAveSal] = 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' }),
|
|
fetch(`/api/signatures-electroniques/avenants?scope=employeur${orgParam}`, { cache: 'no-store' }),
|
|
fetch(`/api/signatures-electroniques/avenants?scope=salarie${orgParam}`, { cache: 'no-store' }),
|
|
]);
|
|
if (!rEmp.ok) throw new Error(`HTTP contrats employeur ${rEmp.status}`);
|
|
if (!rSal.ok) throw new Error(`HTTP contrats salarie ${rSal.status}`);
|
|
if (!rAveEmp.ok) throw new Error(`HTTP avenants employeur ${rAveEmp.status}`);
|
|
if (!rAveSal.ok) throw new Error(`HTTP avenants salarie ${rAveSal.status}`);
|
|
|
|
const emp: ContractsResponse = await rEmp.json();
|
|
const sal: ContractsResponse = await rSal.json();
|
|
const aveEmp: ContractsResponse = await rAveEmp.json();
|
|
const aveSal: ContractsResponse = await rAveSal.json();
|
|
|
|
if (!isMountedRef.current) return;
|
|
|
|
const empRecords = emp.records || [];
|
|
const salarieRecords = sal.records || [];
|
|
const aveEmpRecords = aveEmp.records || [];
|
|
const aveSalRecords = aveSal.records || [];
|
|
|
|
setRecordsEmployeur(empRecords);
|
|
setRecordsSalarie(salarieRecords);
|
|
setAvenantsEmployeur(aveEmpRecords);
|
|
setAvenantsSalarie(aveSalRecords);
|
|
|
|
recordsEmployeurRef.current = empRecords;
|
|
recordsSalarieRef.current = salarieRecords;
|
|
avenantsEmployeurRef.current = aveEmpRecords;
|
|
avenantsSalarieRef.current = aveSalRecords;
|
|
} catch (e: any) {
|
|
console.error('Load contracts/amendments error', e);
|
|
if (!isMountedRef.current) return;
|
|
setError(e?.message || 'Erreur de chargement');
|
|
} finally {
|
|
if (!isMountedRef.current) return;
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function loadWithRetry(options?: { attempts?: number; delayMs?: number; targetContractId?: string | null }) {
|
|
const attempts = options?.attempts ?? 4;
|
|
const delayMs = options?.delayMs ?? 1200;
|
|
const targetContractId = options?.targetContractId ?? null;
|
|
|
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
if (!isMountedRef.current) break;
|
|
await load();
|
|
|
|
if (!targetContractId) break;
|
|
|
|
const stillInEmployeur = recordsEmployeurRef.current.some((rec) => rec.id === targetContractId);
|
|
if (!stillInEmployeur) break;
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
}
|
|
}
|
|
|
|
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>`;
|
|
|
|
// Écouter l'événement 'completed' émis par <docuseal-form>
|
|
const formEl = container.querySelector('docuseal-form') as HTMLElement | null;
|
|
if (formEl) {
|
|
const onCompleted = async (_event: Event) => {
|
|
console.log('✅ [SIGNATURES] Event completed reçu - déclenchement du rafraîchissement');
|
|
|
|
if (!isMountedRef.current) return;
|
|
|
|
// Remplacer le contenu du modal par le message de confirmation
|
|
if (container) {
|
|
container.innerHTML = `
|
|
<div class="min-h-[500px] flex items-center justify-center p-6 animate-fadeIn">
|
|
<div class="max-w-lg w-full">
|
|
<!-- Header -->
|
|
<div class="p-6 bg-emerald-50 border border-emerald-100 rounded-t-2xl">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-12 h-12 rounded-full bg-emerald-100 flex items-center justify-center flex-shrink-0">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-slate-900">Signature employeur prise en compte</h2>
|
|
<p class="text-sm text-slate-600 mt-1">Le processus se poursuit automatiquement côté salarié.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div class="p-6 bg-white border-x border-slate-200">
|
|
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
|
<ul class="text-sm text-slate-700 space-y-2 list-disc pl-5">
|
|
<li>Le salarié va recevoir son propre exemplaire pour signature électronique.</li>
|
|
<li>Vous pourrez télécharger le contrat depuis la fiche contrat dès que la signature du salarié aura été reçue.</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="px-6 py-4 bg-gray-50 border border-t-0 border-slate-200 rounded-b-2xl flex justify-end">
|
|
<button
|
|
id="btn-close-confirmation"
|
|
class="px-6 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium transition-colors"
|
|
>
|
|
J'ai compris
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Ajouter l'événement de fermeture
|
|
const btnClose = container.querySelector('#btn-close-confirmation') as HTMLButtonElement | null;
|
|
if (btnClose) {
|
|
btnClose.onclick = () => {
|
|
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
|
|
if (dlg) dlg.close();
|
|
};
|
|
}
|
|
}
|
|
|
|
setReloadingAfterSignatureChange(true);
|
|
try {
|
|
// Laisser le temps au webhook DocuSeal de mettre à jour Supabase puis recharger avec retries
|
|
await new Promise((resolve) => setTimeout(resolve, 900));
|
|
await loadWithRetry({
|
|
attempts: 4,
|
|
delayMs: 1200,
|
|
targetContractId: signingContractIdRef.current,
|
|
});
|
|
} catch (err) {
|
|
console.warn('DocuSeal fallback load() failed', err);
|
|
} finally {
|
|
if (isMountedRef.current) {
|
|
setReloadingAfterSignatureChange(false);
|
|
}
|
|
}
|
|
};
|
|
formEl.addEventListener('completed', onCompleted as EventListener);
|
|
return () => {
|
|
try { formEl.removeEventListener('completed', onCompleted as EventListener); } catch {}
|
|
};
|
|
}
|
|
}, [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();
|
|
signingContractIdRef.current = null;
|
|
};
|
|
|
|
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);
|
|
}
|
|
};
|
|
}, [selectedOrgId]);
|
|
|
|
// 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]);
|
|
|
|
// Org courante pour filtrer Realtime: staff -> org sélectionnée, sinon org de l'utilisateur
|
|
const currentOrgId = useMemo(() => {
|
|
if (userInfo?.isStaff) {
|
|
return selectedOrgId || null;
|
|
}
|
|
return userInfo?.orgId || null;
|
|
}, [userInfo?.isStaff, selectedOrgId, userInfo?.orgId]);
|
|
|
|
// Realtime: écouter les changements sur cddu_contracts
|
|
useEffect(() => {
|
|
// Désactiver en mode démo (les APIs renvoient vide, inutile d'écouter)
|
|
if (isDemoMode) return;
|
|
|
|
let contractsChannel: any = null;
|
|
let mounted = true;
|
|
|
|
(async () => {
|
|
try {
|
|
// Contrats: écouter INSERT/UPDATE/DELETE, optionnellement filtré par org_id
|
|
contractsChannel = sb.channel('public:cddu_contracts');
|
|
const contractsFilter = currentOrgId ? `org_id=eq.${currentOrgId}` : undefined;
|
|
contractsChannel.on(
|
|
'postgres_changes',
|
|
{ event: '*', schema: 'public', table: 'cddu_contracts', ...(contractsFilter ? { filter: contractsFilter } : {}) },
|
|
(_payload: any) => {
|
|
// Recharger les listes dès qu'un changement pertinent survient
|
|
try { load(); } catch (err) { console.warn('Realtime load() failed', err); }
|
|
// Un changement reçu -> on considère la connexion active
|
|
if (!isRealtimeLive) setIsRealtimeLive(true);
|
|
}
|
|
);
|
|
const sub1 = await contractsChannel.subscribe();
|
|
if (!mounted) return;
|
|
if (sub1 && (sub1.status === 'timed_out' || sub1.status === 'closed' || (sub1 as any)?.error)) {
|
|
console.warn('Realtime subscribe status (cddu_contracts):', sub1);
|
|
} else {
|
|
console.info('[Realtime] SUBSCRIBED -> public:cddu_contracts', { filter: contractsFilter || 'none' });
|
|
setIsRealtimeLive(true);
|
|
}
|
|
} catch (err) {
|
|
console.warn('Realtime subscription failed for public.cddu_contracts.', (err as any)?.message || err);
|
|
}
|
|
|
|
// Note: organization_details subscription désactivée car Realtime n'est pas activé pour cette table
|
|
// La signature est rechargée au changement de l'org sélectionnée via le useEffect
|
|
})();
|
|
|
|
return () => {
|
|
mounted = false;
|
|
try {
|
|
if (contractsChannel) {
|
|
// @ts-ignore
|
|
if (sb.removeChannel) sb.removeChannel(contractsChannel);
|
|
else contractsChannel.unsubscribe && contractsChannel.unsubscribe();
|
|
console.info('[Realtime] UNSUBSCRIBED <- public:cddu_contracts');
|
|
}
|
|
} catch (err) {
|
|
console.warn('Error unsubscribing cddu_contracts channel', err);
|
|
}
|
|
// On repasse l'indicateur en hors ligne lorsque les canaux se ferment
|
|
setIsRealtimeLive(false);
|
|
};
|
|
}, [currentOrgId, isDemoMode]);
|
|
|
|
async function openSignature(rec: AirtableRecord) {
|
|
const f = rec.fields || {};
|
|
const isAvenant = f.is_avenant === true;
|
|
let embed: string | null = null;
|
|
const docRef = f.reference || f.Reference || rec.id;
|
|
const title = `Signature (Employeur) · ${isAvenant ? 'Avenant' : 'Contrat'} ${docRef}`;
|
|
setModalTitle(title);
|
|
signingContractIdRef.current = rec.id;
|
|
|
|
console.log('🔍 [SIGNATURES] Debug - record fields:', f);
|
|
console.log('🔍 [SIGNATURES] Debug - is_avenant:', isAvenant);
|
|
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 - docuseal_submission_id:', f.docuseal_submission_id);
|
|
console.log('🔍 [SIGNATURES] Debug - signature_b64:', f.signature_b64 ? 'présente' : 'absente');
|
|
|
|
// Gérer la signature pré-remplie
|
|
// Pour les contrats: utiliser f.signature_b64
|
|
// Pour les avenants: utiliser currentSignature (signature de l'organisation)
|
|
let signatureToUse = null;
|
|
|
|
if (isAvenant) {
|
|
// Pour les avenants, utiliser la signature de l'organisation
|
|
if (currentSignature) {
|
|
console.log('✅ [SIGNATURES AVENANT] Utilisation de la signature de l\'organisation');
|
|
signatureToUse = currentSignature;
|
|
} else {
|
|
console.log('⚠️ [SIGNATURES AVENANT] Pas de signature d\'organisation disponible');
|
|
}
|
|
} else {
|
|
// Pour les contrats, utiliser la signature du contrat
|
|
if (f.signature_b64) {
|
|
console.log('✅ [SIGNATURES CONTRAT] Utilisation de la signature du contrat');
|
|
signatureToUse = f.signature_b64;
|
|
} else {
|
|
console.log('⚠️ [SIGNATURES CONTRAT] Pas de signature dans les données du contrat');
|
|
}
|
|
}
|
|
|
|
if (signatureToUse) {
|
|
const normalizedSignature = normalizeSignatureFormat(signatureToUse);
|
|
if (normalizedSignature) {
|
|
setActiveSignature(normalizedSignature);
|
|
console.log('✅ [SIGNATURES] Format normalisé:', normalizedSignature.substring(0, 50) + '...');
|
|
}
|
|
} else {
|
|
console.log('⚠️ [SIGNATURES] Pas de signature à pré-remplir');
|
|
setActiveSignature(null);
|
|
}
|
|
|
|
// Pour les avenants, utiliser directement le docuseal_submission_id
|
|
if (isAvenant && f.docuseal_submission_id) {
|
|
console.log('🔍 [SIGNATURES AVENANT] Utilisation du submission_id:', f.docuseal_submission_id);
|
|
|
|
try {
|
|
const detRes = await fetch(`/api/docuseal/submissions/${encodeURIComponent(f.docuseal_submission_id)}`, { cache: 'no-store' });
|
|
const detData = await detRes.json();
|
|
|
|
console.log('📋 [SIGNATURES AVENANT] 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 AVENANT] URL embed depuis slug:', embed);
|
|
}
|
|
} catch (e) {
|
|
console.warn('❌ [SIGNATURES AVENANT] Erreur récupération submission:', e);
|
|
}
|
|
}
|
|
|
|
// 1) Si l'URL d'embed est déjà en base (signature_link) - pour les contrats
|
|
if (!embed && 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">
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-2xl font-semibold">Signatures électroniques</h1>
|
|
{!isDemoMode && (
|
|
<span
|
|
title={isRealtimeLive ? 'Connexion Realtime active' : 'Realtime inactif'}
|
|
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs ${
|
|
isRealtimeLive
|
|
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
|
: 'border-slate-200 bg-slate-50 text-slate-600'
|
|
}`}
|
|
>
|
|
<span className={`inline-block h-2 w-2 rounded-full ${isRealtimeLive ? 'bg-emerald-500' : 'bg-slate-400'}`} />
|
|
{isRealtimeLive ? 'Live' : 'Hors ligne'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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) => {
|
|
const newOrgId = e.target.value;
|
|
setSelectedOrgId(newOrgId);
|
|
|
|
// Synchronisation bidirectionnelle : local → global
|
|
if (newOrgId) {
|
|
const selectedOrg = organizations?.find((org: any) => org.id === newOrgId);
|
|
if (selectedOrg) {
|
|
setGlobalSelectedOrg(newOrgId, selectedOrg.name);
|
|
}
|
|
} else {
|
|
setGlobalSelectedOrg(null, null);
|
|
}
|
|
}}
|
|
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 + avenantsEmployeur.length + avenantsSalarie.length) === 0 ? (
|
|
<div className="text-slate-500">Aucun document à signer.</div>
|
|
) : (
|
|
<>
|
|
{/* Table 1: employeur pending (contrats + avenants) */}
|
|
<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">Documents en attente de signature employeur</div>
|
|
<div className="text-xs text-slate-500">{recordsEmployeur.length + avenantsEmployeur.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">Type</th>
|
|
<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, ...avenantsEmployeur].map((rec, idx) => {
|
|
const f = rec.fields || {};
|
|
const isAvenant = f.is_avenant === true;
|
|
const isSigned = String(f['Contrat signé par employeur'] || '').toLowerCase() === 'oui';
|
|
const mat = f.employee_matricule || f['Matricule API'] || f.Matricule || '—';
|
|
const ref = f.reference || f.Reference || '—';
|
|
const nom = f.employee_name || 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'] || '—';
|
|
const docType = isAvenant ? 'Avenant' : 'Contrat';
|
|
const urlPath = isAvenant ? `/staff/avenants/${rec.id}` : `/contrats/${rec.id}`;
|
|
const docLabel = isAvenant ? `Avenant · ${ref}` : `Contrat · ${ref}`;
|
|
|
|
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 text-slate-700 whitespace-nowrap">
|
|
<span className={classNames(
|
|
'inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border font-medium',
|
|
isAvenant ? 'border-purple-200 text-purple-700 bg-purple-50' : 'border-blue-200 text-blue-700 bg-blue-50'
|
|
)}>
|
|
{docType}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 font-medium text-slate-900 whitespace-nowrap">
|
|
<a
|
|
href={urlPath}
|
|
onClick={(e)=>{ e.preventDefault(); openEmbed(urlPath, docLabel); }}
|
|
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 || mat === '—') return; e.preventDefault(); openEmbed(`/salaries/${mat}`, `Salarié · ${nom}`); }}
|
|
className={classNames('hover:underline', (!mat || 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 ${isAvenant ? "l'avenant" : "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 (contrats + avenants) */}
|
|
<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">Documents en attente de signature salarié</div>
|
|
<div className="text-xs text-slate-500">{recordsSalarie.length + avenantsSalarie.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">Type</th>
|
|
<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, ...avenantsSalarie].map((rec, idx) => {
|
|
const f = rec.fields || {};
|
|
const isAvenant = f.is_avenant === true;
|
|
const mat = f.employee_matricule || f['Matricule API'] || f.Matricule || '—';
|
|
const ref = f.reference || f.Reference || '—';
|
|
const nom = f.employee_name || 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'] || '—';
|
|
const docType = isAvenant ? 'Avenant' : 'Contrat';
|
|
const urlPath = isAvenant ? `/staff/avenants/${rec.id}` : `/contrats/${rec.id}`;
|
|
const docLabel = isAvenant ? `Avenant · ${ref}` : `Contrat · ${ref}`;
|
|
|
|
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 text-slate-700 whitespace-nowrap">
|
|
<span className={classNames(
|
|
'inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border font-medium',
|
|
isAvenant ? 'border-purple-200 text-purple-700 bg-purple-50' : 'border-blue-200 text-blue-700 bg-blue-50'
|
|
)}>
|
|
{docType}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 font-medium text-slate-900 whitespace-nowrap">
|
|
<a
|
|
href={urlPath}
|
|
onClick={(e)=>{ e.preventDefault(); openEmbed(urlPath, docLabel); }}
|
|
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 || mat === '—') return; e.preventDefault(); openEmbed(`/salaries/${mat}`, `Salarié · ${nom}`); }}
|
|
className={classNames('hover:underline', (!mat || 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>
|
|
);
|
|
}
|