feat: Système complet de gestion des avenants avec signatures électroniques

 Nouvelles fonctionnalités
- Page de gestion des avenants (/staff/avenants)
- Page de détail d'un avenant (/staff/avenants/[id])
- Création d'avenants (objet, durée, rémunération)
- Génération automatique de PDF d'avenant
- Signature électronique via DocuSeal (employeur puis salarié)
- Changement manuel du statut d'un avenant
- Suppression d'avenants

🔧 Routes API
- POST /api/staff/amendments/create - Créer un avenant
- POST /api/staff/amendments/generate-pdf - Générer le PDF
- POST /api/staff/amendments/[id]/send-signature - Envoyer en signature
- POST /api/staff/amendments/[id]/change-status - Changer le statut
- POST /api/webhooks/docuseal-amendment - Webhook après signature employeur
- GET /api/signatures-electroniques/avenants - Liste des avenants en signature

📧 Système email universel v2
- Migration vers le système universel v2 pour les emails d'avenants
- Template 'signature-request-employee-amendment' pour salariés
- Insertion automatique dans DynamoDB pour la Lambda
- Mise à jour automatique du statut dans Supabase

🗄️ Base de données
- Table 'avenants' avec tous les champs (objet, durée, rémunération)
- Colonnes de notification (last_employer_notification_at, last_employee_notification_at)
- Liaison avec cddu_contracts

🎨 Composants
- AvenantDetailPageClient - Détail complet d'un avenant
- ChangeStatusModal - Changement de statut manuel
- SendSignatureModal - Envoi en signature
- DeleteAvenantModal - Suppression avec confirmation
- AvenantSuccessModal - Confirmation de création

📚 Documentation
- AVENANT_EMAIL_SYSTEM_MIGRATION.md - Guide complet de migration

🐛 Corrections
- Fix parsing défensif dans Lambda AWS
- Fix récupération des données depuis DynamoDB
- Fix statut MFA !== 'verified' au lieu de === 'unverified'
This commit is contained in:
odentas 2025-10-23 15:30:11 +02:00
parent 34b3464132
commit 5b72941777
29 changed files with 3609 additions and 235 deletions

View file

@ -0,0 +1,232 @@
# Migration du système d'envoi d'email pour les avenants
## 📋 Résumé des changements
### Problèmes identifiés
1. ❌ La Lambda AWS envoyait les emails directement via SES avec un template HTML codé en dur
2. ❌ Pas d'utilisation du système universel v2 pour les emails d'avenants
3. ❌ Pas de mise à jour du statut de l'avenant dans Supabase après signature employeur
4. ❌ Logging dans Airtable au lieu du système centralisé
### Solutions implémentées
✅ Création d'une route API Next.js `/api/webhooks/docuseal-amendment`
✅ Migration vers le système universel v2 pour les emails
✅ Mise à jour automatique du statut de l'avenant dans Supabase
✅ Logging centralisé via `emailLoggingService`
✅ Simplification de la Lambda (appel API au lieu de logique métier)
## 🔄 Nouveau flux
### Avant
```
DocuSeal Webhook
Lambda postDocuSealAvenantSalarie
AWS SES (email HTML codé en dur)
AWS S3 (stockage email)
Airtable (logging)
```
### Après
```
DocuSeal Webhook
Lambda postDocuSealAvenantSalarie
API Next.js /api/webhooks/docuseal-amendment
1. Mise à jour Supabase (statut avenant)
2. Système universel v2 (email)
3. AWS SES (envoi)
4. emailLoggingService (logging centralisé)
```
## 📁 Fichiers modifiés
### 1. Lambda AWS
**Fichier** : `/tmp/aws-toolkit-vscode/lambda/eu-west-3/postDocuSealAvenantSalarie/index.js`
**Changements** :
- ✅ Ajout du parsing défensif pour `event.body`
- ✅ Appel à l'API Next.js au lieu d'envoyer l'email directement
- ✅ Suppression des fonctions `sendSignatureEmail()`, `uploadEmailToS3()`, `logToAirtable()`, `findRecordIdByField()`, `generateEmailHtml()`
- ✅ Utilisation de la variable d'environnement `NEXT_API_URL`
### 2. Route `/api/staff/amendments/[id]/send-signature` (MODIFIÉ)
**Fichier** : `/Users/renaud/Projet Nouvel Espace Paie/app/api/staff/amendments/[id]/send-signature/route.ts`
**Changement critique** :
- ✅ **Insertion des données dans DynamoDB** avant la création de la soumission DocuSeal
- ✅ Clé primaire : `avenant.numero_avenant` (ex: AVE-001)
- ✅ Données stockées : email salarié, référence contrat, nom, dates, poste, etc.
**Pourquoi** : La Lambda a besoin de ces données pour appeler l'API Next.js après la signature de l'employeur.
### 3. Route API Next.js (NOUVEAU)
**Fichier** : `/Users/renaud/Projet Nouvel Espace Paie/app/api/webhooks/docuseal-amendment/route.ts`
**Fonctionnalités** :
- ✅ Récupération de l'avenant par son numéro
- ✅ Mise à jour du statut : `signature_status = "pending_employee"`
- ✅ Envoi de l'email via `sendUniversalEmailV2` avec le type `signature-request-employee-amendment`
- ✅ Logging automatique via `emailLoggingService`
- ✅ Gestion d'erreurs complète avec logs détaillés
## 🎨 Template email utilisé
**Type** : `signature-request-employee-amendment`
**Système** : Universel v2 (`templates-mails/universal-template.html`)
**Couleurs** : Standards Odentas (header #171424, bouton #efc543)
**Contenu** :
- Greeting personnalisé avec prénom du salarié
- Message explicatif sur la signature de l'avenant
- Bouton CTA "Signer l'avenant" avec lien DocuSeal
- Carte d'info : employeur + matricule
- Carte détails : référence contrat, type, profession, date début, production
- Footer légal standard
## 🚀 Déploiement
### Étape 1 : Déployer Next.js sur Vercel
```bash
cd "/Users/renaud/Projet Nouvel Espace Paie"
git add app/api/webhooks/docuseal-amendment/route.ts
git commit -m "feat: Ajouter route webhook avenant avec système universel v2"
git push origin main
```
### Étape 2 : Mettre à jour la Lambda AWS
1. Ouvrir AWS Lambda Console
2. Aller dans `postDocuSealAvenantSalarie`
3. Copier le contenu de `/tmp/aws-toolkit-vscode/lambda/eu-west-3/postDocuSealAvenantSalarie/index.js`
4. Ajouter la variable d'environnement :
- `NEXT_API_URL` = `https://paie.odentas.fr` (production)
- ou `https://staging.paie.odentas.fr` (staging)
5. Déployer la Lambda
### Étape 3 : Tester le flux complet
1. Créer un avenant test dans `/staff/avenants/nouveau`
2. Envoyer en signature à l'employeur
3. Signer en tant qu'employeur via DocuSeal
4. Vérifier dans les logs :
- Lambda CloudWatch : appel API Next.js
- Vercel : route webhook appelée
- Supabase : statut avenant mis à jour
- Email reçu par le salarié (boîte mail)
## 📊 Vérifications
### Dans Supabase
```sql
-- Vérifier le statut de l'avenant
SELECT
numero_avenant,
signature_status,
last_employee_notification_at
FROM avenants
WHERE numero_avenant = 'AVE-XXX';
```
**Attendu** : `signature_status = 'pending_employee'` après signature employeur
### Dans les logs Vercel
Chercher : `[WEBHOOK AVENANT]`
- ✅ Avenant trouvé
- ✅ Statut mis à jour
- ✅ Email envoyé
### Dans email_logs (Supabase)
```sql
-- Vérifier l'envoi de l'email
SELECT
email_type,
to_emails,
email_status,
created_at
FROM email_logs
WHERE email_type = 'signature-request-employee-amendment'
ORDER BY created_at DESC
LIMIT 10;
```
## ⚙️ Variables d'environnement requises
### Lambda AWS
- `DOCUSEAL_API_TOKEN` - Token API DocuSeal
- `NEXT_API_URL` - URL de l'API Next.js (ex: `https://paie.odentas.fr`)
- AWS credentials (automatique via IAM Role)
### Next.js (Vercel)
- `DOCUSEAL_TOKEN` - Token API DocuSeal
- `NEXT_PUBLIC_SUPABASE_URL` - URL Supabase
- `SUPABASE_SERVICE_ROLE_KEY` - Service role key
- `AWS_REGION` - Région AWS (eu-west-3)
- `AWS_ACCESS_KEY_ID` - Credentials AWS SES
- `AWS_SECRET_ACCESS_KEY` - Credentials AWS SES
## 🐛 Debugging
### Si l'email n'est pas envoyé
1. **Vérifier DynamoDB** : Les données sont-elles présentes pour le numéro d'avenant ?
```bash
aws dynamodb get-item \
--table-name DocuSealNotification \
--key '{"submission_id":{"S":"AVE-001"}}'
```
2. Vérifier les logs Lambda CloudWatch : `postDocuSealAvenantSalarie`
3. Vérifier les logs Vercel : `/api/webhooks/docuseal-amendment`
4. Vérifier `email_logs` dans Supabase
5. Vérifier que le type `signature-request-employee-amendment` existe dans `emailTemplateService.ts`
### Si les données ne sont pas dans DynamoDB
- **Cause** : Échec de l'insertion lors de l'envoi en signature
- **Solution** : Vérifier les credentials AWS dans les variables d'environnement Vercel
- **Vérifier** : Logs de `/api/staff/amendments/[id]/send-signature` pour l'erreur DynamoDB
### Si le statut n'est pas mis à jour
1. Vérifier que l'avenant existe avec le bon `numero_avenant`
2. Vérifier les permissions Supabase (service role)
3. Vérifier les logs de la route webhook
### Si la Lambda échoue
1. Vérifier `event.body` dans les logs CloudWatch
2. Vérifier que `NEXT_API_URL` est définie
3. Vérifier que l'API Next.js est accessible (pas de firewall/CORS)
## 📝 Notes importantes
- ⚠️ **Ne pas supprimer** la table DynamoDB `DocuSealNotification` (encore utilisée par la Lambda)
- ⚠️ **Ne pas supprimer** les fonctions Airtable dans la Lambda (encore dans le code mais non utilisées)
- ✅ Le logging Airtable est remplacé par `emailLoggingService`
- ✅ Le stockage S3 des emails est remplacé par le logging centralisé
- ✅ Les emails utilisent maintenant le template universel v2 cohérent avec le reste de l'application
## 🎯 Prochaines étapes (optionnel)
1. Migrer complètement vers webhook DocuSeal direct (sans Lambda)
2. Supprimer DynamoDB et Airtable du flux
3. Ajouter un webhook pour la signature du salarié (mise à jour finale du statut)
4. Ajouter des tests automatisés pour le flux complet
## ✅ Checklist de déploiement
- [ ] Code Next.js déployé sur Vercel
- [ ] Lambda mise à jour avec nouveau code
- [ ] Variable `NEXT_API_URL` ajoutée à la Lambda
- [ ] Test complet du flux (création → signature employeur → email salarié)
- [ ] Vérification dans Supabase (statut mis à jour)
- [ ] Vérification email reçu avec bon template
- [ ] Logs vérifiés (Lambda + Vercel + email_logs)
---
**Date de migration** : 23 octobre 2025
**Auteur** : GitHub Copilot
**Statut** : ✅ Prêt pour déploiement

View file

@ -164,6 +164,12 @@ export default function SignaturesElectroniques() {
const recordsEmployeurRef = useRef<AirtableRecord[]>([]); const recordsEmployeurRef = useRef<AirtableRecord[]>([]);
const recordsSalarieRef = 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 // États pour les modales
const [modalTitle, setModalTitle] = useState(''); const [modalTitle, setModalTitle] = useState('');
const [embedSrc, setEmbedSrc] = useState<string | null>(null); const [embedSrc, setEmbedSrc] = useState<string | null>(null);
@ -192,10 +198,6 @@ export default function SignaturesElectroniques() {
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const signingContractIdRef = useRef<string | null>(null); const signingContractIdRef = useRef<string | null>(null);
// Modal de confirmation après signature employeur (DocuSeal 'completed')
const [showCompletedModal, setShowCompletedModal] = useState(false);
const showCompletedModalRef = useRef(false);
useEffect(() => { useEffect(() => {
isMountedRef.current = true; isMountedRef.current = true;
return () => { return () => {
@ -203,10 +205,6 @@ export default function SignaturesElectroniques() {
}; };
}, []); }, []);
useEffect(() => {
showCompletedModalRef.current = showCompletedModal;
}, [showCompletedModal]);
useEffect(() => { useEffect(() => {
recordsEmployeurRef.current = recordsEmployeur; recordsEmployeurRef.current = recordsEmployeur;
}, [recordsEmployeur]); }, [recordsEmployeur]);
@ -504,7 +502,7 @@ export default function SignaturesElectroniques() {
} }
} }
// Load current contracts to sign (server-side API fetches Airtable) // Load current contracts and amendments to sign (server-side API fetches Supabase)
async function load() { async function load() {
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
try { try {
@ -513,23 +511,40 @@ export default function SignaturesElectroniques() {
// Ajouter le paramètre org_id si sélectionné (staff uniquement) // Ajouter le paramètre org_id si sélectionné (staff uniquement)
const orgParam = selectedOrgId ? `&org_id=${selectedOrgId}` : ''; const orgParam = selectedOrgId ? `&org_id=${selectedOrgId}` : '';
const [rEmp, rSal] = await Promise.all([ 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=employeur${orgParam}`, { cache: 'no-store' }),
fetch(`/api/signatures-electroniques/contrats?scope=salarie${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 employeur ${rEmp.status}`); if (!rEmp.ok) throw new Error(`HTTP contrats employeur ${rEmp.status}`);
if (!rSal.ok) throw new Error(`HTTP salarie ${rSal.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 emp: ContractsResponse = await rEmp.json();
const sal: ContractsResponse = await rSal.json(); const sal: ContractsResponse = await rSal.json();
const aveEmp: ContractsResponse = await rAveEmp.json();
const aveSal: ContractsResponse = await rAveSal.json();
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
const empRecords = emp.records || [];
const salarieRecords = sal.records || []; const empRecords = emp.records || [];
setRecordsEmployeur(empRecords); const salarieRecords = sal.records || [];
setRecordsSalarie(salarieRecords); const aveEmpRecords = aveEmp.records || [];
recordsEmployeurRef.current = empRecords; const aveSalRecords = aveSal.records || [];
recordsSalarieRef.current = salarieRecords;
setRecordsEmployeur(empRecords);
setRecordsSalarie(salarieRecords);
setAvenantsEmployeur(aveEmpRecords);
setAvenantsSalarie(aveSalRecords);
recordsEmployeurRef.current = empRecords;
recordsSalarieRef.current = salarieRecords;
avenantsEmployeurRef.current = aveEmpRecords;
avenantsSalarieRef.current = aveSalRecords;
} catch (e: any) { } catch (e: any) {
console.error('Load contracts error', e); console.error('Load contracts/amendments error', e);
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
setError(e?.message || 'Erreur de chargement'); setError(e?.message || 'Erreur de chargement');
} finally { } finally {
@ -602,17 +617,64 @@ export default function SignaturesElectroniques() {
if (formEl) { if (formEl) {
const onCompleted = async (_event: Event) => { const onCompleted = async (_event: Event) => {
console.log('✅ [SIGNATURES] Event completed reçu - déclenchement du rafraîchissement'); console.log('✅ [SIGNATURES] Event completed reçu - déclenchement du rafraîchissement');
setShowCompletedModal(true);
if (!isMountedRef.current) return; 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 é 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); setReloadingAfterSignatureChange(true);
try { try {
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
if (dlg && !dlg.open) {
try { dlg.showModal(); } catch (err) { console.warn('Impossible de ré-ouvrir le modal DocuSeal', err); }
}
// Laisser le temps au webhook DocuSeal de mettre à jour Supabase puis recharger avec retries // Laisser le temps au webhook DocuSeal de mettre à jour Supabase puis recharger avec retries
await new Promise((resolve) => setTimeout(resolve, 900)); await new Promise((resolve) => setTimeout(resolve, 900));
await loadWithRetry({ await loadWithRetry({
@ -642,10 +704,6 @@ export default function SignaturesElectroniques() {
const handleSignatureClose = () => { const handleSignatureClose = () => {
console.log('🔄 Modal signature fermé, rechargement des données...'); console.log('🔄 Modal signature fermé, rechargement des données...');
// Si la confirmation est affichée, empêcher la fermeture effective et ré-ouvrir le dialog
if (showCompletedModalRef.current) {
try { dlgSignature?.showModal(); } catch {}
}
load(); load();
signingContractIdRef.current = null; signingContractIdRef.current = null;
}; };
@ -748,31 +806,78 @@ export default function SignaturesElectroniques() {
async function openSignature(rec: AirtableRecord) { async function openSignature(rec: AirtableRecord) {
const f = rec.fields || {}; const f = rec.fields || {};
const isAvenant = f.is_avenant === true;
let embed: string | null = null; let embed: string | null = null;
const title = `Signature (Employeur) · ${f.Reference || rec.id}`; const docRef = f.reference || f.Reference || rec.id;
const title = `Signature (Employeur) · ${isAvenant ? 'Avenant' : 'Contrat'} ${docRef}`;
setModalTitle(title); setModalTitle(title);
signingContractIdRef.current = rec.id; signingContractIdRef.current = rec.id;
console.log('🔍 [SIGNATURES] Debug - record fields:', f); 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 - embed_src_employeur:', f.embed_src_employeur);
console.log('🔍 [SIGNATURES] Debug - docuseal_template_id:', f.docuseal_template_id); 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'); console.log('🔍 [SIGNATURES] Debug - signature_b64:', f.signature_b64 ? 'présente' : 'absente');
// Gérer la signature pré-remplie si disponible // Gérer la signature pré-remplie
if (f.signature_b64) { // Pour les contrats: utiliser f.signature_b64
console.log('✅ [SIGNATURES] Signature trouvée, stockage dans l\'état React'); // Pour les avenants: utiliser currentSignature (signature de l'organisation)
const normalizedSignature = normalizeSignatureFormat(f.signature_b64); 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) { if (normalizedSignature) {
setActiveSignature(normalizedSignature); setActiveSignature(normalizedSignature);
console.log('✅ [SIGNATURES] Format normalisé:', normalizedSignature.substring(0, 50) + '...'); console.log('✅ [SIGNATURES] Format normalisé:', normalizedSignature.substring(0, 50) + '...');
} }
} else { } else {
console.log('⚠️ [SIGNATURES] Pas de signature dans les données'); console.log('⚠️ [SIGNATURES] Pas de signature à pré-remplir');
setActiveSignature(null); setActiveSignature(null);
} }
// 1) Si l'URL d'embed est déjà en base (signature_link) // Pour les avenants, utiliser directement le docuseal_submission_id
if (typeof f.embed_src_employeur === 'string' && f.embed_src_employeur.trim()) { 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(); const signatureLink = f.embed_src_employeur.trim();
console.log('🔗 [SIGNATURES] Signature link trouvé:', signatureLink); console.log('🔗 [SIGNATURES] Signature link trouvé:', signatureLink);
@ -1072,20 +1177,21 @@ export default function SignaturesElectroniques() {
<div className="text-slate-500">Chargement</div> <div className="text-slate-500">Chargement</div>
) : error ? ( ) : error ? (
<div className="text-red-600">Erreur: {error}</div> <div className="text-red-600">Erreur: {error}</div>
) : (recordsEmployeur.length + recordsSalarie.length) === 0 ? ( ) : (recordsEmployeur.length + recordsSalarie.length + avenantsEmployeur.length + avenantsSalarie.length) === 0 ? (
<div className="text-slate-500">Aucun contrat à signer.</div> <div className="text-slate-500">Aucun document à signer.</div>
) : ( ) : (
<> <>
{/* Table 1: employeur pending */} {/* Table 1: employeur pending (contrats + avenants) */}
<div className="rounded-xl border overflow-hidden shadow-sm"> <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="flex items-center justify-between gap-3 px-3 py-2 border-b bg-white/60 backdrop-blur supports-[backdrop-filter]:bg-white/60[backdrop-filter]:bg-slate-900/60">
<div className="text-sm font-medium text-slate-700">Contrats en attente de signature employeur</div> <div className="text-sm font-medium text-slate-700">Documents en attente de signature employeur</div>
<div className="text-xs text-slate-500">{recordsEmployeur.length} élément(s)</div> <div className="text-xs text-slate-500">{recordsEmployeur.length + avenantsEmployeur.length} élément(s)</div>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead> <thead>
<tr className="text-left text-slate-500 border-b bg-slate-50/60"> <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">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">Salarié</th>
<th className="px-3 py-2 font-medium whitespace-nowrap">Matricule</th> <th className="px-3 py-2 font-medium whitespace-nowrap">Matricule</th>
@ -1094,26 +1200,39 @@ export default function SignaturesElectroniques() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{recordsEmployeur.map((rec, idx) => { {[...recordsEmployeur, ...avenantsEmployeur].map((rec, idx) => {
const f = rec.fields || {}; const f = rec.fields || {};
const isAvenant = f.is_avenant === true;
const isSigned = String(f['Contrat signé par employeur'] || '').toLowerCase() === 'oui'; const isSigned = String(f['Contrat signé par employeur'] || '').toLowerCase() === 'oui';
const mat = f['Matricule API'] || f.Matricule || '—'; const mat = f.employee_matricule || f['Matricule API'] || f.Matricule || '—';
const ref = f.Reference || '—'; const ref = f.reference || f.Reference || '—';
const nom = f['Nom salarié'] || f['Nom Salarié'] || f['Nom'] || f['Salarié'] || f['Nom complet'] || f['Prénom Nom'] || f['Prenom Nom'] || f['Nom et Prénom'] || '—'; 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 ( return (
<tr key={rec.id} className={classNames(idx % 2 ? 'bg-white' : 'bg-slate-50/30', 'hover:bg-slate-50 transition-colors')}> <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"> <td className="px-3 py-2 font-medium text-slate-900 whitespace-nowrap">
<a <a
href={`/contrats/${encodeURIComponent(rec.id)}`} href={urlPath}
onClick={(e)=>{ e.preventDefault(); openEmbed(`/contrats/${rec.id}`, `Contrat · ${ref}`); }} onClick={(e)=>{ e.preventDefault(); openEmbed(urlPath, docLabel); }}
className="hover:underline" className="hover:underline"
>{ref}</a> >{ref}</a>
</td> </td>
<td className="px-3 py-2 text-slate-700 max-w-[260px] truncate" title={nom}> <td className="px-3 py-2 text-slate-700 max-w-[260px] truncate" title={nom}>
<a <a
href={mat ? `/salaries/${encodeURIComponent(mat)}` : '#'} href={mat ? `/salaries/${encodeURIComponent(mat)}` : '#'}
onClick={(e)=>{ if (!mat) return; e.preventDefault(); openEmbed(`/salaries/${mat}`, `Salarié · ${nom}`); }} onClick={(e)=>{ if (!mat || mat === '—') return; e.preventDefault(); openEmbed(`/salaries/${mat}`, `Salarié · ${nom}`); }}
className={classNames('hover:underline', !mat && 'pointer-events-none opacity-60')} className={classNames('hover:underline', (!mat || mat === '—') && 'pointer-events-none opacity-60')}
>{nom}</a> >{nom}</a>
</td> </td>
<td className="px-3 py-2 text-slate-700 whitespace-nowrap">{mat}</td> <td className="px-3 py-2 text-slate-700 whitespace-nowrap">{mat}</td>
@ -1130,7 +1249,7 @@ export default function SignaturesElectroniques() {
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" 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} disabled={isSigned}
onClick={() => openSignature(rec)} onClick={() => openSignature(rec)}
aria-label={`Signer le contrat ${ref}`} aria-label={`Signer ${isAvenant ? "l'avenant" : "le contrat"} ${ref}`}
> >
<FileSignature className="w-3.5 h-3.5" aria-hidden="true" /> <FileSignature className="w-3.5 h-3.5" aria-hidden="true" />
Signer Signer
@ -1144,16 +1263,17 @@ export default function SignaturesElectroniques() {
</div> </div>
</div> </div>
{/* Table 2: salarie pending */} {/* Table 2: salarie pending (contrats + avenants) */}
<div className="rounded-xl border overflow-hidden shadow-sm mt-8"> <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="flex items-center justify-between gap-3 px-3 py-2 border-b bg-white/60 backdrop-blur supports-[backdrop-filter]:bg-white/60[backdrop-filter]:bg-slate-900/60">
<div className="text-sm font-medium text-slate-700">Contrats en attente de signature salarié</div> <div className="text-sm font-medium text-slate-700">Documents en attente de signature salarié</div>
<div className="text-xs text-slate-500">{recordsSalarie.length} élément(s)</div> <div className="text-xs text-slate-500">{recordsSalarie.length + avenantsSalarie.length} élément(s)</div>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead> <thead>
<tr className="text-left text-slate-500 border-b bg-slate-50/60"> <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">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">Salarié</th>
<th className="px-3 py-2 font-medium whitespace-nowrap">Matricule</th> <th className="px-3 py-2 font-medium whitespace-nowrap">Matricule</th>
@ -1162,25 +1282,38 @@ export default function SignaturesElectroniques() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{recordsSalarie.map((rec, idx) => { {[...recordsSalarie, ...avenantsSalarie].map((rec, idx) => {
const f = rec.fields || {}; const f = rec.fields || {};
const mat = f['Matricule API'] || f.Matricule || '—'; const isAvenant = f.is_avenant === true;
const ref = f.Reference || '—'; const mat = f.employee_matricule || f['Matricule API'] || f.Matricule || '—';
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'] || '—'; 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 ( return (
<tr key={rec.id} className={classNames(idx % 2 ? 'bg-white' : 'bg-slate-50/30', 'hover:bg-slate-50 transition-colors')}> <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"> <td className="px-3 py-2 font-medium text-slate-900 whitespace-nowrap">
<a <a
href={`/contrats/${encodeURIComponent(rec.id)}`} href={urlPath}
onClick={(e)=>{ e.preventDefault(); openEmbed(`/contrats/${rec.id}`, `Contrat · ${ref}`); }} onClick={(e)=>{ e.preventDefault(); openEmbed(urlPath, docLabel); }}
className="hover:underline" className="hover:underline"
>{ref}</a> >{ref}</a>
</td> </td>
<td className="px-3 py-2 text-slate-700 max-w-[260px] truncate" title={nom}> <td className="px-3 py-2 text-slate-700 max-w-[260px] truncate" title={nom}>
<a <a
href={mat ? `/salaries/${encodeURIComponent(mat)}` : '#'} href={mat ? `/salaries/${encodeURIComponent(mat)}` : '#'}
onClick={(e)=>{ if (!mat) return; e.preventDefault(); openEmbed(`/salaries/${mat}`, `Salarié · ${nom}`); }} onClick={(e)=>{ if (!mat || mat === '—') return; e.preventDefault(); openEmbed(`/salaries/${mat}`, `Salarié · ${nom}`); }}
className={classNames('hover:underline', !mat && 'pointer-events-none opacity-60')} className={classNames('hover:underline', (!mat || mat === '—') && 'pointer-events-none opacity-60')}
>{nom}</a> >{nom}</a>
</td> </td>
<td className="px-3 py-2 text-slate-700 whitespace-nowrap">{mat}</td> <td className="px-3 py-2 text-slate-700 whitespace-nowrap">{mat}</td>
@ -1261,65 +1394,6 @@ export default function SignaturesElectroniques() {
</div> </div>
</dialog> </dialog>
{/* Modal de confirmation après signature employeur */}
{showCompletedModal && (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-lg w-full overflow-hidden">
{/* Header */}
<div className="p-6 bg-emerald-50 border-b border-emerald-100">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-emerald-100 flex items-center justify-center">
<CheckCircle2 className="w-6 h-6 text-emerald-600" />
</div>
<div>
<h2 className="text-xl font-semibold text-slate-900">Signature employeur prise en compte</h2>
<p className="text-sm text-slate-600 mt-1">Le processus se poursuit automatiquement côté salarié.</p>
</div>
</div>
<button
onClick={() => {
setShowCompletedModal(false);
// Fermer aussi le modal DocuSeal
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
if (dlg) dlg.close();
}}
className="ml-4 p-2 hover:bg-slate-100 rounded-lg transition-colors"
aria-label="Fermer"
>
<XCircle className="h-5 w-5 text-slate-500" />
</button>
</div>
</div>
{/* Content */}
<div className="p-6 space-y-4">
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
<ul className="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 é reçue.</li>
</ul>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 bg-gray-50 border-t flex justify-end">
<button
onClick={() => {
setShowCompletedModal(false);
// Fermer aussi le modal DocuSeal
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
if (dlg) dlg.close();
}}
className="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>
)}
{/* Modal de gestion de la signature */} {/* Modal de gestion de la signature */}
{showSignatureModal && ( {showSignatureModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">

View file

@ -0,0 +1,68 @@
import { createSbServer } from "@/lib/supabaseServer";
import { redirect, notFound } from "next/navigation";
import AvenantDetailPageClient from "@/components/staff/avenants/AvenantDetailPageClient";
export const dynamic = "force-dynamic";
export default async function AvenantDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const sb = createSbServer();
const { data: { user } } = await sb.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: me } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
const isStaff = !!me?.is_staff;
if (!isStaff) {
redirect("/dashboard");
}
// Récupérer l'avenant avec les informations du contrat
const { data: avenant, error } = await sb
.from("avenants")
.select(`
*,
cddu_contracts (
*,
organizations (
id,
name,
organization_details (
email_signature
)
),
salaries (
prenom,
nom,
adresse_mail
)
)
`)
.eq("id", id)
.single();
console.log("Recherche avenant ID:", id);
console.log("Résultat:", { avenant, error });
if (error || !avenant) {
console.error("Erreur ou avenant non trouvé:", error);
notFound();
}
return (
<main className="p-6">
<AvenantDetailPageClient avenant={avenant} />
</main>
);
}

View file

@ -38,13 +38,56 @@ export default async function StaffAvenantsPage() {
); );
} }
// TODO: Récupérer les avenants depuis la base de données // Récupérer les avenants depuis la base de données
// Pour l'instant, on passe un tableau vide const { data: avenants, error } = await sb
const amendments: any[] = []; .from("avenants")
.select(`
id,
numero_avenant,
date_avenant,
date_effet,
type_avenant,
motif_avenant,
elements_avenantes,
statut,
contract_id,
created_at,
signature_status,
last_employer_notification_at,
last_employee_notification_at,
cddu_contracts!inner(
contract_number,
employee_name,
structure,
org_id
)
`)
.order("created_at", { ascending: false });
if (error) {
console.error("Erreur récupération avenants:", error);
}
// Transformer les données pour le format attendu
const formattedAvenants = (avenants || []).map((a: any) => ({
id: a.id,
contract_id: a.contract_id,
contract_number: a.cddu_contracts?.contract_number,
employee_name: a.cddu_contracts?.employee_name,
organization_name: a.cddu_contracts?.structure,
date_effet: a.date_effet,
date_signature: a.date_avenant,
status: a.statut,
elements: a.elements_avenantes || [],
created_at: a.created_at,
signature_status: a.signature_status,
last_employer_notification_at: a.last_employer_notification_at,
last_employee_notification_at: a.last_employee_notification_at,
}));
return ( return (
<main className="p-6"> <main className="p-6">
<StaffAvenantsPageClient initialData={amendments} /> <StaffAvenantsPageClient initialData={formattedAvenants} />
</main> </main>
); );
} }

View file

@ -0,0 +1,239 @@
import { NextRequest, NextResponse } from 'next/server';
import { sendUniversalEmailV2 } from '@/lib/emailTemplateService';
import { ENV } from '@/lib/cleanEnv';
import { createSbServiceRole } from '@/lib/supabaseServer';
/**
* POST /api/emails/signature-avenant-salarie
*
* Route API appelée par la Lambda postDocuSealAvenantSalarie pour envoyer l'email de signature au salarié
* Utilise le système universel d'emails v2 avec logging automatique
*
* Authentification : API Key dans le header X-API-Key
*/
export async function POST(request: NextRequest) {
console.log('=== API Email Signature Avenant Salarié ===');
try {
// 1. Vérification de l'authentification via API Key
const apiKey = request.headers.get('X-API-Key');
const validApiKey = ENV.LAMBDA_API_KEY;
if (!validApiKey) {
console.error('❌ Configuration error: LAMBDA_API_KEY not configured');
return NextResponse.json(
{ error: 'Server configuration error' },
{ status: 500 }
);
}
if (!apiKey || apiKey !== validApiKey) {
console.error('❌ Unauthorized: Invalid or missing API key');
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
console.log('✅ Authentication successful');
// 2. Récupération et validation des données
const data = await request.json();
console.log('📦 Données reçues:', {
employeeEmail: data.employeeEmail,
reference: data.reference,
structure: data.structure,
firstName: data.firstName,
matricule: data.matricule,
avenantId: data.avenantId
});
const {
employeeEmail,
signatureLink,
reference,
firstName: providedFirstName,
organizationName: providedOrgName,
matricule,
typecontrat,
startDate,
profession,
productionName,
organizationId,
contractId,
avenantId,
numeroAvenant
} = data;
// Validation des champs requis
if (!employeeEmail || !signatureLink || !reference) {
console.error('❌ Champs requis manquants');
return NextResponse.json(
{
error: 'Champs manquants',
required: ['employeeEmail', 'signatureLink', 'reference']
},
{ status: 400 }
);
}
// 3. Récupération du vrai nom de l'organisation depuis Supabase
let organizationName = providedOrgName || 'Employeur';
if (organizationId || contractId) {
console.log('🔍 Récupération du nom de l\'organisation depuis la base de données...');
try {
const supabase = createSbServiceRole();
let actualOrgId = organizationId;
// Si on n'a que le contractId, récupérer l'org_id depuis le contrat
if (!actualOrgId && contractId) {
const { data: contractData } = await supabase
.from('cddu_contracts')
.select('org_id')
.eq('id', contractId)
.single();
if (contractData?.org_id) {
actualOrgId = contractData.org_id;
console.log('✅ org_id récupéré depuis le contrat:', actualOrgId);
}
}
// Récupérer le nom de l'organisation
if (actualOrgId) {
const { data: orgData, error: orgError } = await supabase
.from('organizations')
.select('name')
.eq('id', actualOrgId)
.single();
if (!orgError && orgData?.name) {
organizationName = orgData.name;
console.log('✅ Nom de l\'organisation trouvé:', organizationName);
} else {
console.warn('⚠️ Nom de l\'organisation non trouvé, utilisation du fallback');
}
}
} catch (err) {
console.error('⚠️ Erreur lors de la récupération du nom de l\'organisation:', err);
}
}
// 4. Récupération du prénom depuis Supabase si non fourni
let firstName = providedFirstName;
if (!firstName && (matricule || employeeEmail)) {
console.log('🔍 Récupération du prénom depuis la table salaries...');
try {
const supabase = createSbServiceRole();
// Recherche par matricule ou email
let query = supabase
.from('salaries')
.select('prenom')
.limit(1);
if (organizationId) {
query = query.eq('employer_id', organizationId);
}
// Priorité au matricule
if (matricule) {
query = query.or(`code_salarie.eq.${matricule},num_salarie.eq.${matricule}`);
} else if (employeeEmail) {
query = query.eq('adresse_mail', employeeEmail);
}
const { data: salaryData, error: salaryError } = await query;
if (!salaryError && salaryData && salaryData[0]?.prenom) {
firstName = salaryData[0].prenom;
console.log('✅ Prénom trouvé dans Supabase:', firstName);
} else {
console.warn('⚠️ Prénom non trouvé dans Supabase');
firstName = 'Salarié(e)'; // Fallback
}
} catch (err) {
console.error('⚠️ Erreur lors de la récupération du prénom:', err);
firstName = 'Salarié(e)'; // Fallback
}
} else if (!firstName) {
firstName = 'Salarié(e)'; // Fallback si aucune donnée disponible
}
// 5. Mise à jour du last_employee_notification_at dans la table avenants
if (avenantId) {
console.log('🔄 Mise à jour de last_employee_notification_at pour l\'avenant:', avenantId);
try {
const supabase = createSbServiceRole();
const { error: updateError } = await supabase
.from('avenants')
.update({
last_employee_notification_at: new Date().toISOString(),
signature_status: 'pending_employee'
})
.eq('id', avenantId);
if (updateError) {
console.error('❌ Erreur mise à jour last_employee_notification_at:', updateError);
} else {
console.log('✅ last_employee_notification_at mis à jour');
}
} catch (err) {
console.error('⚠️ Erreur lors de la mise à jour:', err);
}
}
// 6. Envoi de l'email via le système universel v2
console.log('📧 Envoi de l\'email via le système universel v2...');
try {
await sendUniversalEmailV2({
type: 'signature-request-employee-amendment',
toEmail: employeeEmail,
subject: `Signature électronique requise Avenant ${numeroAvenant || reference}`,
data: {
signatureLink,
firstName,
organizationName,
matricule,
contractReference: reference,
contractType: typecontrat || 'CDDU',
startDate,
profession,
productionName,
numeroAvenant
}
});
console.log('✅ Email envoyé avec succès');
return NextResponse.json({
success: true,
message: 'Email envoyé avec succès'
});
} catch (emailError: any) {
console.error('❌ Erreur lors de l\'envoi de l\'email:', emailError);
return NextResponse.json(
{
error: 'Erreur lors de l\'envoi de l\'email',
details: emailError.message
},
{ status: 500 }
);
}
} catch (error: any) {
console.error('❌ Erreur serveur:', error);
return NextResponse.json(
{
error: 'Erreur serveur',
details: error.message
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,178 @@
import { NextResponse, NextRequest } from 'next/server';
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { detectDemoModeFromHeaders } from '@/lib/demo-detector';
// GET /api/signatures-electroniques/avenants
// Retourne les avenants à signer par l'employeur ou salarié depuis Supabase
export async function GET(req: NextRequest) {
// 🎭 Vérifier le mode démo - retourner des données vides pour sécuriser
if (detectDemoModeFromHeaders(req.headers)) {
console.log('🎭 [SIGNATURES AVENANTS API] Mode démo détecté - retour de données vides');
return NextResponse.json({ records: [] }, { status: 200 });
}
const reqUrl = new URL(req.url);
const scope = (reqUrl.searchParams.get('scope') || 'employeur').toLowerCase();
const orgIdParam = reqUrl.searchParams.get('org_id'); // Paramètre org_id pour les staff
// Récupération de l'organisation active
const sb = createRouteHandlerClient({ cookies });
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
// Vérifier staff pour lire la cible via cookie active_org_id ou paramètre org_id
let isStaff = false;
try {
const { data } = await sb.from('staff_users').select('is_staff').eq('user_id', user.id).maybeSingle();
isStaff = !!data?.is_staff;
} catch {}
let orgId: string | null = null;
try {
if (isStaff) {
// Si un org_id est fourni en paramètre, l'utiliser (priorité pour les staff)
if (orgIdParam) {
orgId = orgIdParam;
} else {
// Sinon utiliser le cookie active_org_id
const c = cookies();
orgId = c.get('active_org_id')?.value || null;
}
} else {
const { data, error } = await sb
.from('organization_members')
.select('org_id')
.eq('user_id', user.id)
.single();
if (error || !data?.org_id) {
return NextResponse.json({ error: 'no_active_org' }, { status: 403 });
}
orgId = data.org_id;
}
} catch {}
// Si pas d'organisation trouvée :
// - pour les utilisateurs normaux on retourne une erreur
// - pour le staff on permet l'accès global
if (!orgId && !isStaff) {
return NextResponse.json({ error: 'no_active_org' }, { status: 403 });
}
// Construction de la requête Supabase
try {
// D'abord récupérer les avenants sans filtrer par org_id
let query = sb
.from('avenants')
.select(`
id,
contract_id,
type_avenant,
statut,
signature_status,
docuseal_submission_id,
pdf_url,
created_at
`);
// Filtrer selon le scope demandé
if (scope === 'salarie') {
// Avenants en attente de signature du salarié
query = query.eq('signature_status', 'pending_employee');
} else {
// Avenants en attente de signature de l'employeur (scope = 'employeur')
query = query.eq('signature_status', 'pending_employer');
}
// Trier par date de création décroissante
query = query.order('created_at', { ascending: false });
const { data, error } = await query;
if (error) {
console.error('❌ [signatures-avenants] Erreur Supabase:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
console.log(`🔍 [signatures-avenants] Requête pour scope: ${scope}, orgId: ${orgId || 'null'}`);
console.log(`📊 [signatures-avenants] Avenants trouvés (avant filtrage org): ${data?.length || 0}`);
// Récupérer les données des contrats séparément (avec org_id)
const contractIds = data?.map((a: any) => a.contract_id).filter(Boolean) || [];
console.log(`🔍 [signatures-avenants] Contract IDs à récupérer: ${contractIds.length}`);
let contractsData: any = {};
if (contractIds.length > 0) {
let contractQuery = sb
.from('cddu_contracts')
.select('id, reference, employee_name, employee_matricule, production_name, org_id')
.in('id', contractIds);
// Filtrer par org_id si nécessaire
if (orgId) {
contractQuery = contractQuery.eq('org_id', orgId);
}
const { data: contracts, error: contractsError } = await contractQuery;
if (contractsError) {
console.error('❌ [signatures-avenants] Erreur récupération contrats:', contractsError);
} else {
console.log(`✅ [signatures-avenants] Contrats récupérés: ${contracts?.length || 0}`);
if (contracts) {
contractsData = contracts.reduce((acc: any, c: any) => {
acc[c.id] = c;
return acc;
}, {});
}
}
}
if (data && data.length > 0) {
console.log('📋 [signatures-avenants] Échantillon:', data.slice(0, 3).map((a: any) => ({
id: a.id,
statut: a.statut,
signature_status: a.signature_status,
type_avenant: a.type_avenant,
contract_id: a.contract_id
})));
}
// Transformer les données au format compatible avec la page
// Filtrer uniquement les avenants dont le contrat a été trouvé (et correspond à l'org si filtrage)
const records = (data || [])
.filter((avenant: any) => contractsData[avenant.contract_id]) // Ne garder que les avenants avec contrat valide
.map((avenant: any) => {
const contract = contractsData[avenant.contract_id];
return {
id: avenant.id,
fields: {
id: avenant.id,
reference: contract.reference || 'N/A',
employee_name: contract.employee_name || 'N/A',
employee_matricule: contract.employee_matricule || '',
production_name: contract.production_name || '',
type_avenant: avenant.type_avenant,
statut: avenant.statut,
signature_status: avenant.signature_status,
docuseal_submission_id: avenant.docuseal_submission_id,
pdf_url: avenant.pdf_url,
created_at: avenant.created_at,
// Indicateur pour distinguer les avenants des contrats dans l'UI
is_avenant: true,
}
};
});
console.log(`📊 [signatures-avenants] Avenants après filtrage org: ${records.length}`);
return NextResponse.json({ records }, { status: 200 });
} catch (error: any) {
console.error('Erreur récupération avenants signatures:', error);
return NextResponse.json({ error: error.message || 'Erreur serveur' }, { status: 500 });
}
}

View file

@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const supabase = createRouteHandlerClient({ cookies });
// Vérifier l'authentification
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const { data: staffUser } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staffUser?.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
// Récupérer le nouveau statut
const body = await request.json();
const { status: newStatus } = body;
// Valider le statut
const validStatuses = ["draft", "pending", "signed", "cancelled"];
if (!newStatus || !validStatuses.includes(newStatus)) {
return NextResponse.json({
error: "Statut invalide. Statuts acceptés : draft, pending, signed, cancelled"
}, { status: 400 });
}
// Vérifier que l'avenant existe
const { data: avenant, error: fetchError } = await supabase
.from("avenants")
.select("id, statut, signature_status")
.eq("id", id)
.single();
if (fetchError || !avenant) {
return NextResponse.json({ error: "Avenant non trouvé" }, { status: 404 });
}
// Mettre à jour le statut de l'avenant
const updateData: any = {
statut: newStatus,
updated_at: new Date().toISOString(),
};
// Si on repasse en brouillon, réinitialiser aussi le signature_status
if (newStatus === "draft") {
updateData.signature_status = "not_sent";
}
const { error: updateError } = await supabase
.from("avenants")
.update(updateData)
.eq("id", id);
if (updateError) {
console.error("Erreur mise à jour statut:", updateError);
return NextResponse.json({
error: "Erreur lors de la mise à jour du statut"
}, { status: 500 });
}
return NextResponse.json({
success: true,
message: `Statut modifié avec succès : ${newStatus}`,
newStatus,
});
} catch (error) {
console.error("Erreur changement statut:", error);
return NextResponse.json({
error: "Erreur serveur"
}, { status: 500 });
}
}

View file

@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export const dynamic = "force-dynamic";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const supabase = createRouteHandlerClient({ cookies });
// Vérifier l'authentification
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const { data: staffUser } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staffUser?.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
// Récupérer l'avenant
const { data: avenant, error } = await supabase
.from("avenants")
.select("pdf_s3_key")
.eq("id", id)
.single();
if (error || !avenant || !avenant.pdf_s3_key) {
return NextResponse.json({ error: "PDF non trouvé" }, { status: 404 });
}
// Générer une URL signée
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
const presignedUrl = await getSignedUrl(
s3Client,
new GetObjectCommand({
Bucket: "odentas-docs",
Key: avenant.pdf_s3_key,
}),
{ expiresIn: 3600 } // 1 heure
);
return NextResponse.json({ presignedUrl });
} catch (error) {
console.error("Erreur génération URL PDF:", error);
return NextResponse.json(
{ error: "Erreur serveur" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3";
export const dynamic = "force-dynamic";
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const supabase = createRouteHandlerClient({ cookies });
// Vérifier l'authentification
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const { data: staffUser } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staffUser?.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
// Récupérer l'avenant pour obtenir la clé S3
const { data: avenant, error: fetchError } = await supabase
.from("avenants")
.select("id, numero_avenant, pdf_s3_key")
.eq("id", id)
.single();
if (fetchError || !avenant) {
return NextResponse.json({ error: "Avenant non trouvé" }, { status: 404 });
}
// Supprimer le PDF de S3 s'il existe
if (avenant.pdf_s3_key) {
try {
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
await s3Client.send(
new DeleteObjectCommand({
Bucket: "odentas-docs",
Key: avenant.pdf_s3_key,
})
);
console.log(`PDF supprimé de S3: ${avenant.pdf_s3_key}`);
} catch (s3Error) {
console.error("Erreur suppression S3:", s3Error);
// On continue quand même la suppression en BDD
}
}
// Supprimer l'avenant de la base de données
const { error: deleteError } = await supabase
.from("avenants")
.delete()
.eq("id", id);
if (deleteError) {
console.error("Erreur suppression avenant:", deleteError);
return NextResponse.json(
{ error: "Erreur lors de la suppression de l'avenant" },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
message: `Avenant ${avenant.numero_avenant} supprimé avec succès`,
});
} catch (error) {
console.error("Erreur API delete amendment:", error);
return NextResponse.json(
{ error: "Erreur serveur" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,324 @@
import { NextRequest, NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
import axios from "axios";
import { sendUniversalEmailV2 } from "@/lib/emailTemplateService";
export const dynamic = "force-dynamic";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
console.log("🔵 [SEND SIGNATURE] Début de l'envoi en signature pour avenant:", id);
const supabase = createRouteHandlerClient({ cookies });
// Vérifier l'authentification
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
console.error("❌ [SEND SIGNATURE] Erreur authentification:", authError);
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
console.log("✅ [SEND SIGNATURE] Utilisateur authentifié:", user.id);
// Vérifier que l'utilisateur est staff
const { data: staffUser } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staffUser?.is_staff) {
console.error("❌ [SEND SIGNATURE] Utilisateur non staff");
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
console.log("✅ [SEND SIGNATURE] Utilisateur est staff");
// Récupérer l'avenant avec les données du contrat, salarié et organisation
console.log("🔍 [SEND SIGNATURE] Récupération de l'avenant avec jointures...");
const { data: avenant, error: avenantError } = await supabase
.from("avenants")
.select(`
*,
cddu_contracts (
*,
salaries (
prenom,
nom,
adresse_mail
),
organizations (
id,
name,
organization_details (
email_signature,
prenom_signataire,
code_employeur
)
)
)
`)
.eq("id", id)
.single();
console.log("📊 [SEND SIGNATURE] Résultat requête avenant:", {
error: avenantError,
found: !!avenant,
hasContract: !!avenant?.cddu_contracts,
hasSalarie: !!avenant?.cddu_contracts?.salaries,
hasOrg: !!avenant?.cddu_contracts?.organizations,
hasOrgDetails: !!avenant?.cddu_contracts?.organizations?.organization_details
});
if (avenantError || !avenant) {
console.error("❌ [SEND SIGNATURE] Avenant non trouvé:", avenantError);
return NextResponse.json({ error: "Avenant non trouvé" }, { status: 404 });
}
const contract = avenant.cddu_contracts;
const salarie = contract.salaries;
const organization = contract.organizations;
const orgDetails = organization.organization_details;
console.log("📋 [SEND SIGNATURE] Données récupérées:", {
contractId: contract?.id,
salariePrenom: salarie?.prenom,
salarieNom: salarie?.nom,
salarieEmail: salarie?.adresse_mail,
orgName: organization?.name,
orgEmail: orgDetails?.email_signature,
pdfS3Key: avenant.pdf_s3_key
});
// Vérifier que le PDF existe
if (!avenant.pdf_s3_key) {
console.error("❌ [SEND SIGNATURE] PDF S3 key manquant");
return NextResponse.json(
{ error: "Le PDF de l'avenant doit être généré avant l'envoi en signature" },
{ status: 400 }
);
}
// Vérifier que les détails de l'organisation sont disponibles
if (!orgDetails || !orgDetails.email_signature) {
console.error("❌ [SEND SIGNATURE] Email signature manquant:", {
hasOrgDetails: !!orgDetails,
emailSignature: orgDetails?.email_signature
});
return NextResponse.json(
{ error: "Email de signature de l'organisation non configuré" },
{ status: 400 }
);
}
console.log("✅ [SEND SIGNATURE] Validations passées, récupération du PDF depuis S3...");
// Récupérer le PDF depuis S3
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
const s3Object = await s3Client.send(
new GetObjectCommand({
Bucket: "odentas-docs",
Key: avenant.pdf_s3_key,
})
);
const pdfBuffer = await s3Object.Body?.transformToByteArray();
if (!pdfBuffer) {
console.error("❌ [SEND SIGNATURE] Impossible de lire le PDF depuis S3");
return NextResponse.json({ error: "Impossible de lire le PDF" }, { status: 500 });
}
console.log("✅ [SEND SIGNATURE] PDF récupéré depuis S3, taille:", pdfBuffer.length, "bytes");
const pdfBase64 = Buffer.from(pdfBuffer).toString("base64");
console.log("🔵 [SEND SIGNATURE] Création du template DocuSeal...");
// Créer le template DocuSeal
const templateResponse = await axios.post(
"https://api.docuseal.eu/templates/pdf",
{
name: `AVENANT - CDDU - ${contract.contract_number}`,
documents: [{ name: avenant.numero_avenant, file: pdfBase64 }],
},
{
headers: {
"X-Auth-Token": process.env.DOCUSEAL_TOKEN!,
"Content-Type": "application/json",
},
}
);
const templateId = templateResponse.data.id;
console.log("✅ [SEND SIGNATURE] Template DocuSeal créé:", templateId);
// Insérer les données dans DynamoDB pour la Lambda
console.log("🔵 [SEND SIGNATURE] Insertion des données dans DynamoDB...");
const dynamoDBClient = new DynamoDBClient({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
const formatDateForDisplay = (dateStr?: string) => {
if (!dateStr) return "-";
const [y, m, d] = dateStr.split("-");
return `${d}/${m}/${y}`;
};
try {
await dynamoDBClient.send(
new PutItemCommand({
TableName: "DocuSealNotification",
Item: {
submission_id: { S: avenant.numero_avenant }, // Clé primaire : numéro de l'avenant
employeeEmail: { S: salarie.adresse_mail },
reference: { S: contract.contract_number },
salarie: { S: `${salarie.prenom} ${salarie.nom}` },
date: { S: formatDateForDisplay(contract.date_debut) },
poste: { S: contract.fonction || "Non spécifié" },
analytique: { S: contract.analytique || "Non spécifié" },
structure: { S: organization.name },
prenom_salarie: { S: salarie.prenom },
prenom_signataire: { S: orgDetails.prenom_signataire || "Non spécifié" },
code_employeur: { S: orgDetails.code_employeur || "Non spécifié" },
matricule: { S: contract.matricule || "Non spécifié" },
created_at: { S: new Date().toISOString() },
},
})
);
console.log("✅ [SEND SIGNATURE] Données insérées dans DynamoDB");
} catch (dynamoError: any) {
console.error("❌ [SEND SIGNATURE] Erreur DynamoDB:", dynamoError);
// On continue quand même pour créer la soumission DocuSeal
}
console.log("🔵 [SEND SIGNATURE] Création de la soumission DocuSeal avec emails:", {
employeur: orgDetails.email_signature,
salarie: salarie.adresse_mail
});
// Créer la soumission DocuSeal
const submissionResponse = await axios.post(
"https://api.docuseal.eu/submissions",
{
template_id: templateId,
send_email: false,
submitters: [
{ role: "Employeur", email: orgDetails.email_signature },
{ role: "Salarié", email: salarie.adresse_mail },
],
},
{
headers: {
"X-Auth-Token": process.env.DOCUSEAL_TOKEN!,
"Content-Type": "application/json",
},
}
);
console.log("✅ [SEND SIGNATURE] Soumission DocuSeal créée:", submissionResponse.data);
const employerSubmission = submissionResponse.data.find((sub: any) => sub.role === "Employeur");
const employeeSubmission = submissionResponse.data.find((sub: any) => sub.role === "Salarié");
console.log("📋 [SEND SIGNATURE] Soumissions extraites:", {
hasEmployer: !!employerSubmission,
hasEmployee: !!employeeSubmission,
employerSlug: employerSubmission?.slug,
employeeSlug: employeeSubmission?.slug
});
if (!employerSubmission || !employeeSubmission) {
console.error("❌ [SEND SIGNATURE] Soumissions DocuSeal incomplètes");
return NextResponse.json(
{ error: "Impossible de créer les soumissions DocuSeal" },
{ status: 500 }
);
}
const employerSlug = employerSubmission.slug;
const signatureLink = `https://staging.paie.odentas.fr/odentas-sign?docuseal_id=${employerSlug}`;
console.log("✅ [SEND SIGNATURE] Lien de signature généré:", signatureLink);
console.log("🔵 [SEND SIGNATURE] Mise à jour de l'avenant dans Supabase...");
// Mettre à jour l'avenant avec les informations DocuSeal
await supabase
.from("avenants")
.update({
docuseal_template_id: templateId,
docuseal_submission_id: employerSubmission.submission_id,
employer_docuseal_slug: employerSlug,
employee_docuseal_slug: employeeSubmission.slug,
signature_status: "pending_employer",
statut: "pending", // Passer de "draft" à "pending"
last_employer_notification_at: new Date().toISOString(),
})
.eq("id", id);
console.log("✅ [SEND SIGNATURE] Avenant mis à jour dans Supabase");
console.log("🔵 [SEND SIGNATURE] Envoi de l'email à l'employeur...");
// Formater la date de début
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-";
const [y, m, d] = dateStr.split("-");
return `${d}/${m}/${y}`;
};
// Envoyer l'email via le système universel
try {
await sendUniversalEmailV2({
type: 'signature-request-employer',
toEmail: orgDetails.email_signature,
subject: `Signature électronique requise Avenant ${avenant.numero_avenant}`,
data: {
firstName: orgDetails.prenom_signataire || undefined,
organizationName: organization.name,
employerCode: orgDetails.code_employeur || "Non spécifié",
handlerName: "Renaud BREVIERE-ABRAHAM",
documentType: `Avenant ${avenant.numero_avenant}`,
employeeName: `${salarie.prenom} ${salarie.nom}`,
contractReference: contract.contract_number,
status: 'En attente',
ctaUrl: signatureLink,
contractId: avenant.id, // Pour traçabilité dans email_logs
},
});
console.log("✅ [SEND SIGNATURE] Email envoyé avec succès");
} catch (emailError: any) {
console.error("❌ [SEND SIGNATURE] Erreur envoi email:", emailError);
// On continue quand même car DocuSeal est créé
}
console.log("🎉 [SEND SIGNATURE] Processus terminé avec succès");
return NextResponse.json({
success: true,
message: "Avenant envoyé en signature à l'employeur",
signatureLink,
});
} catch (error: any) {
console.error("❌❌❌ [SEND SIGNATURE] ERREUR FATALE:", error);
console.error("Stack trace:", error.stack);
return NextResponse.json(
{ error: "Erreur serveur", details: error.message },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const supabase = createRouteHandlerClient({ cookies });
// Vérifier l'authentification
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const { data: staffUser } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staffUser?.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
// Récupérer les nouvelles données
const body = await request.json();
const { pdf_s3_key } = body;
if (!pdf_s3_key) {
return NextResponse.json({ error: "Clé S3 manquante" }, { status: 400 });
}
// Mettre à jour l'avenant
const { error: updateError } = await supabase
.from("avenants")
.update({
pdf_s3_key: pdf_s3_key,
updated_at: new Date().toISOString(),
})
.eq("id", id);
if (updateError) {
console.error("Erreur mise à jour PDF:", updateError);
return NextResponse.json(
{ error: "Erreur lors de la mise à jour" },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
message: "PDF mis à jour avec succès",
});
} catch (error) {
console.error("Erreur API update PDF:", error);
return NextResponse.json(
{ error: "Erreur serveur" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,191 @@
import { NextRequest, NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
try {
const supabase = createRouteHandlerClient({ cookies });
// Vérifier l'authentification
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
// Vérifier que l'utilisateur est staff
const { data: staffUser } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staffUser?.is_staff) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
// Récupérer les données de l'avenant
const body = await request.json();
const {
contract_id,
date_effet,
date_signature,
type_avenant = "modification",
motif_avenant,
elements_avenantes,
objet_data,
duree_data,
lieu_horaire_data,
remuneration_data,
pdf_url,
pdf_s3_key,
} = body;
if (!contract_id || !date_effet || !elements_avenantes || elements_avenantes.length === 0) {
return NextResponse.json(
{ error: "Données manquantes" },
{ status: 400 }
);
}
// Récupérer le contrat pour validation et numérotation
const { data: contract, error: contractError } = await supabase
.from("cddu_contracts")
.select("id, contract_number, org_id")
.eq("id", contract_id)
.single();
if (contractError || !contract) {
return NextResponse.json(
{ error: "Contrat non trouvé" },
{ status: 404 }
);
}
// Compter le nombre d'avenants existants pour cette organisation
// pour avoir une numérotation par client
const { count } = await supabase
.from("avenants")
.select("contract_id, cddu_contracts!inner(org_id)", { count: "exact", head: true })
.eq("cddu_contracts.org_id", contract.org_id);
const numeroAvenant = `AVE-${String((count || 0) + 1).padStart(3, "0")}`;
// Créer l'avenant
const { data: avenant, error: avenantError } = await supabase
.from("avenants")
.insert({
contract_id,
numero_avenant: numeroAvenant,
date_avenant: date_signature || date_effet,
date_effet,
type_avenant,
motif_avenant: motif_avenant || null,
elements_avenantes: elements_avenantes,
objet_data: objet_data || null,
duree_data: duree_data || null,
lieu_horaire_data: lieu_horaire_data || null,
remuneration_data: remuneration_data || null,
pdf_url: pdf_url || null,
pdf_s3_key: pdf_s3_key || null,
statut: "draft",
created_by: user.id,
})
.select()
.single();
if (avenantError) {
console.error("Erreur création avenant:", avenantError);
return NextResponse.json(
{ error: "Erreur lors de la création de l'avenant" },
{ status: 500 }
);
}
// Mettre à jour le contrat avec les nouvelles données
const updateData: any = {};
if (elements_avenantes.includes("objet") && objet_data) {
if (objet_data.profession_code && objet_data.profession_label) {
updateData.profession = `${objet_data.profession_code} - ${objet_data.profession_label}`;
}
if (objet_data.production_name) {
updateData.production_name = objet_data.production_name;
}
if (objet_data.production_numero_objet) {
updateData.numero_objet = objet_data.production_numero_objet;
}
}
if (elements_avenantes.includes("duree") && duree_data) {
if (duree_data.date_debut) {
updateData.start_date = duree_data.date_debut;
}
if (duree_data.date_fin) {
updateData.end_date = duree_data.date_fin;
}
if (duree_data.nb_representations !== undefined) {
updateData.cachets_representations = duree_data.nb_representations;
}
if (duree_data.nb_repetitions !== undefined) {
updateData.services_repetitions = duree_data.nb_repetitions;
}
if (duree_data.nb_heures !== undefined) {
updateData.nombre_d_heures = duree_data.nb_heures;
}
if (duree_data.dates_representations) {
updateData.jours_representations = duree_data.dates_representations;
}
if (duree_data.dates_repetitions) {
updateData.jours_repetitions = duree_data.dates_repetitions;
}
if (duree_data.jours_travail) {
updateData.jours_travail = duree_data.jours_travail;
}
}
if (elements_avenantes.includes("lieu_horaire") && lieu_horaire_data) {
if (lieu_horaire_data.lieu) {
updateData.lieu_travail = lieu_horaire_data.lieu;
}
}
if (elements_avenantes.includes("remuneration") && remuneration_data) {
if (remuneration_data.gross_pay !== undefined) {
updateData.gross_pay = remuneration_data.gross_pay;
}
if (remuneration_data.precisions_salaire) {
updateData.precisions_salaire = remuneration_data.precisions_salaire;
}
if (remuneration_data.type_salaire) {
updateData.type_salaire = remuneration_data.type_salaire;
}
}
// Mettre à jour le contrat si des données ont été modifiées
if (Object.keys(updateData).length > 0) {
const { error: updateError } = await supabase
.from("cddu_contracts")
.update(updateData)
.eq("id", contract_id);
if (updateError) {
console.error("Erreur mise à jour contrat:", updateError);
// On ne renvoie pas d'erreur car l'avenant est créé
}
}
return NextResponse.json({
success: true,
avenant,
message: "Avenant créé avec succès",
});
} catch (error) {
console.error("Erreur API create amendment:", error);
return NextResponse.json(
{ error: "Erreur serveur" },
{ status: 500 }
);
}
}

View file

@ -192,10 +192,11 @@ export async function POST(request: NextRequest) {
employee_catpro = "Metteur en scène"; employee_catpro = "Metteur en scène";
} }
// Préparer les détails de cachets // Préparer les détails de cachets - convertir en nombres
const cachetsRepresentations = dureeData.nb_representations || contract.cachets_representations || 0; const cachetsRepresentations = parseInt(dureeData.nb_representations || contract.cachets_representations || 0);
const cachetsRepetitions = dureeData.nb_repetitions || contract.cachets_repetitions || 0; const cachetsRepetitions = parseInt(dureeData.nb_repetitions || contract.cachets_repetitions || 0);
const heures = dureeData.nb_heures || contract.nb_heures || 0; const heures = parseInt(dureeData.nb_heures || contract.nb_heures || 0);
const heuresParJour = parseInt(contract.nombre_d_heures_par_jour || 0);
let detailsCachets = ""; let detailsCachets = "";
if (cachetsRepresentations > 0) { if (cachetsRepresentations > 0) {
@ -294,9 +295,9 @@ export async function POST(request: NextRequest) {
representations: cachetsRepresentations, representations: cachetsRepresentations,
repetitions: cachetsRepetitions, repetitions: cachetsRepetitions,
heures: heures, heures: heures,
heuresparjour: contract.nombre_d_heures_par_jour || 0 heuresparjour: heuresParJour
}, },
imageUrl: "data:image/png;base64" // Placeholder pour la signature imageUrl: orgDetails.logo || ""
}; };
console.log("Payload PDFMonkey:", JSON.stringify(dataPayload, null, 2)); console.log("Payload PDFMonkey:", JSON.stringify(dataPayload, null, 2));

View file

@ -0,0 +1,179 @@
import { NextRequest, NextResponse } from "next/server";
import { createSbServiceRole } from "@/lib/supabaseServer";
import { sendUniversalEmailV2, EmailDataV2 } from "@/lib/emailTemplateService";
export const dynamic = "force-dynamic";
/**
* Webhook pour gérer les signatures d'avenants via DocuSeal
* Appelé après que l'employeur a signé un avenant
*
* Flux :
* 1. Employeur signe via DocuSeal
* 2. DocuSeal Lambda postDocuSealAvenantSalarie Cette route API
* 3. Cette route :
* - Met à jour le statut de l'avenant dans Supabase
* - Envoie l'email au salarié via le système universel v2
* - Retourne les infos pour logging
*/
export async function POST(request: NextRequest) {
console.log("🔔 [WEBHOOK AVENANT] Début du traitement");
try {
const body = await request.json();
console.log("📦 [WEBHOOK AVENANT] Body reçu:", JSON.stringify(body, null, 2));
const {
documentName, // Numéro de l'avenant (ex: AVE-001)
employeeSlug, // Slug DocuSeal du salarié
submissionId, // ID de la soumission DocuSeal
employeeEmail, // Email du salarié
// Données pour l'email
reference, // Référence du contrat
salarie, // Nom complet du salarié
date, // Date de début
poste, // Poste/profession
analytique, // Production
structure, // Nom de l'organisation
prenom_salarie,
prenom_signataire,
code_employeur,
matricule,
} = body;
// Validation des champs requis
if (!documentName || !employeeSlug || !employeeEmail) {
console.error("❌ [WEBHOOK AVENANT] Champs manquants:", {
documentName: !!documentName,
employeeSlug: !!employeeSlug,
employeeEmail: !!employeeEmail,
});
return NextResponse.json(
{ error: "Champs requis manquants" },
{ status: 400 }
);
}
const supabase = createSbServiceRole();
// 1. Trouver l'avenant par son numéro
console.log("🔍 [WEBHOOK AVENANT] Recherche de l'avenant:", documentName);
const { data: avenant, error: avenantError } = await supabase
.from("avenants")
.select(`
*,
cddu_contracts (
*,
salaries (
prenom,
nom,
adresse_mail
),
organizations (
id,
name
)
)
`)
.eq("numero_avenant", documentName)
.maybeSingle();
if (avenantError || !avenant) {
console.error("❌ [WEBHOOK AVENANT] Avenant non trouvé:", avenantError);
return NextResponse.json(
{ error: "Avenant non trouvé", details: avenantError?.message },
{ status: 404 }
);
}
console.log("✅ [WEBHOOK AVENANT] Avenant trouvé:", {
id: avenant.id,
numero: avenant.numero_avenant,
statut_actuel: avenant.signature_status,
});
// 2. Mettre à jour le statut de l'avenant
console.log("🔄 [WEBHOOK AVENANT] Mise à jour du statut...");
const { error: updateError } = await supabase
.from("avenants")
.update({
signature_status: "pending_employee", // Employeur signé, en attente salarié
last_employee_notification_at: new Date().toISOString(),
})
.eq("id", avenant.id);
if (updateError) {
console.error("❌ [WEBHOOK AVENANT] Erreur mise à jour:", updateError);
// On continue quand même pour envoyer l'email
} else {
console.log("✅ [WEBHOOK AVENANT] Statut mis à jour: pending_employee");
}
// 3. Préparer les données pour l'email
const signatureLink = `https://paie.odentas.fr/odentas-sign?docuseal_id=${employeeSlug}`;
// Formater la date
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-";
const [y, m, d] = dateStr.split("-");
return `${d}/${m}/${y}`;
};
const salarie_data = avenant.cddu_contracts?.salaries;
const contract_data = avenant.cddu_contracts;
const organization_data = contract_data?.organizations;
const emailData: EmailDataV2 = {
firstName: prenom_salarie || salarie_data?.prenom,
organizationName: structure || organization_data?.name,
employerCode: code_employeur || "Non spécifié",
matricule: matricule || "Non spécifié",
contractReference: reference || contract_data?.contract_number,
startDate: formatDate(date || contract_data?.date_debut),
profession: poste || contract_data?.fonction,
productionName: analytique || contract_data?.analytique,
ctaUrl: signatureLink,
numeroAvenant: avenant.numero_avenant,
contractType: "CDDU (contrat intermittent)",
};
// 4. Envoyer l'email au salarié via le système universel v2
console.log("📧 [WEBHOOK AVENANT] Envoi de l'email au salarié...");
console.log("📧 [WEBHOOK AVENANT] Email data:", emailData);
try {
const messageId = await sendUniversalEmailV2({
type: "signature-request-employee-amendment",
toEmail: employeeEmail,
data: emailData,
});
console.log("✅ [WEBHOOK AVENANT] Email envoyé avec succès:", messageId);
return NextResponse.json({
success: true,
message: "Email de signature envoyé au salarié",
messageId,
avenantId: avenant.id,
signatureLink,
});
} catch (emailError: any) {
console.error("❌ [WEBHOOK AVENANT] Erreur envoi email:", emailError);
return NextResponse.json(
{
error: "Erreur lors de l'envoi de l'email",
details: emailError.message,
avenantId: avenant.id,
},
{ status: 500 }
);
}
} catch (error: any) {
console.error("❌❌❌ [WEBHOOK AVENANT] ERREUR FATALE:", error);
console.error("Stack trace:", error.stack);
return NextResponse.json(
{ error: "Erreur serveur", details: error.message },
{ status: 500 }
);
}
}

View file

@ -2,7 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Shield, KeyRound, Home, FileSignature, Banknote, Users, Percent, FolderOpen, Receipt, Building2, UserCog, Edit3, LifeBuoy, CreditCard, Calculator, Euro, Mail, Database, Clapperboard, LogOut, Scale } from "lucide-react"; import { Shield, KeyRound, Home, FileSignature, Banknote, Users, Percent, FolderOpen, Receipt, Building2, UserCog, Edit3, LifeBuoy, CreditCard, Calculator, Euro, Mail, Database, Clapperboard, LogOut, Scale, FileEdit } from "lucide-react";
// import { api } from "@/lib/fetcher"; // import { api } from "@/lib/fetcher";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import LogoutButton from "@/components/LogoutButton"; import LogoutButton from "@/components/LogoutButton";
@ -532,6 +532,14 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
<span>Fiches de paie</span> <span>Fiches de paie</span>
</span> </span>
</Link> </Link>
<Link href="/staff/avenants" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/avenants") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Gestion des avenants">
<span className="inline-flex items-center gap-2">
<FileEdit className="w-4 h-4" aria-hidden />
<span>Avenants</span>
</span>
</Link>
<Link href="/staff/salaries" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${ <Link href="/staff/salaries" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
isActivePath(pathname, "/staff/salaries") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50" isActivePath(pathname, "/staff/salaries") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
}`} title="Gestion des salariés"> }`} title="Gestion des salariés">

View file

@ -12,6 +12,7 @@ import {
import AmendmentObjetForm from "@/components/staff/amendments/AmendmentObjetForm"; import AmendmentObjetForm from "@/components/staff/amendments/AmendmentObjetForm";
import AmendmentDureeForm from "@/components/staff/amendments/AmendmentDureeForm"; import AmendmentDureeForm from "@/components/staff/amendments/AmendmentDureeForm";
import AmendmentRemunerationForm from "@/components/staff/amendments/AmendmentRemunerationForm"; import AmendmentRemunerationForm from "@/components/staff/amendments/AmendmentRemunerationForm";
import AvenantSuccessModal from "@/components/staff/amendments/AvenantSuccessModal";
export default function NouvelAvenantPageClient() { export default function NouvelAvenantPageClient() {
const router = useRouter(); const router = useRouter();
@ -26,6 +27,8 @@ export default function NouvelAvenantPageClient() {
// Données du formulaire // Données du formulaire
const [dateEffet, setDateEffet] = useState(""); const [dateEffet, setDateEffet] = useState("");
const [dateSignature, setDateSignature] = useState(""); const [dateSignature, setDateSignature] = useState("");
const [typeAvenant, setTypeAvenant] = useState<"modification" | "annulation">("modification");
const [motifAvenant, setMotifAvenant] = useState("");
const [selectedElements, setSelectedElements] = useState<AmendmentElementType[]>([]); const [selectedElements, setSelectedElements] = useState<AmendmentElementType[]>([]);
// Données spécifiques selon les éléments // Données spécifiques selon les éléments
@ -35,9 +38,15 @@ export default function NouvelAvenantPageClient() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// Modale de succès
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [createdAvenantId, setCreatedAvenantId] = useState("");
const [createdNumeroAvenant, setCreatedNumeroAvenant] = useState("");
// PDF generation // PDF generation
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const [pdfPresignedUrl, setPdfPresignedUrl] = useState<string | null>(null); const [pdfPresignedUrl, setPdfPresignedUrl] = useState<string | null>(null);
const [pdfS3Key, setPdfS3Key] = useState<string | null>(null);
// Recherche de contrats (debounced) // Recherche de contrats (debounced)
useEffect(() => { useEffect(() => {
@ -185,6 +194,7 @@ export default function NouvelAvenantPageClient() {
const data = await response.json(); const data = await response.json();
setPdfPresignedUrl(data.presignedUrl); setPdfPresignedUrl(data.presignedUrl);
setPdfS3Key(data.s3Key);
} catch (error: any) { } catch (error: any) {
console.error("Erreur génération PDF:", error); console.error("Erreur génération PDF:", error);
alert("Erreur lors de la génération du PDF: " + error.message); alert("Erreur lors de la génération du PDF: " + error.message);
@ -199,31 +209,40 @@ export default function NouvelAvenantPageClient() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const amendment: Amendment = { const amendmentData = {
contract_id: selectedContract!.id, contract_id: selectedContract!.id,
contract_number: selectedContract!.contract_number,
employee_name: selectedContract!.employee_name,
organization_name: selectedContract!.organization_name,
date_effet: dateEffet, date_effet: dateEffet,
date_signature: dateSignature || undefined, date_signature: dateSignature || undefined,
status: "draft", type_avenant: typeAvenant,
elements: selectedElements, motif_avenant: motifAvenant || undefined,
elements_avenantes: selectedElements,
objet_data: selectedElements.includes("objet") ? objetData : undefined, objet_data: selectedElements.includes("objet") ? objetData : undefined,
duree_data: selectedElements.includes("duree") ? dureeData : undefined, duree_data: selectedElements.includes("duree") ? dureeData : undefined,
lieu_horaire_data: selectedElements.includes("lieu_horaire") ? {} : undefined,
remuneration_data: selectedElements.includes("remuneration") ? remunerationData : undefined, remuneration_data: selectedElements.includes("remuneration") ? remunerationData : undefined,
created_at: new Date().toISOString(), pdf_url: pdfPresignedUrl || undefined,
pdf_s3_key: pdfS3Key || undefined,
}; };
// TODO: Envoyer à l'API // Appeler l'API pour créer l'avenant et mettre à jour le contrat
// const response = await fetch('/api/staff/amendments', { const response = await fetch('/api/staff/amendments/create', {
// method: 'POST', method: 'POST',
// headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(amendment), body: JSON.stringify(amendmentData),
// }); });
router.push("/staff/avenants"); if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Erreur lors de la création de l'avenant");
}
const data = await response.json();
setCreatedAvenantId(data.avenant.id);
setCreatedNumeroAvenant(data.avenant.numero_avenant);
setShowSuccessModal(true);
} catch (error) { } catch (error) {
console.error("Erreur création avenant:", error); console.error("Erreur création avenant:", error);
alert("Erreur lors de la création de l'avenant");
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -392,6 +411,38 @@ export default function NouvelAvenantPageClient() {
</div> </div>
</div> </div>
{/* Type et Motif */}
<div className="bg-white rounded-xl border shadow-sm p-6">
<h2 className="font-semibold text-slate-900 mb-4">Informations complémentaires</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Type d'avenant <span className="text-red-500">*</span>
</label>
<select
value={typeAvenant}
onChange={(e) => setTypeAvenant(e.target.value as "modification" | "annulation")}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="modification">Modification</option>
<option value="annulation">Annulation</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Motif de l'avenant
</label>
<input
type="text"
value={motifAvenant}
onChange={(e) => setMotifAvenant(e.target.value)}
placeholder="Ex: Changement de dates, modification du salaire..."
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
</div>
{/* Éléments à avenanter */} {/* Éléments à avenanter */}
<div className="bg-white rounded-xl border shadow-sm p-6"> <div className="bg-white rounded-xl border shadow-sm p-6">
<h2 className="font-semibold text-slate-900 mb-4"> <h2 className="font-semibold text-slate-900 mb-4">
@ -520,6 +571,13 @@ export default function NouvelAvenantPageClient() {
</div> </div>
</div> </div>
)} )}
{/* Modale de succès */}
<AvenantSuccessModal
isOpen={showSuccessModal}
numeroAvenant={createdNumeroAvenant}
avenantId={createdAvenantId}
/>
</div> </div>
); );
} }

View file

@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { FileText, Plus, Search } from "lucide-react"; import { FileText, Plus, Search, Check, X } from "lucide-react";
import { Amendment } from "@/types/amendments"; import { Amendment } from "@/types/amendments";
interface StaffAvenantsPageClientProps { interface StaffAvenantsPageClientProps {
@ -49,6 +49,40 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
); );
}; };
const getSignatureIcons = (signatureStatus?: string) => {
// Déterminer si employeur a signé
const employerSigned = signatureStatus === 'pending_employee' || signatureStatus === 'signed';
// Déterminer si salarié a signé
const employeeSigned = signatureStatus === 'signed';
// Si pas encore envoyé
const notSent = !signatureStatus || signatureStatus === 'not_sent';
return (
<div className="flex items-center gap-3">
<div className="flex flex-col items-center gap-1">
<div className="text-xs font-semibold text-slate-600">E</div>
{notSent ? (
<span className="text-xs text-slate-400"></span>
) : employerSigned ? (
<Check className="w-4 h-4 text-green-600" strokeWidth={3} />
) : (
<X className="w-4 h-4 text-red-600" strokeWidth={3} />
)}
</div>
<div className="flex flex-col items-center gap-1">
<div className="text-xs font-semibold text-slate-600">S</div>
{notSent ? (
<span className="text-xs text-slate-400"></span>
) : employeeSigned ? (
<Check className="w-4 h-4 text-green-600" strokeWidth={3} />
) : (
<X className="w-4 h-4 text-red-600" strokeWidth={3} />
)}
</div>
</div>
);
};
const getElementsLabel = (elements: Amendment["elements"]) => { const getElementsLabel = (elements: Amendment["elements"]) => {
const labels = { const labels = {
objet: "Objet", objet: "Objet",
@ -135,6 +169,9 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600"> <th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
Date d'effet Date d'effet
</th> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
Signé
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600"> <th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
Statut Statut
</th> </th>
@ -145,7 +182,11 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
</thead> </thead>
<tbody className="divide-y"> <tbody className="divide-y">
{filteredAmendments.map((amendment) => ( {filteredAmendments.map((amendment) => (
<tr key={amendment.id} className="hover:bg-slate-50 transition-colors"> <tr
key={amendment.id}
onClick={() => router.push(`/staff/avenants/${amendment.id}`)}
className="hover:bg-slate-50 transition-colors cursor-pointer"
>
<td className="px-4 py-3 text-sm font-medium text-slate-900"> <td className="px-4 py-3 text-sm font-medium text-slate-900">
{amendment.contract_number || "-"} {amendment.contract_number || "-"}
</td> </td>
@ -161,9 +202,18 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
<td className="px-4 py-3 text-sm text-slate-700"> <td className="px-4 py-3 text-sm text-slate-700">
{formatDate(amendment.date_effet)} {formatDate(amendment.date_effet)}
</td> </td>
<td className="px-4 py-3">
{getSignatureIcons(amendment.signature_status)}
</td>
<td className="px-4 py-3">{getStatusBadge(amendment.status)}</td> <td className="px-4 py-3">{getStatusBadge(amendment.status)}</td>
<td className="px-4 py-3 text-sm"> <td className="px-4 py-3 text-sm">
<button className="text-indigo-600 hover:text-indigo-700 font-medium"> <button
onClick={(e) => {
e.stopPropagation();
router.push(`/staff/avenants/${amendment.id}`);
}}
className="text-indigo-600 hover:text-indigo-700 font-medium"
>
Voir Voir
</button> </button>
</td> </td>

View file

@ -21,9 +21,7 @@ export default function AmendmentDureeForm({
const isTechnician = originalData.categorie_pro === "Technicien"; const isTechnician = originalData.categorie_pro === "Technicien";
const isArtist = !isTechnician; const isArtist = !isTechnician;
// États pour les calendriers // États pour les calendriers (dates de représentations/répétitions/jours de travail uniquement)
const [showDateDebut, setShowDateDebut] = useState(false);
const [showDateFin, setShowDateFin] = useState(false);
const [showDatesRep, setShowDatesRep] = useState(false); const [showDatesRep, setShowDatesRep] = useState(false);
const [showDatesServ, setShowDatesServ] = useState(false); const [showDatesServ, setShowDatesServ] = useState(false);
const [showJoursTravail, setShowJoursTravail] = useState(false); const [showJoursTravail, setShowJoursTravail] = useState(false);
@ -41,19 +39,13 @@ export default function AmendmentDureeForm({
return `${d}/${m}/${y}`; return `${d}/${m}/${y}`;
}; };
// Handlers pour les sélections de dates // Handlers pour les dates simples (début/fin)
const handleDateDebutApply = (result: { selectedDates: string[] }) => { const handleDateDebutChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (result.selectedDates.length > 0) { onChange({ ...data, date_debut: e.target.value });
onChange({ ...data, date_debut: result.selectedDates[0] });
}
setShowDateDebut(false);
}; };
const handleDateFinApply = (result: { selectedDates: string[] }) => { const handleDateFinChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (result.selectedDates.length > 0) { onChange({ ...data, date_fin: e.target.value });
onChange({ ...data, date_fin: result.selectedDates[0] });
}
setShowDateFin(false);
}; };
const handleDatesRepApply = (result: { const handleDatesRepApply = (result: {
@ -123,7 +115,7 @@ export default function AmendmentDureeForm({
<h3 className="font-medium text-slate-900 text-sm">Modification de la durée de l'engagement</h3> <h3 className="font-medium text-slate-900 text-sm">Modification de la durée de l'engagement</h3>
{/* Dates de début et fin */} {/* Dates de début et fin */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-medium text-slate-700 mb-2"> <label className="block text-xs font-medium text-slate-700 mb-2">
Date de début Date de début
@ -131,24 +123,12 @@ export default function AmendmentDureeForm({
<div className="text-xs text-slate-500 mb-2"> <div className="text-xs text-slate-500 mb-2">
Actuellement : {formatDate(originalData.start_date)} Actuellement : {formatDate(originalData.start_date)}
</div> </div>
<div className="relative"> <input
<button type="date"
onClick={() => setShowDateDebut(!showDateDebut)} value={data.date_debut || ""}
className="w-full px-3 py-2 border rounded-lg text-sm text-left flex items-center justify-between hover:bg-slate-50" onChange={handleDateDebutChange}
> className="w-full px-3 py-2 border rounded-lg text-sm"
<span>{data.date_debut ? formatDate(data.date_debut) : "Sélectionner"}</span> />
<Calendar className="h-4 w-4 text-slate-400" />
</button>
{showDateDebut && (
<DatePickerCalendar
isOpen={showDateDebut}
onClose={() => setShowDateDebut(false)}
onApply={handleDateDebutApply}
initialDates={data.date_debut ? [formatDate(data.date_debut)] : []}
title="Sélectionner la date de début"
/>
)}
</div>
</div> </div>
<div> <div>
@ -158,24 +138,12 @@ export default function AmendmentDureeForm({
<div className="text-xs text-slate-500 mb-2"> <div className="text-xs text-slate-500 mb-2">
Actuellement : {formatDate(originalData.end_date)} Actuellement : {formatDate(originalData.end_date)}
</div> </div>
<div className="relative"> <input
<button type="date"
onClick={() => setShowDateFin(!showDateFin)} value={data.date_fin || ""}
className="w-full px-3 py-2 border rounded-lg text-sm text-left flex items-center justify-between hover:bg-slate-50" onChange={handleDateFinChange}
> className="w-full px-3 py-2 border rounded-lg text-sm"
<span>{data.date_fin ? formatDate(data.date_fin) : "Sélectionner"}</span> />
<Calendar className="h-4 w-4 text-slate-400" />
</button>
{showDateFin && (
<DatePickerCalendar
isOpen={showDateFin}
onClose={() => setShowDateFin(false)}
onApply={handleDateFinApply}
initialDates={data.date_fin ? [formatDate(data.date_fin)] : []}
title="Sélectionner la date de fin"
/>
)}
</div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,57 @@
"use client";
import { CheckCircle2 } from "lucide-react";
import { useRouter } from "next/navigation";
interface AvenantSuccessModalProps {
isOpen: boolean;
numeroAvenant: string;
avenantId: string;
}
export default function AvenantSuccessModal({
isOpen,
numeroAvenant,
avenantId,
}: AvenantSuccessModalProps) {
const router = useRouter();
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="bg-white rounded-2xl max-w-md w-full mx-4 shadow-2xl border">
<div className="p-8">
<div className="flex flex-col items-center text-center">
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mb-4">
<CheckCircle2 className="w-8 h-8 text-green-600" />
</div>
<h3 className="text-xl font-semibold text-slate-900 mb-2">
Avenant créé avec succès !
</h3>
<p className="text-sm text-slate-600 mb-6">
L'avenant <span className="font-semibold text-slate-900">{numeroAvenant}</span> a é créé et le contrat a é mis à jour.
</p>
<div className="flex gap-3 w-full">
<button
onClick={() => router.push("/staff/avenants")}
className="flex-1 px-4 py-2 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors"
>
Liste des avenants
</button>
<button
onClick={() => router.push(`/staff/avenants/${avenantId}`)}
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
Voir l'avenant
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,100 @@
"use client";
import { X, AlertCircle } from "lucide-react";
interface ChangeStatusModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (newStatus: string) => void;
currentStatus: string;
isChanging: boolean;
numeroAvenant: string;
}
const STATUS_OPTIONS = [
{ value: "draft", label: "Brouillon", color: "bg-slate-100 text-slate-700 border-slate-300" },
{ value: "pending", label: "En attente", color: "bg-orange-100 text-orange-700 border-orange-300" },
{ value: "signed", label: "Signé", color: "bg-green-100 text-green-700 border-green-300" },
{ value: "cancelled", label: "Annulé", color: "bg-red-100 text-red-700 border-red-300" },
];
export default function ChangeStatusModal({
isOpen,
onClose,
onConfirm,
currentStatus,
isChanging,
numeroAvenant,
}: ChangeStatusModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/30" onClick={onClose}></div>
<div className="relative bg-white rounded-xl shadow-xl max-w-md w-full p-6">
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="h-5 w-5 text-slate-600" />
</button>
<div className="flex items-start gap-3 mb-6">
<div className="flex-shrink-0 w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<AlertCircle className="h-6 w-6 text-orange-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">
Changer le statut de l'avenant
</h3>
<p className="text-sm text-slate-600 mt-1">
Avenant {numeroAvenant}
</p>
</div>
</div>
<div className="mb-6">
<label className="text-sm font-medium text-slate-700 mb-3 block">
Sélectionnez le nouveau statut :
</label>
<div className="space-y-2">
{STATUS_OPTIONS.map((status) => (
<button
key={status.value}
onClick={() => onConfirm(status.value)}
disabled={isChanging || status.value === currentStatus}
className={`w-full px-4 py-3 rounded-lg border-2 text-left font-medium transition-all
${status.color}
${status.value === currentStatus
? "opacity-50 cursor-not-allowed"
: "hover:scale-[1.02] hover:shadow-md cursor-pointer"
}
${isChanging ? "opacity-50 cursor-not-allowed" : ""}
`}
>
<div className="flex items-center justify-between">
<span>{status.label}</span>
{status.value === currentStatus && (
<span className="text-xs px-2 py-1 bg-white/50 rounded">
Actuel
</span>
)}
</div>
</button>
))}
</div>
</div>
<div className="flex gap-3">
<button
onClick={onClose}
disabled={isChanging}
className="flex-1 px-4 py-2.5 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Annuler
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,80 @@
"use client";
import { X, AlertTriangle } from "lucide-react";
interface DeleteAvenantModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
numeroAvenant: string;
isDeleting: boolean;
}
export default function DeleteAvenantModal({
isOpen,
onClose,
onConfirm,
numeroAvenant,
isDeleting,
}: DeleteAvenantModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 space-y-4">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-red-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900">
Supprimer l'avenant
</h2>
<p className="text-sm text-slate-600">
Avenant {numeroAvenant}
</p>
</div>
</div>
<button
onClick={onClose}
disabled={isDeleting}
className="p-1 hover:bg-slate-100 rounded transition-colors disabled:opacity-50"
>
<X className="h-5 w-5 text-slate-500" />
</button>
</div>
{/* Message */}
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-800">
Êtes-vous sûr de vouloir supprimer cet avenant ? Cette action est{" "}
<strong>irréversible</strong>.
</p>
<p className="text-sm text-red-700 mt-2">
Le PDF associé sera également supprimé du stockage S3.
</p>
</div>
{/* Actions */}
<div className="flex gap-3">
<button
onClick={onClose}
disabled={isDeleting}
className="flex-1 px-4 py-2 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors disabled:opacity-50"
>
Annuler
</button>
<button
onClick={onConfirm}
disabled={isDeleting}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeleting ? "Suppression..." : "Supprimer"}
</button>
</div>
</div>
</div>
);
}

View file

@ -34,6 +34,8 @@ export default function NouvelAvenantModal({
// Données du formulaire // Données du formulaire
const [dateEffet, setDateEffet] = useState(""); const [dateEffet, setDateEffet] = useState("");
const [dateSignature, setDateSignature] = useState(""); const [dateSignature, setDateSignature] = useState("");
const [typeAvenant, setTypeAvenant] = useState<"modification" | "annulation">("modification");
const [motifAvenant, setMotifAvenant] = useState("");
const [selectedElements, setSelectedElements] = useState<AmendmentElementType[]>([]); const [selectedElements, setSelectedElements] = useState<AmendmentElementType[]>([]);
// Données spécifiques selon les éléments // Données spécifiques selon les éléments
@ -236,9 +238,37 @@ export default function NouvelAvenantModal({
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// Pour l'instant, on crée juste l'objet localement // Préparer les données de l'avenant
// Plus tard, tu pourras envoyer ça à ton API const amendmentData = {
contract_id: selectedContract!.id,
date_effet: dateEffet,
date_signature: dateSignature || dateEffet,
type_avenant: typeAvenant,
motif_avenant: motifAvenant,
elements: selectedElements,
objet_data: selectedElements.includes("objet") ? objetData : undefined,
duree_data: selectedElements.includes("duree") ? dureeData : undefined,
lieu_horaire_data: selectedElements.includes("lieu_horaire") ? {} : undefined,
remuneration_data: selectedElements.includes("remuneration") ? remunerationData : undefined,
};
// Appeler l'API pour créer l'avenant et mettre à jour le contrat
const response = await fetch('/api/staff/amendments/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(amendmentData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Erreur lors de la création de l'avenant");
}
const data = await response.json();
// Créer l'objet Amendment pour l'affichage
const amendment: Amendment = { const amendment: Amendment = {
id: data.avenant.id,
contract_id: selectedContract!.id, contract_id: selectedContract!.id,
contract_number: selectedContract!.contract_number, contract_number: selectedContract!.contract_number,
employee_name: selectedContract!.employee_name, employee_name: selectedContract!.employee_name,
@ -250,17 +280,11 @@ export default function NouvelAvenantModal({
objet_data: selectedElements.includes("objet") ? objetData : undefined, objet_data: selectedElements.includes("objet") ? objetData : undefined,
duree_data: selectedElements.includes("duree") ? dureeData : undefined, duree_data: selectedElements.includes("duree") ? dureeData : undefined,
remuneration_data: selectedElements.includes("remuneration") ? remunerationData : undefined, remuneration_data: selectedElements.includes("remuneration") ? remunerationData : undefined,
created_at: new Date().toISOString(), created_at: data.avenant.created_at,
}; };
// TODO: Envoyer à l'API
// const response = await fetch('/api/staff/amendments', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(amendment),
// });
onAmendmentCreated(amendment); onAmendmentCreated(amendment);
alert(`Avenant ${data.avenant.numero_avenant} créé avec succès !`);
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("Erreur création avenant:", error); console.error("Erreur création avenant:", error);
@ -392,33 +416,68 @@ export default function NouvelAvenantModal({
</div> </div>
</div> </div>
{/* Dates */} {/* Dates de l'avenant */}
<div className="grid grid-cols-2 gap-4"> <div>
<div> <h3 className="text-sm font-semibold text-slate-900 mb-3">Dates de l'avenant</h3>
<label className="block text-xs font-medium text-slate-700 mb-2"> <div className="grid grid-cols-2 gap-4">
Date d'effet de l'avenant <span className="text-red-500">*</span> <div>
</label> <label className="block text-xs font-medium text-slate-700 mb-2">
<div className="relative"> Date d'effet de l'avenant <span className="text-red-500">*</span>
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" /> </label>
<input <div className="relative">
type="date" <Calendar className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
value={dateEffet} <input
onChange={(e) => setDateEffet(e.target.value)} type="date"
className="w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" value={dateEffet}
/> onChange={(e) => setDateEffet(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-700 mb-2">
Date de signature
</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<input
type="date"
value={dateSignature}
onChange={(e) => setDateSignature(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div> </div>
</div> </div>
<div> </div>
<label className="block text-xs font-medium text-slate-700 mb-2">
Date de signature {/* Type et Motif de l'avenant */}
</label> <div>
<div className="relative"> <h3 className="text-sm font-semibold text-slate-900 mb-3">Informations complémentaires</h3>
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" /> <div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-700 mb-2">
Type d'avenant <span className="text-red-500">*</span>
</label>
<select
value={typeAvenant}
onChange={(e) => setTypeAvenant(e.target.value as "modification" | "annulation")}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="modification">Modification</option>
<option value="annulation">Annulation</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-700 mb-2">
Motif de l'avenant
</label>
<input <input
type="date" type="text"
value={dateSignature} value={motifAvenant}
onChange={(e) => setDateSignature(e.target.value)} onChange={(e) => setMotifAvenant(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="Ex: Changement de dates, modification du salaire..."
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/> />
</div> </div>
</div> </div>

View file

@ -0,0 +1,174 @@
"use client";
import { X, Send, CheckCircle2 } from "lucide-react";
interface SendSignatureModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
isSending: boolean;
success?: boolean;
avenantData: {
numero_avenant: string;
contractReference: string;
employeeName: string;
employeeEmail: string;
employerEmail: string;
};
}
export default function SendSignatureModal({
isOpen,
onClose,
onConfirm,
isSending,
success = false,
avenantData,
}: SendSignatureModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 space-y-4">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
success ? "bg-green-100" : "bg-orange-100"
}`}>
{success ? (
<CheckCircle2 className="h-5 w-5 text-green-600" />
) : (
<Send className="h-5 w-5 text-orange-600" />
)}
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900">
{success ? "Envoi réussi !" : "Envoyer pour signature"}
</h2>
<p className="text-sm text-slate-600">
Avenant {avenantData.numero_avenant}
</p>
</div>
</div>
<button
onClick={onClose}
disabled={isSending}
className="p-1 hover:bg-slate-100 rounded transition-colors disabled:opacity-50"
>
<X className="h-5 w-5 text-slate-500" />
</button>
</div>
{success ? (
/* Message de succès */
<>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-sm text-slate-700 mb-3">
L'avenant a é envoyé avec succès pour signature électronique.
</p>
{/* Détails */}
<div className="space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-slate-600">Email employeur :</span>
<span className="font-medium text-slate-900 truncate max-w-[200px]">{avenantData.employerEmail}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Statut :</span>
<span className="font-medium text-green-600">En attente signature employeur</span>
</div>
</div>
</div>
{/* Actions succès */}
<div className="flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-slate-900 text-white rounded-lg hover:bg-slate-800 transition-colors"
>
Fermer
</button>
</div>
</>
) : (
/* Message initial */
<>
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
<p className="text-sm text-slate-700 mb-3">
Vous êtes sur le point d'envoyer cet avenant pour signature électronique.
</p>
{/* Détails */}
<div className="space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-slate-600">Contrat :</span>
<span className="font-medium text-slate-900">{avenantData.contractReference}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Salarié :</span>
<span className="font-medium text-slate-900">{avenantData.employeeName}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Email salarié :</span>
<span className="font-medium text-slate-900 truncate max-w-[200px]">{avenantData.employeeEmail}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Email employeur :</span>
<span className="font-medium text-slate-900 truncate max-w-[200px]">{avenantData.employerEmail}</span>
</div>
</div>
</div>
{/* Processus */}
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-slate-900 mb-2">Processus de signature</h3>
<ol className="space-y-2 text-xs text-slate-600">
<li className="flex items-start gap-2">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-slate-200 flex items-center justify-center text-xs font-medium">1</span>
<span>L'employeur recevra un email avec le lien de signature</span>
</li>
<li className="flex items-start gap-2">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-slate-200 flex items-center justify-center text-xs font-medium">2</span>
<span>Après signature de l'employeur, le salarié recevra son lien</span>
</li>
<li className="flex items-start gap-2">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-slate-200 flex items-center justify-center text-xs font-medium">3</span>
<span>Vous serez notifié par email une fois toutes les signatures reçues</span>
</li>
</ol>
</div>
{/* Actions */}
<div className="flex gap-3">
<button
onClick={onClose}
disabled={isSending}
className="flex-1 px-4 py-2 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors disabled:opacity-50"
>
Annuler
</button>
<button
onClick={onConfirm}
disabled={isSending}
className="flex-1 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center gap-2"
>
{isSending ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
Envoi en cours...
</>
) : (
<>
<Send className="h-4 w-4" />
Envoyer
</>
)}
</button>
</div>
</>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,685 @@
"use client";
import { useRouter } from "next/navigation";
import { ArrowLeft, Calendar, FileText, User, Building2, Download, Trash2, RefreshCw, Send, Check, X, Clock, Edit3 } from "lucide-react";
import { useEffect, useState } from "react";
import DeleteAvenantModal from "@/components/staff/amendments/DeleteAvenantModal";
import SendSignatureModal from "@/components/staff/amendments/SendSignatureModal";
import ChangeStatusModal from "@/components/staff/amendments/ChangeStatusModal";
interface AvenantDetailPageClientProps {
avenant: any;
}
export default function AvenantDetailPageClient({ avenant }: AvenantDetailPageClientProps) {
const router = useRouter();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [loadingPdf, setLoadingPdf] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isRegeneratingPdf, setIsRegeneratingPdf] = useState(false);
const [isSendingSignature, setIsSendingSignature] = useState(false);
const [showSendSignatureModal, setShowSendSignatureModal] = useState(false);
const [sendSignatureSuccess, setSendSignatureSuccess] = useState(false);
const [showChangeStatusModal, setShowChangeStatusModal] = useState(false);
const [isChangingStatus, setIsChangingStatus] = useState(false);
// Charger l'URL du PDF si la clé S3 existe
useEffect(() => {
if (avenant.pdf_s3_key) {
setLoadingPdf(true);
fetch(`/api/staff/amendments/${avenant.id}/pdf-url`)
.then(res => res.json())
.then(data => {
if (data.presignedUrl) {
setPdfUrl(data.presignedUrl);
}
})
.catch(err => console.error("Erreur chargement URL PDF:", err))
.finally(() => setLoadingPdf(false));
}
}, [avenant.id, avenant.pdf_s3_key]);
const handleDelete = async () => {
setIsDeleting(true);
try {
const response = await fetch(`/api/staff/amendments/${avenant.id}`, {
method: "DELETE",
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Erreur lors de la suppression");
}
// Rediriger vers la liste des avenants
router.push("/staff/avenants");
} catch (error: any) {
console.error("Erreur suppression avenant:", error);
alert("Erreur lors de la suppression de l'avenant: " + error.message);
setIsDeleting(false);
setShowDeleteModal(false);
}
};
const handleRegeneratePdf = async () => {
setIsRegeneratingPdf(true);
try {
// Préparer les données de l'avenant pour la génération
const amendmentData = {
contract_id: avenant.contract_id,
date_effet: avenant.date_effet,
date_signature: avenant.date_avenant,
elements: avenant.elements_avenantes,
objet_data: avenant.objet_data,
duree_data: avenant.duree_data,
lieu_horaire_data: avenant.lieu_horaire_data,
remuneration_data: avenant.remuneration_data,
};
// Générer le PDF
const generateResponse = await fetch("/api/staff/amendments/generate-pdf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contractId: avenant.contract_id,
amendmentData,
}),
});
if (!generateResponse.ok) {
const error = await generateResponse.json();
throw new Error(error.error || "Erreur lors de la génération du PDF");
}
const { s3Key, presignedUrl } = await generateResponse.json();
// Mettre à jour l'avenant avec la nouvelle clé S3
const updateResponse = await fetch(`/api/staff/amendments/${avenant.id}/update-pdf`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
pdf_s3_key: s3Key,
}),
});
if (!updateResponse.ok) {
const error = await updateResponse.json();
throw new Error(error.error || "Erreur lors de la mise à jour");
}
// Mettre à jour l'URL affichée
setPdfUrl(presignedUrl);
alert("PDF régénéré avec succès !");
// Recharger la page pour avoir les nouvelles données
router.refresh();
} catch (error: any) {
console.error("Erreur regénération PDF:", error);
alert("Erreur lors de la regénération du PDF: " + error.message);
} finally {
setIsRegeneratingPdf(false);
}
};
const handleSendForSignature = async () => {
if (!avenant.pdf_s3_key) {
alert("Vous devez d'abord générer le PDF avant de l'envoyer en signature.");
return;
}
setIsSendingSignature(true);
setSendSignatureSuccess(false);
try {
const response = await fetch(`/api/staff/amendments/${avenant.id}/send-signature`, {
method: "POST",
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Erreur lors de l'envoi");
}
const data = await response.json();
// Afficher le succès dans le modal
setSendSignatureSuccess(true);
// Recharger la page après 2 secondes pour voir le nouveau statut
setTimeout(() => {
router.refresh();
}, 2000);
} catch (error: any) {
console.error("Erreur envoi signature:", error);
alert("Erreur lors de l'envoi en signature: " + error.message);
setShowSendSignatureModal(false);
} finally {
setIsSendingSignature(false);
}
};
const handleChangeStatus = async (newStatus: string) => {
setIsChangingStatus(true);
try {
const response = await fetch(`/api/staff/amendments/${avenant.id}/change-status`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Erreur lors du changement de statut");
}
// Fermer le modal et recharger la page
setShowChangeStatusModal(false);
router.refresh();
} catch (error: any) {
console.error("Erreur changement statut:", error);
alert("Erreur lors du changement de statut: " + error.message);
} finally {
setIsChangingStatus(false);
}
};
const contract = avenant.cddu_contracts;
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-";
const [y, m, d] = dateStr.split("-");
return `${d}/${m}/${y}`;
};
const getStatusBadge = (status: string) => {
const badges = {
draft: "bg-slate-100 text-slate-700",
pending: "bg-orange-100 text-orange-700",
signed: "bg-green-100 text-green-700",
cancelled: "bg-red-100 text-red-700",
};
const labels = {
draft: "Brouillon",
pending: "En attente",
signed: "Signé",
cancelled: "Annulé",
};
return (
<span className={`px-3 py-1 text-sm font-medium rounded-full ${badges[status as keyof typeof badges] || badges.draft}`}>
{labels[status as keyof typeof labels] || status}
</span>
);
};
const getTypeBadge = (type: string) => {
const badges = {
modification: "bg-blue-100 text-blue-700",
annulation: "bg-red-100 text-red-700",
};
const labels = {
modification: "Modification",
annulation: "Annulation",
};
return (
<span className={`px-3 py-1 text-sm font-medium rounded-full ${badges[type as keyof typeof badges] || badges.modification}`}>
{labels[type as keyof typeof labels] || type}
</span>
);
};
const getElementLabel = (element: string) => {
const labels: Record<string, string> = {
objet: "Objet (profession, production)",
duree: "Durée de l'engagement",
lieu_horaire: "Lieu et horaires",
remuneration: "Rémunération",
};
return labels[element] || element;
};
const getSignatureStatusBadge = (status?: string) => {
if (!status || status === 'not_sent') {
return (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-full text-sm font-medium">
<Clock className="w-4 h-4" />
Non envoyé
</span>
);
}
if (status === 'pending_employer') {
return (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-orange-100 text-orange-700 rounded-full text-sm font-medium">
<Clock className="w-4 h-4" />
En attente employeur
</span>
);
}
if (status === 'pending_employee') {
return (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-100 text-blue-700 rounded-full text-sm font-medium">
<Clock className="w-4 h-4" />
En attente salarié
</span>
);
}
if (status === 'signed') {
return (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-green-100 text-green-700 rounded-full text-sm font-medium">
<Check className="w-4 h-4" />
Signé
</span>
);
}
return null;
};
return (
<div className="max-w-5xl mx-auto space-y-6">
{/* Header avec bouton retour */}
<div className="flex items-center gap-4">
<button
onClick={() => router.push("/staff/avenants")}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<ArrowLeft className="h-5 w-5 text-slate-600" />
</button>
<div className="flex-1">
<h1 className="text-2xl font-bold text-slate-900">
Avenant {avenant.numero_avenant}
</h1>
<p className="text-sm text-slate-600 mt-1">
Contrat {contract?.contract_number}
</p>
</div>
<div className="flex items-center gap-3">
{getStatusBadge(avenant.statut)}
{getTypeBadge(avenant.type_avenant)}
<button
onClick={() => setShowChangeStatusModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 border border-indigo-300 text-indigo-700 rounded-lg hover:bg-indigo-50 transition-colors"
title="Changer le statut manuellement"
>
<Edit3 className="h-4 w-4" />
Changer statut
</button>
<button
onClick={() => setShowDeleteModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 transition-colors"
>
<Trash2 className="h-4 w-4" />
Supprimer
</button>
</div>
</div>
{/* Informations générales */}
<div className="bg-white rounded-xl border shadow-sm p-6">
<h2 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
<FileText className="h-5 w-5" />
Informations générales
</h2>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="text-sm font-medium text-slate-500">Date d'effet</label>
<div className="mt-1 text-slate-900">{formatDate(avenant.date_effet)}</div>
</div>
<div>
<label className="text-sm font-medium text-slate-500">Date de signature</label>
<div className="mt-1 text-slate-900">{formatDate(avenant.date_avenant)}</div>
</div>
{avenant.motif_avenant && (
<div className="col-span-2">
<label className="text-sm font-medium text-slate-500">Motif</label>
<div className="mt-1 text-slate-900">{avenant.motif_avenant}</div>
</div>
)}
</div>
</div>
{/* Informations du contrat */}
<div className="bg-white rounded-xl border shadow-sm p-6">
<h2 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
<User className="h-5 w-5" />
Contrat concerné
</h2>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="text-sm font-medium text-slate-500">Salarié</label>
<div className="mt-1 text-slate-900">
{contract?.employee_name}
{contract?.employee_matricule && (
<span className="text-slate-500 ml-2">({contract.employee_matricule})</span>
)}
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-500">Organisation</label>
<div className="mt-1 text-slate-900">{contract?.structure}</div>
</div>
<div>
<label className="text-sm font-medium text-slate-500">Type de contrat</label>
<div className="mt-1 text-slate-900">{contract?.type_de_contrat}</div>
</div>
<div>
<label className="text-sm font-medium text-slate-500">Période</label>
<div className="mt-1 text-slate-900">
{formatDate(contract?.start_date)} - {formatDate(contract?.end_date)}
</div>
</div>
</div>
</div>
{/* Signatures électroniques */}
<div className="bg-white rounded-xl border shadow-sm p-6">
<h2 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
<Send className="h-5 w-5" />
Signatures électroniques
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-lg">
<div>
<div className="text-sm font-medium text-slate-900">Statut global</div>
<div className="text-xs text-slate-600 mt-0.5">État actuel du processus de signature</div>
</div>
<div>
{getSignatureStatusBadge(avenant.signature_status)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-medium text-slate-900">Employeur</div>
{avenant.signature_status === 'pending_employee' || avenant.signature_status === 'signed' ? (
<Check className="w-5 h-5 text-green-600" strokeWidth={3} />
) : avenant.signature_status === 'pending_employer' ? (
<X className="w-5 h-5 text-red-600" strokeWidth={3} />
) : (
<Clock className="w-5 h-5 text-slate-400" />
)}
</div>
{avenant.last_employer_notification_at && (
<div className="text-xs text-slate-600">
Notifié le {new Date(avenant.last_employer_notification_at).toLocaleDateString('fr-FR')}
</div>
)}
</div>
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-medium text-slate-900">Salarié</div>
{avenant.signature_status === 'signed' ? (
<Check className="w-5 h-5 text-green-600" strokeWidth={3} />
) : avenant.signature_status === 'pending_employee' || avenant.signature_status === 'pending_employer' ? (
<X className="w-5 h-5 text-red-600" strokeWidth={3} />
) : (
<Clock className="w-5 h-5 text-slate-400" />
)}
</div>
{avenant.last_employee_notification_at && (
<div className="text-xs text-slate-600">
Notifié le {new Date(avenant.last_employee_notification_at).toLocaleDateString('fr-FR')}
</div>
)}
</div>
</div>
</div>
</div>
{/* Éléments modifiés */}
<div className="bg-white rounded-xl border shadow-sm p-6">
<h2 className="font-semibold text-slate-900 mb-4">Éléments modifiés</h2>
<div className="space-y-4">
{avenant.elements_avenantes?.map((element: string) => (
<div key={element} className="border-l-4 border-indigo-500 pl-4 py-2">
<h3 className="font-medium text-slate-900 mb-2">{getElementLabel(element)}</h3>
{element === "objet" && avenant.objet_data && (
<div className="text-sm text-slate-600 space-y-1">
{avenant.objet_data.profession_label && (
<div>Profession : {avenant.objet_data.profession_label}</div>
)}
{avenant.objet_data.production_name && (
<div>Production : {avenant.objet_data.production_name}</div>
)}
{avenant.objet_data.production_numero_objet && (
<div>N° objet : {avenant.objet_data.production_numero_objet}</div>
)}
</div>
)}
{element === "duree" && avenant.duree_data && (
<div className="text-sm text-slate-600 space-y-1">
{avenant.duree_data.date_debut && (
<div>Nouvelle date de début : {formatDate(avenant.duree_data.date_debut)}</div>
)}
{avenant.duree_data.date_fin && (
<div>Nouvelle date de fin : {formatDate(avenant.duree_data.date_fin)}</div>
)}
{/* Pour les artistes */}
{avenant.duree_data.nb_representations !== undefined && avenant.duree_data.nb_representations > 0 && (
<div>Représentations : {avenant.duree_data.nb_representations}</div>
)}
{avenant.duree_data.nb_repetitions !== undefined && avenant.duree_data.nb_repetitions > 0 && (
<div>Répétitions : {avenant.duree_data.nb_repetitions}</div>
)}
{/* Pour les techniciens */}
{avenant.duree_data.nb_heures !== undefined && avenant.duree_data.nb_heures > 0 && (
<div>Nombre d'heures : {avenant.duree_data.nb_heures}h</div>
)}
{/* Dates de travail */}
{Array.isArray(avenant.duree_data.dates_representations) && avenant.duree_data.dates_representations.length > 0 && (
<div>
<div className="font-medium mt-2">Dates de représentations :</div>
<div className="flex flex-wrap gap-2 mt-1">
{avenant.duree_data.dates_representations.map((d: any, idx: number) => (
<div key={idx} className="inline-flex items-center gap-2 px-2 py-1 bg-blue-100 rounded text-xs">
<span className="font-medium">{formatDate(d.date)}</span>
{d.quantity && d.quantity > 0 && (
<span className="text-blue-700">({d.quantity})</span>
)}
</div>
))}
</div>
</div>
)}
{Array.isArray(avenant.duree_data.dates_repetitions) && avenant.duree_data.dates_repetitions.length > 0 && (
<div>
<div className="font-medium mt-2">Dates de répétitions :</div>
<div className="flex flex-wrap gap-2 mt-1">
{avenant.duree_data.dates_repetitions.map((d: any, idx: number) => (
<div key={idx} className="inline-flex items-center gap-2 px-2 py-1 bg-purple-100 rounded text-xs">
<span className="font-medium">{formatDate(d.date)}</span>
{d.quantity && d.quantity > 0 && (
<span className="text-purple-700">({d.quantity})</span>
)}
</div>
))}
</div>
</div>
)}
{/* Jours de travail (techniciens) */}
{avenant.duree_data.jours_travail && (
<div>
<div className="font-medium mt-2">Jours de travail :</div>
{Array.isArray(avenant.duree_data.jours_travail) ? (
<div className="flex flex-wrap gap-2 mt-1">
{avenant.duree_data.jours_travail.map((d: any, idx: number) => (
<div key={idx} className="inline-flex items-center gap-2 px-2 py-1 bg-slate-100 rounded text-xs">
<span className="font-medium">{formatDate(d.date)}</span>
{d.quantity && d.quantity > 0 && (
<span className="text-slate-600">({d.quantity}h)</span>
)}
</div>
))}
</div>
) : (
<div className="text-xs">{avenant.duree_data.jours_travail}</div>
)}
</div>
)}
</div>
)}
{element === "lieu_horaire" && avenant.lieu_horaire_data && (
<div className="text-sm text-slate-600 space-y-1">
{avenant.lieu_horaire_data.lieu && (
<div>Lieu : {avenant.lieu_horaire_data.lieu}</div>
)}
</div>
)}
{element === "remuneration" && avenant.remuneration_data && (
<div className="text-sm text-slate-600 space-y-1">
{avenant.remuneration_data.gross_pay && (
<div>Salaire brut : {avenant.remuneration_data.gross_pay} </div>
)}
{avenant.remuneration_data.type_salaire && (
<div>Type : {avenant.remuneration_data.type_salaire}</div>
)}
{avenant.remuneration_data.precisions_salaire && (
<div>Précisions : {avenant.remuneration_data.precisions_salaire}</div>
)}
</div>
)}
</div>
))}
</div>
</div>
{/* PDF */}
<div className="bg-white rounded-xl border shadow-sm p-6">
<h2 className="font-semibold text-slate-900 mb-4">Document signé</h2>
<div className="flex items-start gap-4 p-4 bg-slate-50 rounded-lg border">
<div className="flex-shrink-0 w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
<FileText className="h-6 w-6 text-indigo-600" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-900">
{avenant.pdf_s3_key ? `Avenant ${avenant.numero_avenant}.pdf` : "PDF non généré"}
</div>
<div className="text-sm text-slate-500 mt-1">
{avenant.pdf_s3_key ? "Document stocké sur AWS S3" : "Aucun document disponible"}
</div>
{avenant.pdf_s3_key && (
<div className="text-xs text-slate-400 mt-1 font-mono truncate">
{avenant.pdf_s3_key}
</div>
)}
</div>
<div className="flex-shrink-0 flex gap-2">
{loadingPdf ? (
<div className="px-4 py-2 bg-slate-100 text-slate-500 rounded-lg">
Chargement...
</div>
) : pdfUrl ? (
<>
<a
href={pdfUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
<Download className="h-4 w-4" />
Télécharger
</a>
<button
onClick={handleRegeneratePdf}
disabled={isRegeneratingPdf}
className="inline-flex items-center gap-2 px-4 py-2 border border-indigo-300 text-indigo-700 rounded-lg hover:bg-indigo-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`h-4 w-4 ${isRegeneratingPdf ? "animate-spin" : ""}`} />
{isRegeneratingPdf ? "Génération..." : "Regénérer"}
</button>
</>
) : (
<button
onClick={handleRegeneratePdf}
disabled={isRegeneratingPdf}
className="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`h-4 w-4 ${isRegeneratingPdf ? "animate-spin" : ""}`} />
{isRegeneratingPdf ? "Génération..." : "Générer le PDF"}
</button>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="bg-white rounded-xl border shadow-sm p-6">
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/staff/avenants")}
className="px-6 py-3 text-slate-700 hover:bg-slate-100 rounded-lg transition-colors font-medium"
>
Retour à la liste
</button>
{avenant.statut === "draft" && (
<>
<button
onClick={() => {
if (!avenant.pdf_s3_key) {
alert("Vous devez d'abord générer le PDF avant de l'envoyer en signature.");
return;
}
setShowSendSignatureModal(true);
}}
disabled={isSendingSignature}
className="inline-flex items-center gap-2 px-6 py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send className="h-4 w-4" />
Envoyer pour signature
</button>
<button className="px-6 py-3 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors font-medium">
Modifier
</button>
</>
)}
</div>
</div>
{/* Modale de suppression */}
<DeleteAvenantModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={handleDelete}
numeroAvenant={avenant.numero_avenant}
isDeleting={isDeleting}
/>
{/* Modale d'envoi en signature */}
<SendSignatureModal
isOpen={showSendSignatureModal}
onClose={() => {
setShowSendSignatureModal(false);
setSendSignatureSuccess(false);
}}
onConfirm={handleSendForSignature}
isSending={isSendingSignature}
success={sendSignatureSuccess}
avenantData={{
numero_avenant: avenant.numero_avenant,
contractReference: contract?.contract_number || "N/A",
employeeName: contract?.salaries ? `${contract.salaries.prenom} ${contract.salaries.nom}` : "N/A",
employerEmail: contract?.organizations?.organization_details?.email_signature || "N/A",
employeeEmail: contract?.salaries?.adresse_mail || "N/A",
}}
/>
{/* Modale de changement de statut */}
<ChangeStatusModal
isOpen={showChangeStatusModal}
onClose={() => setShowChangeStatusModal(false)}
onConfirm={handleChangeStatus}
currentStatus={avenant.statut}
isChanging={isChangingStatus}
numeroAvenant={avenant.numero_avenant}
/>
</div>
);
}

View file

@ -34,6 +34,7 @@ export type EmailTypeV2 =
| 'signature-request' | 'signature-request'
| 'signature-request-employer' | 'signature-request-employer'
| 'signature-request-employee' | 'signature-request-employee'
| 'signature-request-employee-amendment' // Nouveau type pour signature avenant salarié
| 'signature-request-salarie' // Nouveau type pour demande signature salarié (depuis Lambda DocuSeal) | 'signature-request-salarie' // Nouveau type pour demande signature salarié (depuis Lambda DocuSeal)
| 'bulk-signature-notification' // Nouveau type pour notification de signatures en masse | 'bulk-signature-notification' // Nouveau type pour notification de signatures en masse
| 'salary-transfer-notification' // Nouveau type pour notification d'appel à virement | 'salary-transfer-notification' // Nouveau type pour notification d'appel à virement
@ -637,7 +638,7 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
greeting: '{{#if firstName}}Bonjour {{firstName}},{{/if}}', greeting: '{{#if firstName}}Bonjour {{firstName}},{{/if}}',
mainMessage: 'Un document nécessite votre signature électronique en tant quemployeur.', mainMessage: 'Un document nécessite votre signature électronique en tant quemployeur.',
ctaText: 'Signer le document', ctaText: 'Signer le document',
footerText: 'Vous recevez cet e-mail car votre signature est requise sur un document.', footerText: 'Vous recevez ce document car vous êtes client de Odentas, pour vous notifier d\'une action sur votre compte.',
preheaderText: 'Signature électronique requise · Signez en tant quemployeur', preheaderText: 'Signature électronique requise · Signez en tant quemployeur',
colors: { colors: {
headerColor: STANDARD_COLORS.HEADER, headerColor: STANDARD_COLORS.HEADER,
@ -699,6 +700,41 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
} }
}, },
'signature-request-employee-amendment': {
subject: 'Signez votre avenant {{#if numeroAvenant}}n°{{numeroAvenant}}{{/if}} - {{organizationName}}',
title: 'Demande de signature électronique',
greeting: '{{#if firstName}}Bonjour {{firstName}},{{/if}}',
mainMessage: 'Nous vous invitons à signer votre avenant au contrat de travail ci-dessous.<br><br>Cliquez sur « Signer l\'avenant » pour accéder à Odentas Sign. Vous pourrez télécharger votre avenant dès validation de votre signature.',
ctaText: 'Signer l\'avenant',
closingMessage: 'Pour toute question, contactez-nous à <a href="mailto:paie@odentas.fr" style="color:#0B5FFF; text-decoration:none;">paie@odentas.fr</a>.',
footerText: 'Vous recevez cet e-mail car votre employeur ({{organizationName}}) est client d\'Odentas Media SAS, pour vous notifier d\'une action sur votre contrat de travail avec cet employeur.',
preheaderText: 'Signature électronique · Avenant {{#if numeroAvenant}}n°{{numeroAvenant}}{{/if}} · {{organizationName}}',
colors: {
headerColor: STANDARD_COLORS.HEADER,
titleColor: '#0F172A',
buttonColor: STANDARD_COLORS.BUTTON,
buttonTextColor: STANDARD_COLORS.BUTTON_TEXT,
cardBackgroundColor: '#FFFFFF',
cardBorder: '#E5E7EB',
cardTitleColor: '#0F172A',
alertIndicatorColor: '#22C55E',
},
infoCard: [
{ label: 'Votre employeur', key: 'organizationName' },
{ label: 'Votre matricule', key: 'matricule' },
],
detailsCard: {
title: 'Détails de l\'avenant',
rows: [
{ label: 'Référence contrat', key: 'contractReference' },
{ label: 'Type de contrat', key: 'contractType' },
{ label: 'Profession', key: 'profession' },
{ label: 'Date de début', key: 'startDate' },
{ label: 'Production', key: 'productionName' },
]
}
},
'signature-request-salarie': { 'signature-request-salarie': {
subject: 'Signez votre contrat {{organizationName}}', subject: 'Signez votre contrat {{organizationName}}',
title: 'Demande de signature électronique', title: 'Demande de signature électronique',

View file

@ -0,0 +1,16 @@
-- Migration: Ajout des colonnes de notification pour les avenants
-- Date: 2025-10-23
-- Description: Ajout des colonnes pour suivre les notifications d'envoi en signature
-- Ajouter les colonnes de notification
ALTER TABLE public.avenants
ADD COLUMN IF NOT EXISTS last_employer_notification_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS last_employee_notification_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS docuseal_template_id TEXT,
ADD COLUMN IF NOT EXISTS docuseal_submission_id TEXT,
ADD COLUMN IF NOT EXISTS employer_docuseal_slug TEXT,
ADD COLUMN IF NOT EXISTS employee_docuseal_slug TEXT,
ADD COLUMN IF NOT EXISTS signature_status TEXT DEFAULT 'not_sent'; -- 'not_sent', 'pending_employer', 'pending_employee', 'signed'
-- Index pour les recherches par statut de signature
CREATE INDEX IF NOT EXISTS idx_avenants_signature_status ON public.avenants(signature_status);

View file

@ -0,0 +1,102 @@
-- Migration: Création de la table avenants
-- Date: 2025-10-23
-- Description: Table pour gérer les avenants aux contrats de travail
-- Création de la table avenants
CREATE TABLE IF NOT EXISTS public.avenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Référence au contrat
contract_id UUID NOT NULL REFERENCES public.cddu_contracts(id) ON DELETE CASCADE,
-- Informations de l'avenant
numero_avenant TEXT NOT NULL, -- Ex: "AVE-001", "AVE-002"...
date_avenant DATE NOT NULL, -- Date de signature de l'avenant
date_effet DATE NOT NULL, -- Date d'effet de l'avenant
-- Type d'avenant
type_avenant TEXT NOT NULL DEFAULT 'modification', -- 'modification' ou 'annulation'
motif_avenant TEXT, -- Motif de l'avenant (texte libre)
-- Éléments avenantés (array de strings)
elements_avenantes TEXT[] NOT NULL DEFAULT '{}', -- ['objet', 'duree', 'lieu_horaire', 'remuneration']
-- Données spécifiques selon les éléments avenantés (JSONB pour flexibilité)
objet_data JSONB, -- { profession_code, profession_label, production_name, production_numero_objet }
duree_data JSONB, -- { date_debut, date_fin, nb_representations, nb_repetitions, nb_heures, dates_representations, dates_repetitions, jours_travail }
lieu_horaire_data JSONB, -- { lieu, horaires, adresse }
remuneration_data JSONB, -- { gross_pay, precisions_salaire, type_salaire }
-- Statut de l'avenant
statut TEXT NOT NULL DEFAULT 'draft', -- 'draft', 'pending', 'signed', 'cancelled'
-- URL du PDF généré
pdf_url TEXT,
pdf_s3_key TEXT,
-- Métadonnées
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id)
);
-- Index pour améliorer les performances
CREATE INDEX IF NOT EXISTS idx_avenants_contract_id ON public.avenants(contract_id);
CREATE INDEX IF NOT EXISTS idx_avenants_date_avenant ON public.avenants(date_avenant);
CREATE INDEX IF NOT EXISTS idx_avenants_statut ON public.avenants(statut);
CREATE INDEX IF NOT EXISTS idx_avenants_type_avenant ON public.avenants(type_avenant);
-- Trigger pour mettre à jour updated_at
CREATE OR REPLACE FUNCTION update_avenants_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_avenants_updated_at
BEFORE UPDATE ON public.avenants
FOR EACH ROW
EXECUTE FUNCTION update_avenants_updated_at();
-- RLS (Row Level Security)
ALTER TABLE public.avenants ENABLE ROW LEVEL SECURITY;
-- Policy: Les utilisateurs staff peuvent tout faire
CREATE POLICY "Staff can manage avenants"
ON public.avenants
FOR ALL
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.staff_users
WHERE staff_users.user_id = auth.uid()
AND staff_users.is_staff = true
)
);
-- Policy: Les organisations peuvent voir leurs avenants
CREATE POLICY "Organizations can view their avenants"
ON public.avenants
FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.cddu_contracts c
INNER JOIN public.organization_members om ON c.org_id = om.org_id
WHERE c.id = avenants.contract_id
AND om.user_id = auth.uid()
)
);
-- Commentaires
COMMENT ON TABLE public.avenants IS 'Table des avenants aux contrats de travail';
COMMENT ON COLUMN public.avenants.contract_id IS 'Référence au contrat concerné';
COMMENT ON COLUMN public.avenants.numero_avenant IS 'Numéro de l''avenant (ex: AVE-001)';
COMMENT ON COLUMN public.avenants.date_avenant IS 'Date de signature de l''avenant';
COMMENT ON COLUMN public.avenants.date_effet IS 'Date d''effet de l''avenant';
COMMENT ON COLUMN public.avenants.type_avenant IS 'Type: modification ou annulation';
COMMENT ON COLUMN public.avenants.motif_avenant IS 'Motif de l''avenant';
COMMENT ON COLUMN public.avenants.elements_avenantes IS 'Liste des éléments modifiés par l''avenant';
COMMENT ON COLUMN public.avenants.statut IS 'Statut: draft, pending, signed, cancelled';

View file

@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Demande de signature électronique - avenant</title>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<style>
body { margin:0; padding:0; background:#F4F6FA; color:#0F172A; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; }
a { color:#0B5FFF; text-decoration:none; }
.wrapper { width:100%; padding:32px 0; background:#F4F6FA; }
.container { width:100%; max-width:520px; margin:0 auto; background:#FFFFFF; border-radius:14px; box-shadow: 0 6px 20px rgba(15,23,42,0.06); overflow:hidden; }
.logo { height:60px; display:block; }
.label { font-size:12px; color:#64748B; text-transform:uppercase; letter-spacing:0.03em; margin-bottom:6px; font-weight:500; }
.value { font-size:15px; color:#0F172A; font-weight:500; line-height:1.3; }
@media (max-width: 480px) {
.wrapper { padding:16px 0; }
}
</style>
</head>
<body>
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">Demande de signature électronique - avenant · Signez votre avenant au contrat de travail</div>
<div class="wrapper">
<div class="container">
<div style="padding:28px 28px 10px 28px; text-align:left; background:#FFFFFF;">
<img class="logo" src="https://i.imgur.com/yDQU9G9.png" alt="Odentas">
<div style="display:flex; align-items:baseline; margin:18px 0 16px 0;">
<div style="width:8px; height:8px; background:#F59E0B; border-radius:50%; margin-right:12px; margin-top:8px; flex-shrink:0;"></div>
<h1 style="font-size:22px; line-height:1.3; margin:0; font-weight:700; color:#0F172A;">Demande de signature électronique</h1>
</div>
<!-- Info Card -->
<div style="margin:0 0 24px 0; border:1px solid #E5E7EB; border-radius:12px; padding:16px; background:#F8FAFC;">
<div style="margin-bottom:8px;">
<div class="label">Votre structure</div>
<div class="value">{{structure}}</div>
</div>
<div style="margin-bottom:8px;">
<div class="label">Votre code employeur</div>
<div class="value">{{code_employeur}}</div>
</div>
<div style="margin-bottom:0;">
<div class="label">Votre gestionnaire</div>
<div class="value">Renaud BREVIERE-ABRAHAM</div>
</div>
</div>
<div style="margin:20px 0;">
<p style="font-size:15px; color:#334155; margin:0 0 8px; font-weight:500;">👋 Bonjour {{prenom_signataire}},</p>
<p style="font-size:15px; color:#334155; margin:0 0 8px; line-height:1.4;">Nous vous invitons à signer l'avenant au contrat de travail ci-dessous.</p>
<p style="font-size:15px; color:#334155; margin:0 0 8px; line-height:1.4;">Cliquez sur "Signer l'avenant" pour accéder à Odentas Sign.</p>
<p style="font-size:15px; color:#334155; margin:0; line-height:1.4;">Votre salarié·e recevra ensuite son exemplaire pour signature. Vous recevrez une notification par e-mail dès réception de toutes les signatures.</p>
</div>
</div>
<!-- CTA Button -->
<div style="padding:0 28px 24px 28px; text-align:center;">
<a href="{{signatureLink}}" style="display:inline-block; padding:14px 32px; background:#F59E0B; color:#1C1917; font-weight:700; font-size:16px; border-radius:10px; text-decoration:none; transition: background 0.3s;">
Signer l'avenant
</a>
</div>
<!-- Details Card -->
<div style="margin:0px 28px 24px 28px; border:1px solid #E5E7EB; border-radius:12px; padding:20px; background:#FFFFFF;">
<h2 style="font-size:16px; margin:0 0 12px; color:#0F172A; font-weight:600;">Détails de l'avenant</h2>
<div style="margin-bottom:12px;">
<div class="label">Référence contrat</div>
<div class="value">{{reference}}</div>
</div>
<div style="margin-bottom:12px;">
<div class="label">Numéro avenant</div>
<div class="value">{{numero_avenant}}</div>
</div>
<div style="margin-bottom:12px;">
<div class="label">Type de contrat</div>
<div class="value">CDDU (contrat intermittent)</div>
</div>
<div style="margin-bottom:12px;">
<div class="label">Salarié·e</div>
<div class="value">{{salarie}}</div>
</div>
<div style="margin-bottom:12px;">
<div class="label">Début contrat</div>
<div class="value">{{date_debut}}</div>
</div>
<div style="margin-bottom:12px;">
<div class="label">Poste</div>
<div class="value">{{poste}}</div>
</div>
<div style="margin-bottom:0;">
<div class="label">Analytique</div>
<div class="value">{{analytique}}</div>
</div>
</div>
<!-- Closing message -->
<div style="padding:0 28px 24px 28px;">
<p style="font-size:15px; color:#334155; margin:0 0 8px; line-height:1.4;">N'hésitez pas à répondre à cet e-mail si vous avez besoin d'assistance.</p>
<p style="font-size:15px; color:#334155; margin:0 0 4px; line-height:1.4;">Merci pour votre confiance,</p>
<p style="font-size:15px; color:#334155; margin:0; font-weight:500;">L'équipe Odentas</p>
</div>
<!-- Footer -->
<div style="padding:24px 28px; border-top:1px solid #E5E7EB; text-align:center; background:#F8FAFC;">
<img src="https://i.imgur.com/yDQU9G9.png" alt="Odentas" style="height:32px; display:block; margin:0 auto 12px;">
<p style="font-size:11px; color:#94A3B8; margin:0 0 4px; line-height:1.4;">Odentas Media SAS | RCS Paris 907880348</p>
<p style="font-size:11px; color:#94A3B8; margin:0 0 4px; line-height:1.4;">6 rue d'Armaillé, 75017 Paris</p>
<p style="font-size:11px; color:#94A3B8; margin:0 0 8px; line-height:1.4;">
<a href="mailto:paie@odentas.fr" style="color:#0B5FFF;">paie@odentas.fr</a> ·
<a href="https://paie.odentas.fr" style="color:#0B5FFF;">Accès à l'Espace Paie</a>
</p>
<p style="font-size:11px; color:#94A3B8; margin:0 0 8px; line-height:1.4;">
🇫🇷 Vos documents sont stockés dans un datacenter AWS à Paris
</p>
<p style="font-size:10px; color:#94A3B8; margin:0; line-height:1.3;">
Nous vous recommandons d'ajouter paie@odentas.fr à vos contacts pour être sûr de recevoir nos notifications. Vous recevez cet e-mail car vous êtes client·e de Odentas.
</p>
</div>
</div>
</div>
</body>
</html>

View file

@ -70,6 +70,11 @@ export interface Amendment {
lieu_horaire_data?: AmendmentLieuHoraireData; lieu_horaire_data?: AmendmentLieuHoraireData;
remuneration_data?: AmendmentRemunerationData; remuneration_data?: AmendmentRemunerationData;
// Signature électronique
signature_status?: "not_sent" | "pending_employer" | "pending_employee" | "signed";
last_employer_notification_at?: string;
last_employee_notification_at?: string;
// Timestamps // Timestamps
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;