feat: Système complet de gestion des avenants avec signatures électroniques
✨ Nouvelles fonctionnalités - Page de gestion des avenants (/staff/avenants) - Page de détail d'un avenant (/staff/avenants/[id]) - Création d'avenants (objet, durée, rémunération) - Génération automatique de PDF d'avenant - Signature électronique via DocuSeal (employeur puis salarié) - Changement manuel du statut d'un avenant - Suppression d'avenants 🔧 Routes API - POST /api/staff/amendments/create - Créer un avenant - POST /api/staff/amendments/generate-pdf - Générer le PDF - POST /api/staff/amendments/[id]/send-signature - Envoyer en signature - POST /api/staff/amendments/[id]/change-status - Changer le statut - POST /api/webhooks/docuseal-amendment - Webhook après signature employeur - GET /api/signatures-electroniques/avenants - Liste des avenants en signature 📧 Système email universel v2 - Migration vers le système universel v2 pour les emails d'avenants - Template 'signature-request-employee-amendment' pour salariés - Insertion automatique dans DynamoDB pour la Lambda - Mise à jour automatique du statut dans Supabase 🗄️ Base de données - Table 'avenants' avec tous les champs (objet, durée, rémunération) - Colonnes de notification (last_employer_notification_at, last_employee_notification_at) - Liaison avec cddu_contracts 🎨 Composants - AvenantDetailPageClient - Détail complet d'un avenant - ChangeStatusModal - Changement de statut manuel - SendSignatureModal - Envoi en signature - DeleteAvenantModal - Suppression avec confirmation - AvenantSuccessModal - Confirmation de création 📚 Documentation - AVENANT_EMAIL_SYSTEM_MIGRATION.md - Guide complet de migration 🐛 Corrections - Fix parsing défensif dans Lambda AWS - Fix récupération des données depuis DynamoDB - Fix statut MFA !== 'verified' au lieu de === 'unverified'
This commit is contained in:
parent
34b3464132
commit
5b72941777
29 changed files with 3609 additions and 235 deletions
232
AVENANT_EMAIL_SYSTEM_MIGRATION.md
Normal file
232
AVENANT_EMAIL_SYSTEM_MIGRATION.md
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
# Migration du système d'envoi d'email pour les avenants
|
||||
|
||||
## 📋 Résumé des changements
|
||||
|
||||
### Problèmes identifiés
|
||||
1. ❌ La Lambda AWS envoyait les emails directement via SES avec un template HTML codé en dur
|
||||
2. ❌ Pas d'utilisation du système universel v2 pour les emails d'avenants
|
||||
3. ❌ Pas de mise à jour du statut de l'avenant dans Supabase après signature employeur
|
||||
4. ❌ Logging dans Airtable au lieu du système centralisé
|
||||
|
||||
### Solutions implémentées
|
||||
✅ Création d'une route API Next.js `/api/webhooks/docuseal-amendment`
|
||||
✅ Migration vers le système universel v2 pour les emails
|
||||
✅ Mise à jour automatique du statut de l'avenant dans Supabase
|
||||
✅ Logging centralisé via `emailLoggingService`
|
||||
✅ Simplification de la Lambda (appel API au lieu de logique métier)
|
||||
|
||||
## 🔄 Nouveau flux
|
||||
|
||||
### Avant
|
||||
```
|
||||
DocuSeal Webhook
|
||||
↓
|
||||
Lambda postDocuSealAvenantSalarie
|
||||
↓
|
||||
AWS SES (email HTML codé en dur)
|
||||
↓
|
||||
AWS S3 (stockage email)
|
||||
↓
|
||||
Airtable (logging)
|
||||
```
|
||||
|
||||
### Après
|
||||
```
|
||||
DocuSeal Webhook
|
||||
↓
|
||||
Lambda postDocuSealAvenantSalarie
|
||||
↓
|
||||
API Next.js /api/webhooks/docuseal-amendment
|
||||
↓
|
||||
1. Mise à jour Supabase (statut avenant)
|
||||
↓
|
||||
2. Système universel v2 (email)
|
||||
↓
|
||||
3. AWS SES (envoi)
|
||||
↓
|
||||
4. emailLoggingService (logging centralisé)
|
||||
```
|
||||
|
||||
## 📁 Fichiers modifiés
|
||||
|
||||
### 1. Lambda AWS
|
||||
**Fichier** : `/tmp/aws-toolkit-vscode/lambda/eu-west-3/postDocuSealAvenantSalarie/index.js`
|
||||
|
||||
**Changements** :
|
||||
- ✅ Ajout du parsing défensif pour `event.body`
|
||||
- ✅ Appel à l'API Next.js au lieu d'envoyer l'email directement
|
||||
- ✅ Suppression des fonctions `sendSignatureEmail()`, `uploadEmailToS3()`, `logToAirtable()`, `findRecordIdByField()`, `generateEmailHtml()`
|
||||
- ✅ Utilisation de la variable d'environnement `NEXT_API_URL`
|
||||
|
||||
### 2. Route `/api/staff/amendments/[id]/send-signature` (MODIFIÉ)
|
||||
**Fichier** : `/Users/renaud/Projet Nouvel Espace Paie/app/api/staff/amendments/[id]/send-signature/route.ts`
|
||||
|
||||
**Changement critique** :
|
||||
- ✅ **Insertion des données dans DynamoDB** avant la création de la soumission DocuSeal
|
||||
- ✅ Clé primaire : `avenant.numero_avenant` (ex: AVE-001)
|
||||
- ✅ Données stockées : email salarié, référence contrat, nom, dates, poste, etc.
|
||||
|
||||
**Pourquoi** : La Lambda a besoin de ces données pour appeler l'API Next.js après la signature de l'employeur.
|
||||
|
||||
### 3. Route API Next.js (NOUVEAU)
|
||||
**Fichier** : `/Users/renaud/Projet Nouvel Espace Paie/app/api/webhooks/docuseal-amendment/route.ts`
|
||||
|
||||
**Fonctionnalités** :
|
||||
- ✅ Récupération de l'avenant par son numéro
|
||||
- ✅ Mise à jour du statut : `signature_status = "pending_employee"`
|
||||
- ✅ Envoi de l'email via `sendUniversalEmailV2` avec le type `signature-request-employee-amendment`
|
||||
- ✅ Logging automatique via `emailLoggingService`
|
||||
- ✅ Gestion d'erreurs complète avec logs détaillés
|
||||
|
||||
## 🎨 Template email utilisé
|
||||
|
||||
**Type** : `signature-request-employee-amendment`
|
||||
**Système** : Universel v2 (`templates-mails/universal-template.html`)
|
||||
**Couleurs** : Standards Odentas (header #171424, bouton #efc543)
|
||||
|
||||
**Contenu** :
|
||||
- Greeting personnalisé avec prénom du salarié
|
||||
- Message explicatif sur la signature de l'avenant
|
||||
- Bouton CTA "Signer l'avenant" avec lien DocuSeal
|
||||
- Carte d'info : employeur + matricule
|
||||
- Carte détails : référence contrat, type, profession, date début, production
|
||||
- Footer légal standard
|
||||
|
||||
## 🚀 Déploiement
|
||||
|
||||
### Étape 1 : Déployer Next.js sur Vercel
|
||||
```bash
|
||||
cd "/Users/renaud/Projet Nouvel Espace Paie"
|
||||
git add app/api/webhooks/docuseal-amendment/route.ts
|
||||
git commit -m "feat: Ajouter route webhook avenant avec système universel v2"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### Étape 2 : Mettre à jour la Lambda AWS
|
||||
1. Ouvrir AWS Lambda Console
|
||||
2. Aller dans `postDocuSealAvenantSalarie`
|
||||
3. Copier le contenu de `/tmp/aws-toolkit-vscode/lambda/eu-west-3/postDocuSealAvenantSalarie/index.js`
|
||||
4. Ajouter la variable d'environnement :
|
||||
- `NEXT_API_URL` = `https://paie.odentas.fr` (production)
|
||||
- ou `https://staging.paie.odentas.fr` (staging)
|
||||
5. Déployer la Lambda
|
||||
|
||||
### Étape 3 : Tester le flux complet
|
||||
1. Créer un avenant test dans `/staff/avenants/nouveau`
|
||||
2. Envoyer en signature à l'employeur
|
||||
3. Signer en tant qu'employeur via DocuSeal
|
||||
4. Vérifier dans les logs :
|
||||
- Lambda CloudWatch : appel API Next.js
|
||||
- Vercel : route webhook appelée
|
||||
- Supabase : statut avenant mis à jour
|
||||
- Email reçu par le salarié (boîte mail)
|
||||
|
||||
## 📊 Vérifications
|
||||
|
||||
### Dans Supabase
|
||||
```sql
|
||||
-- Vérifier le statut de l'avenant
|
||||
SELECT
|
||||
numero_avenant,
|
||||
signature_status,
|
||||
last_employee_notification_at
|
||||
FROM avenants
|
||||
WHERE numero_avenant = 'AVE-XXX';
|
||||
```
|
||||
|
||||
**Attendu** : `signature_status = 'pending_employee'` après signature employeur
|
||||
|
||||
### Dans les logs Vercel
|
||||
Chercher : `[WEBHOOK AVENANT]`
|
||||
- ✅ Avenant trouvé
|
||||
- ✅ Statut mis à jour
|
||||
- ✅ Email envoyé
|
||||
|
||||
### Dans email_logs (Supabase)
|
||||
```sql
|
||||
-- Vérifier l'envoi de l'email
|
||||
SELECT
|
||||
email_type,
|
||||
to_emails,
|
||||
email_status,
|
||||
created_at
|
||||
FROM email_logs
|
||||
WHERE email_type = 'signature-request-employee-amendment'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
## ⚙️ Variables d'environnement requises
|
||||
|
||||
### Lambda AWS
|
||||
- `DOCUSEAL_API_TOKEN` - Token API DocuSeal
|
||||
- `NEXT_API_URL` - URL de l'API Next.js (ex: `https://paie.odentas.fr`)
|
||||
- AWS credentials (automatique via IAM Role)
|
||||
|
||||
### Next.js (Vercel)
|
||||
- `DOCUSEAL_TOKEN` - Token API DocuSeal
|
||||
- `NEXT_PUBLIC_SUPABASE_URL` - URL Supabase
|
||||
- `SUPABASE_SERVICE_ROLE_KEY` - Service role key
|
||||
- `AWS_REGION` - Région AWS (eu-west-3)
|
||||
- `AWS_ACCESS_KEY_ID` - Credentials AWS SES
|
||||
- `AWS_SECRET_ACCESS_KEY` - Credentials AWS SES
|
||||
|
||||
## 🐛 Debugging
|
||||
|
||||
### Si l'email n'est pas envoyé
|
||||
1. **Vérifier DynamoDB** : Les données sont-elles présentes pour le numéro d'avenant ?
|
||||
```bash
|
||||
aws dynamodb get-item \
|
||||
--table-name DocuSealNotification \
|
||||
--key '{"submission_id":{"S":"AVE-001"}}'
|
||||
```
|
||||
2. Vérifier les logs Lambda CloudWatch : `postDocuSealAvenantSalarie`
|
||||
3. Vérifier les logs Vercel : `/api/webhooks/docuseal-amendment`
|
||||
4. Vérifier `email_logs` dans Supabase
|
||||
5. Vérifier que le type `signature-request-employee-amendment` existe dans `emailTemplateService.ts`
|
||||
|
||||
### Si les données ne sont pas dans DynamoDB
|
||||
- **Cause** : Échec de l'insertion lors de l'envoi en signature
|
||||
- **Solution** : Vérifier les credentials AWS dans les variables d'environnement Vercel
|
||||
- **Vérifier** : Logs de `/api/staff/amendments/[id]/send-signature` pour l'erreur DynamoDB
|
||||
|
||||
### Si le statut n'est pas mis à jour
|
||||
1. Vérifier que l'avenant existe avec le bon `numero_avenant`
|
||||
2. Vérifier les permissions Supabase (service role)
|
||||
3. Vérifier les logs de la route webhook
|
||||
|
||||
### Si la Lambda échoue
|
||||
1. Vérifier `event.body` dans les logs CloudWatch
|
||||
2. Vérifier que `NEXT_API_URL` est définie
|
||||
3. Vérifier que l'API Next.js est accessible (pas de firewall/CORS)
|
||||
|
||||
## 📝 Notes importantes
|
||||
|
||||
- ⚠️ **Ne pas supprimer** la table DynamoDB `DocuSealNotification` (encore utilisée par la Lambda)
|
||||
- ⚠️ **Ne pas supprimer** les fonctions Airtable dans la Lambda (encore dans le code mais non utilisées)
|
||||
- ✅ Le logging Airtable est remplacé par `emailLoggingService`
|
||||
- ✅ Le stockage S3 des emails est remplacé par le logging centralisé
|
||||
- ✅ Les emails utilisent maintenant le template universel v2 cohérent avec le reste de l'application
|
||||
|
||||
## 🎯 Prochaines étapes (optionnel)
|
||||
|
||||
1. Migrer complètement vers webhook DocuSeal direct (sans Lambda)
|
||||
2. Supprimer DynamoDB et Airtable du flux
|
||||
3. Ajouter un webhook pour la signature du salarié (mise à jour finale du statut)
|
||||
4. Ajouter des tests automatisés pour le flux complet
|
||||
|
||||
## ✅ Checklist de déploiement
|
||||
|
||||
- [ ] Code Next.js déployé sur Vercel
|
||||
- [ ] Lambda mise à jour avec nouveau code
|
||||
- [ ] Variable `NEXT_API_URL` ajoutée à la Lambda
|
||||
- [ ] Test complet du flux (création → signature employeur → email salarié)
|
||||
- [ ] Vérification dans Supabase (statut mis à jour)
|
||||
- [ ] Vérification email reçu avec bon template
|
||||
- [ ] Logs vérifiés (Lambda + Vercel + email_logs)
|
||||
|
||||
---
|
||||
|
||||
**Date de migration** : 23 octobre 2025
|
||||
**Auteur** : GitHub Copilot
|
||||
**Statut** : ✅ Prêt pour déploiement
|
||||
|
|
@ -164,6 +164,12 @@ export default function SignaturesElectroniques() {
|
|||
const recordsEmployeurRef = useRef<AirtableRecord[]>([]);
|
||||
const recordsSalarieRef = useRef<AirtableRecord[]>([]);
|
||||
|
||||
// États pour les avenants
|
||||
const [avenantsEmployeur, setAvenantsEmployeur] = useState<AirtableRecord[]>([]);
|
||||
const [avenantsSalarie, setAvenantsSalarie] = useState<AirtableRecord[]>([]);
|
||||
const avenantsEmployeurRef = useRef<AirtableRecord[]>([]);
|
||||
const avenantsSalarieRef = useRef<AirtableRecord[]>([]);
|
||||
|
||||
// États pour les modales
|
||||
const [modalTitle, setModalTitle] = useState('');
|
||||
const [embedSrc, setEmbedSrc] = useState<string | null>(null);
|
||||
|
|
@ -192,10 +198,6 @@ export default function SignaturesElectroniques() {
|
|||
const isMountedRef = useRef(true);
|
||||
const signingContractIdRef = useRef<string | null>(null);
|
||||
|
||||
// Modal de confirmation après signature employeur (DocuSeal 'completed')
|
||||
const [showCompletedModal, setShowCompletedModal] = useState(false);
|
||||
const showCompletedModalRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
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 || [];
|
||||
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;
|
||||
|
||||
setReloadingAfterSignatureChange(true);
|
||||
try {
|
||||
// Remplacer le contenu du modal par le message de confirmation
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="min-h-[500px] flex items-center justify-center p-6 animate-fadeIn">
|
||||
<div class="max-w-lg w-full">
|
||||
<!-- Header -->
|
||||
<div class="p-6 bg-emerald-50 border border-emerald-100 rounded-t-2xl">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-full bg-emerald-100 flex items-center justify-center flex-shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Signature employeur prise en compte</h2>
|
||||
<p class="text-sm text-slate-600 mt-1">Le processus se poursuit automatiquement côté salarié.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 bg-white border-x border-slate-200">
|
||||
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<ul class="text-sm text-slate-700 space-y-2 list-disc pl-5">
|
||||
<li>Le salarié va recevoir son propre exemplaire pour signature électronique.</li>
|
||||
<li>Vous pourrez télécharger le contrat depuis la fiche contrat dès que la signature du salarié aura été reçue.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-6 py-4 bg-gray-50 border border-t-0 border-slate-200 rounded-b-2xl flex justify-end">
|
||||
<button
|
||||
id="btn-close-confirmation"
|
||||
class="px-6 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
J'ai compris
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Ajouter l'événement de fermeture
|
||||
const btnClose = container.querySelector('#btn-close-confirmation') as HTMLButtonElement | null;
|
||||
if (btnClose) {
|
||||
btnClose.onclick = () => {
|
||||
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
|
||||
if (dlg && !dlg.open) {
|
||||
try { dlg.showModal(); } catch (err) { console.warn('Impossible de ré-ouvrir le modal DocuSeal', err); }
|
||||
if (dlg) dlg.close();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setReloadingAfterSignatureChange(true);
|
||||
try {
|
||||
// Laisser le temps au webhook DocuSeal de mettre à jour Supabase puis recharger avec retries
|
||||
await new Promise((resolve) => setTimeout(resolve, 900));
|
||||
await loadWithRetry({
|
||||
|
|
@ -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
|
||||
// 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] Signature trouvée, stockage dans l\'état React');
|
||||
const normalizedSignature = normalizeSignatureFormat(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() {
|
|||
<div className="text-slate-500">Chargement…</div>
|
||||
) : error ? (
|
||||
<div className="text-red-600">Erreur: {error}</div>
|
||||
) : (recordsEmployeur.length + recordsSalarie.length) === 0 ? (
|
||||
<div className="text-slate-500">Aucun contrat à signer.</div>
|
||||
) : (recordsEmployeur.length + recordsSalarie.length + avenantsEmployeur.length + avenantsSalarie.length) === 0 ? (
|
||||
<div className="text-slate-500">Aucun document à signer.</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Table 1: employeur pending */}
|
||||
{/* Table 1: employeur pending (contrats + avenants) */}
|
||||
<div className="rounded-xl border overflow-hidden shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-2 border-b bg-white/60 backdrop-blur supports-[backdrop-filter]:bg-white/60[backdrop-filter]:bg-slate-900/60">
|
||||
<div className="text-sm font-medium text-slate-700">Contrats en attente de signature employeur</div>
|
||||
<div className="text-xs text-slate-500">{recordsEmployeur.length} élément(s)</div>
|
||||
<div className="text-sm font-medium text-slate-700">Documents en attente de signature employeur</div>
|
||||
<div className="text-xs text-slate-500">{recordsEmployeur.length + avenantsEmployeur.length} élément(s)</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-slate-500 border-b bg-slate-50/60">
|
||||
<th className="px-3 py-2 font-medium whitespace-nowrap">Type</th>
|
||||
<th className="px-3 py-2 font-medium whitespace-nowrap">Référence</th>
|
||||
<th className="px-3 py-2 font-medium whitespace-nowrap">Salarié</th>
|
||||
<th className="px-3 py-2 font-medium whitespace-nowrap">Matricule</th>
|
||||
|
|
@ -1094,26 +1200,39 @@ export default function SignaturesElectroniques() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={rec.id} className={classNames(idx % 2 ? 'bg-white' : 'bg-slate-50/30', 'hover:bg-slate-50 transition-colors')}>
|
||||
<td className="px-3 py-2 text-slate-700 whitespace-nowrap">
|
||||
<span className={classNames(
|
||||
'inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border font-medium',
|
||||
isAvenant ? 'border-purple-200 text-purple-700 bg-purple-50' : 'border-blue-200 text-blue-700 bg-blue-50'
|
||||
)}>
|
||||
{docType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium text-slate-900 whitespace-nowrap">
|
||||
<a
|
||||
href={`/contrats/${encodeURIComponent(rec.id)}`}
|
||||
onClick={(e)=>{ e.preventDefault(); openEmbed(`/contrats/${rec.id}`, `Contrat · ${ref}`); }}
|
||||
href={urlPath}
|
||||
onClick={(e)=>{ e.preventDefault(); openEmbed(urlPath, docLabel); }}
|
||||
className="hover:underline"
|
||||
>{ref}</a>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-700 max-w-[260px] truncate" title={nom}>
|
||||
<a
|
||||
href={mat ? `/salaries/${encodeURIComponent(mat)}` : '#'}
|
||||
onClick={(e)=>{ if (!mat) 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}</a>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-700 whitespace-nowrap">{mat}</td>
|
||||
|
|
@ -1130,7 +1249,7 @@ export default function SignaturesElectroniques() {
|
|||
className="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium hover:bg-slate-50 disabled:opacity-60"
|
||||
disabled={isSigned}
|
||||
onClick={() => openSignature(rec)}
|
||||
aria-label={`Signer le contrat ${ref}`}
|
||||
aria-label={`Signer ${isAvenant ? "l'avenant" : "le contrat"} ${ref}`}
|
||||
>
|
||||
<FileSignature className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
Signer
|
||||
|
|
@ -1144,16 +1263,17 @@ export default function SignaturesElectroniques() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table 2: salarie pending */}
|
||||
{/* Table 2: salarie pending (contrats + avenants) */}
|
||||
<div className="rounded-xl border overflow-hidden shadow-sm mt-8">
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-2 border-b bg-white/60 backdrop-blur supports-[backdrop-filter]:bg-white/60[backdrop-filter]:bg-slate-900/60">
|
||||
<div className="text-sm font-medium text-slate-700">Contrats en attente de signature salarié</div>
|
||||
<div className="text-xs text-slate-500">{recordsSalarie.length} élément(s)</div>
|
||||
<div className="text-sm font-medium text-slate-700">Documents en attente de signature salarié</div>
|
||||
<div className="text-xs text-slate-500">{recordsSalarie.length + avenantsSalarie.length} élément(s)</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-slate-500 border-b bg-slate-50/60">
|
||||
<th className="px-3 py-2 font-medium whitespace-nowrap">Type</th>
|
||||
<th className="px-3 py-2 font-medium whitespace-nowrap">Référence</th>
|
||||
<th className="px-3 py-2 font-medium whitespace-nowrap">Salarié</th>
|
||||
<th className="px-3 py-2 font-medium whitespace-nowrap">Matricule</th>
|
||||
|
|
@ -1162,25 +1282,38 @@ export default function SignaturesElectroniques() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={rec.id} className={classNames(idx % 2 ? 'bg-white' : 'bg-slate-50/30', 'hover:bg-slate-50 transition-colors')}>
|
||||
<td className="px-3 py-2 text-slate-700 whitespace-nowrap">
|
||||
<span className={classNames(
|
||||
'inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border font-medium',
|
||||
isAvenant ? 'border-purple-200 text-purple-700 bg-purple-50' : 'border-blue-200 text-blue-700 bg-blue-50'
|
||||
)}>
|
||||
{docType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium text-slate-900 whitespace-nowrap">
|
||||
<a
|
||||
href={`/contrats/${encodeURIComponent(rec.id)}`}
|
||||
onClick={(e)=>{ e.preventDefault(); openEmbed(`/contrats/${rec.id}`, `Contrat · ${ref}`); }}
|
||||
href={urlPath}
|
||||
onClick={(e)=>{ e.preventDefault(); openEmbed(urlPath, docLabel); }}
|
||||
className="hover:underline"
|
||||
>{ref}</a>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-700 max-w-[260px] truncate" title={nom}>
|
||||
<a
|
||||
href={mat ? `/salaries/${encodeURIComponent(mat)}` : '#'}
|
||||
onClick={(e)=>{ if (!mat) 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}</a>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-700 whitespace-nowrap">{mat}</td>
|
||||
|
|
@ -1261,65 +1394,6 @@ export default function SignaturesElectroniques() {
|
|||
</div>
|
||||
</dialog>
|
||||
|
||||
{/* Modal de confirmation après signature employeur */}
|
||||
{showCompletedModal && (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-lg w-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-6 bg-emerald-50 border-b border-emerald-100">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-emerald-100 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-6 h-6 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Signature employeur prise en compte</h2>
|
||||
<p className="text-sm text-slate-600 mt-1">Le processus se poursuit automatiquement côté salarié.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCompletedModal(false);
|
||||
// Fermer aussi le modal DocuSeal
|
||||
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
|
||||
if (dlg) dlg.close();
|
||||
}}
|
||||
className="ml-4 p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<XCircle className="h-5 w-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<ul className="text-sm text-slate-700 space-y-2 list-disc pl-5">
|
||||
<li>Le salarié va recevoir son propre exemplaire pour signature électronique.</li>
|
||||
<li>Vous pourrez télécharger le contrat depuis la fiche contrat dès que la signature du salarié aura été reçue.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 bg-gray-50 border-t flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCompletedModal(false);
|
||||
// Fermer aussi le modal DocuSeal
|
||||
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
|
||||
if (dlg) dlg.close();
|
||||
}}
|
||||
className="px-6 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
J'ai compris
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de gestion de la signature */}
|
||||
{showSignatureModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
|
|
|
|||
68
app/(app)/staff/avenants/[id]/page.tsx
Normal file
68
app/(app)/staff/avenants/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { createSbServer } from "@/lib/supabaseServer";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import AvenantDetailPageClient from "@/components/staff/avenants/AvenantDetailPageClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AvenantDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const sb = createSbServer();
|
||||
const { data: { user } } = await sb.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const { data: me } = await sb
|
||||
.from("staff_users")
|
||||
.select("is_staff")
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
const isStaff = !!me?.is_staff;
|
||||
if (!isStaff) {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
// Récupérer l'avenant avec les informations du contrat
|
||||
const { data: avenant, error } = await sb
|
||||
.from("avenants")
|
||||
.select(`
|
||||
*,
|
||||
cddu_contracts (
|
||||
*,
|
||||
organizations (
|
||||
id,
|
||||
name,
|
||||
organization_details (
|
||||
email_signature
|
||||
)
|
||||
),
|
||||
salaries (
|
||||
prenom,
|
||||
nom,
|
||||
adresse_mail
|
||||
)
|
||||
)
|
||||
`)
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
console.log("Recherche avenant ID:", id);
|
||||
console.log("Résultat:", { avenant, error });
|
||||
|
||||
if (error || !avenant) {
|
||||
console.error("Erreur ou avenant non trouvé:", error);
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="p-6">
|
||||
<AvenantDetailPageClient avenant={avenant} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<main className="p-6">
|
||||
<StaffAvenantsPageClient initialData={amendments} />
|
||||
<StaffAvenantsPageClient initialData={formattedAvenants} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
239
app/api/emails/signature-avenant-salarie/route.ts
Normal file
239
app/api/emails/signature-avenant-salarie/route.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { sendUniversalEmailV2 } from '@/lib/emailTemplateService';
|
||||
import { ENV } from '@/lib/cleanEnv';
|
||||
import { createSbServiceRole } from '@/lib/supabaseServer';
|
||||
|
||||
/**
|
||||
* POST /api/emails/signature-avenant-salarie
|
||||
*
|
||||
* Route API appelée par la Lambda postDocuSealAvenantSalarie pour envoyer l'email de signature au salarié
|
||||
* Utilise le système universel d'emails v2 avec logging automatique
|
||||
*
|
||||
* Authentification : API Key dans le header X-API-Key
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
console.log('=== API Email Signature Avenant Salarié ===');
|
||||
|
||||
try {
|
||||
// 1. Vérification de l'authentification via API Key
|
||||
const apiKey = request.headers.get('X-API-Key');
|
||||
const validApiKey = ENV.LAMBDA_API_KEY;
|
||||
|
||||
if (!validApiKey) {
|
||||
console.error('❌ Configuration error: LAMBDA_API_KEY not configured');
|
||||
return NextResponse.json(
|
||||
{ error: 'Server configuration error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKey || apiKey !== validApiKey) {
|
||||
console.error('❌ Unauthorized: Invalid or missing API key');
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('✅ Authentication successful');
|
||||
|
||||
// 2. Récupération et validation des données
|
||||
const data = await request.json();
|
||||
console.log('📦 Données reçues:', {
|
||||
employeeEmail: data.employeeEmail,
|
||||
reference: data.reference,
|
||||
structure: data.structure,
|
||||
firstName: data.firstName,
|
||||
matricule: data.matricule,
|
||||
avenantId: data.avenantId
|
||||
});
|
||||
|
||||
const {
|
||||
employeeEmail,
|
||||
signatureLink,
|
||||
reference,
|
||||
firstName: providedFirstName,
|
||||
organizationName: providedOrgName,
|
||||
matricule,
|
||||
typecontrat,
|
||||
startDate,
|
||||
profession,
|
||||
productionName,
|
||||
organizationId,
|
||||
contractId,
|
||||
avenantId,
|
||||
numeroAvenant
|
||||
} = data;
|
||||
|
||||
// Validation des champs requis
|
||||
if (!employeeEmail || !signatureLink || !reference) {
|
||||
console.error('❌ Champs requis manquants');
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Champs manquants',
|
||||
required: ['employeeEmail', 'signatureLink', 'reference']
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Récupération du vrai nom de l'organisation depuis Supabase
|
||||
let organizationName = providedOrgName || 'Employeur';
|
||||
|
||||
if (organizationId || contractId) {
|
||||
console.log('🔍 Récupération du nom de l\'organisation depuis la base de données...');
|
||||
try {
|
||||
const supabase = createSbServiceRole();
|
||||
|
||||
let actualOrgId = organizationId;
|
||||
|
||||
// Si on n'a que le contractId, récupérer l'org_id depuis le contrat
|
||||
if (!actualOrgId && contractId) {
|
||||
const { data: contractData } = await supabase
|
||||
.from('cddu_contracts')
|
||||
.select('org_id')
|
||||
.eq('id', contractId)
|
||||
.single();
|
||||
|
||||
if (contractData?.org_id) {
|
||||
actualOrgId = contractData.org_id;
|
||||
console.log('✅ org_id récupéré depuis le contrat:', actualOrgId);
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer le nom de l'organisation
|
||||
if (actualOrgId) {
|
||||
const { data: orgData, error: orgError } = await supabase
|
||||
.from('organizations')
|
||||
.select('name')
|
||||
.eq('id', actualOrgId)
|
||||
.single();
|
||||
|
||||
if (!orgError && orgData?.name) {
|
||||
organizationName = orgData.name;
|
||||
console.log('✅ Nom de l\'organisation trouvé:', organizationName);
|
||||
} else {
|
||||
console.warn('⚠️ Nom de l\'organisation non trouvé, utilisation du fallback');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('⚠️ Erreur lors de la récupération du nom de l\'organisation:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Récupération du prénom depuis Supabase si non fourni
|
||||
let firstName = providedFirstName;
|
||||
|
||||
if (!firstName && (matricule || employeeEmail)) {
|
||||
console.log('🔍 Récupération du prénom depuis la table salaries...');
|
||||
try {
|
||||
const supabase = createSbServiceRole();
|
||||
|
||||
// Recherche par matricule ou email
|
||||
let query = supabase
|
||||
.from('salaries')
|
||||
.select('prenom')
|
||||
.limit(1);
|
||||
|
||||
if (organizationId) {
|
||||
query = query.eq('employer_id', organizationId);
|
||||
}
|
||||
|
||||
// Priorité au matricule
|
||||
if (matricule) {
|
||||
query = query.or(`code_salarie.eq.${matricule},num_salarie.eq.${matricule}`);
|
||||
} else if (employeeEmail) {
|
||||
query = query.eq('adresse_mail', employeeEmail);
|
||||
}
|
||||
|
||||
const { data: salaryData, error: salaryError } = await query;
|
||||
|
||||
if (!salaryError && salaryData && salaryData[0]?.prenom) {
|
||||
firstName = salaryData[0].prenom;
|
||||
console.log('✅ Prénom trouvé dans Supabase:', firstName);
|
||||
} else {
|
||||
console.warn('⚠️ Prénom non trouvé dans Supabase');
|
||||
firstName = 'Salarié(e)'; // Fallback
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('⚠️ Erreur lors de la récupération du prénom:', err);
|
||||
firstName = 'Salarié(e)'; // Fallback
|
||||
}
|
||||
} else if (!firstName) {
|
||||
firstName = 'Salarié(e)'; // Fallback si aucune donnée disponible
|
||||
}
|
||||
|
||||
// 5. Mise à jour du last_employee_notification_at dans la table avenants
|
||||
if (avenantId) {
|
||||
console.log('🔄 Mise à jour de last_employee_notification_at pour l\'avenant:', avenantId);
|
||||
try {
|
||||
const supabase = createSbServiceRole();
|
||||
const { error: updateError } = await supabase
|
||||
.from('avenants')
|
||||
.update({
|
||||
last_employee_notification_at: new Date().toISOString(),
|
||||
signature_status: 'pending_employee'
|
||||
})
|
||||
.eq('id', avenantId);
|
||||
|
||||
if (updateError) {
|
||||
console.error('❌ Erreur mise à jour last_employee_notification_at:', updateError);
|
||||
} else {
|
||||
console.log('✅ last_employee_notification_at mis à jour');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('⚠️ Erreur lors de la mise à jour:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Envoi de l'email via le système universel v2
|
||||
console.log('📧 Envoi de l\'email via le système universel v2...');
|
||||
|
||||
try {
|
||||
await sendUniversalEmailV2({
|
||||
type: 'signature-request-employee-amendment',
|
||||
toEmail: employeeEmail,
|
||||
subject: `Signature électronique requise – Avenant ${numeroAvenant || reference}`,
|
||||
data: {
|
||||
signatureLink,
|
||||
firstName,
|
||||
organizationName,
|
||||
matricule,
|
||||
contractReference: reference,
|
||||
contractType: typecontrat || 'CDDU',
|
||||
startDate,
|
||||
profession,
|
||||
productionName,
|
||||
numeroAvenant
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✅ Email envoyé avec succès');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Email envoyé avec succès'
|
||||
});
|
||||
|
||||
} catch (emailError: any) {
|
||||
console.error('❌ Erreur lors de l\'envoi de l\'email:', emailError);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Erreur lors de l\'envoi de l\'email',
|
||||
details: emailError.message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('❌ Erreur serveur:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Erreur serveur',
|
||||
details: error.message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
178
app/api/signatures-electroniques/avenants/route.ts
Normal file
178
app/api/signatures-electroniques/avenants/route.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { NextResponse, NextRequest } from 'next/server';
|
||||
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
|
||||
import { cookies } from 'next/headers';
|
||||
import { detectDemoModeFromHeaders } from '@/lib/demo-detector';
|
||||
|
||||
// GET /api/signatures-electroniques/avenants
|
||||
// Retourne les avenants à signer par l'employeur ou salarié depuis Supabase
|
||||
export async function GET(req: NextRequest) {
|
||||
// 🎭 Vérifier le mode démo - retourner des données vides pour sécuriser
|
||||
if (detectDemoModeFromHeaders(req.headers)) {
|
||||
console.log('🎭 [SIGNATURES AVENANTS API] Mode démo détecté - retour de données vides');
|
||||
return NextResponse.json({ records: [] }, { status: 200 });
|
||||
}
|
||||
|
||||
const reqUrl = new URL(req.url);
|
||||
const scope = (reqUrl.searchParams.get('scope') || 'employeur').toLowerCase();
|
||||
const orgIdParam = reqUrl.searchParams.get('org_id'); // Paramètre org_id pour les staff
|
||||
|
||||
// Récupération de l'organisation active
|
||||
const sb = createRouteHandlerClient({ cookies });
|
||||
const { data: { user } } = await sb.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Vérifier staff pour lire la cible via cookie active_org_id ou paramètre org_id
|
||||
let isStaff = false;
|
||||
try {
|
||||
const { data } = await sb.from('staff_users').select('is_staff').eq('user_id', user.id).maybeSingle();
|
||||
isStaff = !!data?.is_staff;
|
||||
} catch {}
|
||||
|
||||
let orgId: string | null = null;
|
||||
try {
|
||||
if (isStaff) {
|
||||
// Si un org_id est fourni en paramètre, l'utiliser (priorité pour les staff)
|
||||
if (orgIdParam) {
|
||||
orgId = orgIdParam;
|
||||
} else {
|
||||
// Sinon utiliser le cookie active_org_id
|
||||
const c = cookies();
|
||||
orgId = c.get('active_org_id')?.value || null;
|
||||
}
|
||||
} else {
|
||||
const { data, error } = await sb
|
||||
.from('organization_members')
|
||||
.select('org_id')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
if (error || !data?.org_id) {
|
||||
return NextResponse.json({ error: 'no_active_org' }, { status: 403 });
|
||||
}
|
||||
orgId = data.org_id;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Si pas d'organisation trouvée :
|
||||
// - pour les utilisateurs normaux on retourne une erreur
|
||||
// - pour le staff on permet l'accès global
|
||||
if (!orgId && !isStaff) {
|
||||
return NextResponse.json({ error: 'no_active_org' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Construction de la requête Supabase
|
||||
try {
|
||||
// D'abord récupérer les avenants sans filtrer par org_id
|
||||
let query = sb
|
||||
.from('avenants')
|
||||
.select(`
|
||||
id,
|
||||
contract_id,
|
||||
type_avenant,
|
||||
statut,
|
||||
signature_status,
|
||||
docuseal_submission_id,
|
||||
pdf_url,
|
||||
created_at
|
||||
`);
|
||||
|
||||
// Filtrer selon le scope demandé
|
||||
if (scope === 'salarie') {
|
||||
// Avenants en attente de signature du salarié
|
||||
query = query.eq('signature_status', 'pending_employee');
|
||||
} else {
|
||||
// Avenants en attente de signature de l'employeur (scope = 'employeur')
|
||||
query = query.eq('signature_status', 'pending_employer');
|
||||
}
|
||||
|
||||
// Trier par date de création décroissante
|
||||
query = query.order('created_at', { ascending: false });
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) {
|
||||
console.error('❌ [signatures-avenants] Erreur Supabase:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
console.log(`🔍 [signatures-avenants] Requête pour scope: ${scope}, orgId: ${orgId || 'null'}`);
|
||||
console.log(`📊 [signatures-avenants] Avenants trouvés (avant filtrage org): ${data?.length || 0}`);
|
||||
|
||||
// Récupérer les données des contrats séparément (avec org_id)
|
||||
const contractIds = data?.map((a: any) => a.contract_id).filter(Boolean) || [];
|
||||
console.log(`🔍 [signatures-avenants] Contract IDs à récupérer: ${contractIds.length}`);
|
||||
|
||||
let contractsData: any = {};
|
||||
|
||||
if (contractIds.length > 0) {
|
||||
let contractQuery = sb
|
||||
.from('cddu_contracts')
|
||||
.select('id, reference, employee_name, employee_matricule, production_name, org_id')
|
||||
.in('id', contractIds);
|
||||
|
||||
// Filtrer par org_id si nécessaire
|
||||
if (orgId) {
|
||||
contractQuery = contractQuery.eq('org_id', orgId);
|
||||
}
|
||||
|
||||
const { data: contracts, error: contractsError } = await contractQuery;
|
||||
|
||||
if (contractsError) {
|
||||
console.error('❌ [signatures-avenants] Erreur récupération contrats:', contractsError);
|
||||
} else {
|
||||
console.log(`✅ [signatures-avenants] Contrats récupérés: ${contracts?.length || 0}`);
|
||||
if (contracts) {
|
||||
contractsData = contracts.reduce((acc: any, c: any) => {
|
||||
acc[c.id] = c;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data && data.length > 0) {
|
||||
console.log('📋 [signatures-avenants] Échantillon:', data.slice(0, 3).map((a: any) => ({
|
||||
id: a.id,
|
||||
statut: a.statut,
|
||||
signature_status: a.signature_status,
|
||||
type_avenant: a.type_avenant,
|
||||
contract_id: a.contract_id
|
||||
})));
|
||||
}
|
||||
|
||||
// Transformer les données au format compatible avec la page
|
||||
// Filtrer uniquement les avenants dont le contrat a été trouvé (et correspond à l'org si filtrage)
|
||||
const records = (data || [])
|
||||
.filter((avenant: any) => contractsData[avenant.contract_id]) // Ne garder que les avenants avec contrat valide
|
||||
.map((avenant: any) => {
|
||||
const contract = contractsData[avenant.contract_id];
|
||||
return {
|
||||
id: avenant.id,
|
||||
fields: {
|
||||
id: avenant.id,
|
||||
reference: contract.reference || 'N/A',
|
||||
employee_name: contract.employee_name || 'N/A',
|
||||
employee_matricule: contract.employee_matricule || '',
|
||||
production_name: contract.production_name || '',
|
||||
type_avenant: avenant.type_avenant,
|
||||
statut: avenant.statut,
|
||||
signature_status: avenant.signature_status,
|
||||
docuseal_submission_id: avenant.docuseal_submission_id,
|
||||
pdf_url: avenant.pdf_url,
|
||||
created_at: avenant.created_at,
|
||||
// Indicateur pour distinguer les avenants des contrats dans l'UI
|
||||
is_avenant: true,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
console.log(`📊 [signatures-avenants] Avenants après filtrage org: ${records.length}`);
|
||||
|
||||
return NextResponse.json({ records }, { status: 200 });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Erreur récupération avenants signatures:', error);
|
||||
return NextResponse.json({ error: error.message || 'Erreur serveur' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
90
app/api/staff/amendments/[id]/change-status/route.ts
Normal file
90
app/api/staff/amendments/[id]/change-status/route.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const supabase = createRouteHandlerClient({ cookies });
|
||||
|
||||
// Vérifier l'authentification
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Vérifier que l'utilisateur est staff
|
||||
const { data: staffUser } = await supabase
|
||||
.from("staff_users")
|
||||
.select("is_staff")
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!staffUser?.is_staff) {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Récupérer le nouveau statut
|
||||
const body = await request.json();
|
||||
const { status: newStatus } = body;
|
||||
|
||||
// Valider le statut
|
||||
const validStatuses = ["draft", "pending", "signed", "cancelled"];
|
||||
if (!newStatus || !validStatuses.includes(newStatus)) {
|
||||
return NextResponse.json({
|
||||
error: "Statut invalide. Statuts acceptés : draft, pending, signed, cancelled"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Vérifier que l'avenant existe
|
||||
const { data: avenant, error: fetchError } = await supabase
|
||||
.from("avenants")
|
||||
.select("id, statut, signature_status")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (fetchError || !avenant) {
|
||||
return NextResponse.json({ error: "Avenant non trouvé" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Mettre à jour le statut de l'avenant
|
||||
const updateData: any = {
|
||||
statut: newStatus,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Si on repasse en brouillon, réinitialiser aussi le signature_status
|
||||
if (newStatus === "draft") {
|
||||
updateData.signature_status = "not_sent";
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from("avenants")
|
||||
.update(updateData)
|
||||
.eq("id", id);
|
||||
|
||||
if (updateError) {
|
||||
console.error("Erreur mise à jour statut:", updateError);
|
||||
return NextResponse.json({
|
||||
error: "Erreur lors de la mise à jour du statut"
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Statut modifié avec succès : ${newStatus}`,
|
||||
newStatus,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Erreur changement statut:", error);
|
||||
return NextResponse.json({
|
||||
error: "Erreur serveur"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
71
app/api/staff/amendments/[id]/pdf-url/route.ts
Normal file
71
app/api/staff/amendments/[id]/pdf-url/route.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
||||
import { cookies } from "next/headers";
|
||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const supabase = createRouteHandlerClient({ cookies });
|
||||
|
||||
// Vérifier l'authentification
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Vérifier que l'utilisateur est staff
|
||||
const { data: staffUser } = await supabase
|
||||
.from("staff_users")
|
||||
.select("is_staff")
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!staffUser?.is_staff) {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Récupérer l'avenant
|
||||
const { data: avenant, error } = await supabase
|
||||
.from("avenants")
|
||||
.select("pdf_s3_key")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (error || !avenant || !avenant.pdf_s3_key) {
|
||||
return NextResponse.json({ error: "PDF non trouvé" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Générer une URL signée
|
||||
const s3Client = new S3Client({
|
||||
region: process.env.AWS_REGION || "eu-west-3",
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
const presignedUrl = await getSignedUrl(
|
||||
s3Client,
|
||||
new GetObjectCommand({
|
||||
Bucket: "odentas-docs",
|
||||
Key: avenant.pdf_s3_key,
|
||||
}),
|
||||
{ expiresIn: 3600 } // 1 heure
|
||||
);
|
||||
|
||||
return NextResponse.json({ presignedUrl });
|
||||
} catch (error) {
|
||||
console.error("Erreur génération URL PDF:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur serveur" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
94
app/api/staff/amendments/[id]/route.ts
Normal file
94
app/api/staff/amendments/[id]/route.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
||||
import { cookies } from "next/headers";
|
||||
import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const supabase = createRouteHandlerClient({ cookies });
|
||||
|
||||
// Vérifier l'authentification
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Vérifier que l'utilisateur est staff
|
||||
const { data: staffUser } = await supabase
|
||||
.from("staff_users")
|
||||
.select("is_staff")
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!staffUser?.is_staff) {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Récupérer l'avenant pour obtenir la clé S3
|
||||
const { data: avenant, error: fetchError } = await supabase
|
||||
.from("avenants")
|
||||
.select("id, numero_avenant, pdf_s3_key")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (fetchError || !avenant) {
|
||||
return NextResponse.json({ error: "Avenant non trouvé" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Supprimer le PDF de S3 s'il existe
|
||||
if (avenant.pdf_s3_key) {
|
||||
try {
|
||||
const s3Client = new S3Client({
|
||||
region: process.env.AWS_REGION || "eu-west-3",
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
await s3Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: "odentas-docs",
|
||||
Key: avenant.pdf_s3_key,
|
||||
})
|
||||
);
|
||||
|
||||
console.log(`PDF supprimé de S3: ${avenant.pdf_s3_key}`);
|
||||
} catch (s3Error) {
|
||||
console.error("Erreur suppression S3:", s3Error);
|
||||
// On continue quand même la suppression en BDD
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer l'avenant de la base de données
|
||||
const { error: deleteError } = await supabase
|
||||
.from("avenants")
|
||||
.delete()
|
||||
.eq("id", id);
|
||||
|
||||
if (deleteError) {
|
||||
console.error("Erreur suppression avenant:", deleteError);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur lors de la suppression de l'avenant" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Avenant ${avenant.numero_avenant} supprimé avec succès`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erreur API delete amendment:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur serveur" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
324
app/api/staff/amendments/[id]/send-signature/route.ts
Normal file
324
app/api/staff/amendments/[id]/send-signature/route.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
||||
import { cookies } from "next/headers";
|
||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
|
||||
import axios from "axios";
|
||||
import { sendUniversalEmailV2 } from "@/lib/emailTemplateService";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
console.log("🔵 [SEND SIGNATURE] Début de l'envoi en signature pour avenant:", id);
|
||||
|
||||
const supabase = createRouteHandlerClient({ cookies });
|
||||
|
||||
// Vérifier l'authentification
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
console.error("❌ [SEND SIGNATURE] Erreur authentification:", authError);
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
console.log("✅ [SEND SIGNATURE] Utilisateur authentifié:", user.id);
|
||||
|
||||
// Vérifier que l'utilisateur est staff
|
||||
const { data: staffUser } = await supabase
|
||||
.from("staff_users")
|
||||
.select("is_staff")
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!staffUser?.is_staff) {
|
||||
console.error("❌ [SEND SIGNATURE] Utilisateur non staff");
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
console.log("✅ [SEND SIGNATURE] Utilisateur est staff");
|
||||
|
||||
// Récupérer l'avenant avec les données du contrat, salarié et organisation
|
||||
console.log("🔍 [SEND SIGNATURE] Récupération de l'avenant avec jointures...");
|
||||
const { data: avenant, error: avenantError } = await supabase
|
||||
.from("avenants")
|
||||
.select(`
|
||||
*,
|
||||
cddu_contracts (
|
||||
*,
|
||||
salaries (
|
||||
prenom,
|
||||
nom,
|
||||
adresse_mail
|
||||
),
|
||||
organizations (
|
||||
id,
|
||||
name,
|
||||
organization_details (
|
||||
email_signature,
|
||||
prenom_signataire,
|
||||
code_employeur
|
||||
)
|
||||
)
|
||||
)
|
||||
`)
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
console.log("📊 [SEND SIGNATURE] Résultat requête avenant:", {
|
||||
error: avenantError,
|
||||
found: !!avenant,
|
||||
hasContract: !!avenant?.cddu_contracts,
|
||||
hasSalarie: !!avenant?.cddu_contracts?.salaries,
|
||||
hasOrg: !!avenant?.cddu_contracts?.organizations,
|
||||
hasOrgDetails: !!avenant?.cddu_contracts?.organizations?.organization_details
|
||||
});
|
||||
|
||||
if (avenantError || !avenant) {
|
||||
console.error("❌ [SEND SIGNATURE] Avenant non trouvé:", avenantError);
|
||||
return NextResponse.json({ error: "Avenant non trouvé" }, { status: 404 });
|
||||
}
|
||||
|
||||
const contract = avenant.cddu_contracts;
|
||||
const salarie = contract.salaries;
|
||||
const organization = contract.organizations;
|
||||
const orgDetails = organization.organization_details;
|
||||
|
||||
console.log("📋 [SEND SIGNATURE] Données récupérées:", {
|
||||
contractId: contract?.id,
|
||||
salariePrenom: salarie?.prenom,
|
||||
salarieNom: salarie?.nom,
|
||||
salarieEmail: salarie?.adresse_mail,
|
||||
orgName: organization?.name,
|
||||
orgEmail: orgDetails?.email_signature,
|
||||
pdfS3Key: avenant.pdf_s3_key
|
||||
});
|
||||
|
||||
// Vérifier que le PDF existe
|
||||
if (!avenant.pdf_s3_key) {
|
||||
console.error("❌ [SEND SIGNATURE] PDF S3 key manquant");
|
||||
return NextResponse.json(
|
||||
{ error: "Le PDF de l'avenant doit être généré avant l'envoi en signature" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier que les détails de l'organisation sont disponibles
|
||||
if (!orgDetails || !orgDetails.email_signature) {
|
||||
console.error("❌ [SEND SIGNATURE] Email signature manquant:", {
|
||||
hasOrgDetails: !!orgDetails,
|
||||
emailSignature: orgDetails?.email_signature
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Email de signature de l'organisation non configuré" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log("✅ [SEND SIGNATURE] Validations passées, récupération du PDF depuis S3...");
|
||||
|
||||
// Récupérer le PDF depuis S3
|
||||
const s3Client = new S3Client({
|
||||
region: process.env.AWS_REGION || "eu-west-3",
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
const s3Object = await s3Client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: "odentas-docs",
|
||||
Key: avenant.pdf_s3_key,
|
||||
})
|
||||
);
|
||||
|
||||
const pdfBuffer = await s3Object.Body?.transformToByteArray();
|
||||
if (!pdfBuffer) {
|
||||
console.error("❌ [SEND SIGNATURE] Impossible de lire le PDF depuis S3");
|
||||
return NextResponse.json({ error: "Impossible de lire le PDF" }, { status: 500 });
|
||||
}
|
||||
|
||||
console.log("✅ [SEND SIGNATURE] PDF récupéré depuis S3, taille:", pdfBuffer.length, "bytes");
|
||||
|
||||
const pdfBase64 = Buffer.from(pdfBuffer).toString("base64");
|
||||
|
||||
console.log("🔵 [SEND SIGNATURE] Création du template DocuSeal...");
|
||||
|
||||
// Créer le template DocuSeal
|
||||
const templateResponse = await axios.post(
|
||||
"https://api.docuseal.eu/templates/pdf",
|
||||
{
|
||||
name: `AVENANT - CDDU - ${contract.contract_number}`,
|
||||
documents: [{ name: avenant.numero_avenant, file: pdfBase64 }],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"X-Auth-Token": process.env.DOCUSEAL_TOKEN!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const templateId = templateResponse.data.id;
|
||||
console.log("✅ [SEND SIGNATURE] Template DocuSeal créé:", templateId);
|
||||
|
||||
// Insérer les données dans DynamoDB pour la Lambda
|
||||
console.log("🔵 [SEND SIGNATURE] Insertion des données dans DynamoDB...");
|
||||
const dynamoDBClient = new DynamoDBClient({
|
||||
region: process.env.AWS_REGION || "eu-west-3",
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
const formatDateForDisplay = (dateStr?: string) => {
|
||||
if (!dateStr) return "-";
|
||||
const [y, m, d] = dateStr.split("-");
|
||||
return `${d}/${m}/${y}`;
|
||||
};
|
||||
|
||||
try {
|
||||
await dynamoDBClient.send(
|
||||
new PutItemCommand({
|
||||
TableName: "DocuSealNotification",
|
||||
Item: {
|
||||
submission_id: { S: avenant.numero_avenant }, // Clé primaire : numéro de l'avenant
|
||||
employeeEmail: { S: salarie.adresse_mail },
|
||||
reference: { S: contract.contract_number },
|
||||
salarie: { S: `${salarie.prenom} ${salarie.nom}` },
|
||||
date: { S: formatDateForDisplay(contract.date_debut) },
|
||||
poste: { S: contract.fonction || "Non spécifié" },
|
||||
analytique: { S: contract.analytique || "Non spécifié" },
|
||||
structure: { S: organization.name },
|
||||
prenom_salarie: { S: salarie.prenom },
|
||||
prenom_signataire: { S: orgDetails.prenom_signataire || "Non spécifié" },
|
||||
code_employeur: { S: orgDetails.code_employeur || "Non spécifié" },
|
||||
matricule: { S: contract.matricule || "Non spécifié" },
|
||||
created_at: { S: new Date().toISOString() },
|
||||
},
|
||||
})
|
||||
);
|
||||
console.log("✅ [SEND SIGNATURE] Données insérées dans DynamoDB");
|
||||
} catch (dynamoError: any) {
|
||||
console.error("❌ [SEND SIGNATURE] Erreur DynamoDB:", dynamoError);
|
||||
// On continue quand même pour créer la soumission DocuSeal
|
||||
}
|
||||
|
||||
console.log("🔵 [SEND SIGNATURE] Création de la soumission DocuSeal avec emails:", {
|
||||
employeur: orgDetails.email_signature,
|
||||
salarie: salarie.adresse_mail
|
||||
});
|
||||
|
||||
// Créer la soumission DocuSeal
|
||||
const submissionResponse = await axios.post(
|
||||
"https://api.docuseal.eu/submissions",
|
||||
{
|
||||
template_id: templateId,
|
||||
send_email: false,
|
||||
submitters: [
|
||||
{ role: "Employeur", email: orgDetails.email_signature },
|
||||
{ role: "Salarié", email: salarie.adresse_mail },
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"X-Auth-Token": process.env.DOCUSEAL_TOKEN!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log("✅ [SEND SIGNATURE] Soumission DocuSeal créée:", submissionResponse.data);
|
||||
|
||||
const employerSubmission = submissionResponse.data.find((sub: any) => sub.role === "Employeur");
|
||||
const employeeSubmission = submissionResponse.data.find((sub: any) => sub.role === "Salarié");
|
||||
|
||||
console.log("📋 [SEND SIGNATURE] Soumissions extraites:", {
|
||||
hasEmployer: !!employerSubmission,
|
||||
hasEmployee: !!employeeSubmission,
|
||||
employerSlug: employerSubmission?.slug,
|
||||
employeeSlug: employeeSubmission?.slug
|
||||
});
|
||||
|
||||
if (!employerSubmission || !employeeSubmission) {
|
||||
console.error("❌ [SEND SIGNATURE] Soumissions DocuSeal incomplètes");
|
||||
return NextResponse.json(
|
||||
{ error: "Impossible de créer les soumissions DocuSeal" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const employerSlug = employerSubmission.slug;
|
||||
const signatureLink = `https://staging.paie.odentas.fr/odentas-sign?docuseal_id=${employerSlug}`;
|
||||
|
||||
console.log("✅ [SEND SIGNATURE] Lien de signature généré:", signatureLink);
|
||||
console.log("🔵 [SEND SIGNATURE] Mise à jour de l'avenant dans Supabase...");
|
||||
|
||||
// Mettre à jour l'avenant avec les informations DocuSeal
|
||||
await supabase
|
||||
.from("avenants")
|
||||
.update({
|
||||
docuseal_template_id: templateId,
|
||||
docuseal_submission_id: employerSubmission.submission_id,
|
||||
employer_docuseal_slug: employerSlug,
|
||||
employee_docuseal_slug: employeeSubmission.slug,
|
||||
signature_status: "pending_employer",
|
||||
statut: "pending", // Passer de "draft" à "pending"
|
||||
last_employer_notification_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", id);
|
||||
|
||||
console.log("✅ [SEND SIGNATURE] Avenant mis à jour dans Supabase");
|
||||
console.log("🔵 [SEND SIGNATURE] Envoi de l'email à l'employeur...");
|
||||
|
||||
// Formater la date de début
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return "-";
|
||||
const [y, m, d] = dateStr.split("-");
|
||||
return `${d}/${m}/${y}`;
|
||||
};
|
||||
|
||||
// Envoyer l'email via le système universel
|
||||
try {
|
||||
await sendUniversalEmailV2({
|
||||
type: 'signature-request-employer',
|
||||
toEmail: orgDetails.email_signature,
|
||||
subject: `Signature électronique requise – Avenant ${avenant.numero_avenant}`,
|
||||
data: {
|
||||
firstName: orgDetails.prenom_signataire || undefined,
|
||||
organizationName: organization.name,
|
||||
employerCode: orgDetails.code_employeur || "Non spécifié",
|
||||
handlerName: "Renaud BREVIERE-ABRAHAM",
|
||||
documentType: `Avenant ${avenant.numero_avenant}`,
|
||||
employeeName: `${salarie.prenom} ${salarie.nom}`,
|
||||
contractReference: contract.contract_number,
|
||||
status: 'En attente',
|
||||
ctaUrl: signatureLink,
|
||||
contractId: avenant.id, // Pour traçabilité dans email_logs
|
||||
},
|
||||
});
|
||||
console.log("✅ [SEND SIGNATURE] Email envoyé avec succès");
|
||||
} catch (emailError: any) {
|
||||
console.error("❌ [SEND SIGNATURE] Erreur envoi email:", emailError);
|
||||
// On continue quand même car DocuSeal est créé
|
||||
}
|
||||
|
||||
console.log("🎉 [SEND SIGNATURE] Processus terminé avec succès");
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Avenant envoyé en signature à l'employeur",
|
||||
signatureLink,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌❌❌ [SEND SIGNATURE] ERREUR FATALE:", error);
|
||||
console.error("Stack trace:", error.stack);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur serveur", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
68
app/api/staff/amendments/[id]/update-pdf/route.ts
Normal file
68
app/api/staff/amendments/[id]/update-pdf/route.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const supabase = createRouteHandlerClient({ cookies });
|
||||
|
||||
// Vérifier l'authentification
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Vérifier que l'utilisateur est staff
|
||||
const { data: staffUser } = await supabase
|
||||
.from("staff_users")
|
||||
.select("is_staff")
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!staffUser?.is_staff) {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Récupérer les nouvelles données
|
||||
const body = await request.json();
|
||||
const { pdf_s3_key } = body;
|
||||
|
||||
if (!pdf_s3_key) {
|
||||
return NextResponse.json({ error: "Clé S3 manquante" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Mettre à jour l'avenant
|
||||
const { error: updateError } = await supabase
|
||||
.from("avenants")
|
||||
.update({
|
||||
pdf_s3_key: pdf_s3_key,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", id);
|
||||
|
||||
if (updateError) {
|
||||
console.error("Erreur mise à jour PDF:", updateError);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur lors de la mise à jour" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "PDF mis à jour avec succès",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erreur API update PDF:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur serveur" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
191
app/api/staff/amendments/create/route.ts
Normal file
191
app/api/staff/amendments/create/route.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const supabase = createRouteHandlerClient({ cookies });
|
||||
|
||||
// Vérifier l'authentification
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Vérifier que l'utilisateur est staff
|
||||
const { data: staffUser } = await supabase
|
||||
.from("staff_users")
|
||||
.select("is_staff")
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!staffUser?.is_staff) {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Récupérer les données de l'avenant
|
||||
const body = await request.json();
|
||||
const {
|
||||
contract_id,
|
||||
date_effet,
|
||||
date_signature,
|
||||
type_avenant = "modification",
|
||||
motif_avenant,
|
||||
elements_avenantes,
|
||||
objet_data,
|
||||
duree_data,
|
||||
lieu_horaire_data,
|
||||
remuneration_data,
|
||||
pdf_url,
|
||||
pdf_s3_key,
|
||||
} = body;
|
||||
|
||||
if (!contract_id || !date_effet || !elements_avenantes || elements_avenantes.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Données manquantes" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Récupérer le contrat pour validation et numérotation
|
||||
const { data: contract, error: contractError } = await supabase
|
||||
.from("cddu_contracts")
|
||||
.select("id, contract_number, org_id")
|
||||
.eq("id", contract_id)
|
||||
.single();
|
||||
|
||||
if (contractError || !contract) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contrat non trouvé" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Compter le nombre d'avenants existants pour cette organisation
|
||||
// pour avoir une numérotation par client
|
||||
const { count } = await supabase
|
||||
.from("avenants")
|
||||
.select("contract_id, cddu_contracts!inner(org_id)", { count: "exact", head: true })
|
||||
.eq("cddu_contracts.org_id", contract.org_id);
|
||||
|
||||
const numeroAvenant = `AVE-${String((count || 0) + 1).padStart(3, "0")}`;
|
||||
|
||||
// Créer l'avenant
|
||||
const { data: avenant, error: avenantError } = await supabase
|
||||
.from("avenants")
|
||||
.insert({
|
||||
contract_id,
|
||||
numero_avenant: numeroAvenant,
|
||||
date_avenant: date_signature || date_effet,
|
||||
date_effet,
|
||||
type_avenant,
|
||||
motif_avenant: motif_avenant || null,
|
||||
elements_avenantes: elements_avenantes,
|
||||
objet_data: objet_data || null,
|
||||
duree_data: duree_data || null,
|
||||
lieu_horaire_data: lieu_horaire_data || null,
|
||||
remuneration_data: remuneration_data || null,
|
||||
pdf_url: pdf_url || null,
|
||||
pdf_s3_key: pdf_s3_key || null,
|
||||
statut: "draft",
|
||||
created_by: user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (avenantError) {
|
||||
console.error("Erreur création avenant:", avenantError);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur lors de la création de l'avenant" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Mettre à jour le contrat avec les nouvelles données
|
||||
const updateData: any = {};
|
||||
|
||||
if (elements_avenantes.includes("objet") && objet_data) {
|
||||
if (objet_data.profession_code && objet_data.profession_label) {
|
||||
updateData.profession = `${objet_data.profession_code} - ${objet_data.profession_label}`;
|
||||
}
|
||||
if (objet_data.production_name) {
|
||||
updateData.production_name = objet_data.production_name;
|
||||
}
|
||||
if (objet_data.production_numero_objet) {
|
||||
updateData.numero_objet = objet_data.production_numero_objet;
|
||||
}
|
||||
}
|
||||
|
||||
if (elements_avenantes.includes("duree") && duree_data) {
|
||||
if (duree_data.date_debut) {
|
||||
updateData.start_date = duree_data.date_debut;
|
||||
}
|
||||
if (duree_data.date_fin) {
|
||||
updateData.end_date = duree_data.date_fin;
|
||||
}
|
||||
if (duree_data.nb_representations !== undefined) {
|
||||
updateData.cachets_representations = duree_data.nb_representations;
|
||||
}
|
||||
if (duree_data.nb_repetitions !== undefined) {
|
||||
updateData.services_repetitions = duree_data.nb_repetitions;
|
||||
}
|
||||
if (duree_data.nb_heures !== undefined) {
|
||||
updateData.nombre_d_heures = duree_data.nb_heures;
|
||||
}
|
||||
if (duree_data.dates_representations) {
|
||||
updateData.jours_representations = duree_data.dates_representations;
|
||||
}
|
||||
if (duree_data.dates_repetitions) {
|
||||
updateData.jours_repetitions = duree_data.dates_repetitions;
|
||||
}
|
||||
if (duree_data.jours_travail) {
|
||||
updateData.jours_travail = duree_data.jours_travail;
|
||||
}
|
||||
}
|
||||
|
||||
if (elements_avenantes.includes("lieu_horaire") && lieu_horaire_data) {
|
||||
if (lieu_horaire_data.lieu) {
|
||||
updateData.lieu_travail = lieu_horaire_data.lieu;
|
||||
}
|
||||
}
|
||||
|
||||
if (elements_avenantes.includes("remuneration") && remuneration_data) {
|
||||
if (remuneration_data.gross_pay !== undefined) {
|
||||
updateData.gross_pay = remuneration_data.gross_pay;
|
||||
}
|
||||
if (remuneration_data.precisions_salaire) {
|
||||
updateData.precisions_salaire = remuneration_data.precisions_salaire;
|
||||
}
|
||||
if (remuneration_data.type_salaire) {
|
||||
updateData.type_salaire = remuneration_data.type_salaire;
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour le contrat si des données ont été modifiées
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
const { error: updateError } = await supabase
|
||||
.from("cddu_contracts")
|
||||
.update(updateData)
|
||||
.eq("id", contract_id);
|
||||
|
||||
if (updateError) {
|
||||
console.error("Erreur mise à jour contrat:", updateError);
|
||||
// On ne renvoie pas d'erreur car l'avenant est créé
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
avenant,
|
||||
message: "Avenant créé avec succès",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erreur API create amendment:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur serveur" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
179
app/api/webhooks/docuseal-amendment/route.ts
Normal file
179
app/api/webhooks/docuseal-amendment/route.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createSbServiceRole } from "@/lib/supabaseServer";
|
||||
import { sendUniversalEmailV2, EmailDataV2 } from "@/lib/emailTemplateService";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Webhook pour gérer les signatures d'avenants via DocuSeal
|
||||
* Appelé après que l'employeur a signé un avenant
|
||||
*
|
||||
* Flux :
|
||||
* 1. Employeur signe via DocuSeal
|
||||
* 2. DocuSeal → Lambda postDocuSealAvenantSalarie → Cette route API
|
||||
* 3. Cette route :
|
||||
* - Met à jour le statut de l'avenant dans Supabase
|
||||
* - Envoie l'email au salarié via le système universel v2
|
||||
* - Retourne les infos pour logging
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
console.log("🔔 [WEBHOOK AVENANT] Début du traitement");
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
console.log("📦 [WEBHOOK AVENANT] Body reçu:", JSON.stringify(body, null, 2));
|
||||
|
||||
const {
|
||||
documentName, // Numéro de l'avenant (ex: AVE-001)
|
||||
employeeSlug, // Slug DocuSeal du salarié
|
||||
submissionId, // ID de la soumission DocuSeal
|
||||
employeeEmail, // Email du salarié
|
||||
// Données pour l'email
|
||||
reference, // Référence du contrat
|
||||
salarie, // Nom complet du salarié
|
||||
date, // Date de début
|
||||
poste, // Poste/profession
|
||||
analytique, // Production
|
||||
structure, // Nom de l'organisation
|
||||
prenom_salarie,
|
||||
prenom_signataire,
|
||||
code_employeur,
|
||||
matricule,
|
||||
} = body;
|
||||
|
||||
// Validation des champs requis
|
||||
if (!documentName || !employeeSlug || !employeeEmail) {
|
||||
console.error("❌ [WEBHOOK AVENANT] Champs manquants:", {
|
||||
documentName: !!documentName,
|
||||
employeeSlug: !!employeeSlug,
|
||||
employeeEmail: !!employeeEmail,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Champs requis manquants" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = createSbServiceRole();
|
||||
|
||||
// 1. Trouver l'avenant par son numéro
|
||||
console.log("🔍 [WEBHOOK AVENANT] Recherche de l'avenant:", documentName);
|
||||
const { data: avenant, error: avenantError } = await supabase
|
||||
.from("avenants")
|
||||
.select(`
|
||||
*,
|
||||
cddu_contracts (
|
||||
*,
|
||||
salaries (
|
||||
prenom,
|
||||
nom,
|
||||
adresse_mail
|
||||
),
|
||||
organizations (
|
||||
id,
|
||||
name
|
||||
)
|
||||
)
|
||||
`)
|
||||
.eq("numero_avenant", documentName)
|
||||
.maybeSingle();
|
||||
|
||||
if (avenantError || !avenant) {
|
||||
console.error("❌ [WEBHOOK AVENANT] Avenant non trouvé:", avenantError);
|
||||
return NextResponse.json(
|
||||
{ error: "Avenant non trouvé", details: avenantError?.message },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log("✅ [WEBHOOK AVENANT] Avenant trouvé:", {
|
||||
id: avenant.id,
|
||||
numero: avenant.numero_avenant,
|
||||
statut_actuel: avenant.signature_status,
|
||||
});
|
||||
|
||||
// 2. Mettre à jour le statut de l'avenant
|
||||
console.log("🔄 [WEBHOOK AVENANT] Mise à jour du statut...");
|
||||
const { error: updateError } = await supabase
|
||||
.from("avenants")
|
||||
.update({
|
||||
signature_status: "pending_employee", // Employeur signé, en attente salarié
|
||||
last_employee_notification_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", avenant.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error("❌ [WEBHOOK AVENANT] Erreur mise à jour:", updateError);
|
||||
// On continue quand même pour envoyer l'email
|
||||
} else {
|
||||
console.log("✅ [WEBHOOK AVENANT] Statut mis à jour: pending_employee");
|
||||
}
|
||||
|
||||
// 3. Préparer les données pour l'email
|
||||
const signatureLink = `https://paie.odentas.fr/odentas-sign?docuseal_id=${employeeSlug}`;
|
||||
|
||||
// Formater la date
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return "-";
|
||||
const [y, m, d] = dateStr.split("-");
|
||||
return `${d}/${m}/${y}`;
|
||||
};
|
||||
|
||||
const salarie_data = avenant.cddu_contracts?.salaries;
|
||||
const contract_data = avenant.cddu_contracts;
|
||||
const organization_data = contract_data?.organizations;
|
||||
|
||||
const emailData: EmailDataV2 = {
|
||||
firstName: prenom_salarie || salarie_data?.prenom,
|
||||
organizationName: structure || organization_data?.name,
|
||||
employerCode: code_employeur || "Non spécifié",
|
||||
matricule: matricule || "Non spécifié",
|
||||
contractReference: reference || contract_data?.contract_number,
|
||||
startDate: formatDate(date || contract_data?.date_debut),
|
||||
profession: poste || contract_data?.fonction,
|
||||
productionName: analytique || contract_data?.analytique,
|
||||
ctaUrl: signatureLink,
|
||||
numeroAvenant: avenant.numero_avenant,
|
||||
contractType: "CDDU (contrat intermittent)",
|
||||
};
|
||||
|
||||
// 4. Envoyer l'email au salarié via le système universel v2
|
||||
console.log("📧 [WEBHOOK AVENANT] Envoi de l'email au salarié...");
|
||||
console.log("📧 [WEBHOOK AVENANT] Email data:", emailData);
|
||||
|
||||
try {
|
||||
const messageId = await sendUniversalEmailV2({
|
||||
type: "signature-request-employee-amendment",
|
||||
toEmail: employeeEmail,
|
||||
data: emailData,
|
||||
});
|
||||
|
||||
console.log("✅ [WEBHOOK AVENANT] Email envoyé avec succès:", messageId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Email de signature envoyé au salarié",
|
||||
messageId,
|
||||
avenantId: avenant.id,
|
||||
signatureLink,
|
||||
});
|
||||
} catch (emailError: any) {
|
||||
console.error("❌ [WEBHOOK AVENANT] Erreur envoi email:", emailError);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Erreur lors de l'envoi de l'email",
|
||||
details: emailError.message,
|
||||
avenantId: avenant.id,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("❌❌❌ [WEBHOOK AVENANT] ERREUR FATALE:", error);
|
||||
console.error("Stack trace:", error.stack);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur serveur", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|||
<span>Fiches de paie</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/staff/avenants" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
|
||||
isActivePath(pathname, "/staff/avenants") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
|
||||
}`} title="Gestion des avenants">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<FileEdit className="w-4 h-4" aria-hidden />
|
||||
<span>Avenants</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/staff/salaries" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -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<AmendmentElementType[]>([]);
|
||||
|
||||
// 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<string | null>(null);
|
||||
const [pdfS3Key, setPdfS3Key] = useState<string | null>(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() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type et Motif */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||
<h2 className="font-semibold text-slate-900 mb-4">Informations complémentaires</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Type d'avenant <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={typeAvenant}
|
||||
onChange={(e) => setTypeAvenant(e.target.value as "modification" | "annulation")}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="modification">Modification</option>
|
||||
<option value="annulation">Annulation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Motif de l'avenant
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={motifAvenant}
|
||||
onChange={(e) => setMotifAvenant(e.target.value)}
|
||||
placeholder="Ex: Changement de dates, modification du salaire..."
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Éléments à avenanter */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||
<h2 className="font-semibold text-slate-900 mb-4">
|
||||
|
|
@ -520,6 +571,13 @@ export default function NouvelAvenantPageClient() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modale de succès */}
|
||||
<AvenantSuccessModal
|
||||
isOpen={showSuccessModal}
|
||||
numeroAvenant={createdNumeroAvenant}
|
||||
avenantId={createdAvenantId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="text-xs font-semibold text-slate-600">E</div>
|
||||
{notSent ? (
|
||||
<span className="text-xs text-slate-400">—</span>
|
||||
) : employerSigned ? (
|
||||
<Check className="w-4 h-4 text-green-600" strokeWidth={3} />
|
||||
) : (
|
||||
<X className="w-4 h-4 text-red-600" strokeWidth={3} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="text-xs font-semibold text-slate-600">S</div>
|
||||
{notSent ? (
|
||||
<span className="text-xs text-slate-400">—</span>
|
||||
) : employeeSigned ? (
|
||||
<Check className="w-4 h-4 text-green-600" strokeWidth={3} />
|
||||
) : (
|
||||
<X className="w-4 h-4 text-red-600" strokeWidth={3} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getElementsLabel = (elements: Amendment["elements"]) => {
|
||||
const labels = {
|
||||
objet: "Objet",
|
||||
|
|
@ -135,6 +169,9 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
|
|||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
|
||||
Date d'effet
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
|
||||
Signé
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
|
||||
Statut
|
||||
</th>
|
||||
|
|
@ -145,7 +182,11 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
|
|||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{filteredAmendments.map((amendment) => (
|
||||
<tr key={amendment.id} className="hover:bg-slate-50 transition-colors">
|
||||
<tr
|
||||
key={amendment.id}
|
||||
onClick={() => router.push(`/staff/avenants/${amendment.id}`)}
|
||||
className="hover:bg-slate-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm font-medium text-slate-900">
|
||||
{amendment.contract_number || "-"}
|
||||
</td>
|
||||
|
|
@ -161,9 +202,18 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
|
|||
<td className="px-4 py-3 text-sm text-slate-700">
|
||||
{formatDate(amendment.date_effet)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{getSignatureIcons(amendment.signature_status)}
|
||||
</td>
|
||||
<td className="px-4 py-3">{getStatusBadge(amendment.status)}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<button className="text-indigo-600 hover:text-indigo-700 font-medium">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/staff/avenants/${amendment.id}`);
|
||||
}}
|
||||
className="text-indigo-600 hover:text-indigo-700 font-medium"
|
||||
>
|
||||
Voir
|
||||
</button>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
onChange({ ...data, date_fin: e.target.value });
|
||||
};
|
||||
|
||||
const handleDatesRepApply = (result: {
|
||||
|
|
@ -131,24 +123,12 @@ export default function AmendmentDureeForm({
|
|||
<div className="text-xs text-slate-500 mb-2">
|
||||
Actuellement : {formatDate(originalData.start_date)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowDateDebut(!showDateDebut)}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm text-left flex items-center justify-between hover:bg-slate-50"
|
||||
>
|
||||
<span>{data.date_debut ? formatDate(data.date_debut) : "Sélectionner"}</span>
|
||||
<Calendar className="h-4 w-4 text-slate-400" />
|
||||
</button>
|
||||
{showDateDebut && (
|
||||
<DatePickerCalendar
|
||||
isOpen={showDateDebut}
|
||||
onClose={() => setShowDateDebut(false)}
|
||||
onApply={handleDateDebutApply}
|
||||
initialDates={data.date_debut ? [formatDate(data.date_debut)] : []}
|
||||
title="Sélectionner la date de début"
|
||||
<input
|
||||
type="date"
|
||||
value={data.date_debut || ""}
|
||||
onChange={handleDateDebutChange}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -158,24 +138,12 @@ export default function AmendmentDureeForm({
|
|||
<div className="text-xs text-slate-500 mb-2">
|
||||
Actuellement : {formatDate(originalData.end_date)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowDateFin(!showDateFin)}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm text-left flex items-center justify-between hover:bg-slate-50"
|
||||
>
|
||||
<span>{data.date_fin ? formatDate(data.date_fin) : "Sélectionner"}</span>
|
||||
<Calendar className="h-4 w-4 text-slate-400" />
|
||||
</button>
|
||||
{showDateFin && (
|
||||
<DatePickerCalendar
|
||||
isOpen={showDateFin}
|
||||
onClose={() => setShowDateFin(false)}
|
||||
onApply={handleDateFinApply}
|
||||
initialDates={data.date_fin ? [formatDate(data.date_fin)] : []}
|
||||
title="Sélectionner la date de fin"
|
||||
<input
|
||||
type="date"
|
||||
value={data.date_fin || ""}
|
||||
onChange={handleDateFinChange}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
57
components/staff/amendments/AvenantSuccessModal.tsx
Normal file
57
components/staff/amendments/AvenantSuccessModal.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface AvenantSuccessModalProps {
|
||||
isOpen: boolean;
|
||||
numeroAvenant: string;
|
||||
avenantId: string;
|
||||
}
|
||||
|
||||
export default function AvenantSuccessModal({
|
||||
isOpen,
|
||||
numeroAvenant,
|
||||
avenantId,
|
||||
}: AvenantSuccessModalProps) {
|
||||
const router = useRouter();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div className="bg-white rounded-2xl max-w-md w-full mx-4 shadow-2xl border">
|
||||
<div className="p-8">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mb-4">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold text-slate-900 mb-2">
|
||||
Avenant créé avec succès !
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-slate-600 mb-6">
|
||||
L'avenant <span className="font-semibold text-slate-900">{numeroAvenant}</span> a été créé et le contrat a été mis à jour.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 w-full">
|
||||
<button
|
||||
onClick={() => router.push("/staff/avenants")}
|
||||
className="flex-1 px-4 py-2 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Liste des avenants
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(`/staff/avenants/${avenantId}`)}
|
||||
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Voir l'avenant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
components/staff/amendments/ChangeStatusModal.tsx
Normal file
100
components/staff/amendments/ChangeStatusModal.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"use client";
|
||||
|
||||
import { X, AlertCircle } from "lucide-react";
|
||||
|
||||
interface ChangeStatusModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (newStatus: string) => void;
|
||||
currentStatus: string;
|
||||
isChanging: boolean;
|
||||
numeroAvenant: string;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "draft", label: "Brouillon", color: "bg-slate-100 text-slate-700 border-slate-300" },
|
||||
{ value: "pending", label: "En attente", color: "bg-orange-100 text-orange-700 border-orange-300" },
|
||||
{ value: "signed", label: "Signé", color: "bg-green-100 text-green-700 border-green-300" },
|
||||
{ value: "cancelled", label: "Annulé", color: "bg-red-100 text-red-700 border-red-300" },
|
||||
];
|
||||
|
||||
export default function ChangeStatusModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
currentStatus,
|
||||
isChanging,
|
||||
numeroAvenant,
|
||||
}: ChangeStatusModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/30" onClick={onClose}></div>
|
||||
<div className="relative bg-white rounded-xl shadow-xl max-w-md w-full p-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5 text-slate-600" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3 mb-6">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<AlertCircle className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
Changer le statut de l'avenant
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
Avenant {numeroAvenant}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="text-sm font-medium text-slate-700 mb-3 block">
|
||||
Sélectionnez le nouveau statut :
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{STATUS_OPTIONS.map((status) => (
|
||||
<button
|
||||
key={status.value}
|
||||
onClick={() => onConfirm(status.value)}
|
||||
disabled={isChanging || status.value === currentStatus}
|
||||
className={`w-full px-4 py-3 rounded-lg border-2 text-left font-medium transition-all
|
||||
${status.color}
|
||||
${status.value === currentStatus
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "hover:scale-[1.02] hover:shadow-md cursor-pointer"
|
||||
}
|
||||
${isChanging ? "opacity-50 cursor-not-allowed" : ""}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{status.label}</span>
|
||||
{status.value === currentStatus && (
|
||||
<span className="text-xs px-2 py-1 bg-white/50 rounded">
|
||||
Actuel
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isChanging}
|
||||
className="flex-1 px-4 py-2.5 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
components/staff/amendments/DeleteAvenantModal.tsx
Normal file
80
components/staff/amendments/DeleteAvenantModal.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"use client";
|
||||
|
||||
import { X, AlertTriangle } from "lucide-react";
|
||||
|
||||
interface DeleteAvenantModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
numeroAvenant: string;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
export default function DeleteAvenantModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
numeroAvenant,
|
||||
isDeleting,
|
||||
}: DeleteAvenantModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Supprimer l'avenant
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
Avenant {numeroAvenant}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isDeleting}
|
||||
className="p-1 hover:bg-slate-100 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-sm text-red-800">
|
||||
Êtes-vous sûr de vouloir supprimer cet avenant ? Cette action est{" "}
|
||||
<strong>irréversible</strong>.
|
||||
</p>
|
||||
<p className="text-sm text-red-700 mt-2">
|
||||
Le PDF associé sera également supprimé du stockage S3.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 px-4 py-2 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDeleting ? "Suppression..." : "Supprimer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<AmendmentElementType[]>([]);
|
||||
|
||||
// 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,7 +416,9 @@ export default function NouvelAvenantModal({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
{/* Dates de l'avenant */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-3">Dates de l'avenant</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-700 mb-2">
|
||||
|
|
@ -423,6 +449,39 @@ export default function NouvelAvenantModal({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type et Motif de l'avenant */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-3">Informations complémentaires</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-700 mb-2">
|
||||
Type d'avenant <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={typeAvenant}
|
||||
onChange={(e) => setTypeAvenant(e.target.value as "modification" | "annulation")}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="modification">Modification</option>
|
||||
<option value="annulation">Annulation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-700 mb-2">
|
||||
Motif de l'avenant
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={motifAvenant}
|
||||
onChange={(e) => setMotifAvenant(e.target.value)}
|
||||
placeholder="Ex: Changement de dates, modification du salaire..."
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Éléments à avenanter */}
|
||||
<div>
|
||||
|
|
|
|||
174
components/staff/amendments/SendSignatureModal.tsx
Normal file
174
components/staff/amendments/SendSignatureModal.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
"use client";
|
||||
|
||||
import { X, Send, CheckCircle2 } from "lucide-react";
|
||||
|
||||
interface SendSignatureModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
isSending: boolean;
|
||||
success?: boolean;
|
||||
avenantData: {
|
||||
numero_avenant: string;
|
||||
contractReference: string;
|
||||
employeeName: string;
|
||||
employeeEmail: string;
|
||||
employerEmail: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function SendSignatureModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isSending,
|
||||
success = false,
|
||||
avenantData,
|
||||
}: SendSignatureModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
success ? "bg-green-100" : "bg-orange-100"
|
||||
}`}>
|
||||
{success ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<Send className="h-5 w-5 text-orange-600" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
{success ? "Envoi réussi !" : "Envoyer pour signature"}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
Avenant {avenantData.numero_avenant}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSending}
|
||||
className="p-1 hover:bg-slate-100 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{success ? (
|
||||
/* Message de succès */
|
||||
<>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<p className="text-sm text-slate-700 mb-3">
|
||||
L'avenant a été envoyé avec succès pour signature électronique.
|
||||
</p>
|
||||
|
||||
{/* Détails */}
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Email employeur :</span>
|
||||
<span className="font-medium text-slate-900 truncate max-w-[200px]">{avenantData.employerEmail}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Statut :</span>
|
||||
<span className="font-medium text-green-600">En attente signature employeur</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions succès */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-slate-900 text-white rounded-lg hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Message initial */
|
||||
<>
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||
<p className="text-sm text-slate-700 mb-3">
|
||||
Vous êtes sur le point d'envoyer cet avenant pour signature électronique.
|
||||
</p>
|
||||
|
||||
{/* Détails */}
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Contrat :</span>
|
||||
<span className="font-medium text-slate-900">{avenantData.contractReference}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Salarié :</span>
|
||||
<span className="font-medium text-slate-900">{avenantData.employeeName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Email salarié :</span>
|
||||
<span className="font-medium text-slate-900 truncate max-w-[200px]">{avenantData.employeeEmail}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Email employeur :</span>
|
||||
<span className="font-medium text-slate-900 truncate max-w-[200px]">{avenantData.employerEmail}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Processus */}
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-slate-900 mb-2">Processus de signature</h3>
|
||||
<ol className="space-y-2 text-xs text-slate-600">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-slate-200 flex items-center justify-center text-xs font-medium">1</span>
|
||||
<span>L'employeur recevra un email avec le lien de signature</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-slate-200 flex items-center justify-center text-xs font-medium">2</span>
|
||||
<span>Après signature de l'employeur, le salarié recevra son lien</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-slate-200 flex items-center justify-center text-xs font-medium">3</span>
|
||||
<span>Vous serez notifié par email une fois toutes les signatures reçues</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSending}
|
||||
className="flex-1 px-4 py-2 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={isSending}
|
||||
className="flex-1 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSending ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Envoi en cours...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4" />
|
||||
Envoyer
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
685
components/staff/avenants/AvenantDetailPageClient.tsx
Normal file
685
components/staff/avenants/AvenantDetailPageClient.tsx
Normal file
|
|
@ -0,0 +1,685 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Calendar, FileText, User, Building2, Download, Trash2, RefreshCw, Send, Check, X, Clock, Edit3 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import DeleteAvenantModal from "@/components/staff/amendments/DeleteAvenantModal";
|
||||
import SendSignatureModal from "@/components/staff/amendments/SendSignatureModal";
|
||||
import ChangeStatusModal from "@/components/staff/amendments/ChangeStatusModal";
|
||||
|
||||
interface AvenantDetailPageClientProps {
|
||||
avenant: any;
|
||||
}
|
||||
|
||||
export default function AvenantDetailPageClient({ avenant }: AvenantDetailPageClientProps) {
|
||||
const router = useRouter();
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [loadingPdf, setLoadingPdf] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isRegeneratingPdf, setIsRegeneratingPdf] = useState(false);
|
||||
const [isSendingSignature, setIsSendingSignature] = useState(false);
|
||||
const [showSendSignatureModal, setShowSendSignatureModal] = useState(false);
|
||||
const [sendSignatureSuccess, setSendSignatureSuccess] = useState(false);
|
||||
const [showChangeStatusModal, setShowChangeStatusModal] = useState(false);
|
||||
const [isChangingStatus, setIsChangingStatus] = useState(false);
|
||||
|
||||
// Charger l'URL du PDF si la clé S3 existe
|
||||
useEffect(() => {
|
||||
if (avenant.pdf_s3_key) {
|
||||
setLoadingPdf(true);
|
||||
fetch(`/api/staff/amendments/${avenant.id}/pdf-url`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.presignedUrl) {
|
||||
setPdfUrl(data.presignedUrl);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Erreur chargement URL PDF:", err))
|
||||
.finally(() => setLoadingPdf(false));
|
||||
}
|
||||
}, [avenant.id, avenant.pdf_s3_key]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const response = await fetch(`/api/staff/amendments/${avenant.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Erreur lors de la suppression");
|
||||
}
|
||||
|
||||
// Rediriger vers la liste des avenants
|
||||
router.push("/staff/avenants");
|
||||
} catch (error: any) {
|
||||
console.error("Erreur suppression avenant:", error);
|
||||
alert("Erreur lors de la suppression de l'avenant: " + error.message);
|
||||
setIsDeleting(false);
|
||||
setShowDeleteModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegeneratePdf = async () => {
|
||||
setIsRegeneratingPdf(true);
|
||||
try {
|
||||
// Préparer les données de l'avenant pour la génération
|
||||
const amendmentData = {
|
||||
contract_id: avenant.contract_id,
|
||||
date_effet: avenant.date_effet,
|
||||
date_signature: avenant.date_avenant,
|
||||
elements: avenant.elements_avenantes,
|
||||
objet_data: avenant.objet_data,
|
||||
duree_data: avenant.duree_data,
|
||||
lieu_horaire_data: avenant.lieu_horaire_data,
|
||||
remuneration_data: avenant.remuneration_data,
|
||||
};
|
||||
|
||||
// Générer le PDF
|
||||
const generateResponse = await fetch("/api/staff/amendments/generate-pdf", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contractId: avenant.contract_id,
|
||||
amendmentData,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!generateResponse.ok) {
|
||||
const error = await generateResponse.json();
|
||||
throw new Error(error.error || "Erreur lors de la génération du PDF");
|
||||
}
|
||||
|
||||
const { s3Key, presignedUrl } = await generateResponse.json();
|
||||
|
||||
// Mettre à jour l'avenant avec la nouvelle clé S3
|
||||
const updateResponse = await fetch(`/api/staff/amendments/${avenant.id}/update-pdf`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
pdf_s3_key: s3Key,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
const error = await updateResponse.json();
|
||||
throw new Error(error.error || "Erreur lors de la mise à jour");
|
||||
}
|
||||
|
||||
// Mettre à jour l'URL affichée
|
||||
setPdfUrl(presignedUrl);
|
||||
alert("PDF régénéré avec succès !");
|
||||
|
||||
// Recharger la page pour avoir les nouvelles données
|
||||
router.refresh();
|
||||
} catch (error: any) {
|
||||
console.error("Erreur regénération PDF:", error);
|
||||
alert("Erreur lors de la regénération du PDF: " + error.message);
|
||||
} finally {
|
||||
setIsRegeneratingPdf(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendForSignature = async () => {
|
||||
if (!avenant.pdf_s3_key) {
|
||||
alert("Vous devez d'abord générer le PDF avant de l'envoyer en signature.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSendingSignature(true);
|
||||
setSendSignatureSuccess(false);
|
||||
try {
|
||||
const response = await fetch(`/api/staff/amendments/${avenant.id}/send-signature`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Erreur lors de l'envoi");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Afficher le succès dans le modal
|
||||
setSendSignatureSuccess(true);
|
||||
|
||||
// Recharger la page après 2 secondes pour voir le nouveau statut
|
||||
setTimeout(() => {
|
||||
router.refresh();
|
||||
}, 2000);
|
||||
} catch (error: any) {
|
||||
console.error("Erreur envoi signature:", error);
|
||||
alert("Erreur lors de l'envoi en signature: " + error.message);
|
||||
setShowSendSignatureModal(false);
|
||||
} finally {
|
||||
setIsSendingSignature(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeStatus = async (newStatus: string) => {
|
||||
setIsChangingStatus(true);
|
||||
try {
|
||||
const response = await fetch(`/api/staff/amendments/${avenant.id}/change-status`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Erreur lors du changement de statut");
|
||||
}
|
||||
|
||||
// Fermer le modal et recharger la page
|
||||
setShowChangeStatusModal(false);
|
||||
router.refresh();
|
||||
} catch (error: any) {
|
||||
console.error("Erreur changement statut:", error);
|
||||
alert("Erreur lors du changement de statut: " + error.message);
|
||||
} finally {
|
||||
setIsChangingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const contract = avenant.cddu_contracts;
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return "-";
|
||||
const [y, m, d] = dateStr.split("-");
|
||||
return `${d}/${m}/${y}`;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges = {
|
||||
draft: "bg-slate-100 text-slate-700",
|
||||
pending: "bg-orange-100 text-orange-700",
|
||||
signed: "bg-green-100 text-green-700",
|
||||
cancelled: "bg-red-100 text-red-700",
|
||||
};
|
||||
const labels = {
|
||||
draft: "Brouillon",
|
||||
pending: "En attente",
|
||||
signed: "Signé",
|
||||
cancelled: "Annulé",
|
||||
};
|
||||
return (
|
||||
<span className={`px-3 py-1 text-sm font-medium rounded-full ${badges[status as keyof typeof badges] || badges.draft}`}>
|
||||
{labels[status as keyof typeof labels] || status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getTypeBadge = (type: string) => {
|
||||
const badges = {
|
||||
modification: "bg-blue-100 text-blue-700",
|
||||
annulation: "bg-red-100 text-red-700",
|
||||
};
|
||||
const labels = {
|
||||
modification: "Modification",
|
||||
annulation: "Annulation",
|
||||
};
|
||||
return (
|
||||
<span className={`px-3 py-1 text-sm font-medium rounded-full ${badges[type as keyof typeof badges] || badges.modification}`}>
|
||||
{labels[type as keyof typeof labels] || type}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getElementLabel = (element: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
objet: "Objet (profession, production)",
|
||||
duree: "Durée de l'engagement",
|
||||
lieu_horaire: "Lieu et horaires",
|
||||
remuneration: "Rémunération",
|
||||
};
|
||||
return labels[element] || element;
|
||||
};
|
||||
|
||||
const getSignatureStatusBadge = (status?: string) => {
|
||||
if (!status || status === 'not_sent') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-full text-sm font-medium">
|
||||
<Clock className="w-4 h-4" />
|
||||
Non envoyé
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'pending_employer') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-orange-100 text-orange-700 rounded-full text-sm font-medium">
|
||||
<Clock className="w-4 h-4" />
|
||||
En attente employeur
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'pending_employee') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-100 text-blue-700 rounded-full text-sm font-medium">
|
||||
<Clock className="w-4 h-4" />
|
||||
En attente salarié
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'signed') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-green-100 text-green-700 rounded-full text-sm font-medium">
|
||||
<Check className="w-4 h-4" />
|
||||
Signé
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
{/* Header avec bouton retour */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push("/staff/avenants")}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 text-slate-600" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-slate-900">
|
||||
Avenant {avenant.numero_avenant}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
Contrat {contract?.contract_number}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusBadge(avenant.statut)}
|
||||
{getTypeBadge(avenant.type_avenant)}
|
||||
<button
|
||||
onClick={() => setShowChangeStatusModal(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 border border-indigo-300 text-indigo-700 rounded-lg hover:bg-indigo-50 transition-colors"
|
||||
title="Changer le statut manuellement"
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
Changer statut
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informations générales */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||
<h2 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Informations générales
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-500">Date d'effet</label>
|
||||
<div className="mt-1 text-slate-900">{formatDate(avenant.date_effet)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-500">Date de signature</label>
|
||||
<div className="mt-1 text-slate-900">{formatDate(avenant.date_avenant)}</div>
|
||||
</div>
|
||||
{avenant.motif_avenant && (
|
||||
<div className="col-span-2">
|
||||
<label className="text-sm font-medium text-slate-500">Motif</label>
|
||||
<div className="mt-1 text-slate-900">{avenant.motif_avenant}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informations du contrat */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||
<h2 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Contrat concerné
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-500">Salarié</label>
|
||||
<div className="mt-1 text-slate-900">
|
||||
{contract?.employee_name}
|
||||
{contract?.employee_matricule && (
|
||||
<span className="text-slate-500 ml-2">({contract.employee_matricule})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-500">Organisation</label>
|
||||
<div className="mt-1 text-slate-900">{contract?.structure}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-500">Type de contrat</label>
|
||||
<div className="mt-1 text-slate-900">{contract?.type_de_contrat}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-500">Période</label>
|
||||
<div className="mt-1 text-slate-900">
|
||||
{formatDate(contract?.start_date)} - {formatDate(contract?.end_date)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signatures électroniques */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||
<h2 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<Send className="h-5 w-5" />
|
||||
Signatures électroniques
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-lg">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-900">Statut global</div>
|
||||
<div className="text-xs text-slate-600 mt-0.5">État actuel du processus de signature</div>
|
||||
</div>
|
||||
<div>
|
||||
{getSignatureStatusBadge(avenant.signature_status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm font-medium text-slate-900">Employeur</div>
|
||||
{avenant.signature_status === 'pending_employee' || avenant.signature_status === 'signed' ? (
|
||||
<Check className="w-5 h-5 text-green-600" strokeWidth={3} />
|
||||
) : avenant.signature_status === 'pending_employer' ? (
|
||||
<X className="w-5 h-5 text-red-600" strokeWidth={3} />
|
||||
) : (
|
||||
<Clock className="w-5 h-5 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
{avenant.last_employer_notification_at && (
|
||||
<div className="text-xs text-slate-600">
|
||||
Notifié le {new Date(avenant.last_employer_notification_at).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm font-medium text-slate-900">Salarié</div>
|
||||
{avenant.signature_status === 'signed' ? (
|
||||
<Check className="w-5 h-5 text-green-600" strokeWidth={3} />
|
||||
) : avenant.signature_status === 'pending_employee' || avenant.signature_status === 'pending_employer' ? (
|
||||
<X className="w-5 h-5 text-red-600" strokeWidth={3} />
|
||||
) : (
|
||||
<Clock className="w-5 h-5 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
{avenant.last_employee_notification_at && (
|
||||
<div className="text-xs text-slate-600">
|
||||
Notifié le {new Date(avenant.last_employee_notification_at).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Éléments modifiés */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||
<h2 className="font-semibold text-slate-900 mb-4">Éléments modifiés</h2>
|
||||
<div className="space-y-4">
|
||||
{avenant.elements_avenantes?.map((element: string) => (
|
||||
<div key={element} className="border-l-4 border-indigo-500 pl-4 py-2">
|
||||
<h3 className="font-medium text-slate-900 mb-2">{getElementLabel(element)}</h3>
|
||||
|
||||
{element === "objet" && avenant.objet_data && (
|
||||
<div className="text-sm text-slate-600 space-y-1">
|
||||
{avenant.objet_data.profession_label && (
|
||||
<div>Profession : {avenant.objet_data.profession_label}</div>
|
||||
)}
|
||||
{avenant.objet_data.production_name && (
|
||||
<div>Production : {avenant.objet_data.production_name}</div>
|
||||
)}
|
||||
{avenant.objet_data.production_numero_objet && (
|
||||
<div>N° objet : {avenant.objet_data.production_numero_objet}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{element === "duree" && avenant.duree_data && (
|
||||
<div className="text-sm text-slate-600 space-y-1">
|
||||
{avenant.duree_data.date_debut && (
|
||||
<div>Nouvelle date de début : {formatDate(avenant.duree_data.date_debut)}</div>
|
||||
)}
|
||||
{avenant.duree_data.date_fin && (
|
||||
<div>Nouvelle date de fin : {formatDate(avenant.duree_data.date_fin)}</div>
|
||||
)}
|
||||
|
||||
{/* Pour les artistes */}
|
||||
{avenant.duree_data.nb_representations !== undefined && avenant.duree_data.nb_representations > 0 && (
|
||||
<div>Représentations : {avenant.duree_data.nb_representations}</div>
|
||||
)}
|
||||
{avenant.duree_data.nb_repetitions !== undefined && avenant.duree_data.nb_repetitions > 0 && (
|
||||
<div>Répétitions : {avenant.duree_data.nb_repetitions}</div>
|
||||
)}
|
||||
|
||||
{/* Pour les techniciens */}
|
||||
{avenant.duree_data.nb_heures !== undefined && avenant.duree_data.nb_heures > 0 && (
|
||||
<div>Nombre d'heures : {avenant.duree_data.nb_heures}h</div>
|
||||
)}
|
||||
|
||||
{/* Dates de travail */}
|
||||
{Array.isArray(avenant.duree_data.dates_representations) && avenant.duree_data.dates_representations.length > 0 && (
|
||||
<div>
|
||||
<div className="font-medium mt-2">Dates de représentations :</div>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{avenant.duree_data.dates_representations.map((d: any, idx: number) => (
|
||||
<div key={idx} className="inline-flex items-center gap-2 px-2 py-1 bg-blue-100 rounded text-xs">
|
||||
<span className="font-medium">{formatDate(d.date)}</span>
|
||||
{d.quantity && d.quantity > 0 && (
|
||||
<span className="text-blue-700">({d.quantity})</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(avenant.duree_data.dates_repetitions) && avenant.duree_data.dates_repetitions.length > 0 && (
|
||||
<div>
|
||||
<div className="font-medium mt-2">Dates de répétitions :</div>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{avenant.duree_data.dates_repetitions.map((d: any, idx: number) => (
|
||||
<div key={idx} className="inline-flex items-center gap-2 px-2 py-1 bg-purple-100 rounded text-xs">
|
||||
<span className="font-medium">{formatDate(d.date)}</span>
|
||||
{d.quantity && d.quantity > 0 && (
|
||||
<span className="text-purple-700">({d.quantity})</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jours de travail (techniciens) */}
|
||||
{avenant.duree_data.jours_travail && (
|
||||
<div>
|
||||
<div className="font-medium mt-2">Jours de travail :</div>
|
||||
{Array.isArray(avenant.duree_data.jours_travail) ? (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{avenant.duree_data.jours_travail.map((d: any, idx: number) => (
|
||||
<div key={idx} className="inline-flex items-center gap-2 px-2 py-1 bg-slate-100 rounded text-xs">
|
||||
<span className="font-medium">{formatDate(d.date)}</span>
|
||||
{d.quantity && d.quantity > 0 && (
|
||||
<span className="text-slate-600">({d.quantity}h)</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs">{avenant.duree_data.jours_travail}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{element === "lieu_horaire" && avenant.lieu_horaire_data && (
|
||||
<div className="text-sm text-slate-600 space-y-1">
|
||||
{avenant.lieu_horaire_data.lieu && (
|
||||
<div>Lieu : {avenant.lieu_horaire_data.lieu}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{element === "remuneration" && avenant.remuneration_data && (
|
||||
<div className="text-sm text-slate-600 space-y-1">
|
||||
{avenant.remuneration_data.gross_pay && (
|
||||
<div>Salaire brut : {avenant.remuneration_data.gross_pay} €</div>
|
||||
)}
|
||||
{avenant.remuneration_data.type_salaire && (
|
||||
<div>Type : {avenant.remuneration_data.type_salaire}</div>
|
||||
)}
|
||||
{avenant.remuneration_data.precisions_salaire && (
|
||||
<div>Précisions : {avenant.remuneration_data.precisions_salaire}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PDF */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||
<h2 className="font-semibold text-slate-900 mb-4">Document signé</h2>
|
||||
<div className="flex items-start gap-4 p-4 bg-slate-50 rounded-lg border">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
|
||||
<FileText className="h-6 w-6 text-indigo-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-900">
|
||||
{avenant.pdf_s3_key ? `Avenant ${avenant.numero_avenant}.pdf` : "PDF non généré"}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 mt-1">
|
||||
{avenant.pdf_s3_key ? "Document stocké sur AWS S3" : "Aucun document disponible"}
|
||||
</div>
|
||||
{avenant.pdf_s3_key && (
|
||||
<div className="text-xs text-slate-400 mt-1 font-mono truncate">
|
||||
{avenant.pdf_s3_key}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex gap-2">
|
||||
{loadingPdf ? (
|
||||
<div className="px-4 py-2 bg-slate-100 text-slate-500 rounded-lg">
|
||||
Chargement...
|
||||
</div>
|
||||
) : pdfUrl ? (
|
||||
<>
|
||||
<a
|
||||
href={pdfUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Télécharger
|
||||
</a>
|
||||
<button
|
||||
onClick={handleRegeneratePdf}
|
||||
disabled={isRegeneratingPdf}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 border border-indigo-300 text-indigo-700 rounded-lg hover:bg-indigo-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRegeneratingPdf ? "animate-spin" : ""}`} />
|
||||
{isRegeneratingPdf ? "Génération..." : "Regénérer"}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRegeneratePdf}
|
||||
disabled={isRegeneratingPdf}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRegeneratingPdf ? "animate-spin" : ""}`} />
|
||||
{isRegeneratingPdf ? "Génération..." : "Générer le PDF"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push("/staff/avenants")}
|
||||
className="px-6 py-3 text-slate-700 hover:bg-slate-100 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Retour à la liste
|
||||
</button>
|
||||
{avenant.statut === "draft" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!avenant.pdf_s3_key) {
|
||||
alert("Vous devez d'abord générer le PDF avant de l'envoyer en signature.");
|
||||
return;
|
||||
}
|
||||
setShowSendSignatureModal(true);
|
||||
}}
|
||||
disabled={isSendingSignature}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
Envoyer pour signature
|
||||
</button>
|
||||
<button className="px-6 py-3 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors font-medium">
|
||||
Modifier
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modale de suppression */}
|
||||
<DeleteAvenantModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={handleDelete}
|
||||
numeroAvenant={avenant.numero_avenant}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
|
||||
{/* Modale d'envoi en signature */}
|
||||
<SendSignatureModal
|
||||
isOpen={showSendSignatureModal}
|
||||
onClose={() => {
|
||||
setShowSendSignatureModal(false);
|
||||
setSendSignatureSuccess(false);
|
||||
}}
|
||||
onConfirm={handleSendForSignature}
|
||||
isSending={isSendingSignature}
|
||||
success={sendSignatureSuccess}
|
||||
avenantData={{
|
||||
numero_avenant: avenant.numero_avenant,
|
||||
contractReference: contract?.contract_number || "N/A",
|
||||
employeeName: contract?.salaries ? `${contract.salaries.prenom} ${contract.salaries.nom}` : "N/A",
|
||||
employerEmail: contract?.organizations?.organization_details?.email_signature || "N/A",
|
||||
employeeEmail: contract?.salaries?.adresse_mail || "N/A",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Modale de changement de statut */}
|
||||
<ChangeStatusModal
|
||||
isOpen={showChangeStatusModal}
|
||||
onClose={() => setShowChangeStatusModal(false)}
|
||||
onConfirm={handleChangeStatus}
|
||||
currentStatus={avenant.statut}
|
||||
isChanging={isChangingStatus}
|
||||
numeroAvenant={avenant.numero_avenant}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<EmailTypeV2, EmailTemplateV2> = {
|
|||
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<EmailTypeV2, EmailTemplateV2> = {
|
|||
}
|
||||
},
|
||||
|
||||
'signature-request-employee-amendment': {
|
||||
subject: 'Signez votre avenant {{#if numeroAvenant}}n°{{numeroAvenant}}{{/if}} - {{organizationName}}',
|
||||
title: 'Demande de signature électronique',
|
||||
greeting: '{{#if firstName}}Bonjour {{firstName}},{{/if}}',
|
||||
mainMessage: 'Nous vous invitons à signer votre avenant au contrat de travail ci-dessous.<br><br>Cliquez sur « Signer l\'avenant » pour accéder à Odentas Sign. Vous pourrez télécharger votre avenant dès validation de votre signature.',
|
||||
ctaText: 'Signer l\'avenant',
|
||||
closingMessage: 'Pour toute question, contactez-nous à <a href="mailto:paie@odentas.fr" style="color:#0B5FFF; text-decoration:none;">paie@odentas.fr</a>.',
|
||||
footerText: 'Vous recevez cet e-mail car votre employeur ({{organizationName}}) est client d\'Odentas Media SAS, pour vous notifier d\'une action sur votre contrat de travail avec cet employeur.',
|
||||
preheaderText: 'Signature électronique · Avenant {{#if numeroAvenant}}n°{{numeroAvenant}}{{/if}} · {{organizationName}}',
|
||||
colors: {
|
||||
headerColor: STANDARD_COLORS.HEADER,
|
||||
titleColor: '#0F172A',
|
||||
buttonColor: STANDARD_COLORS.BUTTON,
|
||||
buttonTextColor: STANDARD_COLORS.BUTTON_TEXT,
|
||||
cardBackgroundColor: '#FFFFFF',
|
||||
cardBorder: '#E5E7EB',
|
||||
cardTitleColor: '#0F172A',
|
||||
alertIndicatorColor: '#22C55E',
|
||||
},
|
||||
infoCard: [
|
||||
{ label: 'Votre employeur', key: 'organizationName' },
|
||||
{ label: 'Votre matricule', key: 'matricule' },
|
||||
],
|
||||
detailsCard: {
|
||||
title: 'Détails de l\'avenant',
|
||||
rows: [
|
||||
{ label: 'Référence contrat', key: 'contractReference' },
|
||||
{ label: 'Type de contrat', key: 'contractType' },
|
||||
{ label: 'Profession', key: 'profession' },
|
||||
{ label: 'Date de début', key: 'startDate' },
|
||||
{ label: 'Production', key: 'productionName' },
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
'signature-request-salarie': {
|
||||
subject: 'Signez votre contrat {{organizationName}}',
|
||||
title: 'Demande de signature électronique',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
102
supabase/migrations/20251023_create_avenants_table.sql
Normal file
102
supabase/migrations/20251023_create_avenants_table.sql
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
-- Migration: Création de la table avenants
|
||||
-- Date: 2025-10-23
|
||||
-- Description: Table pour gérer les avenants aux contrats de travail
|
||||
|
||||
-- Création de la table avenants
|
||||
CREATE TABLE IF NOT EXISTS public.avenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Référence au contrat
|
||||
contract_id UUID NOT NULL REFERENCES public.cddu_contracts(id) ON DELETE CASCADE,
|
||||
|
||||
-- Informations de l'avenant
|
||||
numero_avenant TEXT NOT NULL, -- Ex: "AVE-001", "AVE-002"...
|
||||
date_avenant DATE NOT NULL, -- Date de signature de l'avenant
|
||||
date_effet DATE NOT NULL, -- Date d'effet de l'avenant
|
||||
|
||||
-- Type d'avenant
|
||||
type_avenant TEXT NOT NULL DEFAULT 'modification', -- 'modification' ou 'annulation'
|
||||
motif_avenant TEXT, -- Motif de l'avenant (texte libre)
|
||||
|
||||
-- Éléments avenantés (array de strings)
|
||||
elements_avenantes TEXT[] NOT NULL DEFAULT '{}', -- ['objet', 'duree', 'lieu_horaire', 'remuneration']
|
||||
|
||||
-- Données spécifiques selon les éléments avenantés (JSONB pour flexibilité)
|
||||
objet_data JSONB, -- { profession_code, profession_label, production_name, production_numero_objet }
|
||||
duree_data JSONB, -- { date_debut, date_fin, nb_representations, nb_repetitions, nb_heures, dates_representations, dates_repetitions, jours_travail }
|
||||
lieu_horaire_data JSONB, -- { lieu, horaires, adresse }
|
||||
remuneration_data JSONB, -- { gross_pay, precisions_salaire, type_salaire }
|
||||
|
||||
-- Statut de l'avenant
|
||||
statut TEXT NOT NULL DEFAULT 'draft', -- 'draft', 'pending', 'signed', 'cancelled'
|
||||
|
||||
-- URL du PDF généré
|
||||
pdf_url TEXT,
|
||||
pdf_s3_key TEXT,
|
||||
|
||||
-- Métadonnées
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id)
|
||||
);
|
||||
|
||||
-- Index pour améliorer les performances
|
||||
CREATE INDEX IF NOT EXISTS idx_avenants_contract_id ON public.avenants(contract_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_avenants_date_avenant ON public.avenants(date_avenant);
|
||||
CREATE INDEX IF NOT EXISTS idx_avenants_statut ON public.avenants(statut);
|
||||
CREATE INDEX IF NOT EXISTS idx_avenants_type_avenant ON public.avenants(type_avenant);
|
||||
|
||||
-- Trigger pour mettre à jour updated_at
|
||||
CREATE OR REPLACE FUNCTION update_avenants_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_update_avenants_updated_at
|
||||
BEFORE UPDATE ON public.avenants
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_avenants_updated_at();
|
||||
|
||||
-- RLS (Row Level Security)
|
||||
ALTER TABLE public.avenants ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policy: Les utilisateurs staff peuvent tout faire
|
||||
CREATE POLICY "Staff can manage avenants"
|
||||
ON public.avenants
|
||||
FOR ALL
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.staff_users
|
||||
WHERE staff_users.user_id = auth.uid()
|
||||
AND staff_users.is_staff = true
|
||||
)
|
||||
);
|
||||
|
||||
-- Policy: Les organisations peuvent voir leurs avenants
|
||||
CREATE POLICY "Organizations can view their avenants"
|
||||
ON public.avenants
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.cddu_contracts c
|
||||
INNER JOIN public.organization_members om ON c.org_id = om.org_id
|
||||
WHERE c.id = avenants.contract_id
|
||||
AND om.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Commentaires
|
||||
COMMENT ON TABLE public.avenants IS 'Table des avenants aux contrats de travail';
|
||||
COMMENT ON COLUMN public.avenants.contract_id IS 'Référence au contrat concerné';
|
||||
COMMENT ON COLUMN public.avenants.numero_avenant IS 'Numéro de l''avenant (ex: AVE-001)';
|
||||
COMMENT ON COLUMN public.avenants.date_avenant IS 'Date de signature de l''avenant';
|
||||
COMMENT ON COLUMN public.avenants.date_effet IS 'Date d''effet de l''avenant';
|
||||
COMMENT ON COLUMN public.avenants.type_avenant IS 'Type: modification ou annulation';
|
||||
COMMENT ON COLUMN public.avenants.motif_avenant IS 'Motif de l''avenant';
|
||||
COMMENT ON COLUMN public.avenants.elements_avenantes IS 'Liste des éléments modifiés par l''avenant';
|
||||
COMMENT ON COLUMN public.avenants.statut IS 'Statut: draft, pending, signed, cancelled';
|
||||
124
templates-mails/signature-avenant-employeur.html
Normal file
124
templates-mails/signature-avenant-employeur.html
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Demande de signature électronique - avenant</title>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<style>
|
||||
body { margin:0; padding:0; background:#F4F6FA; color:#0F172A; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; }
|
||||
a { color:#0B5FFF; text-decoration:none; }
|
||||
.wrapper { width:100%; padding:32px 0; background:#F4F6FA; }
|
||||
.container { width:100%; max-width:520px; margin:0 auto; background:#FFFFFF; border-radius:14px; box-shadow: 0 6px 20px rgba(15,23,42,0.06); overflow:hidden; }
|
||||
.logo { height:60px; display:block; }
|
||||
.label { font-size:12px; color:#64748B; text-transform:uppercase; letter-spacing:0.03em; margin-bottom:6px; font-weight:500; }
|
||||
.value { font-size:15px; color:#0F172A; font-weight:500; line-height:1.3; }
|
||||
@media (max-width: 480px) {
|
||||
.wrapper { padding:16px 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">Demande de signature électronique - avenant · Signez votre avenant au contrat de travail</div>
|
||||
|
||||
<div class="wrapper">
|
||||
<div class="container">
|
||||
<div style="padding:28px 28px 10px 28px; text-align:left; background:#FFFFFF;">
|
||||
<img class="logo" src="https://i.imgur.com/yDQU9G9.png" alt="Odentas">
|
||||
|
||||
<div style="display:flex; align-items:baseline; margin:18px 0 16px 0;">
|
||||
<div style="width:8px; height:8px; background:#F59E0B; border-radius:50%; margin-right:12px; margin-top:8px; flex-shrink:0;"></div>
|
||||
<h1 style="font-size:22px; line-height:1.3; margin:0; font-weight:700; color:#0F172A;">Demande de signature électronique</h1>
|
||||
</div>
|
||||
|
||||
<!-- Info Card -->
|
||||
<div style="margin:0 0 24px 0; border:1px solid #E5E7EB; border-radius:12px; padding:16px; background:#F8FAFC;">
|
||||
<div style="margin-bottom:8px;">
|
||||
<div class="label">Votre structure</div>
|
||||
<div class="value">{{structure}}</div>
|
||||
</div>
|
||||
<div style="margin-bottom:8px;">
|
||||
<div class="label">Votre code employeur</div>
|
||||
<div class="value">{{code_employeur}}</div>
|
||||
</div>
|
||||
<div style="margin-bottom:0;">
|
||||
<div class="label">Votre gestionnaire</div>
|
||||
<div class="value">Renaud BREVIERE-ABRAHAM</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin:20px 0;">
|
||||
<p style="font-size:15px; color:#334155; margin:0 0 8px; font-weight:500;">👋 Bonjour {{prenom_signataire}},</p>
|
||||
<p style="font-size:15px; color:#334155; margin:0 0 8px; line-height:1.4;">Nous vous invitons à signer l'avenant au contrat de travail ci-dessous.</p>
|
||||
<p style="font-size:15px; color:#334155; margin:0 0 8px; line-height:1.4;">Cliquez sur "Signer l'avenant" pour accéder à Odentas Sign.</p>
|
||||
<p style="font-size:15px; color:#334155; margin:0; line-height:1.4;">Votre salarié·e recevra ensuite son exemplaire pour signature. Vous recevrez une notification par e-mail dès réception de toutes les signatures.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<div style="padding:0 28px 24px 28px; text-align:center;">
|
||||
<a href="{{signatureLink}}" style="display:inline-block; padding:14px 32px; background:#F59E0B; color:#1C1917; font-weight:700; font-size:16px; border-radius:10px; text-decoration:none; transition: background 0.3s;">
|
||||
Signer l'avenant
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Details Card -->
|
||||
<div style="margin:0px 28px 24px 28px; border:1px solid #E5E7EB; border-radius:12px; padding:20px; background:#FFFFFF;">
|
||||
<h2 style="font-size:16px; margin:0 0 12px; color:#0F172A; font-weight:600;">Détails de l'avenant</h2>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div class="label">Référence contrat</div>
|
||||
<div class="value">{{reference}}</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div class="label">Numéro avenant</div>
|
||||
<div class="value">{{numero_avenant}}</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div class="label">Type de contrat</div>
|
||||
<div class="value">CDDU (contrat intermittent)</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div class="label">Salarié·e</div>
|
||||
<div class="value">{{salarie}}</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div class="label">Début contrat</div>
|
||||
<div class="value">{{date_debut}}</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div class="label">Poste</div>
|
||||
<div class="value">{{poste}}</div>
|
||||
</div>
|
||||
<div style="margin-bottom:0;">
|
||||
<div class="label">Analytique</div>
|
||||
<div class="value">{{analytique}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Closing message -->
|
||||
<div style="padding:0 28px 24px 28px;">
|
||||
<p style="font-size:15px; color:#334155; margin:0 0 8px; line-height:1.4;">N'hésitez pas à répondre à cet e-mail si vous avez besoin d'assistance.</p>
|
||||
<p style="font-size:15px; color:#334155; margin:0 0 4px; line-height:1.4;">Merci pour votre confiance,</p>
|
||||
<p style="font-size:15px; color:#334155; margin:0; font-weight:500;">L'équipe Odentas</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="padding:24px 28px; border-top:1px solid #E5E7EB; text-align:center; background:#F8FAFC;">
|
||||
<img src="https://i.imgur.com/yDQU9G9.png" alt="Odentas" style="height:32px; display:block; margin:0 auto 12px;">
|
||||
<p style="font-size:11px; color:#94A3B8; margin:0 0 4px; line-height:1.4;">Odentas Media SAS | RCS Paris 907880348</p>
|
||||
<p style="font-size:11px; color:#94A3B8; margin:0 0 4px; line-height:1.4;">6 rue d'Armaillé, 75017 Paris</p>
|
||||
<p style="font-size:11px; color:#94A3B8; margin:0 0 8px; line-height:1.4;">
|
||||
<a href="mailto:paie@odentas.fr" style="color:#0B5FFF;">paie@odentas.fr</a> ·
|
||||
<a href="https://paie.odentas.fr" style="color:#0B5FFF;">Accès à l'Espace Paie</a>
|
||||
</p>
|
||||
<p style="font-size:11px; color:#94A3B8; margin:0 0 8px; line-height:1.4;">
|
||||
🇫🇷 Vos documents sont stockés dans un datacenter AWS à Paris
|
||||
</p>
|
||||
<p style="font-size:10px; color:#94A3B8; margin:0; line-height:1.3;">
|
||||
Nous vous recommandons d'ajouter paie@odentas.fr à vos contacts pour être sûr de recevoir nos notifications. Vous recevez cet e-mail car vous êtes client·e de Odentas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue