diff --git a/AVENANT_EMAIL_SYSTEM_MIGRATION.md b/AVENANT_EMAIL_SYSTEM_MIGRATION.md new file mode 100644 index 0000000..d5a4b25 --- /dev/null +++ b/AVENANT_EMAIL_SYSTEM_MIGRATION.md @@ -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 diff --git a/app/(app)/signatures-electroniques/page.tsx b/app/(app)/signatures-electroniques/page.tsx index 2364918..0578c79 100644 --- a/app/(app)/signatures-electroniques/page.tsx +++ b/app/(app)/signatures-electroniques/page.tsx @@ -164,6 +164,12 @@ export default function SignaturesElectroniques() { const recordsEmployeurRef = useRef([]); const recordsSalarieRef = useRef([]); + // États pour les avenants + const [avenantsEmployeur, setAvenantsEmployeur] = useState([]); + const [avenantsSalarie, setAvenantsSalarie] = useState([]); + const avenantsEmployeurRef = useRef([]); + const avenantsSalarieRef = useRef([]); + // États pour les modales const [modalTitle, setModalTitle] = useState(''); const [embedSrc, setEmbedSrc] = useState(null); @@ -192,10 +198,6 @@ export default function SignaturesElectroniques() { const isMountedRef = useRef(true); const signingContractIdRef = useRef(null); - // Modal de confirmation après signature employeur (DocuSeal 'completed') - const [showCompletedModal, setShowCompletedModal] = useState(false); - const showCompletedModalRef = useRef(false); - useEffect(() => { isMountedRef.current = true; return () => { @@ -203,10 +205,6 @@ export default function SignaturesElectroniques() { }; }, []); - useEffect(() => { - showCompletedModalRef.current = showCompletedModal; - }, [showCompletedModal]); - useEffect(() => { recordsEmployeurRef.current = 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() { if (!isMountedRef.current) return; try { @@ -513,23 +511,40 @@ export default function SignaturesElectroniques() { // Ajouter le paramètre org_id si sélectionné (staff uniquement) 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=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 (!rSal.ok) throw new Error(`HTTP salarie ${rSal.status}`); + if (!rEmp.ok) throw new Error(`HTTP contrats employeur ${rEmp.status}`); + if (!rSal.ok) throw new Error(`HTTP contrats salarie ${rSal.status}`); + if (!rAveEmp.ok) throw new Error(`HTTP avenants employeur ${rAveEmp.status}`); + if (!rAveSal.ok) throw new Error(`HTTP avenants salarie ${rAveSal.status}`); + const emp: ContractsResponse = await rEmp.json(); const sal: ContractsResponse = await rSal.json(); + const aveEmp: ContractsResponse = await rAveEmp.json(); + const aveSal: ContractsResponse = await rAveSal.json(); + if (!isMountedRef.current) return; - const empRecords = emp.records || []; - const salarieRecords = sal.records || []; - setRecordsEmployeur(empRecords); - setRecordsSalarie(salarieRecords); - recordsEmployeurRef.current = empRecords; - recordsSalarieRef.current = salarieRecords; + + const empRecords = emp.records || []; + const salarieRecords = sal.records || []; + const aveEmpRecords = aveEmp.records || []; + const aveSalRecords = aveSal.records || []; + + setRecordsEmployeur(empRecords); + setRecordsSalarie(salarieRecords); + setAvenantsEmployeur(aveEmpRecords); + setAvenantsSalarie(aveSalRecords); + + recordsEmployeurRef.current = empRecords; + recordsSalarieRef.current = salarieRecords; + avenantsEmployeurRef.current = aveEmpRecords; + avenantsSalarieRef.current = aveSalRecords; } catch (e: any) { - console.error('Load contracts error', e); + console.error('Load contracts/amendments error', e); if (!isMountedRef.current) return; setError(e?.message || 'Erreur de chargement'); } finally { @@ -602,17 +617,64 @@ export default function SignaturesElectroniques() { if (formEl) { const onCompleted = async (_event: Event) => { console.log('✅ [SIGNATURES] Event completed reçu - déclenchement du rafraîchissement'); - setShowCompletedModal(true); if (!isMountedRef.current) return; + // Remplacer le contenu du modal par le message de confirmation + if (container) { + container.innerHTML = ` +
+
+ +
+
+
+ + + +
+
+

Signature employeur prise en compte

+

Le processus se poursuit automatiquement côté salarié.

+
+
+
+ + +
+
+
    +
  • Le salarié va recevoir son propre exemplaire pour signature électronique.
  • +
  • Vous pourrez télécharger le contrat depuis la fiche contrat dès que la signature du salarié aura été reçue.
  • +
+
+
+ + +
+ +
+
+
+ `; + + // Ajouter l'événement de fermeture + const btnClose = container.querySelector('#btn-close-confirmation') as HTMLButtonElement | null; + if (btnClose) { + btnClose.onclick = () => { + const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null; + if (dlg) dlg.close(); + }; + } + } + setReloadingAfterSignatureChange(true); try { - 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 await new Promise((resolve) => setTimeout(resolve, 900)); await loadWithRetry({ @@ -642,10 +704,6 @@ export default function SignaturesElectroniques() { const handleSignatureClose = () => { 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(); signingContractIdRef.current = null; }; @@ -748,31 +806,78 @@ export default function SignaturesElectroniques() { async function openSignature(rec: AirtableRecord) { const f = rec.fields || {}; + const isAvenant = f.is_avenant === true; 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); signingContractIdRef.current = rec.id; console.log('🔍 [SIGNATURES] Debug - record fields:', f); + console.log('🔍 [SIGNATURES] Debug - is_avenant:', isAvenant); console.log('🔍 [SIGNATURES] Debug - embed_src_employeur:', f.embed_src_employeur); console.log('🔍 [SIGNATURES] Debug - docuseal_template_id:', f.docuseal_template_id); + console.log('🔍 [SIGNATURES] Debug - docuseal_submission_id:', f.docuseal_submission_id); console.log('🔍 [SIGNATURES] Debug - signature_b64:', f.signature_b64 ? 'présente' : 'absente'); - // Gérer la signature pré-remplie si disponible - if (f.signature_b64) { - console.log('✅ [SIGNATURES] Signature trouvée, stockage dans l\'état React'); - const normalizedSignature = normalizeSignatureFormat(f.signature_b64); + // Gérer la signature pré-remplie + // Pour les contrats: utiliser f.signature_b64 + // Pour les avenants: utiliser currentSignature (signature de l'organisation) + let signatureToUse = null; + + if (isAvenant) { + // Pour les avenants, utiliser la signature de l'organisation + if (currentSignature) { + console.log('✅ [SIGNATURES AVENANT] Utilisation de la signature de l\'organisation'); + signatureToUse = currentSignature; + } else { + console.log('⚠️ [SIGNATURES AVENANT] Pas de signature d\'organisation disponible'); + } + } else { + // Pour les contrats, utiliser la signature du contrat + if (f.signature_b64) { + console.log('✅ [SIGNATURES CONTRAT] Utilisation de la signature du contrat'); + signatureToUse = f.signature_b64; + } else { + console.log('⚠️ [SIGNATURES CONTRAT] Pas de signature dans les données du contrat'); + } + } + + if (signatureToUse) { + const normalizedSignature = normalizeSignatureFormat(signatureToUse); if (normalizedSignature) { setActiveSignature(normalizedSignature); console.log('✅ [SIGNATURES] Format normalisé:', normalizedSignature.substring(0, 50) + '...'); } } else { - console.log('⚠️ [SIGNATURES] Pas de signature dans les données'); + console.log('⚠️ [SIGNATURES] Pas de signature à pré-remplir'); setActiveSignature(null); } - // 1) Si l'URL d'embed est déjà en base (signature_link) - if (typeof f.embed_src_employeur === 'string' && f.embed_src_employeur.trim()) { + // Pour les avenants, utiliser directement le docuseal_submission_id + if (isAvenant && f.docuseal_submission_id) { + console.log('🔍 [SIGNATURES AVENANT] Utilisation du submission_id:', f.docuseal_submission_id); + + try { + const detRes = await fetch(`/api/docuseal/submissions/${encodeURIComponent(f.docuseal_submission_id)}`, { cache: 'no-store' }); + const detData = await detRes.json(); + + console.log('📋 [SIGNATURES AVENANT] Détails submission DocuSeal:', detData); + + const roles = detData?.submitters || detData?.roles || []; + const employer = roles.find((r: any) => (r.role || r.name) === 'Employeur') || {}; + + if (employer?.slug) { + embed = `https://docuseal.eu/s/${employer.slug}`; + console.log('🔗 [SIGNATURES AVENANT] URL embed depuis slug:', embed); + } + } catch (e) { + console.warn('❌ [SIGNATURES AVENANT] Erreur récupération submission:', e); + } + } + + // 1) Si l'URL d'embed est déjà en base (signature_link) - pour les contrats + if (!embed && typeof f.embed_src_employeur === 'string' && f.embed_src_employeur.trim()) { const signatureLink = f.embed_src_employeur.trim(); console.log('🔗 [SIGNATURES] Signature link trouvé:', signatureLink); @@ -1072,20 +1177,21 @@ export default function SignaturesElectroniques() {
Chargement…
) : error ? (
Erreur: {error}
- ) : (recordsEmployeur.length + recordsSalarie.length) === 0 ? ( -
Aucun contrat à signer.
+ ) : (recordsEmployeur.length + recordsSalarie.length + avenantsEmployeur.length + avenantsSalarie.length) === 0 ? ( +
Aucun document à signer.
) : ( <> - {/* Table 1: employeur pending */} + {/* Table 1: employeur pending (contrats + avenants) */}
-
Contrats en attente de signature employeur
-
{recordsEmployeur.length} élément(s)
+
Documents en attente de signature employeur
+
{recordsEmployeur.length + avenantsEmployeur.length} élément(s)
+ @@ -1094,26 +1200,39 @@ export default function SignaturesElectroniques() { - {recordsEmployeur.map((rec, idx) => { + {[...recordsEmployeur, ...avenantsEmployeur].map((rec, idx) => { const f = rec.fields || {}; + const isAvenant = f.is_avenant === true; const isSigned = String(f['Contrat signé par employeur'] || '').toLowerCase() === 'oui'; - const mat = f['Matricule API'] || f.Matricule || '—'; - const ref = f.Reference || '—'; - const nom = f['Nom salarié'] || f['Nom Salarié'] || f['Nom'] || f['Salarié'] || f['Nom complet'] || f['Prénom Nom'] || f['Prenom Nom'] || f['Nom et Prénom'] || '—'; + const mat = f.employee_matricule || f['Matricule API'] || f.Matricule || '—'; + const ref = f.reference || f.Reference || '—'; + const nom = f.employee_name || f['Nom salarié'] || f['Nom Salarié'] || f['Nom'] || f['Salarié'] || f['Nom complet'] || f['Prénom Nom'] || f['Prenom Nom'] || f['Nom et Prénom'] || '—'; + const docType = isAvenant ? 'Avenant' : 'Contrat'; + const urlPath = isAvenant ? `/staff/avenants/${rec.id}` : `/contrats/${rec.id}`; + const docLabel = isAvenant ? `Avenant · ${ref}` : `Contrat · ${ref}`; + return ( + @@ -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" disabled={isSigned} onClick={() => openSignature(rec)} - aria-label={`Signer le contrat ${ref}`} + aria-label={`Signer ${isAvenant ? "l'avenant" : "le contrat"} ${ref}`} >
Type Référence Salarié Matricule
+ + {docType} + + { e.preventDefault(); openEmbed(`/contrats/${rec.id}`, `Contrat · ${ref}`); }} + href={urlPath} + onClick={(e)=>{ e.preventDefault(); openEmbed(urlPath, docLabel); }} className="hover:underline" >{ref} { if (!mat) return; e.preventDefault(); openEmbed(`/salaries/${mat}`, `Salarié · ${nom}`); }} - className={classNames('hover:underline', !mat && 'pointer-events-none opacity-60')} + onClick={(e)=>{ if (!mat || mat === '—') return; e.preventDefault(); openEmbed(`/salaries/${mat}`, `Salarié · ${nom}`); }} + className={classNames('hover:underline', (!mat || mat === '—') && 'pointer-events-none opacity-60')} >{nom} {mat}
+ @@ -1162,25 +1282,38 @@ export default function SignaturesElectroniques() { - {recordsSalarie.map((rec, idx) => { + {[...recordsSalarie, ...avenantsSalarie].map((rec, idx) => { const f = rec.fields || {}; - const mat = f['Matricule API'] || f.Matricule || '—'; - const ref = f.Reference || '—'; - const nom = f['Nom salarié'] || f['Nom Salarié'] || f['Nom'] || f['Salarié'] || f['Nom complet'] || f['Prénom Nom'] || f['Prenom Nom'] || f['Nom et Prénom'] || '—'; + const isAvenant = f.is_avenant === true; + const mat = f.employee_matricule || f['Matricule API'] || f.Matricule || '—'; + const ref = f.reference || f.Reference || '—'; + const nom = f.employee_name || f['Nom salarié'] || f['Nom Salarié'] || f['Nom'] || f['Salarié'] || f['Nom complet'] || f['Prénom Nom'] || f['Prenom Nom'] || f['Nom et Prénom'] || '—'; + const docType = isAvenant ? 'Avenant' : 'Contrat'; + const urlPath = isAvenant ? `/staff/avenants/${rec.id}` : `/contrats/${rec.id}`; + const docLabel = isAvenant ? `Avenant · ${ref}` : `Contrat · ${ref}`; + return ( + @@ -1261,65 +1394,6 @@ export default function SignaturesElectroniques() { - {/* Modal de confirmation après signature employeur */} - {showCompletedModal && ( -
-
- {/* Header */} -
-
-
-
- -
-
-

Signature employeur prise en compte

-

Le processus se poursuit automatiquement côté salarié.

-
-
- -
-
- - {/* Content */} -
-
-
    -
  • Le salarié va recevoir son propre exemplaire pour signature électronique.
  • -
  • Vous pourrez télécharger le contrat depuis la fiche contrat dès que la signature du salarié aura été reçue.
  • -
-
-
- - {/* Footer */} -
- -
-
-
- )} - {/* Modal de gestion de la signature */} {showSignatureModal && (
diff --git a/app/(app)/staff/avenants/[id]/page.tsx b/app/(app)/staff/avenants/[id]/page.tsx new file mode 100644 index 0000000..c43b063 --- /dev/null +++ b/app/(app)/staff/avenants/[id]/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/app/(app)/staff/avenants/page.tsx b/app/(app)/staff/avenants/page.tsx index 311f402..5fa5bed 100644 --- a/app/(app)/staff/avenants/page.tsx +++ b/app/(app)/staff/avenants/page.tsx @@ -38,13 +38,56 @@ export default async function StaffAvenantsPage() { ); } - // TODO: Récupérer les avenants depuis la base de données - // Pour l'instant, on passe un tableau vide - const amendments: any[] = []; + // Récupérer les avenants depuis la base de données + const { data: avenants, error } = await sb + .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 (
- +
); } diff --git a/app/api/emails/signature-avenant-salarie/route.ts b/app/api/emails/signature-avenant-salarie/route.ts new file mode 100644 index 0000000..29d1823 --- /dev/null +++ b/app/api/emails/signature-avenant-salarie/route.ts @@ -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 } + ); + } +} diff --git a/app/api/signatures-electroniques/avenants/route.ts b/app/api/signatures-electroniques/avenants/route.ts new file mode 100644 index 0000000..56f6a02 --- /dev/null +++ b/app/api/signatures-electroniques/avenants/route.ts @@ -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 }); + } +} diff --git a/app/api/staff/amendments/[id]/change-status/route.ts b/app/api/staff/amendments/[id]/change-status/route.ts new file mode 100644 index 0000000..e7c6e22 --- /dev/null +++ b/app/api/staff/amendments/[id]/change-status/route.ts @@ -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 }); + } +} diff --git a/app/api/staff/amendments/[id]/pdf-url/route.ts b/app/api/staff/amendments/[id]/pdf-url/route.ts new file mode 100644 index 0000000..dd3dd25 --- /dev/null +++ b/app/api/staff/amendments/[id]/pdf-url/route.ts @@ -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 } + ); + } +} diff --git a/app/api/staff/amendments/[id]/route.ts b/app/api/staff/amendments/[id]/route.ts new file mode 100644 index 0000000..78b74ae --- /dev/null +++ b/app/api/staff/amendments/[id]/route.ts @@ -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 } + ); + } +} diff --git a/app/api/staff/amendments/[id]/send-signature/route.ts b/app/api/staff/amendments/[id]/send-signature/route.ts new file mode 100644 index 0000000..0a9c5d1 --- /dev/null +++ b/app/api/staff/amendments/[id]/send-signature/route.ts @@ -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 } + ); + } +} diff --git a/app/api/staff/amendments/[id]/update-pdf/route.ts b/app/api/staff/amendments/[id]/update-pdf/route.ts new file mode 100644 index 0000000..1ac094f --- /dev/null +++ b/app/api/staff/amendments/[id]/update-pdf/route.ts @@ -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 } + ); + } +} diff --git a/app/api/staff/amendments/create/route.ts b/app/api/staff/amendments/create/route.ts new file mode 100644 index 0000000..5349924 --- /dev/null +++ b/app/api/staff/amendments/create/route.ts @@ -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 } + ); + } +} diff --git a/app/api/staff/amendments/generate-pdf/route.ts b/app/api/staff/amendments/generate-pdf/route.ts index 20cdfb0..c959a43 100644 --- a/app/api/staff/amendments/generate-pdf/route.ts +++ b/app/api/staff/amendments/generate-pdf/route.ts @@ -192,10 +192,11 @@ export async function POST(request: NextRequest) { employee_catpro = "Metteur en scène"; } - // Préparer les détails de cachets - const cachetsRepresentations = dureeData.nb_representations || contract.cachets_representations || 0; - const cachetsRepetitions = dureeData.nb_repetitions || contract.cachets_repetitions || 0; - const heures = dureeData.nb_heures || contract.nb_heures || 0; + // Préparer les détails de cachets - convertir en nombres + const cachetsRepresentations = parseInt(dureeData.nb_representations || contract.cachets_representations || 0); + const cachetsRepetitions = parseInt(dureeData.nb_repetitions || contract.cachets_repetitions || 0); + const heures = parseInt(dureeData.nb_heures || contract.nb_heures || 0); + const heuresParJour = parseInt(contract.nombre_d_heures_par_jour || 0); let detailsCachets = ""; if (cachetsRepresentations > 0) { @@ -294,9 +295,9 @@ export async function POST(request: NextRequest) { representations: cachetsRepresentations, repetitions: cachetsRepetitions, 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)); diff --git a/app/api/webhooks/docuseal-amendment/route.ts b/app/api/webhooks/docuseal-amendment/route.ts new file mode 100644 index 0000000..dcf7d76 --- /dev/null +++ b/app/api/webhooks/docuseal-amendment/route.ts @@ -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 } + ); + } +} diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 1467fd9..72e48d7 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; 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 { createPortal } from "react-dom"; import LogoutButton from "@/components/LogoutButton"; @@ -532,6 +532,14 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o Fiches de paie + 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"> + + + Avenants + + 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" }`} title="Gestion des salariés"> diff --git a/components/staff/NouvelAvenantPageClient.tsx b/components/staff/NouvelAvenantPageClient.tsx index 1346c14..c6d2aec 100644 --- a/components/staff/NouvelAvenantPageClient.tsx +++ b/components/staff/NouvelAvenantPageClient.tsx @@ -12,6 +12,7 @@ import { import AmendmentObjetForm from "@/components/staff/amendments/AmendmentObjetForm"; import AmendmentDureeForm from "@/components/staff/amendments/AmendmentDureeForm"; import AmendmentRemunerationForm from "@/components/staff/amendments/AmendmentRemunerationForm"; +import AvenantSuccessModal from "@/components/staff/amendments/AvenantSuccessModal"; export default function NouvelAvenantPageClient() { const router = useRouter(); @@ -26,6 +27,8 @@ export default function NouvelAvenantPageClient() { // Données du formulaire const [dateEffet, setDateEffet] = useState(""); const [dateSignature, setDateSignature] = useState(""); + const [typeAvenant, setTypeAvenant] = useState<"modification" | "annulation">("modification"); + const [motifAvenant, setMotifAvenant] = useState(""); const [selectedElements, setSelectedElements] = useState([]); // Données spécifiques selon les éléments @@ -35,9 +38,15 @@ export default function NouvelAvenantPageClient() { const [isSubmitting, setIsSubmitting] = useState(false); + // Modale de succès + const [showSuccessModal, setShowSuccessModal] = useState(false); + const [createdAvenantId, setCreatedAvenantId] = useState(""); + const [createdNumeroAvenant, setCreatedNumeroAvenant] = useState(""); + // PDF generation const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); const [pdfPresignedUrl, setPdfPresignedUrl] = useState(null); + const [pdfS3Key, setPdfS3Key] = useState(null); // Recherche de contrats (debounced) useEffect(() => { @@ -185,6 +194,7 @@ export default function NouvelAvenantPageClient() { const data = await response.json(); setPdfPresignedUrl(data.presignedUrl); + setPdfS3Key(data.s3Key); } catch (error: any) { console.error("Erreur génération PDF:", error); alert("Erreur lors de la génération du PDF: " + error.message); @@ -199,31 +209,40 @@ export default function NouvelAvenantPageClient() { setIsSubmitting(true); try { - const amendment: Amendment = { + const amendmentData = { contract_id: selectedContract!.id, - contract_number: selectedContract!.contract_number, - employee_name: selectedContract!.employee_name, - organization_name: selectedContract!.organization_name, date_effet: dateEffet, date_signature: dateSignature || undefined, - status: "draft", - elements: selectedElements, + type_avenant: typeAvenant, + motif_avenant: motifAvenant || undefined, + elements_avenantes: 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, - created_at: new Date().toISOString(), + pdf_url: pdfPresignedUrl || undefined, + pdf_s3_key: pdfS3Key || undefined, }; - // TODO: Envoyer à l'API - // const response = await fetch('/api/staff/amendments', { - // method: 'POST', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify(amendment), - // }); + // 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), + }); - 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) { console.error("Erreur création avenant:", error); + alert("Erreur lors de la création de l'avenant"); } finally { setIsSubmitting(false); } @@ -392,6 +411,38 @@ export default function NouvelAvenantPageClient() {
+ {/* Type et Motif */} +
+

Informations complémentaires

+
+
+ + +
+
+ + 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" + /> +
+
+
+ {/* Éléments à avenanter */}

@@ -520,6 +571,13 @@ export default function NouvelAvenantPageClient() {

)} + + {/* Modale de succès */} + ); } diff --git a/components/staff/StaffAvenantsPageClient.tsx b/components/staff/StaffAvenantsPageClient.tsx index b635b5a..771fc1d 100644 --- a/components/staff/StaffAvenantsPageClient.tsx +++ b/components/staff/StaffAvenantsPageClient.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; 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"; 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 ( +
+
+
E
+ {notSent ? ( + + ) : employerSigned ? ( + + ) : ( + + )} +
+
+
S
+ {notSent ? ( + + ) : employeeSigned ? ( + + ) : ( + + )} +
+
+ ); + }; + const getElementsLabel = (elements: Amendment["elements"]) => { const labels = { objet: "Objet", @@ -135,6 +169,9 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
+ @@ -145,7 +182,11 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa {filteredAmendments.map((amendment) => ( - + router.push(`/staff/avenants/${amendment.id}`)} + className="hover:bg-slate-50 transition-colors cursor-pointer" + > @@ -161,9 +202,18 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa + diff --git a/components/staff/amendments/AmendmentDureeForm.tsx b/components/staff/amendments/AmendmentDureeForm.tsx index bc50f09..38310ee 100644 --- a/components/staff/amendments/AmendmentDureeForm.tsx +++ b/components/staff/amendments/AmendmentDureeForm.tsx @@ -21,9 +21,7 @@ export default function AmendmentDureeForm({ const isTechnician = originalData.categorie_pro === "Technicien"; const isArtist = !isTechnician; - // États pour les calendriers - const [showDateDebut, setShowDateDebut] = useState(false); - const [showDateFin, setShowDateFin] = useState(false); + // États pour les calendriers (dates de représentations/répétitions/jours de travail uniquement) const [showDatesRep, setShowDatesRep] = useState(false); const [showDatesServ, setShowDatesServ] = useState(false); const [showJoursTravail, setShowJoursTravail] = useState(false); @@ -41,19 +39,13 @@ export default function AmendmentDureeForm({ return `${d}/${m}/${y}`; }; - // Handlers pour les sélections de dates - const handleDateDebutApply = (result: { selectedDates: string[] }) => { - if (result.selectedDates.length > 0) { - onChange({ ...data, date_debut: result.selectedDates[0] }); - } - setShowDateDebut(false); + // Handlers pour les dates simples (début/fin) + const handleDateDebutChange = (e: React.ChangeEvent) => { + onChange({ ...data, date_debut: e.target.value }); }; - const handleDateFinApply = (result: { selectedDates: string[] }) => { - if (result.selectedDates.length > 0) { - onChange({ ...data, date_fin: result.selectedDates[0] }); - } - setShowDateFin(false); + const handleDateFinChange = (e: React.ChangeEvent) => { + onChange({ ...data, date_fin: e.target.value }); }; const handleDatesRepApply = (result: { @@ -123,7 +115,7 @@ export default function AmendmentDureeForm({

Modification de la durée de l'engagement

{/* Dates de début et fin */} -
+
@@ -158,24 +138,12 @@ export default function AmendmentDureeForm({
Actuellement : {formatDate(originalData.end_date)}
-
- - {showDateFin && ( - setShowDateFin(false)} - onApply={handleDateFinApply} - initialDates={data.date_fin ? [formatDate(data.date_fin)] : []} - title="Sélectionner la date de fin" - /> - )} -
+
diff --git a/components/staff/amendments/AvenantSuccessModal.tsx b/components/staff/amendments/AvenantSuccessModal.tsx new file mode 100644 index 0000000..179013f --- /dev/null +++ b/components/staff/amendments/AvenantSuccessModal.tsx @@ -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 ( +
+
+
+
+
+ +
+ +

+ Avenant créé avec succès ! +

+ +

+ L'avenant {numeroAvenant} a été créé et le contrat a été mis à jour. +

+ +
+ + +
+
+
+
+
+ ); +} diff --git a/components/staff/amendments/ChangeStatusModal.tsx b/components/staff/amendments/ChangeStatusModal.tsx new file mode 100644 index 0000000..7775b7f --- /dev/null +++ b/components/staff/amendments/ChangeStatusModal.tsx @@ -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 ( +
+
+
+ + +
+
+ +
+
+

+ Changer le statut de l'avenant +

+

+ Avenant {numeroAvenant} +

+
+
+ +
+ +
+ {STATUS_OPTIONS.map((status) => ( + + ))} +
+
+ +
+ +
+
+
+ ); +} diff --git a/components/staff/amendments/DeleteAvenantModal.tsx b/components/staff/amendments/DeleteAvenantModal.tsx new file mode 100644 index 0000000..3031cee --- /dev/null +++ b/components/staff/amendments/DeleteAvenantModal.tsx @@ -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 ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ Supprimer l'avenant +

+

+ Avenant {numeroAvenant} +

+
+
+ +
+ + {/* Message */} +
+

+ Êtes-vous sûr de vouloir supprimer cet avenant ? Cette action est{" "} + irréversible. +

+

+ Le PDF associé sera également supprimé du stockage S3. +

+
+ + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/components/staff/amendments/NouvelAvenantModal.tsx b/components/staff/amendments/NouvelAvenantModal.tsx index e932ce0..62be22b 100644 --- a/components/staff/amendments/NouvelAvenantModal.tsx +++ b/components/staff/amendments/NouvelAvenantModal.tsx @@ -34,6 +34,8 @@ export default function NouvelAvenantModal({ // Données du formulaire const [dateEffet, setDateEffet] = useState(""); const [dateSignature, setDateSignature] = useState(""); + const [typeAvenant, setTypeAvenant] = useState<"modification" | "annulation">("modification"); + const [motifAvenant, setMotifAvenant] = useState(""); const [selectedElements, setSelectedElements] = useState([]); // Données spécifiques selon les éléments @@ -236,9 +238,37 @@ export default function NouvelAvenantModal({ setIsSubmitting(true); try { - // Pour l'instant, on crée juste l'objet localement - // Plus tard, tu pourras envoyer ça à ton API + // Préparer les données de l'avenant + 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 = { + id: data.avenant.id, contract_id: selectedContract!.id, contract_number: selectedContract!.contract_number, employee_name: selectedContract!.employee_name, @@ -250,17 +280,11 @@ export default function NouvelAvenantModal({ objet_data: selectedElements.includes("objet") ? objetData : undefined, duree_data: selectedElements.includes("duree") ? dureeData : 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); + alert(`Avenant ${data.avenant.numero_avenant} créé avec succès !`); onClose(); } catch (error) { console.error("Erreur création avenant:", error); @@ -392,33 +416,68 @@ export default function NouvelAvenantModal({
- {/* Dates */} -
-
- -
- - 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" - /> + {/* Dates de l'avenant */} +
+

Dates de l'avenant

+
+
+ +
+ + 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" + /> +
+
+
+ +
+ + 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" + /> +
-
- -
- +
+ + {/* Type et Motif de l'avenant */} +
+

Informations complémentaires

+
+
+ + +
+
+ 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" + 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" />
diff --git a/components/staff/amendments/SendSignatureModal.tsx b/components/staff/amendments/SendSignatureModal.tsx new file mode 100644 index 0000000..bebb8ce --- /dev/null +++ b/components/staff/amendments/SendSignatureModal.tsx @@ -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 ( +
+
+ {/* Header */} +
+
+
+ {success ? ( + + ) : ( + + )} +
+
+

+ {success ? "Envoi réussi !" : "Envoyer pour signature"} +

+

+ Avenant {avenantData.numero_avenant} +

+
+
+ +
+ + {success ? ( + /* Message de succès */ + <> +
+

+ L'avenant a été envoyé avec succès pour signature électronique. +

+ + {/* Détails */} +
+
+ Email employeur : + {avenantData.employerEmail} +
+
+ Statut : + En attente signature employeur +
+
+
+ + {/* Actions succès */} +
+ +
+ + ) : ( + /* Message initial */ + <> +
+

+ Vous êtes sur le point d'envoyer cet avenant pour signature électronique. +

+ + {/* Détails */} +
+
+ Contrat : + {avenantData.contractReference} +
+
+ Salarié : + {avenantData.employeeName} +
+
+ Email salarié : + {avenantData.employeeEmail} +
+
+ Email employeur : + {avenantData.employerEmail} +
+
+
+ + {/* Processus */} +
+

Processus de signature

+
    +
  1. + 1 + L'employeur recevra un email avec le lien de signature +
  2. +
  3. + 2 + Après signature de l'employeur, le salarié recevra son lien +
  4. +
  5. + 3 + Vous serez notifié par email une fois toutes les signatures reçues +
  6. +
+
+ + {/* Actions */} +
+ + +
+ + )} +
+
+ ); +} diff --git a/components/staff/avenants/AvenantDetailPageClient.tsx b/components/staff/avenants/AvenantDetailPageClient.tsx new file mode 100644 index 0000000..778c503 --- /dev/null +++ b/components/staff/avenants/AvenantDetailPageClient.tsx @@ -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(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 ( + + {labels[status as keyof typeof labels] || status} + + ); + }; + + 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 ( + + {labels[type as keyof typeof labels] || type} + + ); + }; + + const getElementLabel = (element: string) => { + const labels: Record = { + 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 ( + + + Non envoyé + + ); + } + if (status === 'pending_employer') { + return ( + + + En attente employeur + + ); + } + if (status === 'pending_employee') { + return ( + + + En attente salarié + + ); + } + if (status === 'signed') { + return ( + + + Signé + + ); + } + return null; + }; + + return ( +
+ {/* Header avec bouton retour */} +
+ +
+

+ Avenant {avenant.numero_avenant} +

+

+ Contrat {contract?.contract_number} +

+
+
+ {getStatusBadge(avenant.statut)} + {getTypeBadge(avenant.type_avenant)} + + +
+
+ + {/* Informations générales */} +
+

+ + Informations générales +

+
+
+ +
{formatDate(avenant.date_effet)}
+
+
+ +
{formatDate(avenant.date_avenant)}
+
+ {avenant.motif_avenant && ( +
+ +
{avenant.motif_avenant}
+
+ )} +
+
+ + {/* Informations du contrat */} +
+

+ + Contrat concerné +

+
+
+ +
+ {contract?.employee_name} + {contract?.employee_matricule && ( + ({contract.employee_matricule}) + )} +
+
+
+ +
{contract?.structure}
+
+
+ +
{contract?.type_de_contrat}
+
+
+ +
+ {formatDate(contract?.start_date)} - {formatDate(contract?.end_date)} +
+
+
+
+ + {/* Signatures électroniques */} +
+

+ + Signatures électroniques +

+
+
+
+
Statut global
+
État actuel du processus de signature
+
+
+ {getSignatureStatusBadge(avenant.signature_status)} +
+
+ +
+
+
+
Employeur
+ {avenant.signature_status === 'pending_employee' || avenant.signature_status === 'signed' ? ( + + ) : avenant.signature_status === 'pending_employer' ? ( + + ) : ( + + )} +
+ {avenant.last_employer_notification_at && ( +
+ Notifié le {new Date(avenant.last_employer_notification_at).toLocaleDateString('fr-FR')} +
+ )} +
+ +
+
+
Salarié
+ {avenant.signature_status === 'signed' ? ( + + ) : avenant.signature_status === 'pending_employee' || avenant.signature_status === 'pending_employer' ? ( + + ) : ( + + )} +
+ {avenant.last_employee_notification_at && ( +
+ Notifié le {new Date(avenant.last_employee_notification_at).toLocaleDateString('fr-FR')} +
+ )} +
+
+
+
+ + {/* Éléments modifiés */} +
+

Éléments modifiés

+
+ {avenant.elements_avenantes?.map((element: string) => ( +
+

{getElementLabel(element)}

+ + {element === "objet" && avenant.objet_data && ( +
+ {avenant.objet_data.profession_label && ( +
Profession : {avenant.objet_data.profession_label}
+ )} + {avenant.objet_data.production_name && ( +
Production : {avenant.objet_data.production_name}
+ )} + {avenant.objet_data.production_numero_objet && ( +
N° objet : {avenant.objet_data.production_numero_objet}
+ )} +
+ )} + + {element === "duree" && avenant.duree_data && ( +
+ {avenant.duree_data.date_debut && ( +
Nouvelle date de début : {formatDate(avenant.duree_data.date_debut)}
+ )} + {avenant.duree_data.date_fin && ( +
Nouvelle date de fin : {formatDate(avenant.duree_data.date_fin)}
+ )} + + {/* Pour les artistes */} + {avenant.duree_data.nb_representations !== undefined && avenant.duree_data.nb_representations > 0 && ( +
Représentations : {avenant.duree_data.nb_representations}
+ )} + {avenant.duree_data.nb_repetitions !== undefined && avenant.duree_data.nb_repetitions > 0 && ( +
Répétitions : {avenant.duree_data.nb_repetitions}
+ )} + + {/* Pour les techniciens */} + {avenant.duree_data.nb_heures !== undefined && avenant.duree_data.nb_heures > 0 && ( +
Nombre d'heures : {avenant.duree_data.nb_heures}h
+ )} + + {/* Dates de travail */} + {Array.isArray(avenant.duree_data.dates_representations) && avenant.duree_data.dates_representations.length > 0 && ( +
+
Dates de représentations :
+
+ {avenant.duree_data.dates_representations.map((d: any, idx: number) => ( +
+ {formatDate(d.date)} + {d.quantity && d.quantity > 0 && ( + ({d.quantity}) + )} +
+ ))} +
+
+ )} + {Array.isArray(avenant.duree_data.dates_repetitions) && avenant.duree_data.dates_repetitions.length > 0 && ( +
+
Dates de répétitions :
+
+ {avenant.duree_data.dates_repetitions.map((d: any, idx: number) => ( +
+ {formatDate(d.date)} + {d.quantity && d.quantity > 0 && ( + ({d.quantity}) + )} +
+ ))} +
+
+ )} + + {/* Jours de travail (techniciens) */} + {avenant.duree_data.jours_travail && ( +
+
Jours de travail :
+ {Array.isArray(avenant.duree_data.jours_travail) ? ( +
+ {avenant.duree_data.jours_travail.map((d: any, idx: number) => ( +
+ {formatDate(d.date)} + {d.quantity && d.quantity > 0 && ( + ({d.quantity}h) + )} +
+ ))} +
+ ) : ( +
{avenant.duree_data.jours_travail}
+ )} +
+ )} +
+ )} + + {element === "lieu_horaire" && avenant.lieu_horaire_data && ( +
+ {avenant.lieu_horaire_data.lieu && ( +
Lieu : {avenant.lieu_horaire_data.lieu}
+ )} +
+ )} + + {element === "remuneration" && avenant.remuneration_data && ( +
+ {avenant.remuneration_data.gross_pay && ( +
Salaire brut : {avenant.remuneration_data.gross_pay} €
+ )} + {avenant.remuneration_data.type_salaire && ( +
Type : {avenant.remuneration_data.type_salaire}
+ )} + {avenant.remuneration_data.precisions_salaire && ( +
Précisions : {avenant.remuneration_data.precisions_salaire}
+ )} +
+ )} +
+ ))} +
+
+ + {/* PDF */} +
+

Document signé

+
+
+ +
+
+
+ {avenant.pdf_s3_key ? `Avenant ${avenant.numero_avenant}.pdf` : "PDF non généré"} +
+
+ {avenant.pdf_s3_key ? "Document stocké sur AWS S3" : "Aucun document disponible"} +
+ {avenant.pdf_s3_key && ( +
+ {avenant.pdf_s3_key} +
+ )} +
+
+ {loadingPdf ? ( +
+ Chargement... +
+ ) : pdfUrl ? ( + <> + + + Télécharger + + + + ) : ( + + )} +
+
+
+ + {/* Actions */} +
+
+ + {avenant.statut === "draft" && ( + <> + + + + )} +
+
+ + {/* Modale de suppression */} + setShowDeleteModal(false)} + onConfirm={handleDelete} + numeroAvenant={avenant.numero_avenant} + isDeleting={isDeleting} + /> + + {/* Modale d'envoi en signature */} + { + 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 */} + setShowChangeStatusModal(false)} + onConfirm={handleChangeStatus} + currentStatus={avenant.statut} + isChanging={isChangingStatus} + numeroAvenant={avenant.numero_avenant} + /> +
+ ); +} diff --git a/lib/emailTemplateService.ts b/lib/emailTemplateService.ts index 1936a4f..25f977b 100644 --- a/lib/emailTemplateService.ts +++ b/lib/emailTemplateService.ts @@ -34,6 +34,7 @@ export type EmailTypeV2 = | 'signature-request' | 'signature-request-employer' | '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) | 'bulk-signature-notification' // Nouveau type pour notification de signatures en masse | 'salary-transfer-notification' // Nouveau type pour notification d'appel à virement @@ -637,7 +638,7 @@ const EMAIL_TEMPLATES_V2: Record = { greeting: '{{#if firstName}}Bonjour {{firstName}},{{/if}}', mainMessage: 'Un document nécessite votre signature électronique en tant qu’employeur.', 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 qu’employeur', colors: { headerColor: STANDARD_COLORS.HEADER, @@ -699,6 +700,41 @@ const EMAIL_TEMPLATES_V2: Record = { } }, + '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.

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 à paie@odentas.fr.', + 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': { subject: 'Signez votre contrat {{organizationName}}', title: 'Demande de signature électronique', diff --git a/supabase/migrations/20251023_add_avenants_notification_columns.sql b/supabase/migrations/20251023_add_avenants_notification_columns.sql new file mode 100644 index 0000000..099b9a6 --- /dev/null +++ b/supabase/migrations/20251023_add_avenants_notification_columns.sql @@ -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); diff --git a/supabase/migrations/20251023_create_avenants_table.sql b/supabase/migrations/20251023_create_avenants_table.sql new file mode 100644 index 0000000..abab173 --- /dev/null +++ b/supabase/migrations/20251023_create_avenants_table.sql @@ -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'; diff --git a/templates-mails/signature-avenant-employeur.html b/templates-mails/signature-avenant-employeur.html new file mode 100644 index 0000000..ab9d159 --- /dev/null +++ b/templates-mails/signature-avenant-employeur.html @@ -0,0 +1,124 @@ + + + + + Demande de signature électronique - avenant + + + + + +
Demande de signature électronique - avenant · Signez votre avenant au contrat de travail
+ +
+
+
+ + +
+
+

Demande de signature électronique

+
+ + +
+
+
Votre structure
+
{{structure}}
+
+
+
Votre code employeur
+
{{code_employeur}}
+
+
+
Votre gestionnaire
+
Renaud BREVIERE-ABRAHAM
+
+
+ +
+

👋 Bonjour {{prenom_signataire}},

+

Nous vous invitons à signer l'avenant au contrat de travail ci-dessous.

+

Cliquez sur "Signer l'avenant" pour accéder à Odentas Sign.

+

Votre salarié·e recevra ensuite son exemplaire pour signature. Vous recevrez une notification par e-mail dès réception de toutes les signatures.

+
+
+ + + + + +
+

Détails de l'avenant

+
+
Référence contrat
+
{{reference}}
+
+
+
Numéro avenant
+
{{numero_avenant}}
+
+
+
Type de contrat
+
CDDU (contrat intermittent)
+
+
+
Salarié·e
+
{{salarie}}
+
+
+
Début contrat
+
{{date_debut}}
+
+
+
Poste
+
{{poste}}
+
+
+
Analytique
+
{{analytique}}
+
+
+ + +
+

N'hésitez pas à répondre à cet e-mail si vous avez besoin d'assistance.

+

Merci pour votre confiance,

+

L'équipe Odentas

+
+ + +
+ Odentas +

Odentas Media SAS | RCS Paris 907880348

+

6 rue d'Armaillé, 75017 Paris

+

+ paie@odentas.fr · + Accès à l'Espace Paie +

+

+ 🇫🇷 Vos documents sont stockés dans un datacenter AWS à Paris +

+

+ 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. +

+
+
+
+ + diff --git a/types/amendments.ts b/types/amendments.ts index c8c2a54..8e6ac94 100644 --- a/types/amendments.ts +++ b/types/amendments.ts @@ -70,6 +70,11 @@ export interface Amendment { lieu_horaire_data?: AmendmentLieuHoraireData; 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 created_at?: string; updated_at?: string;
Type Référence Salarié Matricule
+ + {docType} + + { e.preventDefault(); openEmbed(`/contrats/${rec.id}`, `Contrat · ${ref}`); }} + href={urlPath} + onClick={(e)=>{ e.preventDefault(); openEmbed(urlPath, docLabel); }} className="hover:underline" >{ref} { if (!mat) return; e.preventDefault(); openEmbed(`/salaries/${mat}`, `Salarié · ${nom}`); }} - className={classNames('hover:underline', !mat && 'pointer-events-none opacity-60')} + onClick={(e)=>{ if (!mat || mat === '—') return; e.preventDefault(); openEmbed(`/salaries/${mat}`, `Salarié · ${nom}`); }} + className={classNames('hover:underline', (!mat || mat === '—') && 'pointer-events-none opacity-60')} >{nom} {mat} Date d'effet + Signé + Statut
{amendment.contract_number || "-"} {formatDate(amendment.date_effet)} + {getSignatureIcons(amendment.signature_status)} + {getStatusBadge(amendment.status)} -