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 recordsEmployeurRef = useRef<AirtableRecord[]>([]);
|
||||||
const recordsSalarieRef = useRef<AirtableRecord[]>([]);
|
const recordsSalarieRef = useRef<AirtableRecord[]>([]);
|
||||||
|
|
||||||
|
// États pour les avenants
|
||||||
|
const [avenantsEmployeur, setAvenantsEmployeur] = useState<AirtableRecord[]>([]);
|
||||||
|
const [avenantsSalarie, setAvenantsSalarie] = useState<AirtableRecord[]>([]);
|
||||||
|
const avenantsEmployeurRef = useRef<AirtableRecord[]>([]);
|
||||||
|
const avenantsSalarieRef = useRef<AirtableRecord[]>([]);
|
||||||
|
|
||||||
// États pour les modales
|
// États pour les modales
|
||||||
const [modalTitle, setModalTitle] = useState('');
|
const [modalTitle, setModalTitle] = useState('');
|
||||||
const [embedSrc, setEmbedSrc] = useState<string | null>(null);
|
const [embedSrc, setEmbedSrc] = useState<string | null>(null);
|
||||||
|
|
@ -192,10 +198,6 @@ export default function SignaturesElectroniques() {
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
const signingContractIdRef = useRef<string | null>(null);
|
const signingContractIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Modal de confirmation après signature employeur (DocuSeal 'completed')
|
|
||||||
const [showCompletedModal, setShowCompletedModal] = useState(false);
|
|
||||||
const showCompletedModalRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true;
|
isMountedRef.current = true;
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -203,10 +205,6 @@ export default function SignaturesElectroniques() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
showCompletedModalRef.current = showCompletedModal;
|
|
||||||
}, [showCompletedModal]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
recordsEmployeurRef.current = recordsEmployeur;
|
recordsEmployeurRef.current = recordsEmployeur;
|
||||||
}, [recordsEmployeur]);
|
}, [recordsEmployeur]);
|
||||||
|
|
@ -504,7 +502,7 @@ export default function SignaturesElectroniques() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load current contracts to sign (server-side API fetches Airtable)
|
// Load current contracts and amendments to sign (server-side API fetches Supabase)
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!isMountedRef.current) return;
|
if (!isMountedRef.current) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -513,23 +511,40 @@ export default function SignaturesElectroniques() {
|
||||||
// Ajouter le paramètre org_id si sélectionné (staff uniquement)
|
// Ajouter le paramètre org_id si sélectionné (staff uniquement)
|
||||||
const orgParam = selectedOrgId ? `&org_id=${selectedOrgId}` : '';
|
const orgParam = selectedOrgId ? `&org_id=${selectedOrgId}` : '';
|
||||||
|
|
||||||
const [rEmp, rSal] = await Promise.all([
|
const [rEmp, rSal, rAveEmp, rAveSal] = await Promise.all([
|
||||||
fetch(`/api/signatures-electroniques/contrats?scope=employeur${orgParam}`, { cache: 'no-store' }),
|
fetch(`/api/signatures-electroniques/contrats?scope=employeur${orgParam}`, { cache: 'no-store' }),
|
||||||
fetch(`/api/signatures-electroniques/contrats?scope=salarie${orgParam}`, { cache: 'no-store' }),
|
fetch(`/api/signatures-electroniques/contrats?scope=salarie${orgParam}`, { cache: 'no-store' }),
|
||||||
|
fetch(`/api/signatures-electroniques/avenants?scope=employeur${orgParam}`, { cache: 'no-store' }),
|
||||||
|
fetch(`/api/signatures-electroniques/avenants?scope=salarie${orgParam}`, { cache: 'no-store' }),
|
||||||
]);
|
]);
|
||||||
if (!rEmp.ok) throw new Error(`HTTP employeur ${rEmp.status}`);
|
if (!rEmp.ok) throw new Error(`HTTP contrats employeur ${rEmp.status}`);
|
||||||
if (!rSal.ok) throw new Error(`HTTP salarie ${rSal.status}`);
|
if (!rSal.ok) throw new Error(`HTTP contrats salarie ${rSal.status}`);
|
||||||
|
if (!rAveEmp.ok) throw new Error(`HTTP avenants employeur ${rAveEmp.status}`);
|
||||||
|
if (!rAveSal.ok) throw new Error(`HTTP avenants salarie ${rAveSal.status}`);
|
||||||
|
|
||||||
const emp: ContractsResponse = await rEmp.json();
|
const emp: ContractsResponse = await rEmp.json();
|
||||||
const sal: ContractsResponse = await rSal.json();
|
const sal: ContractsResponse = await rSal.json();
|
||||||
|
const aveEmp: ContractsResponse = await rAveEmp.json();
|
||||||
|
const aveSal: ContractsResponse = await rAveSal.json();
|
||||||
|
|
||||||
if (!isMountedRef.current) return;
|
if (!isMountedRef.current) return;
|
||||||
const empRecords = emp.records || [];
|
|
||||||
const salarieRecords = sal.records || [];
|
const empRecords = emp.records || [];
|
||||||
setRecordsEmployeur(empRecords);
|
const salarieRecords = sal.records || [];
|
||||||
setRecordsSalarie(salarieRecords);
|
const aveEmpRecords = aveEmp.records || [];
|
||||||
recordsEmployeurRef.current = empRecords;
|
const aveSalRecords = aveSal.records || [];
|
||||||
recordsSalarieRef.current = salarieRecords;
|
|
||||||
|
setRecordsEmployeur(empRecords);
|
||||||
|
setRecordsSalarie(salarieRecords);
|
||||||
|
setAvenantsEmployeur(aveEmpRecords);
|
||||||
|
setAvenantsSalarie(aveSalRecords);
|
||||||
|
|
||||||
|
recordsEmployeurRef.current = empRecords;
|
||||||
|
recordsSalarieRef.current = salarieRecords;
|
||||||
|
avenantsEmployeurRef.current = aveEmpRecords;
|
||||||
|
avenantsSalarieRef.current = aveSalRecords;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('Load contracts error', e);
|
console.error('Load contracts/amendments error', e);
|
||||||
if (!isMountedRef.current) return;
|
if (!isMountedRef.current) return;
|
||||||
setError(e?.message || 'Erreur de chargement');
|
setError(e?.message || 'Erreur de chargement');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -602,17 +617,64 @@ export default function SignaturesElectroniques() {
|
||||||
if (formEl) {
|
if (formEl) {
|
||||||
const onCompleted = async (_event: Event) => {
|
const onCompleted = async (_event: Event) => {
|
||||||
console.log('✅ [SIGNATURES] Event completed reçu - déclenchement du rafraîchissement');
|
console.log('✅ [SIGNATURES] Event completed reçu - déclenchement du rafraîchissement');
|
||||||
setShowCompletedModal(true);
|
|
||||||
|
|
||||||
if (!isMountedRef.current) return;
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
|
// Remplacer le contenu du modal par le message de confirmation
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="min-h-[500px] flex items-center justify-center p-6 animate-fadeIn">
|
||||||
|
<div class="max-w-lg w-full">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="p-6 bg-emerald-50 border border-emerald-100 rounded-t-2xl">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-emerald-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-slate-900">Signature employeur prise en compte</h2>
|
||||||
|
<p class="text-sm text-slate-600 mt-1">Le processus se poursuit automatiquement côté salarié.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="p-6 bg-white border-x border-slate-200">
|
||||||
|
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||||
|
<ul class="text-sm text-slate-700 space-y-2 list-disc pl-5">
|
||||||
|
<li>Le salarié va recevoir son propre exemplaire pour signature électronique.</li>
|
||||||
|
<li>Vous pourrez télécharger le contrat depuis la fiche contrat dès que la signature du salarié aura été reçue.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="px-6 py-4 bg-gray-50 border border-t-0 border-slate-200 rounded-b-2xl flex justify-end">
|
||||||
|
<button
|
||||||
|
id="btn-close-confirmation"
|
||||||
|
class="px-6 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
J'ai compris
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Ajouter l'événement de fermeture
|
||||||
|
const btnClose = container.querySelector('#btn-close-confirmation') as HTMLButtonElement | null;
|
||||||
|
if (btnClose) {
|
||||||
|
btnClose.onclick = () => {
|
||||||
|
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
|
||||||
|
if (dlg) dlg.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setReloadingAfterSignatureChange(true);
|
setReloadingAfterSignatureChange(true);
|
||||||
try {
|
try {
|
||||||
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
|
|
||||||
if (dlg && !dlg.open) {
|
|
||||||
try { dlg.showModal(); } catch (err) { console.warn('Impossible de ré-ouvrir le modal DocuSeal', err); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Laisser le temps au webhook DocuSeal de mettre à jour Supabase puis recharger avec retries
|
// Laisser le temps au webhook DocuSeal de mettre à jour Supabase puis recharger avec retries
|
||||||
await new Promise((resolve) => setTimeout(resolve, 900));
|
await new Promise((resolve) => setTimeout(resolve, 900));
|
||||||
await loadWithRetry({
|
await loadWithRetry({
|
||||||
|
|
@ -642,10 +704,6 @@ export default function SignaturesElectroniques() {
|
||||||
|
|
||||||
const handleSignatureClose = () => {
|
const handleSignatureClose = () => {
|
||||||
console.log('🔄 Modal signature fermé, rechargement des données...');
|
console.log('🔄 Modal signature fermé, rechargement des données...');
|
||||||
// Si la confirmation est affichée, empêcher la fermeture effective et ré-ouvrir le dialog
|
|
||||||
if (showCompletedModalRef.current) {
|
|
||||||
try { dlgSignature?.showModal(); } catch {}
|
|
||||||
}
|
|
||||||
load();
|
load();
|
||||||
signingContractIdRef.current = null;
|
signingContractIdRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
@ -748,31 +806,78 @@ export default function SignaturesElectroniques() {
|
||||||
|
|
||||||
async function openSignature(rec: AirtableRecord) {
|
async function openSignature(rec: AirtableRecord) {
|
||||||
const f = rec.fields || {};
|
const f = rec.fields || {};
|
||||||
|
const isAvenant = f.is_avenant === true;
|
||||||
let embed: string | null = null;
|
let embed: string | null = null;
|
||||||
const title = `Signature (Employeur) · ${f.Reference || rec.id}`;
|
const docRef = f.reference || f.Reference || rec.id;
|
||||||
|
const title = `Signature (Employeur) · ${isAvenant ? 'Avenant' : 'Contrat'} ${docRef}`;
|
||||||
setModalTitle(title);
|
setModalTitle(title);
|
||||||
signingContractIdRef.current = rec.id;
|
signingContractIdRef.current = rec.id;
|
||||||
|
|
||||||
console.log('🔍 [SIGNATURES] Debug - record fields:', f);
|
console.log('🔍 [SIGNATURES] Debug - record fields:', f);
|
||||||
|
console.log('🔍 [SIGNATURES] Debug - is_avenant:', isAvenant);
|
||||||
console.log('🔍 [SIGNATURES] Debug - embed_src_employeur:', f.embed_src_employeur);
|
console.log('🔍 [SIGNATURES] Debug - embed_src_employeur:', f.embed_src_employeur);
|
||||||
console.log('🔍 [SIGNATURES] Debug - docuseal_template_id:', f.docuseal_template_id);
|
console.log('🔍 [SIGNATURES] Debug - docuseal_template_id:', f.docuseal_template_id);
|
||||||
|
console.log('🔍 [SIGNATURES] Debug - docuseal_submission_id:', f.docuseal_submission_id);
|
||||||
console.log('🔍 [SIGNATURES] Debug - signature_b64:', f.signature_b64 ? 'présente' : 'absente');
|
console.log('🔍 [SIGNATURES] Debug - signature_b64:', f.signature_b64 ? 'présente' : 'absente');
|
||||||
|
|
||||||
// Gérer la signature pré-remplie si disponible
|
// Gérer la signature pré-remplie
|
||||||
if (f.signature_b64) {
|
// Pour les contrats: utiliser f.signature_b64
|
||||||
console.log('✅ [SIGNATURES] Signature trouvée, stockage dans l\'état React');
|
// Pour les avenants: utiliser currentSignature (signature de l'organisation)
|
||||||
const normalizedSignature = normalizeSignatureFormat(f.signature_b64);
|
let signatureToUse = null;
|
||||||
|
|
||||||
|
if (isAvenant) {
|
||||||
|
// Pour les avenants, utiliser la signature de l'organisation
|
||||||
|
if (currentSignature) {
|
||||||
|
console.log('✅ [SIGNATURES AVENANT] Utilisation de la signature de l\'organisation');
|
||||||
|
signatureToUse = currentSignature;
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ [SIGNATURES AVENANT] Pas de signature d\'organisation disponible');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Pour les contrats, utiliser la signature du contrat
|
||||||
|
if (f.signature_b64) {
|
||||||
|
console.log('✅ [SIGNATURES CONTRAT] Utilisation de la signature du contrat');
|
||||||
|
signatureToUse = f.signature_b64;
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ [SIGNATURES CONTRAT] Pas de signature dans les données du contrat');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatureToUse) {
|
||||||
|
const normalizedSignature = normalizeSignatureFormat(signatureToUse);
|
||||||
if (normalizedSignature) {
|
if (normalizedSignature) {
|
||||||
setActiveSignature(normalizedSignature);
|
setActiveSignature(normalizedSignature);
|
||||||
console.log('✅ [SIGNATURES] Format normalisé:', normalizedSignature.substring(0, 50) + '...');
|
console.log('✅ [SIGNATURES] Format normalisé:', normalizedSignature.substring(0, 50) + '...');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('⚠️ [SIGNATURES] Pas de signature dans les données');
|
console.log('⚠️ [SIGNATURES] Pas de signature à pré-remplir');
|
||||||
setActiveSignature(null);
|
setActiveSignature(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Si l'URL d'embed est déjà en base (signature_link)
|
// Pour les avenants, utiliser directement le docuseal_submission_id
|
||||||
if (typeof f.embed_src_employeur === 'string' && f.embed_src_employeur.trim()) {
|
if (isAvenant && f.docuseal_submission_id) {
|
||||||
|
console.log('🔍 [SIGNATURES AVENANT] Utilisation du submission_id:', f.docuseal_submission_id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detRes = await fetch(`/api/docuseal/submissions/${encodeURIComponent(f.docuseal_submission_id)}`, { cache: 'no-store' });
|
||||||
|
const detData = await detRes.json();
|
||||||
|
|
||||||
|
console.log('📋 [SIGNATURES AVENANT] Détails submission DocuSeal:', detData);
|
||||||
|
|
||||||
|
const roles = detData?.submitters || detData?.roles || [];
|
||||||
|
const employer = roles.find((r: any) => (r.role || r.name) === 'Employeur') || {};
|
||||||
|
|
||||||
|
if (employer?.slug) {
|
||||||
|
embed = `https://docuseal.eu/s/${employer.slug}`;
|
||||||
|
console.log('🔗 [SIGNATURES AVENANT] URL embed depuis slug:', embed);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('❌ [SIGNATURES AVENANT] Erreur récupération submission:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Si l'URL d'embed est déjà en base (signature_link) - pour les contrats
|
||||||
|
if (!embed && typeof f.embed_src_employeur === 'string' && f.embed_src_employeur.trim()) {
|
||||||
const signatureLink = f.embed_src_employeur.trim();
|
const signatureLink = f.embed_src_employeur.trim();
|
||||||
console.log('🔗 [SIGNATURES] Signature link trouvé:', signatureLink);
|
console.log('🔗 [SIGNATURES] Signature link trouvé:', signatureLink);
|
||||||
|
|
||||||
|
|
@ -1072,20 +1177,21 @@ export default function SignaturesElectroniques() {
|
||||||
<div className="text-slate-500">Chargement…</div>
|
<div className="text-slate-500">Chargement…</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="text-red-600">Erreur: {error}</div>
|
<div className="text-red-600">Erreur: {error}</div>
|
||||||
) : (recordsEmployeur.length + recordsSalarie.length) === 0 ? (
|
) : (recordsEmployeur.length + recordsSalarie.length + avenantsEmployeur.length + avenantsSalarie.length) === 0 ? (
|
||||||
<div className="text-slate-500">Aucun contrat à signer.</div>
|
<div className="text-slate-500">Aucun document à signer.</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Table 1: employeur pending */}
|
{/* Table 1: employeur pending (contrats + avenants) */}
|
||||||
<div className="rounded-xl border overflow-hidden shadow-sm">
|
<div className="rounded-xl border overflow-hidden shadow-sm">
|
||||||
<div className="flex items-center justify-between gap-3 px-3 py-2 border-b bg-white/60 backdrop-blur supports-[backdrop-filter]:bg-white/60[backdrop-filter]:bg-slate-900/60">
|
<div className="flex items-center justify-between gap-3 px-3 py-2 border-b bg-white/60 backdrop-blur supports-[backdrop-filter]:bg-white/60[backdrop-filter]:bg-slate-900/60">
|
||||||
<div className="text-sm font-medium text-slate-700">Contrats en attente de signature employeur</div>
|
<div className="text-sm font-medium text-slate-700">Documents en attente de signature employeur</div>
|
||||||
<div className="text-xs text-slate-500">{recordsEmployeur.length} élément(s)</div>
|
<div className="text-xs text-slate-500">{recordsEmployeur.length + avenantsEmployeur.length} élément(s)</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full text-sm">
|
<table className="min-w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-slate-500 border-b bg-slate-50/60">
|
<tr className="text-left text-slate-500 border-b bg-slate-50/60">
|
||||||
|
<th className="px-3 py-2 font-medium whitespace-nowrap">Type</th>
|
||||||
<th className="px-3 py-2 font-medium whitespace-nowrap">Référence</th>
|
<th className="px-3 py-2 font-medium whitespace-nowrap">Référence</th>
|
||||||
<th className="px-3 py-2 font-medium whitespace-nowrap">Salarié</th>
|
<th className="px-3 py-2 font-medium whitespace-nowrap">Salarié</th>
|
||||||
<th className="px-3 py-2 font-medium whitespace-nowrap">Matricule</th>
|
<th className="px-3 py-2 font-medium whitespace-nowrap">Matricule</th>
|
||||||
|
|
@ -1094,26 +1200,39 @@ export default function SignaturesElectroniques() {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{recordsEmployeur.map((rec, idx) => {
|
{[...recordsEmployeur, ...avenantsEmployeur].map((rec, idx) => {
|
||||||
const f = rec.fields || {};
|
const f = rec.fields || {};
|
||||||
|
const isAvenant = f.is_avenant === true;
|
||||||
const isSigned = String(f['Contrat signé par employeur'] || '').toLowerCase() === 'oui';
|
const isSigned = String(f['Contrat signé par employeur'] || '').toLowerCase() === 'oui';
|
||||||
const mat = f['Matricule API'] || f.Matricule || '—';
|
const mat = f.employee_matricule || f['Matricule API'] || f.Matricule || '—';
|
||||||
const ref = f.Reference || '—';
|
const ref = f.reference || f.Reference || '—';
|
||||||
const nom = f['Nom salarié'] || f['Nom Salarié'] || f['Nom'] || f['Salarié'] || f['Nom complet'] || f['Prénom Nom'] || f['Prenom Nom'] || f['Nom et Prénom'] || '—';
|
const nom = f.employee_name || f['Nom salarié'] || f['Nom Salarié'] || f['Nom'] || f['Salarié'] || f['Nom complet'] || f['Prénom Nom'] || f['Prenom Nom'] || f['Nom et Prénom'] || '—';
|
||||||
|
const docType = isAvenant ? 'Avenant' : 'Contrat';
|
||||||
|
const urlPath = isAvenant ? `/staff/avenants/${rec.id}` : `/contrats/${rec.id}`;
|
||||||
|
const docLabel = isAvenant ? `Avenant · ${ref}` : `Contrat · ${ref}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={rec.id} className={classNames(idx % 2 ? 'bg-white' : 'bg-slate-50/30', 'hover:bg-slate-50 transition-colors')}>
|
<tr key={rec.id} className={classNames(idx % 2 ? 'bg-white' : 'bg-slate-50/30', 'hover:bg-slate-50 transition-colors')}>
|
||||||
|
<td className="px-3 py-2 text-slate-700 whitespace-nowrap">
|
||||||
|
<span className={classNames(
|
||||||
|
'inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border font-medium',
|
||||||
|
isAvenant ? 'border-purple-200 text-purple-700 bg-purple-50' : 'border-blue-200 text-blue-700 bg-blue-50'
|
||||||
|
)}>
|
||||||
|
{docType}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2 font-medium text-slate-900 whitespace-nowrap">
|
<td className="px-3 py-2 font-medium text-slate-900 whitespace-nowrap">
|
||||||
<a
|
<a
|
||||||
href={`/contrats/${encodeURIComponent(rec.id)}`}
|
href={urlPath}
|
||||||
onClick={(e)=>{ e.preventDefault(); openEmbed(`/contrats/${rec.id}`, `Contrat · ${ref}`); }}
|
onClick={(e)=>{ e.preventDefault(); openEmbed(urlPath, docLabel); }}
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
>{ref}</a>
|
>{ref}</a>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-slate-700 max-w-[260px] truncate" title={nom}>
|
<td className="px-3 py-2 text-slate-700 max-w-[260px] truncate" title={nom}>
|
||||||
<a
|
<a
|
||||||
href={mat ? `/salaries/${encodeURIComponent(mat)}` : '#'}
|
href={mat ? `/salaries/${encodeURIComponent(mat)}` : '#'}
|
||||||
onClick={(e)=>{ if (!mat) return; e.preventDefault(); openEmbed(`/salaries/${mat}`, `Salarié · ${nom}`); }}
|
onClick={(e)=>{ if (!mat || mat === '—') return; e.preventDefault(); openEmbed(`/salaries/${mat}`, `Salarié · ${nom}`); }}
|
||||||
className={classNames('hover:underline', !mat && 'pointer-events-none opacity-60')}
|
className={classNames('hover:underline', (!mat || mat === '—') && 'pointer-events-none opacity-60')}
|
||||||
>{nom}</a>
|
>{nom}</a>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-slate-700 whitespace-nowrap">{mat}</td>
|
<td className="px-3 py-2 text-slate-700 whitespace-nowrap">{mat}</td>
|
||||||
|
|
@ -1130,7 +1249,7 @@ export default function SignaturesElectroniques() {
|
||||||
className="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium hover:bg-slate-50 disabled:opacity-60"
|
className="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium hover:bg-slate-50 disabled:opacity-60"
|
||||||
disabled={isSigned}
|
disabled={isSigned}
|
||||||
onClick={() => openSignature(rec)}
|
onClick={() => openSignature(rec)}
|
||||||
aria-label={`Signer le contrat ${ref}`}
|
aria-label={`Signer ${isAvenant ? "l'avenant" : "le contrat"} ${ref}`}
|
||||||
>
|
>
|
||||||
<FileSignature className="w-3.5 h-3.5" aria-hidden="true" />
|
<FileSignature className="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
Signer
|
Signer
|
||||||
|
|
@ -1144,16 +1263,17 @@ export default function SignaturesElectroniques() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table 2: salarie pending */}
|
{/* Table 2: salarie pending (contrats + avenants) */}
|
||||||
<div className="rounded-xl border overflow-hidden shadow-sm mt-8">
|
<div className="rounded-xl border overflow-hidden shadow-sm mt-8">
|
||||||
<div className="flex items-center justify-between gap-3 px-3 py-2 border-b bg-white/60 backdrop-blur supports-[backdrop-filter]:bg-white/60[backdrop-filter]:bg-slate-900/60">
|
<div className="flex items-center justify-between gap-3 px-3 py-2 border-b bg-white/60 backdrop-blur supports-[backdrop-filter]:bg-white/60[backdrop-filter]:bg-slate-900/60">
|
||||||
<div className="text-sm font-medium text-slate-700">Contrats en attente de signature salarié</div>
|
<div className="text-sm font-medium text-slate-700">Documents en attente de signature salarié</div>
|
||||||
<div className="text-xs text-slate-500">{recordsSalarie.length} élément(s)</div>
|
<div className="text-xs text-slate-500">{recordsSalarie.length + avenantsSalarie.length} élément(s)</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full text-sm">
|
<table className="min-w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-slate-500 border-b bg-slate-50/60">
|
<tr className="text-left text-slate-500 border-b bg-slate-50/60">
|
||||||
|
<th className="px-3 py-2 font-medium whitespace-nowrap">Type</th>
|
||||||
<th className="px-3 py-2 font-medium whitespace-nowrap">Référence</th>
|
<th className="px-3 py-2 font-medium whitespace-nowrap">Référence</th>
|
||||||
<th className="px-3 py-2 font-medium whitespace-nowrap">Salarié</th>
|
<th className="px-3 py-2 font-medium whitespace-nowrap">Salarié</th>
|
||||||
<th className="px-3 py-2 font-medium whitespace-nowrap">Matricule</th>
|
<th className="px-3 py-2 font-medium whitespace-nowrap">Matricule</th>
|
||||||
|
|
@ -1162,25 +1282,38 @@ export default function SignaturesElectroniques() {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{recordsSalarie.map((rec, idx) => {
|
{[...recordsSalarie, ...avenantsSalarie].map((rec, idx) => {
|
||||||
const f = rec.fields || {};
|
const f = rec.fields || {};
|
||||||
const mat = f['Matricule API'] || f.Matricule || '—';
|
const isAvenant = f.is_avenant === true;
|
||||||
const ref = f.Reference || '—';
|
const mat = f.employee_matricule || f['Matricule API'] || f.Matricule || '—';
|
||||||
const nom = f['Nom salarié'] || f['Nom Salarié'] || f['Nom'] || f['Salarié'] || f['Nom complet'] || f['Prénom Nom'] || f['Prenom Nom'] || f['Nom et Prénom'] || '—';
|
const ref = f.reference || f.Reference || '—';
|
||||||
|
const nom = f.employee_name || f['Nom salarié'] || f['Nom Salarié'] || f['Nom'] || f['Salarié'] || f['Nom complet'] || f['Prénom Nom'] || f['Prenom Nom'] || f['Nom et Prénom'] || '—';
|
||||||
|
const docType = isAvenant ? 'Avenant' : 'Contrat';
|
||||||
|
const urlPath = isAvenant ? `/staff/avenants/${rec.id}` : `/contrats/${rec.id}`;
|
||||||
|
const docLabel = isAvenant ? `Avenant · ${ref}` : `Contrat · ${ref}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={rec.id} className={classNames(idx % 2 ? 'bg-white' : 'bg-slate-50/30', 'hover:bg-slate-50 transition-colors')}>
|
<tr key={rec.id} className={classNames(idx % 2 ? 'bg-white' : 'bg-slate-50/30', 'hover:bg-slate-50 transition-colors')}>
|
||||||
|
<td className="px-3 py-2 text-slate-700 whitespace-nowrap">
|
||||||
|
<span className={classNames(
|
||||||
|
'inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border font-medium',
|
||||||
|
isAvenant ? 'border-purple-200 text-purple-700 bg-purple-50' : 'border-blue-200 text-blue-700 bg-blue-50'
|
||||||
|
)}>
|
||||||
|
{docType}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2 font-medium text-slate-900 whitespace-nowrap">
|
<td className="px-3 py-2 font-medium text-slate-900 whitespace-nowrap">
|
||||||
<a
|
<a
|
||||||
href={`/contrats/${encodeURIComponent(rec.id)}`}
|
href={urlPath}
|
||||||
onClick={(e)=>{ e.preventDefault(); openEmbed(`/contrats/${rec.id}`, `Contrat · ${ref}`); }}
|
onClick={(e)=>{ e.preventDefault(); openEmbed(urlPath, docLabel); }}
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
>{ref}</a>
|
>{ref}</a>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-slate-700 max-w-[260px] truncate" title={nom}>
|
<td className="px-3 py-2 text-slate-700 max-w-[260px] truncate" title={nom}>
|
||||||
<a
|
<a
|
||||||
href={mat ? `/salaries/${encodeURIComponent(mat)}` : '#'}
|
href={mat ? `/salaries/${encodeURIComponent(mat)}` : '#'}
|
||||||
onClick={(e)=>{ if (!mat) return; e.preventDefault(); openEmbed(`/salaries/${mat}`, `Salarié · ${nom}`); }}
|
onClick={(e)=>{ if (!mat || mat === '—') return; e.preventDefault(); openEmbed(`/salaries/${mat}`, `Salarié · ${nom}`); }}
|
||||||
className={classNames('hover:underline', !mat && 'pointer-events-none opacity-60')}
|
className={classNames('hover:underline', (!mat || mat === '—') && 'pointer-events-none opacity-60')}
|
||||||
>{nom}</a>
|
>{nom}</a>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-slate-700 whitespace-nowrap">{mat}</td>
|
<td className="px-3 py-2 text-slate-700 whitespace-nowrap">{mat}</td>
|
||||||
|
|
@ -1261,65 +1394,6 @@ export default function SignaturesElectroniques() {
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
{/* Modal de confirmation après signature employeur */}
|
|
||||||
{showCompletedModal && (
|
|
||||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4">
|
|
||||||
<div className="bg-white rounded-2xl shadow-2xl max-w-lg w-full overflow-hidden">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="p-6 bg-emerald-50 border-b border-emerald-100">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-12 h-12 rounded-full bg-emerald-100 flex items-center justify-center">
|
|
||||||
<CheckCircle2 className="w-6 h-6 text-emerald-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold text-slate-900">Signature employeur prise en compte</h2>
|
|
||||||
<p className="text-sm text-slate-600 mt-1">Le processus se poursuit automatiquement côté salarié.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowCompletedModal(false);
|
|
||||||
// Fermer aussi le modal DocuSeal
|
|
||||||
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
|
|
||||||
if (dlg) dlg.close();
|
|
||||||
}}
|
|
||||||
className="ml-4 p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
|
||||||
aria-label="Fermer"
|
|
||||||
>
|
|
||||||
<XCircle className="h-5 w-5 text-slate-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-6 space-y-4">
|
|
||||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
|
||||||
<ul className="text-sm text-slate-700 space-y-2 list-disc pl-5">
|
|
||||||
<li>Le salarié va recevoir son propre exemplaire pour signature électronique.</li>
|
|
||||||
<li>Vous pourrez télécharger le contrat depuis la fiche contrat dès que la signature du salarié aura é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 */}
|
{/* Modal de gestion de la signature */}
|
||||||
{showSignatureModal && (
|
{showSignatureModal && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
|
|
||||||
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
|
// Récupérer les avenants depuis la base de données
|
||||||
// Pour l'instant, on passe un tableau vide
|
const { data: avenants, error } = await sb
|
||||||
const amendments: any[] = [];
|
.from("avenants")
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
numero_avenant,
|
||||||
|
date_avenant,
|
||||||
|
date_effet,
|
||||||
|
type_avenant,
|
||||||
|
motif_avenant,
|
||||||
|
elements_avenantes,
|
||||||
|
statut,
|
||||||
|
contract_id,
|
||||||
|
created_at,
|
||||||
|
signature_status,
|
||||||
|
last_employer_notification_at,
|
||||||
|
last_employee_notification_at,
|
||||||
|
cddu_contracts!inner(
|
||||||
|
contract_number,
|
||||||
|
employee_name,
|
||||||
|
structure,
|
||||||
|
org_id
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Erreur récupération avenants:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transformer les données pour le format attendu
|
||||||
|
const formattedAvenants = (avenants || []).map((a: any) => ({
|
||||||
|
id: a.id,
|
||||||
|
contract_id: a.contract_id,
|
||||||
|
contract_number: a.cddu_contracts?.contract_number,
|
||||||
|
employee_name: a.cddu_contracts?.employee_name,
|
||||||
|
organization_name: a.cddu_contracts?.structure,
|
||||||
|
date_effet: a.date_effet,
|
||||||
|
date_signature: a.date_avenant,
|
||||||
|
status: a.statut,
|
||||||
|
elements: a.elements_avenantes || [],
|
||||||
|
created_at: a.created_at,
|
||||||
|
signature_status: a.signature_status,
|
||||||
|
last_employer_notification_at: a.last_employer_notification_at,
|
||||||
|
last_employee_notification_at: a.last_employee_notification_at,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="p-6">
|
<main className="p-6">
|
||||||
<StaffAvenantsPageClient initialData={amendments} />
|
<StaffAvenantsPageClient initialData={formattedAvenants} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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";
|
employee_catpro = "Metteur en scène";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Préparer les détails de cachets
|
// Préparer les détails de cachets - convertir en nombres
|
||||||
const cachetsRepresentations = dureeData.nb_representations || contract.cachets_representations || 0;
|
const cachetsRepresentations = parseInt(dureeData.nb_representations || contract.cachets_representations || 0);
|
||||||
const cachetsRepetitions = dureeData.nb_repetitions || contract.cachets_repetitions || 0;
|
const cachetsRepetitions = parseInt(dureeData.nb_repetitions || contract.cachets_repetitions || 0);
|
||||||
const heures = dureeData.nb_heures || contract.nb_heures || 0;
|
const heures = parseInt(dureeData.nb_heures || contract.nb_heures || 0);
|
||||||
|
const heuresParJour = parseInt(contract.nombre_d_heures_par_jour || 0);
|
||||||
|
|
||||||
let detailsCachets = "";
|
let detailsCachets = "";
|
||||||
if (cachetsRepresentations > 0) {
|
if (cachetsRepresentations > 0) {
|
||||||
|
|
@ -294,9 +295,9 @@ export async function POST(request: NextRequest) {
|
||||||
representations: cachetsRepresentations,
|
representations: cachetsRepresentations,
|
||||||
repetitions: cachetsRepetitions,
|
repetitions: cachetsRepetitions,
|
||||||
heures: heures,
|
heures: heures,
|
||||||
heuresparjour: contract.nombre_d_heures_par_jour || 0
|
heuresparjour: heuresParJour
|
||||||
},
|
},
|
||||||
imageUrl: "data:image/png;base64" // Placeholder pour la signature
|
imageUrl: orgDetails.logo || ""
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Payload PDFMonkey:", JSON.stringify(dataPayload, null, 2));
|
console.log("Payload PDFMonkey:", JSON.stringify(dataPayload, null, 2));
|
||||||
|
|
|
||||||
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 Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Shield, KeyRound, Home, FileSignature, Banknote, Users, Percent, FolderOpen, Receipt, Building2, UserCog, Edit3, LifeBuoy, CreditCard, Calculator, Euro, Mail, Database, Clapperboard, LogOut, Scale } from "lucide-react";
|
import { Shield, KeyRound, Home, FileSignature, Banknote, Users, Percent, FolderOpen, Receipt, Building2, UserCog, Edit3, LifeBuoy, CreditCard, Calculator, Euro, Mail, Database, Clapperboard, LogOut, Scale, FileEdit } from "lucide-react";
|
||||||
// import { api } from "@/lib/fetcher";
|
// import { api } from "@/lib/fetcher";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import LogoutButton from "@/components/LogoutButton";
|
import LogoutButton from "@/components/LogoutButton";
|
||||||
|
|
@ -532,6 +532,14 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
|
||||||
<span>Fiches de paie</span>
|
<span>Fiches de paie</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/staff/avenants" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
|
||||||
|
isActivePath(pathname, "/staff/avenants") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
|
||||||
|
}`} title="Gestion des avenants">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<FileEdit className="w-4 h-4" aria-hidden />
|
||||||
|
<span>Avenants</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
<Link href="/staff/salaries" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
|
<Link href="/staff/salaries" onClick={() => onNavigate && onNavigate()} className={`block px-3 py-2 rounded-xl text-sm transition truncate ${
|
||||||
isActivePath(pathname, "/staff/salaries") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
|
isActivePath(pathname, "/staff/salaries") ? "bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-slate-700 font-semibold" : "hover:bg-slate-50"
|
||||||
}`} title="Gestion des salariés">
|
}`} title="Gestion des salariés">
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
import AmendmentObjetForm from "@/components/staff/amendments/AmendmentObjetForm";
|
import AmendmentObjetForm from "@/components/staff/amendments/AmendmentObjetForm";
|
||||||
import AmendmentDureeForm from "@/components/staff/amendments/AmendmentDureeForm";
|
import AmendmentDureeForm from "@/components/staff/amendments/AmendmentDureeForm";
|
||||||
import AmendmentRemunerationForm from "@/components/staff/amendments/AmendmentRemunerationForm";
|
import AmendmentRemunerationForm from "@/components/staff/amendments/AmendmentRemunerationForm";
|
||||||
|
import AvenantSuccessModal from "@/components/staff/amendments/AvenantSuccessModal";
|
||||||
|
|
||||||
export default function NouvelAvenantPageClient() {
|
export default function NouvelAvenantPageClient() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -26,6 +27,8 @@ export default function NouvelAvenantPageClient() {
|
||||||
// Données du formulaire
|
// Données du formulaire
|
||||||
const [dateEffet, setDateEffet] = useState("");
|
const [dateEffet, setDateEffet] = useState("");
|
||||||
const [dateSignature, setDateSignature] = useState("");
|
const [dateSignature, setDateSignature] = useState("");
|
||||||
|
const [typeAvenant, setTypeAvenant] = useState<"modification" | "annulation">("modification");
|
||||||
|
const [motifAvenant, setMotifAvenant] = useState("");
|
||||||
const [selectedElements, setSelectedElements] = useState<AmendmentElementType[]>([]);
|
const [selectedElements, setSelectedElements] = useState<AmendmentElementType[]>([]);
|
||||||
|
|
||||||
// Données spécifiques selon les éléments
|
// Données spécifiques selon les éléments
|
||||||
|
|
@ -35,9 +38,15 @@ export default function NouvelAvenantPageClient() {
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Modale de succès
|
||||||
|
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||||
|
const [createdAvenantId, setCreatedAvenantId] = useState("");
|
||||||
|
const [createdNumeroAvenant, setCreatedNumeroAvenant] = useState("");
|
||||||
|
|
||||||
// PDF generation
|
// PDF generation
|
||||||
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
|
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
|
||||||
const [pdfPresignedUrl, setPdfPresignedUrl] = useState<string | null>(null);
|
const [pdfPresignedUrl, setPdfPresignedUrl] = useState<string | null>(null);
|
||||||
|
const [pdfS3Key, setPdfS3Key] = useState<string | null>(null);
|
||||||
|
|
||||||
// Recherche de contrats (debounced)
|
// Recherche de contrats (debounced)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -185,6 +194,7 @@ export default function NouvelAvenantPageClient() {
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setPdfPresignedUrl(data.presignedUrl);
|
setPdfPresignedUrl(data.presignedUrl);
|
||||||
|
setPdfS3Key(data.s3Key);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Erreur génération PDF:", error);
|
console.error("Erreur génération PDF:", error);
|
||||||
alert("Erreur lors de la génération du PDF: " + error.message);
|
alert("Erreur lors de la génération du PDF: " + error.message);
|
||||||
|
|
@ -199,31 +209,40 @@ export default function NouvelAvenantPageClient() {
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const amendment: Amendment = {
|
const amendmentData = {
|
||||||
contract_id: selectedContract!.id,
|
contract_id: selectedContract!.id,
|
||||||
contract_number: selectedContract!.contract_number,
|
|
||||||
employee_name: selectedContract!.employee_name,
|
|
||||||
organization_name: selectedContract!.organization_name,
|
|
||||||
date_effet: dateEffet,
|
date_effet: dateEffet,
|
||||||
date_signature: dateSignature || undefined,
|
date_signature: dateSignature || undefined,
|
||||||
status: "draft",
|
type_avenant: typeAvenant,
|
||||||
elements: selectedElements,
|
motif_avenant: motifAvenant || undefined,
|
||||||
|
elements_avenantes: selectedElements,
|
||||||
objet_data: selectedElements.includes("objet") ? objetData : undefined,
|
objet_data: selectedElements.includes("objet") ? objetData : undefined,
|
||||||
duree_data: selectedElements.includes("duree") ? dureeData : undefined,
|
duree_data: selectedElements.includes("duree") ? dureeData : undefined,
|
||||||
|
lieu_horaire_data: selectedElements.includes("lieu_horaire") ? {} : undefined,
|
||||||
remuneration_data: selectedElements.includes("remuneration") ? remunerationData : undefined,
|
remuneration_data: selectedElements.includes("remuneration") ? remunerationData : undefined,
|
||||||
created_at: new Date().toISOString(),
|
pdf_url: pdfPresignedUrl || undefined,
|
||||||
|
pdf_s3_key: pdfS3Key || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: Envoyer à l'API
|
// Appeler l'API pour créer l'avenant et mettre à jour le contrat
|
||||||
// const response = await fetch('/api/staff/amendments', {
|
const response = await fetch('/api/staff/amendments/create', {
|
||||||
// method: 'POST',
|
method: 'POST',
|
||||||
// headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
// body: JSON.stringify(amendment),
|
body: JSON.stringify(amendmentData),
|
||||||
// });
|
});
|
||||||
|
|
||||||
router.push("/staff/avenants");
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || "Erreur lors de la création de l'avenant");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setCreatedAvenantId(data.avenant.id);
|
||||||
|
setCreatedNumeroAvenant(data.avenant.numero_avenant);
|
||||||
|
setShowSuccessModal(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur création avenant:", error);
|
console.error("Erreur création avenant:", error);
|
||||||
|
alert("Erreur lors de la création de l'avenant");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -392,6 +411,38 @@ export default function NouvelAvenantPageClient() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Type et Motif */}
|
||||||
|
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||||
|
<h2 className="font-semibold text-slate-900 mb-4">Informations complémentaires</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
Type d'avenant <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={typeAvenant}
|
||||||
|
onChange={(e) => setTypeAvenant(e.target.value as "modification" | "annulation")}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="modification">Modification</option>
|
||||||
|
<option value="annulation">Annulation</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
Motif de l'avenant
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={motifAvenant}
|
||||||
|
onChange={(e) => setMotifAvenant(e.target.value)}
|
||||||
|
placeholder="Ex: Changement de dates, modification du salaire..."
|
||||||
|
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Éléments à avenanter */}
|
{/* Éléments à avenanter */}
|
||||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||||
<h2 className="font-semibold text-slate-900 mb-4">
|
<h2 className="font-semibold text-slate-900 mb-4">
|
||||||
|
|
@ -520,6 +571,13 @@ export default function NouvelAvenantPageClient() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modale de succès */}
|
||||||
|
<AvenantSuccessModal
|
||||||
|
isOpen={showSuccessModal}
|
||||||
|
numeroAvenant={createdNumeroAvenant}
|
||||||
|
avenantId={createdAvenantId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { FileText, Plus, Search } from "lucide-react";
|
import { FileText, Plus, Search, Check, X } from "lucide-react";
|
||||||
import { Amendment } from "@/types/amendments";
|
import { Amendment } from "@/types/amendments";
|
||||||
|
|
||||||
interface StaffAvenantsPageClientProps {
|
interface StaffAvenantsPageClientProps {
|
||||||
|
|
@ -49,6 +49,40 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSignatureIcons = (signatureStatus?: string) => {
|
||||||
|
// Déterminer si employeur a signé
|
||||||
|
const employerSigned = signatureStatus === 'pending_employee' || signatureStatus === 'signed';
|
||||||
|
// Déterminer si salarié a signé
|
||||||
|
const employeeSigned = signatureStatus === 'signed';
|
||||||
|
// Si pas encore envoyé
|
||||||
|
const notSent = !signatureStatus || signatureStatus === 'not_sent';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<div className="text-xs font-semibold text-slate-600">E</div>
|
||||||
|
{notSent ? (
|
||||||
|
<span className="text-xs text-slate-400">—</span>
|
||||||
|
) : employerSigned ? (
|
||||||
|
<Check className="w-4 h-4 text-green-600" strokeWidth={3} />
|
||||||
|
) : (
|
||||||
|
<X className="w-4 h-4 text-red-600" strokeWidth={3} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<div className="text-xs font-semibold text-slate-600">S</div>
|
||||||
|
{notSent ? (
|
||||||
|
<span className="text-xs text-slate-400">—</span>
|
||||||
|
) : employeeSigned ? (
|
||||||
|
<Check className="w-4 h-4 text-green-600" strokeWidth={3} />
|
||||||
|
) : (
|
||||||
|
<X className="w-4 h-4 text-red-600" strokeWidth={3} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const getElementsLabel = (elements: Amendment["elements"]) => {
|
const getElementsLabel = (elements: Amendment["elements"]) => {
|
||||||
const labels = {
|
const labels = {
|
||||||
objet: "Objet",
|
objet: "Objet",
|
||||||
|
|
@ -135,6 +169,9 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
|
||||||
Date d'effet
|
Date d'effet
|
||||||
</th>
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
|
||||||
|
Signé
|
||||||
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-600">
|
||||||
Statut
|
Statut
|
||||||
</th>
|
</th>
|
||||||
|
|
@ -145,7 +182,11 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{filteredAmendments.map((amendment) => (
|
{filteredAmendments.map((amendment) => (
|
||||||
<tr key={amendment.id} className="hover:bg-slate-50 transition-colors">
|
<tr
|
||||||
|
key={amendment.id}
|
||||||
|
onClick={() => router.push(`/staff/avenants/${amendment.id}`)}
|
||||||
|
className="hover:bg-slate-50 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
<td className="px-4 py-3 text-sm font-medium text-slate-900">
|
<td className="px-4 py-3 text-sm font-medium text-slate-900">
|
||||||
{amendment.contract_number || "-"}
|
{amendment.contract_number || "-"}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -161,9 +202,18 @@ export default function StaffAvenantsPageClient({ initialData }: StaffAvenantsPa
|
||||||
<td className="px-4 py-3 text-sm text-slate-700">
|
<td className="px-4 py-3 text-sm text-slate-700">
|
||||||
{formatDate(amendment.date_effet)}
|
{formatDate(amendment.date_effet)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{getSignatureIcons(amendment.signature_status)}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3">{getStatusBadge(amendment.status)}</td>
|
<td className="px-4 py-3">{getStatusBadge(amendment.status)}</td>
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
<button className="text-indigo-600 hover:text-indigo-700 font-medium">
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push(`/staff/avenants/${amendment.id}`);
|
||||||
|
}}
|
||||||
|
className="text-indigo-600 hover:text-indigo-700 font-medium"
|
||||||
|
>
|
||||||
Voir
|
Voir
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,7 @@ export default function AmendmentDureeForm({
|
||||||
const isTechnician = originalData.categorie_pro === "Technicien";
|
const isTechnician = originalData.categorie_pro === "Technicien";
|
||||||
const isArtist = !isTechnician;
|
const isArtist = !isTechnician;
|
||||||
|
|
||||||
// États pour les calendriers
|
// États pour les calendriers (dates de représentations/répétitions/jours de travail uniquement)
|
||||||
const [showDateDebut, setShowDateDebut] = useState(false);
|
|
||||||
const [showDateFin, setShowDateFin] = useState(false);
|
|
||||||
const [showDatesRep, setShowDatesRep] = useState(false);
|
const [showDatesRep, setShowDatesRep] = useState(false);
|
||||||
const [showDatesServ, setShowDatesServ] = useState(false);
|
const [showDatesServ, setShowDatesServ] = useState(false);
|
||||||
const [showJoursTravail, setShowJoursTravail] = useState(false);
|
const [showJoursTravail, setShowJoursTravail] = useState(false);
|
||||||
|
|
@ -41,19 +39,13 @@ export default function AmendmentDureeForm({
|
||||||
return `${d}/${m}/${y}`;
|
return `${d}/${m}/${y}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handlers pour les sélections de dates
|
// Handlers pour les dates simples (début/fin)
|
||||||
const handleDateDebutApply = (result: { selectedDates: string[] }) => {
|
const handleDateDebutChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (result.selectedDates.length > 0) {
|
onChange({ ...data, date_debut: e.target.value });
|
||||||
onChange({ ...data, date_debut: result.selectedDates[0] });
|
|
||||||
}
|
|
||||||
setShowDateDebut(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDateFinApply = (result: { selectedDates: string[] }) => {
|
const handleDateFinChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (result.selectedDates.length > 0) {
|
onChange({ ...data, date_fin: e.target.value });
|
||||||
onChange({ ...data, date_fin: result.selectedDates[0] });
|
|
||||||
}
|
|
||||||
setShowDateFin(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDatesRepApply = (result: {
|
const handleDatesRepApply = (result: {
|
||||||
|
|
@ -123,7 +115,7 @@ export default function AmendmentDureeForm({
|
||||||
<h3 className="font-medium text-slate-900 text-sm">Modification de la durée de l'engagement</h3>
|
<h3 className="font-medium text-slate-900 text-sm">Modification de la durée de l'engagement</h3>
|
||||||
|
|
||||||
{/* Dates de début et fin */}
|
{/* Dates de début et fin */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-700 mb-2">
|
<label className="block text-xs font-medium text-slate-700 mb-2">
|
||||||
Date de début
|
Date de début
|
||||||
|
|
@ -131,24 +123,12 @@ export default function AmendmentDureeForm({
|
||||||
<div className="text-xs text-slate-500 mb-2">
|
<div className="text-xs text-slate-500 mb-2">
|
||||||
Actuellement : {formatDate(originalData.start_date)}
|
Actuellement : {formatDate(originalData.start_date)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<input
|
||||||
<button
|
type="date"
|
||||||
onClick={() => setShowDateDebut(!showDateDebut)}
|
value={data.date_debut || ""}
|
||||||
className="w-full px-3 py-2 border rounded-lg text-sm text-left flex items-center justify-between hover:bg-slate-50"
|
onChange={handleDateDebutChange}
|
||||||
>
|
className="w-full px-3 py-2 border rounded-lg text-sm"
|
||||||
<span>{data.date_debut ? formatDate(data.date_debut) : "Sélectionner"}</span>
|
/>
|
||||||
<Calendar className="h-4 w-4 text-slate-400" />
|
|
||||||
</button>
|
|
||||||
{showDateDebut && (
|
|
||||||
<DatePickerCalendar
|
|
||||||
isOpen={showDateDebut}
|
|
||||||
onClose={() => setShowDateDebut(false)}
|
|
||||||
onApply={handleDateDebutApply}
|
|
||||||
initialDates={data.date_debut ? [formatDate(data.date_debut)] : []}
|
|
||||||
title="Sélectionner la date de début"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -158,24 +138,12 @@ export default function AmendmentDureeForm({
|
||||||
<div className="text-xs text-slate-500 mb-2">
|
<div className="text-xs text-slate-500 mb-2">
|
||||||
Actuellement : {formatDate(originalData.end_date)}
|
Actuellement : {formatDate(originalData.end_date)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<input
|
||||||
<button
|
type="date"
|
||||||
onClick={() => setShowDateFin(!showDateFin)}
|
value={data.date_fin || ""}
|
||||||
className="w-full px-3 py-2 border rounded-lg text-sm text-left flex items-center justify-between hover:bg-slate-50"
|
onChange={handleDateFinChange}
|
||||||
>
|
className="w-full px-3 py-2 border rounded-lg text-sm"
|
||||||
<span>{data.date_fin ? formatDate(data.date_fin) : "Sélectionner"}</span>
|
/>
|
||||||
<Calendar className="h-4 w-4 text-slate-400" />
|
|
||||||
</button>
|
|
||||||
{showDateFin && (
|
|
||||||
<DatePickerCalendar
|
|
||||||
isOpen={showDateFin}
|
|
||||||
onClose={() => setShowDateFin(false)}
|
|
||||||
onApply={handleDateFinApply}
|
|
||||||
initialDates={data.date_fin ? [formatDate(data.date_fin)] : []}
|
|
||||||
title="Sélectionner la date de fin"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
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
|
// Données du formulaire
|
||||||
const [dateEffet, setDateEffet] = useState("");
|
const [dateEffet, setDateEffet] = useState("");
|
||||||
const [dateSignature, setDateSignature] = useState("");
|
const [dateSignature, setDateSignature] = useState("");
|
||||||
|
const [typeAvenant, setTypeAvenant] = useState<"modification" | "annulation">("modification");
|
||||||
|
const [motifAvenant, setMotifAvenant] = useState("");
|
||||||
const [selectedElements, setSelectedElements] = useState<AmendmentElementType[]>([]);
|
const [selectedElements, setSelectedElements] = useState<AmendmentElementType[]>([]);
|
||||||
|
|
||||||
// Données spécifiques selon les éléments
|
// Données spécifiques selon les éléments
|
||||||
|
|
@ -236,9 +238,37 @@ export default function NouvelAvenantModal({
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
// Pour l'instant, on crée juste l'objet localement
|
// Préparer les données de l'avenant
|
||||||
// Plus tard, tu pourras envoyer ça à ton API
|
const amendmentData = {
|
||||||
|
contract_id: selectedContract!.id,
|
||||||
|
date_effet: dateEffet,
|
||||||
|
date_signature: dateSignature || dateEffet,
|
||||||
|
type_avenant: typeAvenant,
|
||||||
|
motif_avenant: motifAvenant,
|
||||||
|
elements: selectedElements,
|
||||||
|
objet_data: selectedElements.includes("objet") ? objetData : undefined,
|
||||||
|
duree_data: selectedElements.includes("duree") ? dureeData : undefined,
|
||||||
|
lieu_horaire_data: selectedElements.includes("lieu_horaire") ? {} : undefined,
|
||||||
|
remuneration_data: selectedElements.includes("remuneration") ? remunerationData : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Appeler l'API pour créer l'avenant et mettre à jour le contrat
|
||||||
|
const response = await fetch('/api/staff/amendments/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(amendmentData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || "Erreur lors de la création de l'avenant");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Créer l'objet Amendment pour l'affichage
|
||||||
const amendment: Amendment = {
|
const amendment: Amendment = {
|
||||||
|
id: data.avenant.id,
|
||||||
contract_id: selectedContract!.id,
|
contract_id: selectedContract!.id,
|
||||||
contract_number: selectedContract!.contract_number,
|
contract_number: selectedContract!.contract_number,
|
||||||
employee_name: selectedContract!.employee_name,
|
employee_name: selectedContract!.employee_name,
|
||||||
|
|
@ -250,17 +280,11 @@ export default function NouvelAvenantModal({
|
||||||
objet_data: selectedElements.includes("objet") ? objetData : undefined,
|
objet_data: selectedElements.includes("objet") ? objetData : undefined,
|
||||||
duree_data: selectedElements.includes("duree") ? dureeData : undefined,
|
duree_data: selectedElements.includes("duree") ? dureeData : undefined,
|
||||||
remuneration_data: selectedElements.includes("remuneration") ? remunerationData : undefined,
|
remuneration_data: selectedElements.includes("remuneration") ? remunerationData : undefined,
|
||||||
created_at: new Date().toISOString(),
|
created_at: data.avenant.created_at,
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: Envoyer à l'API
|
|
||||||
// const response = await fetch('/api/staff/amendments', {
|
|
||||||
// method: 'POST',
|
|
||||||
// headers: { 'Content-Type': 'application/json' },
|
|
||||||
// body: JSON.stringify(amendment),
|
|
||||||
// });
|
|
||||||
|
|
||||||
onAmendmentCreated(amendment);
|
onAmendmentCreated(amendment);
|
||||||
|
alert(`Avenant ${data.avenant.numero_avenant} créé avec succès !`);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur création avenant:", error);
|
console.error("Erreur création avenant:", error);
|
||||||
|
|
@ -392,33 +416,68 @@ export default function NouvelAvenantModal({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dates */}
|
{/* Dates de l'avenant */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div>
|
||||||
<div>
|
<h3 className="text-sm font-semibold text-slate-900 mb-3">Dates de l'avenant</h3>
|
||||||
<label className="block text-xs font-medium text-slate-700 mb-2">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
Date d'effet de l'avenant <span className="text-red-500">*</span>
|
<div>
|
||||||
</label>
|
<label className="block text-xs font-medium text-slate-700 mb-2">
|
||||||
<div className="relative">
|
Date d'effet de l'avenant <span className="text-red-500">*</span>
|
||||||
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
</label>
|
||||||
<input
|
<div className="relative">
|
||||||
type="date"
|
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||||
value={dateEffet}
|
<input
|
||||||
onChange={(e) => setDateEffet(e.target.value)}
|
type="date"
|
||||||
className="w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
value={dateEffet}
|
||||||
/>
|
onChange={(e) => setDateEffet(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-700 mb-2">
|
||||||
|
Date de signature
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateSignature}
|
||||||
|
onChange={(e) => setDateSignature(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<label className="block text-xs font-medium text-slate-700 mb-2">
|
|
||||||
Date de signature
|
{/* Type et Motif de l'avenant */}
|
||||||
</label>
|
<div>
|
||||||
<div className="relative">
|
<h3 className="text-sm font-semibold text-slate-900 mb-3">Informations complémentaires</h3>
|
||||||
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-700 mb-2">
|
||||||
|
Type d'avenant <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={typeAvenant}
|
||||||
|
onChange={(e) => setTypeAvenant(e.target.value as "modification" | "annulation")}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="modification">Modification</option>
|
||||||
|
<option value="annulation">Annulation</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-700 mb-2">
|
||||||
|
Motif de l'avenant
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="text"
|
||||||
value={dateSignature}
|
value={motifAvenant}
|
||||||
onChange={(e) => setDateSignature(e.target.value)}
|
onChange={(e) => setMotifAvenant(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
placeholder="Ex: Changement de dates, modification du salaire..."
|
||||||
|
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
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'
|
||||||
| 'signature-request-employer'
|
| 'signature-request-employer'
|
||||||
| 'signature-request-employee'
|
| 'signature-request-employee'
|
||||||
|
| 'signature-request-employee-amendment' // Nouveau type pour signature avenant salarié
|
||||||
| 'signature-request-salarie' // Nouveau type pour demande signature salarié (depuis Lambda DocuSeal)
|
| 'signature-request-salarie' // Nouveau type pour demande signature salarié (depuis Lambda DocuSeal)
|
||||||
| 'bulk-signature-notification' // Nouveau type pour notification de signatures en masse
|
| 'bulk-signature-notification' // Nouveau type pour notification de signatures en masse
|
||||||
| 'salary-transfer-notification' // Nouveau type pour notification d'appel à virement
|
| 'salary-transfer-notification' // Nouveau type pour notification d'appel à virement
|
||||||
|
|
@ -637,7 +638,7 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
|
||||||
greeting: '{{#if firstName}}Bonjour {{firstName}},{{/if}}',
|
greeting: '{{#if firstName}}Bonjour {{firstName}},{{/if}}',
|
||||||
mainMessage: 'Un document nécessite votre signature électronique en tant qu’employeur.',
|
mainMessage: 'Un document nécessite votre signature électronique en tant qu’employeur.',
|
||||||
ctaText: 'Signer le document',
|
ctaText: 'Signer le document',
|
||||||
footerText: 'Vous recevez cet e-mail car votre signature est requise sur un document.',
|
footerText: 'Vous recevez ce document car vous êtes client de Odentas, pour vous notifier d\'une action sur votre compte.',
|
||||||
preheaderText: 'Signature électronique requise · Signez en tant qu’employeur',
|
preheaderText: 'Signature électronique requise · Signez en tant qu’employeur',
|
||||||
colors: {
|
colors: {
|
||||||
headerColor: STANDARD_COLORS.HEADER,
|
headerColor: STANDARD_COLORS.HEADER,
|
||||||
|
|
@ -699,6 +700,41 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'signature-request-employee-amendment': {
|
||||||
|
subject: 'Signez votre avenant {{#if numeroAvenant}}n°{{numeroAvenant}}{{/if}} - {{organizationName}}',
|
||||||
|
title: 'Demande de signature électronique',
|
||||||
|
greeting: '{{#if firstName}}Bonjour {{firstName}},{{/if}}',
|
||||||
|
mainMessage: 'Nous vous invitons à signer votre avenant au contrat de travail ci-dessous.<br><br>Cliquez sur « Signer l\'avenant » pour accéder à Odentas Sign. Vous pourrez télécharger votre avenant dès validation de votre signature.',
|
||||||
|
ctaText: 'Signer l\'avenant',
|
||||||
|
closingMessage: 'Pour toute question, contactez-nous à <a href="mailto:paie@odentas.fr" style="color:#0B5FFF; text-decoration:none;">paie@odentas.fr</a>.',
|
||||||
|
footerText: 'Vous recevez cet e-mail car votre employeur ({{organizationName}}) est client d\'Odentas Media SAS, pour vous notifier d\'une action sur votre contrat de travail avec cet employeur.',
|
||||||
|
preheaderText: 'Signature électronique · Avenant {{#if numeroAvenant}}n°{{numeroAvenant}}{{/if}} · {{organizationName}}',
|
||||||
|
colors: {
|
||||||
|
headerColor: STANDARD_COLORS.HEADER,
|
||||||
|
titleColor: '#0F172A',
|
||||||
|
buttonColor: STANDARD_COLORS.BUTTON,
|
||||||
|
buttonTextColor: STANDARD_COLORS.BUTTON_TEXT,
|
||||||
|
cardBackgroundColor: '#FFFFFF',
|
||||||
|
cardBorder: '#E5E7EB',
|
||||||
|
cardTitleColor: '#0F172A',
|
||||||
|
alertIndicatorColor: '#22C55E',
|
||||||
|
},
|
||||||
|
infoCard: [
|
||||||
|
{ label: 'Votre employeur', key: 'organizationName' },
|
||||||
|
{ label: 'Votre matricule', key: 'matricule' },
|
||||||
|
],
|
||||||
|
detailsCard: {
|
||||||
|
title: 'Détails de l\'avenant',
|
||||||
|
rows: [
|
||||||
|
{ label: 'Référence contrat', key: 'contractReference' },
|
||||||
|
{ label: 'Type de contrat', key: 'contractType' },
|
||||||
|
{ label: 'Profession', key: 'profession' },
|
||||||
|
{ label: 'Date de début', key: 'startDate' },
|
||||||
|
{ label: 'Production', key: 'productionName' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
'signature-request-salarie': {
|
'signature-request-salarie': {
|
||||||
subject: 'Signez votre contrat {{organizationName}}',
|
subject: 'Signez votre contrat {{organizationName}}',
|
||||||
title: 'Demande de signature électronique',
|
title: 'Demande de signature électronique',
|
||||||
|
|
|
||||||
|
|
@ -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;
|
lieu_horaire_data?: AmendmentLieuHoraireData;
|
||||||
remuneration_data?: AmendmentRemunerationData;
|
remuneration_data?: AmendmentRemunerationData;
|
||||||
|
|
||||||
|
// Signature électronique
|
||||||
|
signature_status?: "not_sent" | "pending_employer" | "pending_employee" | "signed";
|
||||||
|
last_employer_notification_at?: string;
|
||||||
|
last_employee_notification_at?: string;
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue