Système signature intégrée

This commit is contained in:
odentas 2025-10-14 21:35:58 +02:00
parent 2257203831
commit f460c1da5a
4 changed files with 944 additions and 31 deletions

View file

@ -0,0 +1,162 @@
# Refactorisation : Suppression de la dépendance à sessionStorage pour les signatures
## 📋 Résumé
Suppression de l'utilisation de `sessionStorage` pour stocker les signatures électroniques. Les signatures sont maintenant gérées via l'état React, éliminant les dépendances inter-pages et les problèmes de synchronisation.
## 🔧 Changements techniques
### 1. Page signatures électroniques (`app/(app)/signatures-electroniques/page.tsx`)
#### Avant
```typescript
// Stockage dans sessionStorage
sessionStorage.setItem('docuseal_signature_b64', normalizedSignature);
// Lecture depuis sessionStorage
const signature = sessionStorage.getItem('docuseal_signature_b64');
```
#### Après
```typescript
// État React local
const [activeSignature, setActiveSignature] = useState<string | null>(null);
// Mise à jour de l'état
setActiveSignature(normalizedSignature);
// Utilisation directe de l'état
const signature = activeSignature;
```
**Fichier modifié :**
- Ajout de l'état `activeSignature` (ligne ~119)
- Remplacement de `sessionStorage.setItem()` par `setActiveSignature()` (ligne ~524)
- Remplacement de `sessionStorage.getItem()` par lecture de `activeSignature` (ligne ~444)
- Ajout d'`activeSignature` dans les dépendances du useEffect (ligne ~465)
### 2. Page contrats (`app/(app)/contrats/[id]/page.tsx`)
#### Avant
```typescript
// Écriture dans sessionStorage
sessionStorage.setItem('docuseal_signature_b64', signatureB64);
// Lecture depuis sessionStorage dans le rendu
const signatureB64 = sessionStorage.getItem('docuseal_signature_b64');
```
#### Après
```typescript
// État React
const [signatureB64ForDocuSeal, setSignatureB64ForDocuSeal] = useState<string | null>(null);
// Mise à jour lors du chargement
setSignatureB64ForDocuSeal(signatureB64);
// Utilisation directe dans le rendu
const signatureB64 = signatureB64ForDocuSeal;
```
**Fichier modifié :**
- Ajout de l'état `signatureB64ForDocuSeal` (ligne ~586)
- Remplacement de `sessionStorage.setItem()` par `setSignatureB64ForDocuSeal()` (ligne ~780)
- Remplacement de `sessionStorage.getItem()` par lecture de `signatureB64ForDocuSeal` (ligne ~1544)
## ✅ Avantages
### 1. **Isolation des composants**
- Chaque page gère sa propre signature indépendamment
- Plus de dépendance inter-pages cachée
- Code plus prévisible et maintenable
### 2. **Chargement dynamique**
- La signature est chargée directement depuis l'API `/api/contrats/[id]/signature`
- Plus besoin de passer par une autre page pour "initialiser" la signature
- Fonctionne même si l'utilisateur arrive directement sur la page contrat
### 3. **Réactivité améliorée**
- Les changements de signature se propagent automatiquement via React
- Re-render automatique quand la signature change
- Meilleure intégration avec l'écosystème React
### 4. **Débogage simplifié**
- L'état est visible dans React DevTools
- Plus de problèmes de synchronisation avec sessionStorage
- Logs plus clairs (état React vs storage navigateur)
### 5. **Sécurité**
- Les données ne persistent pas entre sessions
- Pas de risque de lecture de signatures par d'autres onglets
- Nettoyage automatique à la fermeture de la page
## 🔍 Flux de données
### Page signatures-electroniques
```
1. Utilisateur ouvre un formulaire DocuSeal
2. openSignature() charge les données du contrat depuis l'API
3. La signature est extraite et stockée dans activeSignature
4. useEffect détecte le changement d'activeSignature
5. Re-render du DocuSeal form avec data-signature mis à jour
```
### Page contrats/[id]
```
1. Utilisateur clique sur "Signature électronique"
2. openSignature() appelle /api/contrats/[id]/signature
3. L'API joint automatiquement signature_b64 depuis organization_details
4. La signature est stockée dans signatureB64ForDocuSeal
5. DocuSeal form est rendu avec data-signature pré-rempli
```
## 📦 API impliquée
### `/api/contrats/[id]/signature`
- Récupère le contrat et joint automatiquement la signature de l'organisation
- Requête sur `organization_details` avec `org_id` du contrat
- Retourne `signature_b64` dans la réponse
### `/api/signatures-electroniques/contrats`
- Liste les contrats disponibles pour signature
- Enrichit chaque contrat avec `signature_b64` de son organisation
## 🧪 Tests recommandés
1. **Isolation** : Ouvrir un contrat directement par URL → signature doit être pré-remplie
2. **Mise à jour** : Modifier une signature → devrait être visible immédiatement
3. **Staff** : En tant que staff, changer d'organisation → nouvelle signature chargée
4. **Sans signature** : Organisation sans signature → pas de pré-remplissage (normal)
5. **Suppression** : Supprimer une signature → formulaires futurs sans pré-remplissage
## 🔄 Migration
### Pour les utilisateurs
Aucun impact visible. Le comportement reste identique, mais plus fiable.
### Pour les développeurs
Si vous avez du code qui :
- Lit `sessionStorage.getItem('docuseal_signature_b64')` → Utiliser l'état React approprié
- Écrit dans sessionStorage pour les signatures → Utiliser setState
## 📝 Notes techniques
- Le format de signature reste inchangé : `data:image/png;base64,...`
- La fonction `normalizeSignatureFormat()` assure la compatibilité avec les anciennes données
- Les logs de debug contiennent toujours "sessionStorage" dans les messages pour clarté historique
## 🎯 Prochaines étapes possibles
1. Créer un hook personnalisé `useOrganizationSignature(orgId)` pour centraliser la logique
2. Implémenter un cache React Query pour éviter les rechargements
3. Ajouter un indicateur visuel de chargement pendant la récupération de la signature
4. Précharger les signatures des organisations fréquemment utilisées
---
**Date de refactorisation** : 14 octobre 2025
**Fichiers modifiés** :
- `app/(app)/signatures-electroniques/page.tsx`
- `app/(app)/contrats/[id]/page.tsx`
**Compatibilité** : ✅ Rétrocompatible (les anciennes signatures en base64 pur fonctionnent toujours)

View file

@ -582,6 +582,7 @@ export default function ContratPage() {
// State pour la modale de signature DocuSeal
const [embedSrc, setEmbedSrc] = useState<string>("");
const [modalTitle, setModalTitle] = useState<string>("");
const [signatureB64ForDocuSeal, setSignatureB64ForDocuSeal] = useState<string | null>(null); // Signature pour pré-remplissage
// State pour la modale de chargement
const [isLoadingSignature, setIsLoadingSignature] = useState<boolean>(false);
@ -770,29 +771,16 @@ export default function ContratPage() {
console.log('✅ [SIGNATURE] URL embed trouvée:', embed);
setEmbedSrc(embed);
// Stocker la signature pour l'ajouter au composant
console.log('🔍 [SIGNATURE] Tentative de stockage de la signature...');
// Stocker la signature dans l'etat React pour l'ajouter au composant
console.log('🔍 [SIGNATURE] Stockage de la signature dans l\'etat React...');
console.log('🔍 [SIGNATURE] signatureB64 value:', signatureB64);
if (signatureB64) {
console.log('📝 [SIGNATURE] Stockage de la signature dans sessionStorage');
console.log('📝 [SIGNATURE] Longueur de la signature:', signatureB64.length);
console.log('📝 [SIGNATURE] Preview:', signatureB64.substring(0, 100));
try {
sessionStorage.setItem('docuseal_signature_b64', signatureB64);
const stored = sessionStorage.getItem('docuseal_signature_b64');
console.log('✅ [SIGNATURE] Signature stockée avec succès, vérification:', {
stored: !!stored,
length: stored?.length,
matches: stored === signatureB64
});
} catch (e) {
console.error('❌ [SIGNATURE] Erreur lors du stockage:', e);
}
console.log('✅ [SIGNATURE] Signature B64 disponible pour pre-remplissage');
setSignatureB64ForDocuSeal(signatureB64);
} else {
console.log('⚠️ [SIGNATURE] Pas de signature à stocker, nettoyage sessionStorage');
sessionStorage.removeItem('docuseal_signature_b64');
console.log('⚠️ [SIGNATURE] Aucune signature B64 disponible');
setSignatureB64ForDocuSeal(null);
}
// Masquer la modale de chargement
@ -1543,10 +1531,10 @@ return (
console.log('🎨 [SIGNATURE RENDER] Génération du HTML docuseal-form');
console.log('🎨 [SIGNATURE RENDER] embedSrc:', embedSrc);
// Récupérer la signature depuis sessionStorage
const signatureB64 = typeof window !== 'undefined' ? sessionStorage.getItem('docuseal_signature_b64') : null;
// Utiliser la signature depuis l'etat React au lieu de sessionStorage
const signatureB64 = signatureB64ForDocuSeal;
console.log('🎨 [SIGNATURE RENDER] Signature depuis sessionStorage:', {
console.log('🎨 [SIGNATURE RENDER] Signature depuis l\'etat React:', {
exists: !!signatureB64,
length: signatureB64?.length,
preview: signatureB64?.substring(0, 50)

View file

@ -1,7 +1,7 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { FileSignature, BellRing, XCircle } from 'lucide-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';
@ -85,6 +85,7 @@ export default function SignaturesElectroniques() {
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);
@ -109,12 +110,305 @@ export default function SignaturesElectroniques() {
// 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 {
@ -142,8 +436,17 @@ export default function SignaturesElectroniques() {
}
useEffect(() => {
console.log('🔄 [useEffect] Déclenchement avec:', { selectedOrgId, userOrgId: userInfo?.orgId });
load();
}, [selectedOrgId]); // Recharger quand l'organisation change
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(() => {
@ -151,8 +454,8 @@ export default function SignaturesElectroniques() {
const container = docusealContainerRef.current;
// Récupérer la signature depuis sessionStorage
const signature = sessionStorage.getItem('docuseal_signature_b64');
// Utiliser la signature depuis l'état React au lieu de sessionStorage
const signature = activeSignature;
// Construire les attributs
let dataSignatureAttr = '';
@ -172,7 +475,7 @@ export default function SignaturesElectroniques() {
data-allow-typed-signature="false"
${dataSignatureAttr}>
</docuseal-form>`;
}, [embedSrc]); // Ne se ré-exécute que quand embedSrc change
}, [embedSrc, activeSignature]); // Re-render quand embedSrc ou activeSignature change
// Ajouter des écouteurs pour recharger les données quand un modal se ferme
useEffect(() => {
@ -229,11 +532,15 @@ export default function SignaturesElectroniques() {
// Gérer la signature pré-remplie si disponible
if (f.signature_b64) {
console.log('✅ [SIGNATURES] Signature trouvée, stockage dans sessionStorage');
sessionStorage.setItem('docuseal_signature_b64', 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');
sessionStorage.removeItem('docuseal_signature_b64');
setActiveSignature(null);
}
// 1) Si l'URL d'embed est déjà en base (signature_link)
@ -424,7 +731,7 @@ export default function SignaturesElectroniques() {
<div className="text-xl font-bold">{stats.total}</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-slate-500">À signer par lemployeur</div>
<div className="text-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">
@ -433,6 +740,70 @@ export default function SignaturesElectroniques() {
</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 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">
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
<span className="text-sm font-medium text-emerald-700">Signature connue</span>
</div>
<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"
>
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">
<AlertCircle className="w-4 h-4 text-amber-600" />
<span className="text-sm font-medium text-amber-700">Signature non connue</span>
</div>
<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"
>
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>
@ -626,6 +997,198 @@ export default function SignaturesElectroniques() {
)}
</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 items-center justify-between mb-2">
<label className="text-sm font-medium text-slate-700">
Signature actuelle :
</label>
{!showDeleteConfirm ? (
<button
onClick={() => setShowDeleteConfirm(true)}
disabled={uploadingSignature}
className="flex items-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"
>
<XCircle className="w-4 h-4" />
Supprimer
</button>
) : (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-600">Confirmer ?</span>
<button
onClick={deleteSignature}
disabled={uploadingSignature}
className="flex items-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="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 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>
);
}

View file

@ -0,0 +1,200 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
// GET: Récupérer la signature d'une organisation
export async function GET(req: NextRequest) {
try {
// Vérifier l'authentification
const supabaseAuth = createRouteHandlerClient({ cookies });
const { data: { user } } = await supabaseAuth.auth.getUser();
if (!user) {
console.log('❌ [GET /api/organization/signature] Non authentifié');
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
}
console.log('✅ [GET /api/organization/signature] Utilisateur authentifié:', user.id);
// Récupérer l'org_id depuis les paramètres
const { searchParams } = new URL(req.url);
const orgId = searchParams.get('org_id');
console.log('🔍 [GET /api/organization/signature] org_id:', orgId);
if (!orgId) {
console.log('❌ [GET /api/organization/signature] org_id manquant');
return NextResponse.json({ error: 'org_id manquant' }, { status: 400 });
}
// Créer un client avec service role pour lire organization_details
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } }
);
// Récupérer la signature depuis organization_details
const { data, error } = await supabase
.from('organization_details')
.select('signature_b64')
.eq('org_id', orgId)
.maybeSingle();
console.log('📦 [GET /api/organization/signature] Query result:', { data, error });
if (error) {
console.error('❌ [GET /api/organization/signature] Erreur Supabase:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
const signature = data?.signature_b64 || null;
console.log('✅ [GET /api/organization/signature] Signature:', signature ? `présente (${signature.substring(0, 50)}...)` : 'absente');
return NextResponse.json({
signature_b64: signature
});
} catch (e: any) {
console.error('❌ [GET /api/organization/signature] Exception:', e);
return NextResponse.json({ error: e.message }, { status: 500 });
}
}
// POST: Sauvegarder ou mettre à jour la signature d'une organisation
export async function POST(req: NextRequest) {
try {
// Vérifier l'authentification
const supabaseAuth = createRouteHandlerClient({ cookies });
const { data: { user } } = await supabaseAuth.auth.getUser();
if (!user) {
console.log('❌ [POST /api/organization/signature] Non authentifié');
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
}
console.log('✅ [POST /api/organization/signature] Utilisateur authentifié:', user.id);
const body = await req.json();
const { org_id, signature_b64 } = body;
console.log('💾 [POST /api/organization/signature] org_id:', org_id);
console.log('💾 [POST /api/organization/signature] signature_b64 length:', signature_b64?.length);
if (!org_id) {
return NextResponse.json({ error: 'org_id manquant' }, { status: 400 });
}
if (!signature_b64 || typeof signature_b64 !== 'string') {
console.log('❌ [POST /api/organization/signature] signature_b64 invalide:', typeof signature_b64);
return NextResponse.json({ error: 'signature_b64 invalide' }, { status: 400 });
}
// Créer un client avec service role pour écrire dans organization_details
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } }
);
console.log('📝 [POST /api/organization/signature] Tentative upsert...');
// Vérifier d'abord si l'enregistrement existe
const { data: existing, error: selectError } = await supabase
.from('organization_details')
.select('org_id')
.eq('org_id', org_id)
.maybeSingle();
if (selectError) {
console.error('❌ [POST /api/organization/signature] Erreur SELECT:', selectError);
} else {
console.log('📦 [POST /api/organization/signature] Enregistrement existant:', existing ? 'oui' : 'non');
}
// Upsert dans organization_details
const { error: upsertError } = await supabase
.from('organization_details')
.upsert({
org_id: org_id,
signature_b64: signature_b64
}, {
onConflict: 'org_id'
});
if (upsertError) {
console.error('❌ [POST /api/organization/signature] Erreur upsert Supabase:', upsertError);
console.error('❌ [POST /api/organization/signature] Détails erreur:', JSON.stringify(upsertError, null, 2));
return NextResponse.json({ error: upsertError.message }, { status: 500 });
}
console.log('✅ [POST /api/organization/signature] Signature sauvegardée avec succès');
return NextResponse.json({
success: true,
message: 'Signature enregistrée avec succès'
});
} catch (e: any) {
console.error('❌ [POST /api/organization/signature] Exception:', e);
return NextResponse.json({ error: e.message }, { status: 500 });
}
}
// DELETE: Supprimer la signature d'une organisation
export async function DELETE(req: NextRequest) {
try {
// Vérifier l'authentification
const supabaseAuth = createRouteHandlerClient({ cookies });
const { data: { user } } = await supabaseAuth.auth.getUser();
if (!user) {
console.log('❌ [DELETE /api/organization/signature] Non authentifié');
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
}
console.log('✅ [DELETE /api/organization/signature] Utilisateur authentifié:', user.id);
const body = await req.json();
const { org_id } = body;
console.log('🗑️ [DELETE /api/organization/signature] org_id:', org_id);
if (!org_id) {
return NextResponse.json({ error: 'org_id manquant' }, { status: 400 });
}
// Créer un client avec service role pour écrire dans organization_details
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } }
);
console.log('📝 [DELETE /api/organization/signature] Tentative suppression...');
// Mettre à jour en mettant signature_b64 à NULL
const { error: updateError } = await supabase
.from('organization_details')
.update({ signature_b64: null })
.eq('org_id', org_id);
if (updateError) {
console.error('❌ [DELETE /api/organization/signature] Erreur update Supabase:', updateError);
console.error('❌ [DELETE /api/organization/signature] Détails erreur:', JSON.stringify(updateError, null, 2));
return NextResponse.json({ error: updateError.message }, { status: 500 });
}
console.log('✅ [DELETE /api/organization/signature] Signature supprimée avec succès');
return NextResponse.json({
success: true,
message: 'Signature supprimée avec succès'
});
} catch (e: any) {
console.error('❌ [DELETE /api/organization/signature] Exception:', e);
return NextResponse.json({ error: e.message }, { status: 500 });
}
}