Ajout cards contrats multi et RG + survey nouveau contrat
This commit is contained in:
parent
98f694fc3f
commit
4694b87a18
13 changed files with 2364 additions and 146 deletions
|
|
@ -84,6 +84,21 @@ function MonComposant() {
|
|||
}
|
||||
```
|
||||
|
||||
**Exemple concret : Tracking de création de contrat** ✨
|
||||
|
||||
```tsx
|
||||
// Après création réussie d'un contrat
|
||||
posthog?.capture('contract_created', {
|
||||
contract_type: 'CDDU',
|
||||
regime: 'CDDU_MONO',
|
||||
categorie_professionnelle: 'Artiste',
|
||||
contract_id: contractId,
|
||||
validation_immediate: true,
|
||||
});
|
||||
```
|
||||
|
||||
> 💡 **Voir le guide complet** : `POSTHOG_SURVEY_CONTRATS.md` pour la mise en place d'un survey de satisfaction après création de contrats.
|
||||
|
||||
### Identifier un utilisateur manuellement
|
||||
|
||||
```typescript
|
||||
|
|
|
|||
300
POSTHOG_SURVEY_CHECKLIST.md
Normal file
300
POSTHOG_SURVEY_CHECKLIST.md
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
# ✅ Survey PostHog - Checklist complète
|
||||
|
||||
## 🎯 Objectif
|
||||
Mesurer la satisfaction des utilisateurs sur le processus de création de contrats avec une note de 1 à 5.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Ce qui a été fait
|
||||
|
||||
### 1. ✅ Code frontend modifié
|
||||
- **Fichier** : `components/contrats/NouveauCDDUForm.tsx`
|
||||
- **Modification** : Import de `usePostHog` et tracking de l'événement `contract_created`
|
||||
- **Événement envoyé** : Contient `contract_type`, `regime`, `categorie_professionnelle`, etc.
|
||||
|
||||
### 2. ✅ Composant survey créé (optionnel)
|
||||
- **Fichier** : `components/surveys/ContractCreationSurvey.tsx`
|
||||
- **Usage** : Si vous voulez un contrôle total sur le design
|
||||
- **Avantage** : Customisable à 100%
|
||||
|
||||
### 3. ✅ Documentation créée
|
||||
- `POSTHOG_SURVEY_CONTRATS.md` - Guide complet du survey
|
||||
- `POSTHOG_SURVEY_INTEGRATION_GUIDE.md` - Guide d'intégration rapide
|
||||
- `POSTHOG_ANALYTICS_GUIDE.md` - Mis à jour avec référence au survey
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Prochaines étapes (à faire par vous)
|
||||
|
||||
### Étape 1 : Vérifier l'environnement (5 min)
|
||||
|
||||
Vérifiez que ces variables sont dans votre `.env.local` :
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_POSTHOG_KEY=phc_...
|
||||
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
|
||||
```
|
||||
|
||||
Si elles manquent, ajoutez-les et **redémarrez le serveur** :
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Étape 2 : Tester l'événement (10 min)
|
||||
|
||||
1. Créez un contrat de test
|
||||
2. Ouvrez la **console du navigateur** (F12)
|
||||
3. Vous devriez voir : `📊 PostHog: Événement contract_created envoyé`
|
||||
4. Vérifiez dans PostHog → **Activity** → **Live Events**
|
||||
5. Cherchez l'événement `contract_created` - il devrait apparaître dans les 30 secondes
|
||||
|
||||
### Étape 3 : Créer le survey dans PostHog (15 min)
|
||||
|
||||
#### 🔧 Configuration du survey
|
||||
|
||||
1. **Aller sur PostHog** : https://eu.posthog.com/
|
||||
2. **Menu** → **Surveys** → **New survey**
|
||||
3. **Type** : Choisir **"Rating"**
|
||||
|
||||
#### 📝 Paramètres du survey
|
||||
|
||||
**Question** :
|
||||
```
|
||||
Comment évaluez-vous la facilité du processus de création de contrat ?
|
||||
```
|
||||
|
||||
**Type de réponse** :
|
||||
- **Scale** : 1 to 5
|
||||
- **Display** : Stars ⭐ (ou Numbers 1-5)
|
||||
- **Labels** :
|
||||
- Low (1) : Très difficile
|
||||
- High (5) : Très facile
|
||||
|
||||
**Nom du survey** :
|
||||
```
|
||||
Satisfaction création de contrats
|
||||
```
|
||||
|
||||
#### 🎯 Ciblage (Targeting)
|
||||
|
||||
**Option simple (recommandée pour commencer)** :
|
||||
|
||||
```
|
||||
Event name: contract_created
|
||||
Show when: Event is captured
|
||||
Wait: 2 seconds
|
||||
Position: Bottom right
|
||||
Frequency: Show every time (pour avoir plusieurs réponses)
|
||||
Days between appearances: 7 (ne pas harceler)
|
||||
```
|
||||
|
||||
**Option avancée (après X créations)** :
|
||||
|
||||
```
|
||||
Event name: contract_created
|
||||
Condition: User has completed 3+ contract_created events
|
||||
Show when: Event is captured
|
||||
```
|
||||
|
||||
#### 🎨 Apparence
|
||||
|
||||
```
|
||||
Title: 📊 Votre avis compte !
|
||||
Question: Comment évaluez-vous la facilité du processus de création de contrat ?
|
||||
Button text: Envoyer
|
||||
Dismiss text: Plus tard
|
||||
Primary color: #10b981 (vert de votre app)
|
||||
```
|
||||
|
||||
#### ➕ Question de suivi (optionnelle)
|
||||
|
||||
Si note ≤ 3 :
|
||||
```
|
||||
Question: Que pourrions-nous améliorer ?
|
||||
Type: Open text
|
||||
Placeholder: Partagez vos suggestions...
|
||||
```
|
||||
|
||||
4. **Sauvegarder** et **Activer** le survey
|
||||
|
||||
### Étape 4 : Tester le survey (5 min)
|
||||
|
||||
1. Créez un nouveau contrat
|
||||
2. Le survey devrait apparaître 2 secondes après la création
|
||||
3. Donnez une note
|
||||
4. Vérifiez dans PostHog → **Surveys** → **Votre survey** que la réponse apparaît
|
||||
|
||||
---
|
||||
|
||||
## 📊 Analyser les résultats
|
||||
|
||||
### Dashboard automatique
|
||||
|
||||
PostHog crée automatiquement un dashboard pour votre survey avec :
|
||||
- **Response rate** : Taux de réponse
|
||||
- **Average rating** : Note moyenne
|
||||
- **Distribution** : Répartition 1-5
|
||||
- **Responses over time** : Évolution
|
||||
|
||||
### Insights personnalisés
|
||||
|
||||
Créez des insights pour analyser plus en détail :
|
||||
|
||||
**1. Note moyenne par type de contrat**
|
||||
```
|
||||
Event: survey_responded
|
||||
Survey: Satisfaction création de contrats
|
||||
Breakdown: contract_type
|
||||
Aggregation: Average rating
|
||||
```
|
||||
|
||||
**2. Taux de réponse par catégorie professionnelle**
|
||||
```
|
||||
Event: survey_responded
|
||||
Survey: Satisfaction création de contrats
|
||||
Breakdown: categorie_professionnelle
|
||||
Aggregation: Count
|
||||
```
|
||||
|
||||
**3. Évolution de la satisfaction dans le temps**
|
||||
```
|
||||
Event: survey_responded
|
||||
Survey: Satisfaction création de contrats
|
||||
Aggregation: Average rating
|
||||
Graph: Line chart (par semaine)
|
||||
```
|
||||
|
||||
### Alertes
|
||||
|
||||
Configurez des alertes pour être notifié :
|
||||
|
||||
```
|
||||
Condition: Average rating < 3.0 (sur les 7 derniers jours)
|
||||
Action: Envoyer email à l'équipe produit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Option alternative : Survey personnalisé
|
||||
|
||||
Si vous préférez un contrôle total sur le design, utilisez le composant React créé.
|
||||
|
||||
### Intégration rapide
|
||||
|
||||
Dans `NouveauCDDUForm.tsx`, ajoutez :
|
||||
|
||||
```tsx
|
||||
import { ContractCreationSurvey } from '@/components/surveys/ContractCreationSurvey';
|
||||
|
||||
// States
|
||||
const [showSurvey, setShowSurvey] = useState(false);
|
||||
const [surveyContractId, setSurveyContractId] = useState<string>();
|
||||
|
||||
// Après création réussie
|
||||
setSurveyContractId(result?.contract?.id);
|
||||
setTimeout(() => setShowSurvey(true), 1500);
|
||||
|
||||
// Dans le JSX
|
||||
<ContractCreationSurvey
|
||||
isOpen={showSurvey}
|
||||
onClose={() => setShowSurvey(false)}
|
||||
contractId={surveyContractId}
|
||||
contractType={isRegimeRG ? 'RG' : 'CDDU'}
|
||||
/>
|
||||
```
|
||||
|
||||
Voir `POSTHOG_SURVEY_INTEGRATION_GUIDE.md` pour les détails.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Le survey ne s'affiche pas
|
||||
|
||||
**1. Vérifier que l'événement arrive bien**
|
||||
- PostHog → Activity → Live Events
|
||||
- Chercher `contract_created`
|
||||
- Si absent → vérifier les variables d'environnement
|
||||
|
||||
**2. Vérifier le ciblage du survey**
|
||||
- PostHog → Surveys → Votre survey → Targeting
|
||||
- S'assurer que l'événement `contract_created` est bien configuré
|
||||
|
||||
**3. Vérifier les conditions**
|
||||
- Si vous avez mis une condition (ex: "after 3 contracts"), créez suffisamment de contrats de test
|
||||
|
||||
### L'événement n'arrive pas dans PostHog
|
||||
|
||||
**1. Console du navigateur**
|
||||
```javascript
|
||||
window.posthog.debug(true)
|
||||
// Créez un contrat
|
||||
// Vous devriez voir des logs PostHog
|
||||
```
|
||||
|
||||
**2. Network tab**
|
||||
- DevTools → Network
|
||||
- Filtrer par "batch"
|
||||
- Vérifier statut 200
|
||||
|
||||
**3. Variables d'environnement**
|
||||
```bash
|
||||
# Vérifier qu'elles sont bien définies
|
||||
echo $NEXT_PUBLIC_POSTHOG_KEY
|
||||
echo $NEXT_PUBLIC_POSTHOG_HOST
|
||||
|
||||
# Si vides, les ajouter à .env.local et redémarrer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Métriques à surveiller
|
||||
|
||||
### Court terme (premières semaines)
|
||||
- **Taux de réponse** : Objectif > 30%
|
||||
- **Note moyenne** : Objectif > 4.0/5
|
||||
- **% de notes ≤ 3** : À minimiser
|
||||
|
||||
### Moyen terme (après 1 mois)
|
||||
- **Évolution de la note** : Doit augmenter
|
||||
- **Feedback qualitatif** : Identifier les points de friction récurrents
|
||||
- **Corrélations** : Note vs type de contrat, catégorie pro, etc.
|
||||
|
||||
### Actions selon les résultats
|
||||
|
||||
| Note moyenne | Action |
|
||||
|--------------|--------|
|
||||
| ≥ 4.5 | 🎉 Excellent ! Continuez comme ça |
|
||||
| 4.0 - 4.4 | ✅ Bien. Identifier les petites améliorations |
|
||||
| 3.0 - 3.9 | ⚠️ Moyen. Analyser les feedbacks et prioriser des améliorations |
|
||||
| < 3.0 | 🚨 Urgent. Revoir le processus de création |
|
||||
|
||||
---
|
||||
|
||||
## 📚 Ressources
|
||||
|
||||
- **PostHog Surveys** : https://posthog.com/docs/surveys
|
||||
- **Best practices** : https://posthog.com/docs/surveys/best-practices
|
||||
- **Survey templates** : https://posthog.com/templates/surveys
|
||||
- **Guide interne** : `POSTHOG_SURVEY_CONTRATS.md`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist finale
|
||||
|
||||
- [ ] Variables PostHog dans `.env.local`
|
||||
- [ ] Serveur redémarré après ajout des variables
|
||||
- [ ] Test : création d'un contrat → événement `contract_created` dans PostHog
|
||||
- [ ] Survey créé dans PostHog dashboard
|
||||
- [ ] Ciblage configuré sur `contract_created`
|
||||
- [ ] Test : création d'un contrat → survey s'affiche
|
||||
- [ ] Test : répondre au survey → réponse enregistrée
|
||||
- [ ] Dashboard de résultats vérifié
|
||||
- [ ] (Optionnel) Alertes configurées
|
||||
|
||||
---
|
||||
|
||||
**Temps estimé total** : 30-45 minutes
|
||||
|
||||
**Dernière mise à jour** : 15 octobre 2025
|
||||
319
POSTHOG_SURVEY_CONTRATS.md
Normal file
319
POSTHOG_SURVEY_CONTRATS.md
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
# 📊 Survey PostHog : Satisfaction création de contrats
|
||||
|
||||
## 🎯 Objectif
|
||||
|
||||
Mesurer la satisfaction des utilisateurs concernant le processus de création de contrats avec une note de 1 à 5.
|
||||
|
||||
## ✅ Étape 1 : Événement tracké
|
||||
|
||||
L'événement `contract_created` est maintenant envoyé à PostHog après chaque création de contrat réussie, avec les propriétés suivantes :
|
||||
|
||||
```typescript
|
||||
posthog.capture('contract_created', {
|
||||
contract_type: 'CDDU' | 'RG',
|
||||
regime: 'CDDU_MONO' | 'CDDU_MULTI' | 'Régime Général',
|
||||
multi_mois: boolean,
|
||||
categorie_professionnelle: 'Artiste' | 'Technicien' | 'Autre',
|
||||
contract_id: string,
|
||||
has_notes: boolean,
|
||||
validation_immediate: boolean,
|
||||
});
|
||||
```
|
||||
|
||||
## 🔧 Étape 2 : Créer le survey dans PostHog
|
||||
|
||||
### 1. Accéder à PostHog
|
||||
- Rendez-vous sur https://eu.posthog.com/
|
||||
- Connectez-vous avec vos identifiants
|
||||
|
||||
### 2. Créer un nouveau survey
|
||||
1. Dans le menu de gauche, cliquez sur **"Surveys"**
|
||||
2. Cliquez sur **"New survey"**
|
||||
3. Choisissez **"Rating"** comme type de question
|
||||
|
||||
### 3. Configuration du survey
|
||||
|
||||
#### **Question**
|
||||
```
|
||||
Comment évaluez-vous la facilité du processus de création de contrat ?
|
||||
```
|
||||
|
||||
#### **Type de réponse**
|
||||
- **Type** : Rating (échelle)
|
||||
- **Scale** : 1 à 5
|
||||
- **Display** : Stars (⭐) ou Numbers (1 2 3 4 5)
|
||||
|
||||
#### **Labels optionnels**
|
||||
- **1 étoile** : Très difficile
|
||||
- **5 étoiles** : Très facile
|
||||
|
||||
ou simplement :
|
||||
- **Low** : 1 - Difficile
|
||||
- **High** : 5 - Facile
|
||||
|
||||
#### **Nom du survey**
|
||||
```
|
||||
Satisfaction création de contrats
|
||||
```
|
||||
|
||||
#### **Description (optionnelle)**
|
||||
```
|
||||
Aidez-nous à améliorer votre expérience
|
||||
```
|
||||
|
||||
### 4. Ciblage du survey
|
||||
|
||||
#### **Quand afficher le survey ?**
|
||||
|
||||
**Option A : Après chaque création (recommandé pour commencer)**
|
||||
```
|
||||
Événement : contract_created
|
||||
Affichage : Immédiatement après l'événement
|
||||
```
|
||||
|
||||
**Option B : Après X créations (pour ne pas être trop intrusif)**
|
||||
```
|
||||
Événement : contract_created
|
||||
Condition : event_count >= 3
|
||||
(Afficher seulement après 3 créations de contrats)
|
||||
```
|
||||
|
||||
**Option C : Une seule fois par utilisateur**
|
||||
```
|
||||
Événement : contract_created
|
||||
Condition : survey_not_completed
|
||||
Fréquence : Une fois par utilisateur
|
||||
```
|
||||
|
||||
#### **Configuration recommandée pour démarrer :**
|
||||
|
||||
Dans la section **"Targeting"** du survey PostHog :
|
||||
|
||||
1. **Linked flag or targeting** :
|
||||
- Event name: `contract_created`
|
||||
|
||||
2. **Conditions** :
|
||||
- Show survey when: `contract_created` event is captured
|
||||
- Wait: `1 second` (laisser le temps à la redirection de se faire)
|
||||
|
||||
3. **Appearance** :
|
||||
- Position: Bottom right (en bas à droite)
|
||||
- Type: Popover (fenêtre flottante)
|
||||
|
||||
4. **Frequency** :
|
||||
- Show once per user: **No** (pour avoir plusieurs réponses)
|
||||
- Days between survey appearances: **7** (ne pas harceler l'utilisateur)
|
||||
|
||||
### 5. Design et style
|
||||
|
||||
```
|
||||
Title: 📊 Votre avis compte !
|
||||
Question: Comment évaluez-vous la facilité du processus de création de contrat ?
|
||||
Button text: Envoyer
|
||||
Dismiss button: Peut-être plus tard
|
||||
```
|
||||
|
||||
**Couleurs** :
|
||||
- Primary color: `#10b981` (vert Emerald de votre app)
|
||||
- Background: `#ffffff`
|
||||
|
||||
### 6. Question de suivi (optionnelle)
|
||||
|
||||
Vous pouvez ajouter une **question de suivi** si la note est basse (≤ 3) :
|
||||
|
||||
```
|
||||
Si note ≤ 3 : "Que pourrions-nous améliorer ?"
|
||||
Type : Open text (texte libre)
|
||||
Placeholder : "Partagez vos suggestions..."
|
||||
```
|
||||
|
||||
Configuration dans PostHog :
|
||||
- Cliquez sur **"Add follow-up question"**
|
||||
- Condition: `rating <= 3`
|
||||
- Type: Open text
|
||||
|
||||
## 📈 Étape 3 : Analyser les résultats
|
||||
|
||||
### Dashboard dans PostHog
|
||||
|
||||
1. Aller dans **Surveys** → **Votre survey**
|
||||
2. Consulter :
|
||||
- **Response rate** : Taux de réponse
|
||||
- **Average rating** : Note moyenne
|
||||
- **Distribution** : Répartition des notes (1 à 5)
|
||||
- **Responses over time** : Évolution dans le temps
|
||||
|
||||
### Créer des insights personnalisés
|
||||
|
||||
1. Aller dans **Insights** → **New insight**
|
||||
2. Créer un graphique :
|
||||
```
|
||||
Event: survey_responded
|
||||
Filter: survey_name = "Satisfaction création de contrats"
|
||||
Breakdown: rating
|
||||
```
|
||||
|
||||
3. Analyser par type de contrat :
|
||||
```
|
||||
Event: survey_responded
|
||||
Filter: survey_name = "Satisfaction création de contrats"
|
||||
Breakdown: contract_type (CDDU vs RG)
|
||||
```
|
||||
|
||||
### Alertes automatiques
|
||||
|
||||
Configurez une alerte si la note moyenne descend sous un seuil :
|
||||
|
||||
1. Aller dans **Alerts** → **New alert**
|
||||
2. Condition : `average_rating < 3`
|
||||
3. Notification : Email ou Slack
|
||||
|
||||
## 🎨 Option avancée : Survey personnalisé dans le code
|
||||
|
||||
Si vous préférez un contrôle total sur le design et le timing, vous pouvez créer un survey personnalisé :
|
||||
|
||||
```tsx
|
||||
// components/ContractCreationSurvey.tsx
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { usePostHog } from 'posthog-js/react';
|
||||
import { Star, X } from 'lucide-react';
|
||||
|
||||
export function ContractCreationSurvey({
|
||||
isOpen,
|
||||
onClose,
|
||||
contractId
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
contractId?: string;
|
||||
}) {
|
||||
const posthog = usePostHog();
|
||||
const [rating, setRating] = useState<number | null>(null);
|
||||
const [hoverRating, setHoverRating] = useState<number | null>(null);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
if (!isOpen || submitted) return null;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (rating) {
|
||||
posthog?.capture('contract_creation_survey_submitted', {
|
||||
rating,
|
||||
contract_id: contractId,
|
||||
});
|
||||
setSubmitted(true);
|
||||
setTimeout(onClose, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 w-96 bg-white rounded-xl shadow-2xl border border-slate-200 p-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-3 right-3 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<h3 className="text-lg font-semibold mb-2">📊 Votre avis compte !</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Comment évaluez-vous la facilité du processus de création de contrat ?
|
||||
</p>
|
||||
|
||||
<div className="flex justify-center gap-2 mb-6">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => setRating(star)}
|
||||
onMouseEnter={() => setHoverRating(star)}
|
||||
onMouseLeave={() => setHoverRating(null)}
|
||||
className="transition-transform hover:scale-110"
|
||||
>
|
||||
<Star
|
||||
className={`w-8 h-8 ${
|
||||
(hoverRating || rating || 0) >= star
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-slate-300'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-slate-500 mb-4">
|
||||
<span>Très difficile</span>
|
||||
<span>Très facile</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!rating}
|
||||
className="w-full py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition"
|
||||
>
|
||||
Envoyer
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Puis l'intégrer dans `NouveauCDDUForm.tsx` :
|
||||
|
||||
```tsx
|
||||
const [showSurvey, setShowSurvey] = useState(false);
|
||||
const [createdContractId, setCreatedContractId] = useState<string>();
|
||||
|
||||
// Après création réussie :
|
||||
setCreatedContractId(result?.contract?.id);
|
||||
setShowSurvey(true);
|
||||
|
||||
// Dans le JSX :
|
||||
<ContractCreationSurvey
|
||||
isOpen={showSurvey}
|
||||
onClose={() => setShowSurvey(false)}
|
||||
contractId={createdContractId}
|
||||
/>
|
||||
```
|
||||
|
||||
## 📊 Comparaison des deux approches
|
||||
|
||||
| Critère | Survey PostHog natif | Survey personnalisé |
|
||||
|---------|---------------------|---------------------|
|
||||
| **Facilité de mise en place** | ⭐⭐⭐⭐⭐ Très facile | ⭐⭐⭐ Moyen |
|
||||
| **Contrôle du design** | ⭐⭐⭐ Limité | ⭐⭐⭐⭐⭐ Total |
|
||||
| **Analytics automatiques** | ⭐⭐⭐⭐⭐ Intégré | ⭐⭐⭐ À configurer |
|
||||
| **A/B testing** | ⭐⭐⭐⭐⭐ Natif | ⭐⭐ Manuel |
|
||||
| **Multilingue** | ⭐⭐⭐⭐ Facile | ⭐⭐⭐⭐⭐ Total |
|
||||
| **Maintenance** | ⭐⭐⭐⭐⭐ Aucune | ⭐⭐⭐ Régulière |
|
||||
|
||||
**Recommandation** : Commencez avec le **Survey PostHog natif**, c'est plus rapide et vous aurez les analytics automatiquement. Si vous avez besoin de plus de contrôle, passez au survey personnalisé plus tard.
|
||||
|
||||
## ✅ Checklist de déploiement
|
||||
|
||||
- [ ] Code modifié dans `NouveauCDDUForm.tsx` (déjà fait ✅)
|
||||
- [ ] Variables PostHog configurées dans `.env.local`
|
||||
- [ ] Créer le survey dans le dashboard PostHog
|
||||
- [ ] Configurer le ciblage sur l'événement `contract_created`
|
||||
- [ ] Tester en créant un contrat
|
||||
- [ ] Vérifier dans PostHog que l'événement arrive
|
||||
- [ ] Vérifier que le survey s'affiche
|
||||
- [ ] Configurer les alertes si nécessaire
|
||||
|
||||
## 🧪 Test
|
||||
|
||||
1. Créez un contrat de test
|
||||
2. Vérifiez dans la console : `📊 PostHog: Événement contract_created envoyé`
|
||||
3. Le survey devrait apparaître quelques secondes après la création
|
||||
4. Donnez une note
|
||||
5. Vérifiez dans PostHog → Surveys que la réponse est enregistrée
|
||||
|
||||
## 📚 Ressources
|
||||
|
||||
- [Documentation PostHog Surveys](https://posthog.com/docs/surveys)
|
||||
- [Survey templates](https://posthog.com/templates/surveys)
|
||||
- [Best practices](https://posthog.com/docs/surveys/best-practices)
|
||||
|
||||
---
|
||||
|
||||
**Dernière mise à jour** : 15 octobre 2025
|
||||
189
POSTHOG_SURVEY_INTEGRATION_GUIDE.md
Normal file
189
POSTHOG_SURVEY_INTEGRATION_GUIDE.md
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
# 🎯 Guide rapide : Intégration du survey personnalisé
|
||||
|
||||
## Option 1 : Utiliser le survey PostHog natif (Recommandé)
|
||||
|
||||
✅ **Déjà configuré !** L'événement `contract_created` est envoyé automatiquement.
|
||||
|
||||
Il vous suffit de :
|
||||
1. Aller sur https://eu.posthog.com/
|
||||
2. Créer un survey (voir `POSTHOG_SURVEY_CONTRATS.md`)
|
||||
3. Le cibler sur l'événement `contract_created`
|
||||
|
||||
**Avantages** : Analytics automatiques, A/B testing, maintenance zéro.
|
||||
|
||||
---
|
||||
|
||||
## Option 2 : Utiliser le composant personnalisé
|
||||
|
||||
Si vous voulez un contrôle total sur le design et le timing, utilisez le composant `ContractCreationSurvey`.
|
||||
|
||||
### Intégration dans NouveauCDDUForm
|
||||
|
||||
Ajoutez ces imports en haut du fichier :
|
||||
|
||||
```tsx
|
||||
import { ContractCreationSurvey } from '@/components/surveys/ContractCreationSurvey';
|
||||
```
|
||||
|
||||
Ajoutez ces states avec les autres states du formulaire :
|
||||
|
||||
```tsx
|
||||
const [showSurvey, setShowSurvey] = useState(false);
|
||||
const [surveyContractId, setSurveyContractId] = useState<string>();
|
||||
const [surveyContractType, setSurveyContractType] = useState<'CDDU' | 'RG'>();
|
||||
```
|
||||
|
||||
Dans la fonction `onSubmit`, après la création réussie, ajoutez :
|
||||
|
||||
```tsx
|
||||
// Après: const result = await res.json().catch(() => ({}));
|
||||
setSurveyContractId(result?.contract?.id || result?.contract?.contract_number);
|
||||
setSurveyContractType(isRegimeRG ? 'RG' : 'CDDU');
|
||||
|
||||
// Afficher le survey après un court délai (laisser le temps à la redirection de commencer)
|
||||
setTimeout(() => {
|
||||
setShowSurvey(true);
|
||||
}, 1500);
|
||||
```
|
||||
|
||||
Ajoutez le composant dans le JSX, après les overlays existants :
|
||||
|
||||
```tsx
|
||||
{/* Survey de satisfaction - après la fin des overlays existants */}
|
||||
<ContractCreationSurvey
|
||||
isOpen={showSurvey}
|
||||
onClose={() => setShowSurvey(false)}
|
||||
contractId={surveyContractId}
|
||||
contractType={surveyContractType}
|
||||
/>
|
||||
```
|
||||
|
||||
### Exemple complet de modification
|
||||
|
||||
```tsx
|
||||
// Dans onSubmit(), juste après la création réussie :
|
||||
|
||||
const result = await res.json().catch(() => ({}));
|
||||
|
||||
// 🎯 PostHog event (déjà en place)
|
||||
posthog?.capture('contract_created', { ... });
|
||||
|
||||
// 🎯 Préparer le survey personnalisé
|
||||
setSurveyContractId(result?.contract?.id);
|
||||
setSurveyContractType(isRegimeRG ? 'RG' : 'CDDU');
|
||||
|
||||
// Autoriser la navigation
|
||||
allowNavRef.current = true;
|
||||
setRedirecting(true);
|
||||
|
||||
// Afficher le survey après 1.5s (pendant la redirection)
|
||||
setTimeout(() => {
|
||||
setShowSurvey(true);
|
||||
}, 1500);
|
||||
|
||||
// Redirection après 3s
|
||||
setTimeout(() => {
|
||||
router.push("/contrats");
|
||||
}, 3000);
|
||||
```
|
||||
|
||||
### Résultat
|
||||
|
||||
Le survey apparaîtra en **bas à droite** de l'écran pendant la redirection, permettant à l'utilisateur de donner son avis avant d'être redirigé vers la liste des contrats.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Analyser les résultats
|
||||
|
||||
### Dans PostHog
|
||||
|
||||
1. Allez dans **Activity** → **Live events**
|
||||
2. Filtrez par événement : `contract_creation_survey_submitted`
|
||||
3. Vous verrez les propriétés :
|
||||
- `rating` : Note de 1 à 5
|
||||
- `feedback` : Commentaire (si donné)
|
||||
- `contract_id` : ID du contrat
|
||||
- `contract_type` : CDDU ou RG
|
||||
|
||||
### Créer un insight
|
||||
|
||||
```
|
||||
Event: contract_creation_survey_submitted
|
||||
Aggregation: Average of rating
|
||||
Breakdown: contract_type
|
||||
```
|
||||
|
||||
Cela vous donnera la note moyenne par type de contrat.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Personnalisation
|
||||
|
||||
### Changer les couleurs
|
||||
|
||||
Dans `ContractCreationSurvey.tsx`, modifiez les classes Tailwind :
|
||||
|
||||
```tsx
|
||||
// Bouton principal
|
||||
className="... bg-emerald-600 hover:bg-emerald-700"
|
||||
|
||||
// Étoiles
|
||||
className="... fill-yellow-400 text-yellow-400"
|
||||
|
||||
// Border du composant
|
||||
className="... border-slate-200"
|
||||
```
|
||||
|
||||
### Changer la position
|
||||
|
||||
```tsx
|
||||
// En bas à gauche
|
||||
className="fixed bottom-4 left-4 ..."
|
||||
|
||||
// En haut à droite
|
||||
className="fixed top-4 right-4 ..."
|
||||
|
||||
// Centre de l'écran
|
||||
className="fixed inset-0 flex items-center justify-center ..."
|
||||
```
|
||||
|
||||
### Changer le timing
|
||||
|
||||
```tsx
|
||||
// Afficher immédiatement après création
|
||||
setTimeout(() => setShowSurvey(true), 0);
|
||||
|
||||
// Afficher après 5 secondes
|
||||
setTimeout(() => setShowSurvey(true), 5000);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Le survey ne s'affiche pas
|
||||
|
||||
1. Vérifiez que `isOpen={true}` dans les props
|
||||
2. Vérifiez que PostHog est bien initialisé (`window.posthog` dans la console)
|
||||
3. Vérifiez les z-index (le composant a `z-50`)
|
||||
|
||||
### Le survey se ferme trop vite
|
||||
|
||||
Augmentez le délai dans `handleSubmit` :
|
||||
|
||||
```tsx
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 3000); // 3 secondes au lieu de 2
|
||||
```
|
||||
|
||||
### Les événements n'arrivent pas dans PostHog
|
||||
|
||||
1. Ouvrez DevTools → Network
|
||||
2. Filtrez par "batch"
|
||||
3. Vérifiez que les requêtes vers PostHog ont un statut 200
|
||||
4. Vérifiez dans la console : `📊 Survey soumis: ...`
|
||||
|
||||
---
|
||||
|
||||
**Recommandation finale** : Commencez avec le **survey PostHog natif** pour sa simplicité et ses analytics automatiques. Passez au composant personnalisé seulement si vous avez des besoins spécifiques de design ou de UX.
|
||||
|
|
@ -1,15 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Script from "next/script";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Download, Info, Loader2, Clock, CheckCircle, Euro } from "lucide-react";
|
||||
import { ArrowLeft, Download, Info, Loader2, Clock, CheckCircle, Euro, PenTool, XCircle, Users, Send, AlertTriangle, X, ExternalLink, AlertCircle } from "lucide-react";
|
||||
import { NotesSection } from "@/components/NotesSection";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmationModal } from "@/components/ui/confirmation-modal";
|
||||
import { LoadingModal } from "@/components/ui/loading-modal";
|
||||
import DocumentsCard from "@/components/contrats/DocumentsCard";
|
||||
import { toast } from "sonner";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
/* =========================
|
||||
Types attendus du backend
|
||||
|
|
@ -458,9 +462,84 @@ export default function ContratMultiPage() {
|
|||
const [selectedPaieId, setSelectedPaieId] = useState<string>("");
|
||||
const [selectedPaieStatus, setSelectedPaieStatus] = useState<boolean>(false);
|
||||
|
||||
// State pour la modale de signature DocuSeal
|
||||
const [embedSrc, setEmbedSrc] = useState<string>("");
|
||||
const [modalTitle, setModalTitle] = useState<string>("");
|
||||
const [signatureB64ForDocuSeal, setSignatureB64ForDocuSeal] = useState<string | null>(null);
|
||||
|
||||
// State pour la modale de chargement
|
||||
const [isLoadingSignature, setIsLoadingSignature] = useState<boolean>(false);
|
||||
|
||||
// State pour la modale d'erreur DocuSeal
|
||||
const [showErrorModal, setShowErrorModal] = useState<boolean>(false);
|
||||
|
||||
// State pour la modale de visualisation des fiches de paie
|
||||
const [isPayslipModalOpen, setIsPayslipModalOpen] = useState<boolean>(false);
|
||||
const [currentPayslipUrl, setCurrentPayslipUrl] = useState<string>("");
|
||||
const [currentPayslipTitle, setCurrentPayslipTitle] = useState<string>("");
|
||||
const [payslipPdfError, setPayslipPdfError] = useState<boolean>(false);
|
||||
|
||||
// Query client pour la mise à jour du cache
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Effet pour bloquer le défilement quand le modal DocuSeal est ouvert
|
||||
useEffect(() => {
|
||||
// Vérifier si le dialog est ouvert en surveillant embedSrc
|
||||
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
|
||||
if (dlg && embedSrc) {
|
||||
// Bloquer le défilement du body
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Observer pour détecter la fermeture du dialog
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!dlg.open) {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(dlg, { attributes: true, attributeFilter: ['open'] });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
} else {
|
||||
// S'assurer que le défilement est rétabli si embedSrc est vide
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}, [embedSrc]);
|
||||
|
||||
// Effet pour bloquer le défilement quand le modal de fiche de paie est ouvert
|
||||
useEffect(() => {
|
||||
if (isPayslipModalOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Handler pour fermer avec Escape
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsPayslipModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}, [isPayslipModalOpen]);
|
||||
|
||||
// Fonction pour ouvrir une fiche de paie dans le modal
|
||||
const openPayslipInModal = (url: string, title: string) => {
|
||||
setCurrentPayslipUrl(url);
|
||||
setCurrentPayslipTitle(title);
|
||||
setPayslipPdfError(false);
|
||||
setIsPayslipModalOpen(true);
|
||||
};
|
||||
|
||||
// Mutation pour marquer une paie multi comme payée/non payée
|
||||
const markAsPaidMutation = useMutation({
|
||||
mutationFn: async ({ payslipId, transferDone }: { payslipId: string; transferDone: boolean }) => {
|
||||
|
|
@ -579,6 +658,213 @@ export default function ContratMultiPage() {
|
|||
);
|
||||
}
|
||||
|
||||
// Fonction pour déterminer l'état de la signature électronique
|
||||
const getSignatureStatus = () => {
|
||||
const etatDemande = data.etat_demande;
|
||||
const contratSigneEmployeur = data.contrat_signe_employeur;
|
||||
const contratSigne = data.contrat_signe_salarie;
|
||||
|
||||
// Détermine le statut
|
||||
if (contratSigneEmployeur === "oui" && contratSigne === "oui") {
|
||||
return {
|
||||
status: "completed" as const,
|
||||
label: "Complété",
|
||||
icon: CheckCircle,
|
||||
color: "text-green-600",
|
||||
bgColor: "bg-green-50",
|
||||
borderColor: "border-green-200"
|
||||
};
|
||||
} else if (contratSigneEmployeur === "oui" && contratSigne !== "oui") {
|
||||
return {
|
||||
status: "waiting_employee" as const,
|
||||
label: "En attente salarié",
|
||||
icon: Users,
|
||||
color: "text-blue-600",
|
||||
bgColor: "bg-blue-50",
|
||||
borderColor: "border-blue-200"
|
||||
};
|
||||
} else if (String(etatDemande || "").toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').includes("traitee") || String(etatDemande || "").toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').includes("traitée")) {
|
||||
return {
|
||||
status: "waiting_employer" as const,
|
||||
label: "En attente employeur",
|
||||
icon: Send,
|
||||
color: "text-orange-600",
|
||||
bgColor: "bg-orange-50",
|
||||
borderColor: "border-orange-200"
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: "not_sent" as const,
|
||||
label: "Non envoyé",
|
||||
icon: Clock,
|
||||
color: "text-gray-600",
|
||||
bgColor: "bg-gray-50",
|
||||
borderColor: "border-gray-200"
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour ouvrir la signature DocuSeal
|
||||
async function openSignature() {
|
||||
if (!data) return;
|
||||
|
||||
// Afficher la modale de chargement
|
||||
setIsLoadingSignature(true);
|
||||
|
||||
let embed: string | null = null;
|
||||
const title = `Signature (Employeur) · ${data.numero}`;
|
||||
setModalTitle(title);
|
||||
|
||||
console.log('🔍 [SIGNATURE] Debug - data complète:', data);
|
||||
console.log('🔍 [SIGNATURE] Debug - data.id:', data.id);
|
||||
|
||||
// Utiliser notre API pour récupérer les données de signature avec service role
|
||||
try {
|
||||
const response = await fetch(`/api/contrats/${data.id}/signature`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setIsLoadingSignature(false);
|
||||
toast.error("Ce contrat n'a pas encore de signature électronique configurée. Veuillez d'abord créer la signature via l'interface d'édition du contrat.");
|
||||
return;
|
||||
}
|
||||
throw new Error(`Erreur API: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('📋 [SIGNATURE] Données contrat depuis API:', result);
|
||||
console.log('🔍 [SIGNATURE] result.data:', result.data);
|
||||
console.log('🔍 [SIGNATURE] result.data.signature_b64:', result.data?.signature_b64);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
setIsLoadingSignature(false);
|
||||
toast.error("Aucune donnée de signature trouvée pour ce contrat");
|
||||
return;
|
||||
}
|
||||
|
||||
const contractData = result.data;
|
||||
console.log('📦 [SIGNATURE] contractData extrait:', contractData);
|
||||
|
||||
// Stocker la signature si disponible
|
||||
const signatureB64 = contractData.signature_b64;
|
||||
console.log('🖊️ [SIGNATURE] signatureB64 extraite:', {
|
||||
exists: !!signatureB64,
|
||||
type: typeof signatureB64,
|
||||
length: signatureB64?.length,
|
||||
preview: signatureB64?.substring(0, 50)
|
||||
});
|
||||
|
||||
if (signatureB64) {
|
||||
console.log('✅ [SIGNATURE] Signature B64 disponible, longueur:', signatureB64.length);
|
||||
} else {
|
||||
console.log('⚠️ [SIGNATURE] Aucune signature B64 disponible');
|
||||
}
|
||||
|
||||
// Vérifier si la soumission DocuSeal n'a pas été créée
|
||||
if (!contractData.docuseal_submission_id && contractData.signature_status === "Non initiée") {
|
||||
setIsLoadingSignature(false);
|
||||
setShowErrorModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) Si on a un signature_link direct, l'utiliser
|
||||
if (contractData.signature_link) {
|
||||
console.log('🔗 [SIGNATURE] Signature link trouvé:', contractData.signature_link);
|
||||
|
||||
// Extraire le docuseal_id du lien de signature
|
||||
const signatureLinkMatch = contractData.signature_link.match(/docuseal_id=([^&]+)/);
|
||||
if (signatureLinkMatch) {
|
||||
const docusealId = signatureLinkMatch[1];
|
||||
|
||||
// L'URL doit être propre sans paramètres
|
||||
embed = `https://docuseal.eu/s/${docusealId}`;
|
||||
console.log('🔗 [SIGNATURE] URL embed depuis signature_link:', embed);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Sinon, récupérer via l'API DocuSeal à partir du template_id
|
||||
if (!embed && contractData.docuseal_template_id) {
|
||||
console.log('🔍 [SIGNATURE] Template ID trouvé:', contractData.docuseal_template_id);
|
||||
|
||||
try {
|
||||
const tId = String(contractData.docuseal_template_id);
|
||||
|
||||
const subRes = await fetch(`/api/docuseal/templates/${encodeURIComponent(tId)}/submissions`, { cache: 'no-store' });
|
||||
const subData = await subRes.json();
|
||||
|
||||
console.log('📋 [SIGNATURE] Submissions DocuSeal:', subData);
|
||||
|
||||
const first = Array.isArray(subData?.data) ? subData.data[0] : (Array.isArray(subData) ? subData[0] : subData);
|
||||
const subId = first?.id;
|
||||
|
||||
if (subId) {
|
||||
const detRes = await fetch(`/api/docuseal/submissions/${encodeURIComponent(subId)}`, { cache: 'no-store' });
|
||||
const detData = await detRes.json();
|
||||
|
||||
console.log('📋 [SIGNATURE] 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) {
|
||||
// URL propre sans paramètres
|
||||
embed = `https://docuseal.eu/s/${employer.slug}`;
|
||||
console.log('🔗 [SIGNATURE] URL embed depuis DocuSeal API:', embed);
|
||||
} else {
|
||||
embed = employer?.embed_src || employer?.sign_src || detData?.embed_src || null;
|
||||
console.log('🔗 [SIGNATURE] URL embed alternative:', embed);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('❌ [SIGNATURE] DocuSeal fetch failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (embed) {
|
||||
console.log('✅ [SIGNATURE] URL embed trouvée:', embed);
|
||||
setEmbedSrc(embed);
|
||||
|
||||
// Stocker la signature dans l'etat React pour l'ajouter au composant
|
||||
console.log('🔍 [SIGNATURE] Stockage de la signature dans l\'etat React...');
|
||||
console.log('🔍 [SIGNATURE] signatureB64 value:', signatureB64);
|
||||
|
||||
if (signatureB64) {
|
||||
console.log('✅ [SIGNATURE] Signature B64 disponible pour pre-remplissage');
|
||||
setSignatureB64ForDocuSeal(signatureB64);
|
||||
} else {
|
||||
console.log('⚠️ [SIGNATURE] Aucune signature B64 disponible');
|
||||
setSignatureB64ForDocuSeal(null);
|
||||
}
|
||||
|
||||
// Masquer la modale de chargement
|
||||
setIsLoadingSignature(false);
|
||||
|
||||
// Ouvrir la modale
|
||||
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
|
||||
if (dlg) {
|
||||
if (typeof dlg.showModal === 'function') {
|
||||
dlg.showModal();
|
||||
} else {
|
||||
dlg.setAttribute('open', '');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('❌ [SIGNATURE] Aucune URL d\'embed trouvée');
|
||||
setIsLoadingSignature(false);
|
||||
toast.error("Impossible de récupérer le lien de signature");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [SIGNATURE] Erreur:', error);
|
||||
setIsLoadingSignature(false);
|
||||
toast.error("Erreur lors du chargement de la signature");
|
||||
}
|
||||
}
|
||||
|
||||
const paies = paiesData?.items ?? [];
|
||||
|
||||
return (
|
||||
|
|
@ -600,8 +886,20 @@ export default function ContratMultiPage() {
|
|||
|
||||
{/* Disposition 2 colonnes (colonnes indépendantes en hauteur) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{/* Colonne gauche : Demande puis Temps de travail réel */}
|
||||
{/* Colonne gauche : Documents, Demande puis Temps de travail réel */}
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Card Documents */}
|
||||
<DocumentsCard
|
||||
contractId={id}
|
||||
contractNumber={data.numero}
|
||||
contractData={{
|
||||
pdf_contrat: data.pdf_contrat,
|
||||
contrat_signe_employeur: data.contrat_signe_employeur,
|
||||
contrat_signe_salarie: data.contrat_signe_salarie
|
||||
}}
|
||||
showPayslips={false}
|
||||
/>
|
||||
|
||||
<Section title="Demande">
|
||||
<Field
|
||||
label="Salarié"
|
||||
|
|
@ -615,30 +913,6 @@ export default function ContratMultiPage() {
|
|||
)
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="Contrat de travail PDF"
|
||||
value={
|
||||
data.pdf_contrat?.available ? (
|
||||
<a className="inline-flex items-center gap-2 underline" href={data.pdf_contrat.url} target="_blank" rel="noreferrer">
|
||||
<Download className="w-4 h-4" /> Télécharger
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-slate-400">Bientôt disponible…</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="Avenant contrat PDF"
|
||||
value={
|
||||
data.pdf_avenant?.available ? (
|
||||
<a className="inline-flex items-center gap-2 underline" href={data.pdf_avenant.url} target="_blank" rel="noreferrer">
|
||||
<Download className="w-4 h-4" /> Télécharger
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-slate-400">n/a</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Field label="État de la demande" value={stateBadgeDemande(data.etat_demande)} />
|
||||
<Field label="Contrat signé par employeur" value={boolBadge(data.contrat_signe_employeur)} />
|
||||
<Field label="Contrat signé par salarié·e" value={boolBadge(data.contrat_signe_salarie)} />
|
||||
|
|
@ -683,8 +957,83 @@ export default function ContratMultiPage() {
|
|||
</Section>
|
||||
</div>
|
||||
|
||||
{/* Colonne droite : Déclarations au-dessus des Paies */}
|
||||
{/* Colonne droite : Signature électronique, Déclarations, puis Paies */}
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Card de signature électronique */}
|
||||
<Card className="rounded-3xl overflow-hidden">
|
||||
<CardHeader className={`${getSignatureStatus().bgColor} ${getSignatureStatus().borderColor} border-b`}>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<PenTool className="size-5 text-indigo-600" />
|
||||
<span>Signature électronique</span>
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 px-3 py-1 rounded-full ${getSignatureStatus().bgColor} ${getSignatureStatus().color} border ${getSignatureStatus().borderColor}`}>
|
||||
{(() => {
|
||||
const IconComponent = getSignatureStatus().icon;
|
||||
return <IconComponent className="size-4" />;
|
||||
})()}
|
||||
<span className="text-sm font-medium">{getSignatureStatus().label}</span>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
{/* Affichage détaillé du statut */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<span className="text-gray-500 font-medium">État de l'envoi</span>
|
||||
<span className={`font-semibold ${
|
||||
(() => {
|
||||
const etatNormalise = String(data.etat_demande || "").toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||||
return etatNormalise.includes("traitee") || etatNormalise.includes("traitée") ? "text-green-600" : "text-gray-600";
|
||||
})()
|
||||
}`}>
|
||||
{(() => {
|
||||
const etatNormalise = String(data.etat_demande || "").toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||||
return etatNormalise.includes("traitee") || etatNormalise.includes("traitée") ? "Envoyé" : "En cours";
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<span className="text-gray-500 font-medium">Signature employeur</span>
|
||||
<span className={`font-semibold ${
|
||||
data.contrat_signe_employeur === "oui" ? "text-green-600" : "text-gray-600"
|
||||
}`}>
|
||||
{data.contrat_signe_employeur === "oui" ? "Oui" : "Non"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<span className="text-gray-500 font-medium">Signature salarié</span>
|
||||
<span className={`font-semibold ${
|
||||
data.contrat_signe_salarie === "oui" ? "text-green-600" : "text-gray-600"
|
||||
}`}>
|
||||
{data.contrat_signe_salarie === "oui" ? "Oui" : "Non"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bouton Signer maintenant */}
|
||||
{(() => {
|
||||
const etatNormalise = String(data.etat_demande || "").toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||||
const isTraitee = etatNormalise.includes("traitee") || etatNormalise.includes("traitée");
|
||||
const signatureEmployeurNon = data.contrat_signe_employeur !== "oui";
|
||||
|
||||
return (isTraitee && signatureEmployeurNon) ? (
|
||||
<div className="pt-4 border-t">
|
||||
<Button
|
||||
onClick={openSignature}
|
||||
className="w-full bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-black hover:from-indigo-300 hover:via-purple-300 hover:to-pink-300"
|
||||
>
|
||||
<PenTool className="size-4 mr-2 text-black" />
|
||||
Signer maintenant
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Section title="Déclarations">
|
||||
{(() => {
|
||||
const raw = String(data.dpae || "");
|
||||
|
|
@ -803,19 +1152,12 @@ export default function ContratMultiPage() {
|
|||
<span className="text-[11px] px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-800 border border-indigo-200/60">
|
||||
# {p.ordre ?? '—'}
|
||||
</span>
|
||||
{/* Période (Paie traitée) */}
|
||||
{
|
||||
// Prefer explicit period_start/period_end from the API when present
|
||||
(p as any).period_start && (p as any).period_end ? (
|
||||
{/* Période (format texte) */}
|
||||
{label && (
|
||||
<span className="text-[11px] px-2 py-1 rounded-lg bg-gradient-to-br from-sky-50 to-blue-100 text-blue-800 border border-blue-200/70 shadow-[0_1px_0_rgba(255,255,255,0.6)]">
|
||||
{formatDateFR((p as any).period_start)} – {formatDateFR((p as any).period_end)}
|
||||
{label}
|
||||
</span>
|
||||
) : p.paie_traitee ? (
|
||||
<span className="text-[11px] px-2 py-1 rounded-lg bg-gradient-to-br from-sky-50 to-blue-100 text-blue-800 border border-blue-200/70 shadow-[0_1px_0_rgba(255,255,255,0.6)]">
|
||||
{formatPeriodDisplay(p.paie_traitee)}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div className="sm:ml-auto flex items-center gap-2">
|
||||
{/* 1. Traitée */}
|
||||
|
|
@ -877,11 +1219,16 @@ export default function ContratMultiPage() {
|
|||
|
||||
// Récupérer l'URL signée depuis le map
|
||||
const signedUrl = payslipUrlsMap.get(p.id);
|
||||
const payslipTitle = payLabel(p) || (p.ordre ? `Paie ${p.ordre}` : 'Fiche de paie');
|
||||
|
||||
return signedUrl ? (
|
||||
<a key={p.id} href={signedUrl} target="_blank" rel="noreferrer" className="group block">
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => openPayslipInModal(signedUrl, payslipTitle)}
|
||||
className="group block cursor-pointer"
|
||||
>
|
||||
{CardInner}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div key={p.id} className="opacity-70 cursor-not-allowed group block" title="PDF de paie indisponible">
|
||||
{CardInner}
|
||||
|
|
@ -897,6 +1244,114 @@ export default function ContratMultiPage() {
|
|||
{/* Notes */}
|
||||
<NotesSection contractId={id} contractRef={data?.numero} />
|
||||
|
||||
{/* Script DocuSeal */}
|
||||
<Script src="https://cdn.docuseal.com/js/form.js" strategy="lazyOnload" />
|
||||
|
||||
{/* Modale d'erreur DocuSeal */}
|
||||
{showErrorModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-2xl max-w-md mx-4 p-6 shadow-xl">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Signature non disponible</h2>
|
||||
</div>
|
||||
<div className="text-slate-600 mb-6">
|
||||
<p className="mb-3">Nous nous excusons pour la gêne occasionnée.</p>
|
||||
<p>La signature électronique n'est pas encore prête pour ce contrat. Nos équipes travaillent activement sur la préparation des documents.</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowErrorModal(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Fermer
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.location.href = '/support';
|
||||
}}
|
||||
className="flex-1 bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-black hover:from-indigo-300 hover:via-purple-300 hover:to-pink-300"
|
||||
>
|
||||
Nous contacter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modale de signature DocuSeal */}
|
||||
<dialog id="dlg-signature" className="rounded-lg border max-w-5xl w-[96vw] p-0">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-4 py-3 border-b bg-white">
|
||||
<strong>{modalTitle || 'Signature électronique'}</strong>
|
||||
<button
|
||||
onClick={() => {
|
||||
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
|
||||
if (dlg) dlg.close();
|
||||
}}
|
||||
className="p-1.5 rounded hover:bg-slate-50"
|
||||
aria-label="Fermer"
|
||||
title="Fermer"
|
||||
>
|
||||
<XCircle className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-0" style={{ height: '80vh', minHeight: 520, overflowY: 'auto' }}>
|
||||
{embedSrc ? (
|
||||
<div dangerouslySetInnerHTML={{
|
||||
__html: (() => {
|
||||
console.log('🎨 [SIGNATURE RENDER] Génération du HTML docuseal-form');
|
||||
console.log('🎨 [SIGNATURE RENDER] embedSrc:', embedSrc);
|
||||
|
||||
// Utiliser la signature depuis l'etat React au lieu de sessionStorage
|
||||
const signatureB64 = signatureB64ForDocuSeal;
|
||||
|
||||
console.log('🎨 [SIGNATURE RENDER] Signature depuis l\'etat React:', {
|
||||
exists: !!signatureB64,
|
||||
length: signatureB64?.length,
|
||||
preview: signatureB64?.substring(0, 50)
|
||||
});
|
||||
|
||||
const signatureAttr = signatureB64 ? `data-signature="${signatureB64.replace(/"/g, '"')}"` : '';
|
||||
|
||||
console.log('🎨 [SIGNATURE RENDER] signatureAttr généré:', {
|
||||
hasAttr: !!signatureAttr,
|
||||
attrLength: signatureAttr.length,
|
||||
attrPreview: signatureAttr.substring(0, 100)
|
||||
});
|
||||
|
||||
const html = `<docuseal-form
|
||||
data-src="${embedSrc}"
|
||||
data-language="fr"
|
||||
data-with-title="false"
|
||||
data-background-color="#fff"
|
||||
data-allow-typed-signature="false"
|
||||
${signatureAttr}>
|
||||
</docuseal-form>`;
|
||||
|
||||
console.log('🎨 [SIGNATURE RENDER] HTML final généré');
|
||||
console.log('🎨 [SIGNATURE RENDER] HTML length:', html.length);
|
||||
console.log('🎨 [SIGNATURE RENDER] HTML contient data-signature:', html.includes('data-signature='));
|
||||
|
||||
return html;
|
||||
})()
|
||||
}} />
|
||||
) : (
|
||||
<div className="p-4 text-slate-500">Préparation du formulaire…</div>
|
||||
)}
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
{/* Modale de chargement */}
|
||||
<LoadingModal
|
||||
isOpen={isLoadingSignature}
|
||||
title="Préparation de la signature"
|
||||
description="Chargement de l'interface de signature électronique..."
|
||||
onClose={() => setIsLoadingSignature(false)}
|
||||
/>
|
||||
|
||||
{/* Modale de confirmation de paiement */}
|
||||
<ConfirmationModal
|
||||
isOpen={showPaymentModal}
|
||||
|
|
@ -918,6 +1373,95 @@ export default function ContratMultiPage() {
|
|||
isLoading={markAsPaidMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Modale de visualisation des fiches de paie */}
|
||||
{isPayslipModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/50"
|
||||
onClick={() => setIsPayslipModalOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-6xl h-[90vh] bg-white rounded-2xl shadow-2xl flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header sticky */}
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 bg-white border-b rounded-t-2xl">
|
||||
<h2 className="text-lg font-semibold text-slate-800 flex items-center gap-2">
|
||||
{currentPayslipTitle}
|
||||
{data?.numero && (
|
||||
<>
|
||||
<span className="text-slate-400">•</span>
|
||||
<span className="text-sm font-normal text-slate-500">
|
||||
Contrat {data.numero}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => window.open(currentPayslipUrl, '_blank')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Ouvrir dans un nouvel onglet
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const link = document.createElement('a');
|
||||
link.href = currentPayslipUrl;
|
||||
link.download = `${currentPayslipTitle.replace(/\s+/g, '_')}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Télécharger
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsPayslipModalOpen(false)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{payslipPdfError ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-slate-500 gap-4">
|
||||
<AlertCircle className="w-16 h-16 text-slate-400" />
|
||||
<p>Impossible de charger le PDF</p>
|
||||
<Button
|
||||
onClick={() => window.open(currentPayslipUrl, '_blank')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Ouvrir dans un nouvel onglet
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
src={currentPayslipUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={currentPayslipTitle}
|
||||
onError={() => setPayslipPdfError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Script from "next/script";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Download, Info, Loader2, Clock, CheckCircle, Euro } from "lucide-react";
|
||||
import { ArrowLeft, Download, Info, Loader2, Clock, CheckCircle, Euro, X, ExternalLink, AlertCircle, PenTool, XCircle } from "lucide-react";
|
||||
import { NotesSection } from "@/components/NotesSection";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmationModal } from "@/components/ui/confirmation-modal";
|
||||
import { LoadingModal } from "@/components/ui/loading-modal";
|
||||
import DocumentsCard from "@/components/contrats/DocumentsCard";
|
||||
import { toast } from "sonner";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
/* =========================
|
||||
Types attendus du backend
|
||||
|
|
@ -52,7 +56,7 @@ type ContratMultiDetail = {
|
|||
pdf_avenant?: { available: boolean; url?: string };
|
||||
|
||||
// Déclarations
|
||||
dpae?: "a_traiter" | "envoyee" | "refusee" | "retour_ok";
|
||||
dpae?: "a_traiter" | "envoyee" | "refusee" | "retour_ok" | "Faite" | "Refusée" | "À traiter";
|
||||
|
||||
// Temps de travail cumulé
|
||||
jours_travailles?: string | number;
|
||||
|
|
@ -176,7 +180,7 @@ function usePaies(id: string) {
|
|||
/* =========
|
||||
Helpers
|
||||
========= */
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
function Section({ title, children }: { title: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="rounded-2xl border bg-white">
|
||||
<div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
|
||||
|
|
@ -353,6 +357,23 @@ export default function ContratMultiPage() {
|
|||
currentStatus: false,
|
||||
});
|
||||
|
||||
// State pour la modale de signature DocuSeal
|
||||
const [embedSrc, setEmbedSrc] = useState<string>("");
|
||||
const [modalTitle, setModalTitle] = useState<string>("");
|
||||
const [signatureB64ForDocuSeal, setSignatureB64ForDocuSeal] = useState<string | null>(null);
|
||||
|
||||
// State pour la modale de chargement
|
||||
const [isLoadingSignature, setIsLoadingSignature] = useState<boolean>(false);
|
||||
|
||||
// State pour la modale d'erreur DocuSeal
|
||||
const [showErrorModal, setShowErrorModal] = useState<boolean>(false);
|
||||
|
||||
// State pour la modale de visualisation des fiches de paie
|
||||
const [isPayslipModalOpen, setIsPayslipModalOpen] = useState<boolean>(false);
|
||||
const [currentPayslipUrl, setCurrentPayslipUrl] = useState<string>("");
|
||||
const [currentPayslipTitle, setCurrentPayslipTitle] = useState<string>("");
|
||||
const [payslipPdfError, setPayslipPdfError] = useState<boolean>(false);
|
||||
|
||||
// Query client pour la mise à jour du cache
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -409,6 +430,146 @@ export default function ContratMultiPage() {
|
|||
setPaiesPage(1);
|
||||
}, [id]);
|
||||
|
||||
// Effet pour bloquer le défilement quand le modal DocuSeal est ouvert
|
||||
useEffect(() => {
|
||||
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
|
||||
if (dlg && embedSrc) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!dlg.open) {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(dlg, { attributes: true, attributeFilter: ['open'] });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}, [embedSrc]);
|
||||
|
||||
// Effet pour bloquer le défilement quand le modal de fiche de paie est ouvert
|
||||
useEffect(() => {
|
||||
if (isPayslipModalOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsPayslipModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}, [isPayslipModalOpen]);
|
||||
|
||||
// Fonction pour ouvrir une fiche de paie dans le modal
|
||||
const openPayslipInModal = (url: string, title: string) => {
|
||||
setCurrentPayslipUrl(url);
|
||||
setCurrentPayslipTitle(title);
|
||||
setPayslipPdfError(false);
|
||||
setIsPayslipModalOpen(true);
|
||||
};
|
||||
|
||||
// Fonctions pour la signature électronique
|
||||
const getSignatureStatus = () => {
|
||||
if (!data) return {
|
||||
label: "En attente",
|
||||
color: "text-gray-600",
|
||||
bgColor: "bg-gray-50",
|
||||
borderColor: "border-gray-200",
|
||||
icon: Clock
|
||||
};
|
||||
|
||||
const employerSigned = data.contrat_signe_employeur === 'oui';
|
||||
const employeeSigned = data.contrat_signe_salarie === 'oui';
|
||||
|
||||
if (employerSigned && employeeSigned) {
|
||||
return {
|
||||
label: "Signature complète",
|
||||
color: "text-green-600",
|
||||
bgColor: "bg-green-50",
|
||||
borderColor: "border-green-200",
|
||||
icon: CheckCircle
|
||||
};
|
||||
}
|
||||
|
||||
if (employerSigned || employeeSigned) {
|
||||
return {
|
||||
label: "Signature en cours",
|
||||
color: "text-amber-600",
|
||||
bgColor: "bg-amber-50",
|
||||
borderColor: "border-amber-200",
|
||||
icon: Clock
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: "En attente de signature",
|
||||
color: "text-gray-600",
|
||||
bgColor: "bg-gray-50",
|
||||
borderColor: "border-gray-200",
|
||||
icon: PenTool
|
||||
};
|
||||
};
|
||||
|
||||
const openSignature = async () => {
|
||||
if (!data?.numero) {
|
||||
toast.error("Numéro de contrat manquant");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingSignature(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/contrats/${id}/docuseal-url`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ contractReference: data.numero })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Erreur ${response.status}`);
|
||||
}
|
||||
|
||||
const { url, signatureBase64 } = await response.json();
|
||||
|
||||
if (signatureBase64) {
|
||||
setSignatureB64ForDocuSeal(signatureBase64);
|
||||
}
|
||||
|
||||
setEmbedSrc(url);
|
||||
setModalTitle(`Signature électronique - Contrat ${data.numero}`);
|
||||
|
||||
setTimeout(() => {
|
||||
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
|
||||
if (dlg) {
|
||||
dlg.showModal();
|
||||
}
|
||||
setIsLoadingSignature(false);
|
||||
}, 100);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Erreur lors de l\'ouverture de la signature:', error);
|
||||
setIsLoadingSignature(false);
|
||||
setShowErrorModal(true);
|
||||
toast.error(error.message || "Impossible de charger l'interface de signature");
|
||||
}
|
||||
};
|
||||
|
||||
// Calcule l'état du contrat en fonction des dates et de l'état de la demande
|
||||
const etatContratCalcule = useMemo(() => {
|
||||
if (!data) return undefined;
|
||||
|
|
@ -495,8 +656,20 @@ export default function ContratMultiPage() {
|
|||
|
||||
{/* Disposition 2 colonnes (colonnes indépendantes en hauteur) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{/* Colonne gauche : Demande puis Temps de travail réel */}
|
||||
{/* Colonne gauche : Documents, Demande puis Notes */}
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Documents */}
|
||||
<DocumentsCard
|
||||
contractId={id}
|
||||
contractNumber={data?.numero}
|
||||
contractData={{
|
||||
pdf_contrat: data?.pdf_contrat,
|
||||
contrat_signe_employeur: data?.contrat_signe_employeur,
|
||||
contrat_signe_salarie: data?.contrat_signe_salarie
|
||||
}}
|
||||
showPayslips={false}
|
||||
/>
|
||||
|
||||
<Section title="Demande">
|
||||
<Field
|
||||
label="Salarié"
|
||||
|
|
@ -510,30 +683,6 @@ export default function ContratMultiPage() {
|
|||
)
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="Contrat de travail PDF"
|
||||
value={
|
||||
data.pdf_contrat?.available ? (
|
||||
<a className="inline-flex items-center gap-2 underline" href={data.pdf_contrat.url} target="_blank" rel="noreferrer">
|
||||
<Download className="w-4 h-4" /> Télécharger
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-slate-400">Bientôt disponible…</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="Avenant contrat PDF"
|
||||
value={
|
||||
data.pdf_avenant?.available ? (
|
||||
<a className="inline-flex items-center gap-2 underline" href={data.pdf_avenant.url} target="_blank" rel="noreferrer">
|
||||
<Download className="w-4 h-4" /> Télécharger
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-slate-400">n/a</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Field label="État de la demande" value={stateBadgeDemande(data.etat_demande)} />
|
||||
<Field label="Contrat signé par employeur" value={boolBadge(data.contrat_signe_employeur)} />
|
||||
<Field label="Contrat signé par salarié·e" value={boolBadge(data.contrat_signe_salarie)} />
|
||||
|
|
@ -559,29 +708,118 @@ export default function ContratMultiPage() {
|
|||
<Field label="Profession" value={data.profession} />
|
||||
<Field label="Catégorie professionnelle" value={data.categorie_prof} />
|
||||
<Field label="Type de salaire demandé" value={data.type_salaire} />
|
||||
<Field label="Salaire demandé" value={data.salaire_demande} />
|
||||
<Field
|
||||
label="Salaire demandé"
|
||||
value={data.salaire_demande ? formatEUR(data.salaire_demande) : undefined}
|
||||
/>
|
||||
<Field label="Début contrat" value={formatDateFR(data.date_debut)} />
|
||||
<Field label="Fin contrat" value={formatDateFR(data.date_fin)} />
|
||||
<Field
|
||||
label="Fin contrat"
|
||||
value={(() => {
|
||||
// Masquer la date de fin si c'est 01/01/2099 (CDI non terminé)
|
||||
if (!data.date_fin) return "—";
|
||||
const date = new Date(data.date_fin);
|
||||
if (date.getFullYear() === 2099 && date.getMonth() === 0 && date.getDate() === 1) {
|
||||
return <span className="text-slate-400 italic">CDI en cours</span>;
|
||||
}
|
||||
return formatDateFR(data.date_fin);
|
||||
})()}
|
||||
/>
|
||||
<Field label="Panier repas" value={boolBadge(data.panier_repas)} />
|
||||
</Section>
|
||||
<NotesSection contractId={id} contractRef={data?.numero} />
|
||||
</div>
|
||||
|
||||
{/* Colonne droite : Déclarations au-dessus des Paies */}
|
||||
{/* Colonne droite : Signature, Déclarations et Paies */}
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Card de signature électronique */}
|
||||
<Card className="rounded-3xl overflow-hidden">
|
||||
<CardHeader className={`${getSignatureStatus().bgColor} ${getSignatureStatus().borderColor} border-b`}>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<PenTool className="size-5 text-indigo-600" />
|
||||
<span>Signature électronique</span>
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 px-3 py-1 rounded-full ${getSignatureStatus().bgColor} ${getSignatureStatus().color} border ${getSignatureStatus().borderColor}`}>
|
||||
{(() => {
|
||||
const IconComponent = getSignatureStatus().icon;
|
||||
return <IconComponent className="size-4" />;
|
||||
})()}
|
||||
<span className="text-sm font-medium">{getSignatureStatus().label}</span>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
{/* Affichage détaillé du statut */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<span className="text-gray-500 font-medium">État de l'envoi</span>
|
||||
<span className={`font-semibold ${
|
||||
(() => {
|
||||
const etatNormalise = String(data.etat_demande || "").toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||||
return etatNormalise.includes("traitee") || etatNormalise.includes("traitée") ? "text-green-600" : "text-gray-600";
|
||||
})()
|
||||
}`}>
|
||||
{(() => {
|
||||
const etatNormalise = String(data.etat_demande || "").toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||||
return etatNormalise.includes("traitee") || etatNormalise.includes("traitée") ? "Envoyé" : "En cours";
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<span className="text-gray-500 font-medium">Signature employeur</span>
|
||||
<span className={`font-semibold ${
|
||||
data.contrat_signe_employeur === "oui" ? "text-green-600" : "text-gray-600"
|
||||
}`}>
|
||||
{data.contrat_signe_employeur === "oui" ? "Oui" : "Non"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<span className="text-gray-500 font-medium">Signature salarié</span>
|
||||
<span className={`font-semibold ${
|
||||
data.contrat_signe_salarie === "oui" ? "text-green-600" : "text-gray-600"
|
||||
}`}>
|
||||
{data.contrat_signe_salarie === "oui" ? "Oui" : "Non"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bouton Signer maintenant */}
|
||||
{(() => {
|
||||
const etatNormalise = String(data.etat_demande || "").toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||||
const isTraitee = etatNormalise.includes("traitee") || etatNormalise.includes("traitée");
|
||||
const signatureEmployeurNon = data.contrat_signe_employeur !== "oui";
|
||||
|
||||
return (isTraitee && signatureEmployeurNon) ? (
|
||||
<div className="pt-4 border-t">
|
||||
<Button
|
||||
onClick={openSignature}
|
||||
className="w-full bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 text-black hover:from-indigo-300 hover:via-purple-300 hover:to-pink-300"
|
||||
>
|
||||
<PenTool className="size-4 mr-2 text-black" />
|
||||
Signer maintenant
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Section title="Déclarations">
|
||||
<Field
|
||||
label="DPAE"
|
||||
value={
|
||||
data.dpae === 'envoyee' || data.dpae === 'retour_ok' ? (
|
||||
data.dpae === 'envoyee' || data.dpae === 'retour_ok' || data.dpae === 'Faite' ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800">
|
||||
<CheckCircle className="w-3 h-3" /> Effectuée
|
||||
</span>
|
||||
) : data.dpae === 'refusee' ? (
|
||||
) : data.dpae === 'refusee' || data.dpae === 'Refusée' ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-rose-100 text-rose-800">
|
||||
<Clock className="w-3 h-3" /> Refusée
|
||||
</span>
|
||||
) : data.dpae === 'a_traiter' ? (
|
||||
) : data.dpae === 'a_traiter' || data.dpae === 'À traiter' ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-800">
|
||||
<Clock className="w-3 h-3" /> En cours
|
||||
</span>
|
||||
|
|
@ -592,7 +830,7 @@ export default function ContratMultiPage() {
|
|||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Paies">
|
||||
<Section title={<><span>Paies</span><span className="ml-2 text-xs italic text-slate-500">- Cliquez sur la carte d'une paie pour afficher ses documents de paie et sur le symbole € pour la marquer comme payée ou non payée.</span></>}>
|
||||
{paiesLoading ? (
|
||||
<div className="px-3 py-8 text-center text-slate-500">
|
||||
<Loader2 className="w-4 h-4 inline animate-spin mr-2" /> Chargement des paies…
|
||||
|
|
@ -607,15 +845,6 @@ export default function ContratMultiPage() {
|
|||
) : paies.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-slate-500">
|
||||
Aucune paie trouvée.
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div className="mt-2 text-xs text-slate-400 space-y-1">
|
||||
<div>Debug: Contract ID = {id}</div>
|
||||
<div>Debug: Contract Reference = {data?.numero || 'non définie'}</div>
|
||||
<div className="text-yellow-600">
|
||||
Vérifiez que le champ "Reference" dans la table "Paies RG" contient la valeur "{data?.numero || '???'}".
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -645,12 +874,12 @@ export default function ContratMultiPage() {
|
|||
<span className="text-[11px] px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-800 border border-indigo-200/60">
|
||||
# {p.ordre ?? '—'}
|
||||
</span>
|
||||
{/* Période (Paie traitée) */}
|
||||
{p.paie_traitee ? (
|
||||
{/* Période (format texte) */}
|
||||
{label && (
|
||||
<span className="text-[11px] px-2 py-1 rounded-lg bg-gradient-to-br from-sky-50 to-blue-100 text-blue-800 border border-blue-200/70 shadow-[0_1px_0_rgba(255,255,255,0.6)]">
|
||||
{p.paie_traitee}
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
<div className="sm:ml-auto flex items-center gap-2">
|
||||
{/* 1. Traitée */}
|
||||
|
|
@ -694,9 +923,13 @@ export default function ContratMultiPage() {
|
|||
);
|
||||
|
||||
return p.paie_pdf ? (
|
||||
<a key={p.id} href={p.paie_pdf} target="_blank" rel="noreferrer" className="group block">
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => openPayslipInModal(p.paie_pdf!, label)}
|
||||
className="group block cursor-pointer"
|
||||
>
|
||||
{CardInner}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div key={p.id} className="opacity-70 cursor-not-allowed group block" title="PDF de paie indisponible">
|
||||
{CardInner}
|
||||
|
|
@ -752,6 +985,155 @@ export default function ContratMultiPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Script DocuSeal */}
|
||||
<Script src="https://cdn.docuseal.co/js/form.js" strategy="afterInteractive" />
|
||||
|
||||
{/* Dialog pour la signature DocuSeal */}
|
||||
<dialog id="dlg-signature" className="rounded-2xl shadow-2xl backdrop:bg-black/50 w-full max-w-5xl p-0 overflow-hidden">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 bg-white border-b">
|
||||
<h2 className="text-lg font-semibold text-slate-800">{modalTitle}</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
|
||||
if (dlg) dlg.close();
|
||||
setEmbedSrc("");
|
||||
}}
|
||||
className="text-slate-500 hover:text-slate-700"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-white min-h-[500px]">
|
||||
{embedSrc ? (
|
||||
<div dangerouslySetInnerHTML={{
|
||||
__html: (() => {
|
||||
const signatureB64 = signatureB64ForDocuSeal;
|
||||
const signatureAttr = signatureB64 ? `data-signature="${signatureB64.replace(/"/g, '"')}"` : '';
|
||||
|
||||
return `<docuseal-form
|
||||
data-src="${embedSrc}"
|
||||
data-language="fr"
|
||||
data-with-title="false"
|
||||
data-background-color="#fff"
|
||||
data-allow-typed-signature="false"
|
||||
${signatureAttr}>
|
||||
</docuseal-form>`;
|
||||
})()
|
||||
}} />
|
||||
) : (
|
||||
<div className="p-4 text-slate-500">Préparation du formulaire…</div>
|
||||
)}
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
{/* Modale de chargement */}
|
||||
<LoadingModal
|
||||
isOpen={isLoadingSignature}
|
||||
title="Préparation de la signature"
|
||||
description="Chargement de l'interface de signature électronique..."
|
||||
onClose={() => setIsLoadingSignature(false)}
|
||||
/>
|
||||
|
||||
{/* Modale d'erreur DocuSeal */}
|
||||
<ConfirmationModal
|
||||
isOpen={showErrorModal}
|
||||
title="Erreur de signature"
|
||||
description="Impossible de charger l'interface de signature. Veuillez réessayer plus tard."
|
||||
confirmText="Fermer"
|
||||
onConfirm={() => setShowErrorModal(false)}
|
||||
onCancel={() => setShowErrorModal(false)}
|
||||
/>
|
||||
|
||||
{/* Modale de visualisation des fiches de paie */}
|
||||
{isPayslipModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/50"
|
||||
onClick={() => setIsPayslipModalOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-6xl h-[90vh] bg-white rounded-2xl shadow-2xl flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header sticky */}
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 bg-white border-b rounded-t-2xl">
|
||||
<h2 className="text-lg font-semibold text-slate-800 flex items-center gap-2">
|
||||
{currentPayslipTitle}
|
||||
{data?.numero && (
|
||||
<>
|
||||
<span className="text-slate-400">•</span>
|
||||
<span className="text-sm font-normal text-slate-500">
|
||||
Contrat {data.numero}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => window.open(currentPayslipUrl, '_blank')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Ouvrir dans un nouvel onglet
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const link = document.createElement('a');
|
||||
link.href = currentPayslipUrl;
|
||||
link.download = `${currentPayslipTitle.replace(/\s+/g, '_')}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Télécharger
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsPayslipModalOpen(false)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{payslipPdfError ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-slate-500 gap-4">
|
||||
<AlertCircle className="w-16 h-16 text-slate-400" />
|
||||
<p>Impossible de charger le PDF</p>
|
||||
<Button
|
||||
onClick={() => window.open(currentPayslipUrl, '_blank')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Ouvrir dans un nouvel onglet
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
src={currentPayslipUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={currentPayslipTitle}
|
||||
onError={() => setPayslipPdfError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -598,6 +598,33 @@ export default function ContratPage() {
|
|||
// Query client pour la mise à jour du cache
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Effet pour bloquer le défilement quand le modal DocuSeal est ouvert
|
||||
useEffect(() => {
|
||||
// Vérifier si le dialog est ouvert en surveillant embedSrc
|
||||
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
|
||||
if (dlg && embedSrc) {
|
||||
// Bloquer le défilement du body
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Observer pour détecter la fermeture du dialog
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!dlg.open) {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(dlg, { attributes: true, attributeFilter: ['open'] });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
} else {
|
||||
// S'assurer que le défilement est rétabli si embedSrc est vide
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}, [embedSrc]);
|
||||
|
||||
// Mutation pour marquer une paie comme payée/non payée
|
||||
const markAsPaidMutation = useMutation({
|
||||
mutationFn: async ({ payslipId, transferDone }: { payslipId: string; transferDone: boolean }) => {
|
||||
|
|
@ -1228,6 +1255,7 @@ return (
|
|||
{/* Card Documents */}
|
||||
<DocumentsCard
|
||||
contractId={id}
|
||||
contractNumber={data.numero}
|
||||
contractData={{
|
||||
pdf_contrat: data.pdf_contrat,
|
||||
contrat_signe_employeur: data.contrat_signe_employeur,
|
||||
|
|
@ -1512,8 +1540,8 @@ return (
|
|||
)}
|
||||
|
||||
{/* Modale de signature DocuSeal */}
|
||||
<dialog id="dlg-signature" className="rounded-lg border max-w-5xl w-[96vw]">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<dialog id="dlg-signature" className="rounded-lg border max-w-5xl w-[96vw] p-0">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-4 py-3 border-b bg-white">
|
||||
<strong>{modalTitle || 'Signature électronique'}</strong>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -1527,7 +1555,7 @@ return (
|
|||
<XCircle className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-0" style={{ height: '80vh', minHeight: 520 }}>
|
||||
<div className="p-0" style={{ height: '80vh', minHeight: 520, overflowY: 'auto' }}>
|
||||
{embedSrc ? (
|
||||
<div dangerouslySetInnerHTML={{
|
||||
__html: (() => {
|
||||
|
|
|
|||
|
|
@ -98,10 +98,10 @@ export async function GET(req: NextRequest, { params }: { params: { id: string }
|
|||
.slice(0, 200);
|
||||
|
||||
// Try to fetch the contract reference to build storage paths
|
||||
// When org.id is null (staff global access), use the admin client to bypass RLS.
|
||||
// When staff (org.isStaff === true), use the admin client to bypass RLS.
|
||||
let contractRow: any = null;
|
||||
let contractErr: any = null;
|
||||
if (org.id === null) {
|
||||
if (org.isStaff) {
|
||||
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
|
||||
const r = await admin.from("cddu_contracts").select("contract_number").eq("id", id).maybeSingle();
|
||||
contractRow = r.data;
|
||||
|
|
@ -123,10 +123,11 @@ export async function GET(req: NextRequest, { params }: { params: { id: string }
|
|||
const orgSlug = slugify(org.name || "unknown");
|
||||
|
||||
// Query payslips table for this contract
|
||||
// When org.id === null (staff) use admin client to return all payslips for contract.
|
||||
// When staff (org.isStaff === true) use admin client to return all payslips for contract,
|
||||
// regardless of whether they have an active org selected or not.
|
||||
let pays: any = null;
|
||||
let paysErr: any = null;
|
||||
if (org.id === null) {
|
||||
if (org.isStaff) {
|
||||
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
|
||||
const r = await admin.from("payslips").select("*").eq("contract_id", id).order("pay_number", { ascending: true });
|
||||
pays = r.data;
|
||||
|
|
|
|||
|
|
@ -84,11 +84,11 @@ export async function GET(req: NextRequest, { params }: { params: { id: string }
|
|||
const org = await resolveOrganization(supabase, session);
|
||||
|
||||
// 1. Essayer de trouver le contrat dans Supabase (table cddu_contracts)
|
||||
// If org.id === null this indicates a staff/global access; use the
|
||||
// service-role admin client to bypass RLS for reads so staff can see all rows.
|
||||
// If staff (org.isStaff === true), use the service-role admin client to bypass RLS
|
||||
// for reads so staff can see all rows, regardless of active org selection.
|
||||
let cddu: any = null;
|
||||
let cdduError: any = null;
|
||||
if (org.id === null) {
|
||||
if (org.isStaff) {
|
||||
// ADMIN client requires SUPABASE_SERVICE_ROLE_KEY and NEXT_PUBLIC_SUPABASE_URL
|
||||
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
|
||||
const q = admin.from("cddu_contracts").select("*").eq("id", contractId);
|
||||
|
|
@ -106,11 +106,29 @@ export async function GET(req: NextRequest, { params }: { params: { id: string }
|
|||
return NextResponse.json({ error: cdduError.message }, { status: 500 });
|
||||
}
|
||||
if (cddu) {
|
||||
// Helper pour slugifier le nom de l'organisation
|
||||
const slugify = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "") // Enlever les accents
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "")
|
||||
.slice(0, 200);
|
||||
|
||||
// Générer l'URL signée S3 si la clé est présente
|
||||
let pdfUrl: string | undefined = undefined;
|
||||
if (cddu.contract_pdf_s3_key) {
|
||||
let s3Key = cddu.contract_pdf_s3_key;
|
||||
|
||||
// Si contract_pdf_s3_key est vide, essayer de construire le chemin à partir de structure + contract_number
|
||||
if (!s3Key && cddu.structure && cddu.contract_number) {
|
||||
const orgSlug = slugify(cddu.structure);
|
||||
s3Key = `contracts/${orgSlug}/${cddu.contract_number}.pdf`;
|
||||
}
|
||||
|
||||
if (s3Key) {
|
||||
try {
|
||||
const maybe = await getS3SignedUrlIfExists(cddu.contract_pdf_s3_key);
|
||||
const maybe = await getS3SignedUrlIfExists(s3Key);
|
||||
pdfUrl = maybe ?? undefined;
|
||||
} catch (e) {
|
||||
pdfUrl = undefined;
|
||||
|
|
@ -199,6 +217,19 @@ export async function GET(req: NextRequest, { params }: { params: { id: string }
|
|||
return NextResponse.json({ error: "upstream_error", status: res.status, body: text, requestId }, { status: 502 });
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
// Si l'upstream retourne un contract_pdf_s3_key, générer l'URL signée
|
||||
if (data.contract_pdf_s3_key && typeof data.contract_pdf_s3_key === 'string') {
|
||||
try {
|
||||
const signedUrl = await getS3SignedUrlIfExists(data.contract_pdf_s3_key);
|
||||
if (signedUrl) {
|
||||
data.pdf_contrat = { available: true, url: signedUrl };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error generating S3 URL for upstream contract:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const response = NextResponse.json(data, { status: 200 });
|
||||
response.headers.set("x-request-id", requestId);
|
||||
return response;
|
||||
|
|
@ -239,7 +270,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||
let cddu: any = null;
|
||||
let isSupabaseOnly = false;
|
||||
|
||||
if (org.id === null) {
|
||||
if (org.isStaff) {
|
||||
// Staff avec accès admin
|
||||
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
|
||||
const { data } = await admin.from("cddu_contracts").select("*").eq("id", contractId).maybeSingle();
|
||||
|
|
@ -320,7 +351,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||
|
||||
// Mise à jour directe dans Supabase
|
||||
let updateResult;
|
||||
if (org.id === null) {
|
||||
if (org.isStaff) {
|
||||
// Staff avec accès admin
|
||||
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
|
||||
updateResult = await admin.from("cddu_contracts").update(supabaseData).eq("id", contractId);
|
||||
|
|
@ -339,7 +370,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||
try {
|
||||
// Récupérer les données du contrat mis à jour pour les emails
|
||||
let contractData;
|
||||
if (org.id === null) {
|
||||
if (org.isStaff) {
|
||||
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
|
||||
const { data } = await admin.from("cddu_contracts").select("*").eq("id", contractId).single();
|
||||
contractData = data;
|
||||
|
|
@ -351,7 +382,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||
if (contractData) {
|
||||
// Récupérer les données d'organisation avec tous les détails
|
||||
let organizationData;
|
||||
if (org.id === null) {
|
||||
if (org.isStaff) {
|
||||
// Pour staff, récupérer l'organisation du contrat depuis la DB
|
||||
if (contractData.org_id) {
|
||||
const { data: orgDetails } = await supabase
|
||||
|
|
@ -498,7 +529,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||
// Ne faire l'update que s'il y a des données à sauvegarder
|
||||
if (Object.keys(supabaseData).length > 0) {
|
||||
let updateResult;
|
||||
if (org.id === null) {
|
||||
if (org.isStaff) {
|
||||
// Staff avec accès admin
|
||||
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
|
||||
updateResult = await admin.from("cddu_contracts").update(supabaseData).eq("id", contractId);
|
||||
|
|
@ -512,7 +543,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||
try {
|
||||
// Récupérer les données du contrat mis à jour pour les emails
|
||||
let contractData;
|
||||
if (org.id === null) {
|
||||
if (org.isStaff) {
|
||||
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
|
||||
const { data } = await admin.from("cddu_contracts").select("*").eq("id", contractId).single();
|
||||
contractData = data;
|
||||
|
|
@ -524,7 +555,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||
if (contractData) {
|
||||
// Récupérer les données d'organisation avec tous les détails
|
||||
let organizationData;
|
||||
if (org.id === null) {
|
||||
if (org.isStaff) {
|
||||
// Pour staff, récupérer l'organisation du contrat depuis la DB
|
||||
if (contractData.org_id) {
|
||||
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
|
||||
|
|
@ -625,7 +656,7 @@ export async function DELETE(req: NextRequest, { params }: { params: { id: strin
|
|||
let contractData;
|
||||
let organizationData;
|
||||
|
||||
if (org.id === null) {
|
||||
if (org.isStaff) {
|
||||
// Staff avec accès admin
|
||||
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
|
||||
const { data } = await admin.from("cddu_contracts").select("*").eq("id", contractId).single();
|
||||
|
|
@ -678,7 +709,7 @@ export async function DELETE(req: NextRequest, { params }: { params: { id: strin
|
|||
|
||||
// 3. Supprimer le contrat de Supabase
|
||||
let deleteResult;
|
||||
if (org.id === null) {
|
||||
if (org.isStaff) {
|
||||
// Staff avec accès admin
|
||||
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
|
||||
deleteResult = await admin.from("cddu_contracts").delete().eq("id", contractId);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// app/api/contrats/[id]/signed-pdf/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createSbServer } from "@/lib/supabaseServer";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { getS3SignedUrlIfExists } from "@/lib/aws-s3";
|
||||
|
||||
export async function GET(
|
||||
|
|
@ -19,14 +20,48 @@ export async function GET(
|
|||
);
|
||||
}
|
||||
|
||||
// Vérifier si l'utilisateur est staff
|
||||
const { data: staffData } = await sb
|
||||
.from("staff_users")
|
||||
.select("is_staff")
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
const isStaff = !!staffData?.is_staff;
|
||||
|
||||
console.log(`[signed-pdf] 🔍 Fetching contract ${params.id} for ${isStaff ? 'staff' : 'client'} user`);
|
||||
|
||||
// Récupération du contrat avec vérification d'accès via RLS
|
||||
const { data: contract, error: contractError } = await sb
|
||||
// Si staff, utiliser le client admin pour bypass RLS
|
||||
let contract: any = null;
|
||||
let contractError: any = null;
|
||||
|
||||
if (isStaff) {
|
||||
const admin = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL || "",
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY || ""
|
||||
);
|
||||
const r = await admin
|
||||
.from("cddu_contracts")
|
||||
.select("id, contract_pdf_s3_key, employee_email")
|
||||
.eq("id", params.id)
|
||||
.single();
|
||||
contract = r.data;
|
||||
contractError = r.error;
|
||||
console.log(`[signed-pdf] 📊 Admin query result:`, { contract, error: contractError?.message });
|
||||
} else {
|
||||
const r = await sb
|
||||
.from("cddu_contracts")
|
||||
.select("id, contract_pdf_s3_key, employee_email")
|
||||
.eq("id", params.id)
|
||||
.single();
|
||||
contract = r.data;
|
||||
contractError = r.error;
|
||||
console.log(`[signed-pdf] 📊 Client query result:`, { contract, error: contractError?.message });
|
||||
}
|
||||
|
||||
if (contractError || !contract) {
|
||||
console.log(`[signed-pdf] ❌ Contract not found or error:`, contractError?.message);
|
||||
return NextResponse.json(
|
||||
{ error: "Contrat introuvable ou accès refusé" },
|
||||
{ status: 404 }
|
||||
|
|
@ -35,6 +70,7 @@ export async function GET(
|
|||
|
||||
// Vérification de la présence de la clé S3
|
||||
if (!contract.contract_pdf_s3_key) {
|
||||
console.log(`[signed-pdf] ℹ️ No contract_pdf_s3_key for contract ${params.id}`);
|
||||
return NextResponse.json(
|
||||
{ error: "Aucun contrat signé disponible", hasSignedPdf: false },
|
||||
{ status: 404 }
|
||||
|
|
|
|||
|
|
@ -3,16 +3,18 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { FileText, CheckCircle2, RefreshCw, Download } from "lucide-react";
|
||||
import { FileText, CheckCircle2, RefreshCw, Download, X, ExternalLink, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface DocumentsCardProps {
|
||||
contractId: string;
|
||||
contractNumber?: string;
|
||||
contractData?: {
|
||||
pdf_contrat?: { available: boolean; url?: string };
|
||||
contrat_signe_employeur?: string;
|
||||
contrat_signe_salarie?: string;
|
||||
};
|
||||
showPayslips?: boolean; // Par défaut true, si false ne charge pas les fiches de paie
|
||||
}
|
||||
|
||||
interface SignedPdfData {
|
||||
|
|
@ -36,16 +38,38 @@ interface PayslipData {
|
|||
hasDocument: boolean;
|
||||
}
|
||||
|
||||
export default function DocumentsCard({ contractId, contractData }: DocumentsCardProps) {
|
||||
export default function DocumentsCard({ contractId, contractNumber, contractData, showPayslips = true }: DocumentsCardProps) {
|
||||
const [signedPdfData, setSignedPdfData] = useState<SignedPdfData | null>(null);
|
||||
const [loadingSignedPdf, setLoadingSignedPdf] = useState(true);
|
||||
const [payslips, setPayslips] = useState<PayslipData[]>([]);
|
||||
const [loadingPayslips, setLoadingPayslips] = useState(true);
|
||||
|
||||
// États pour le modal
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [currentDocumentUrl, setCurrentDocumentUrl] = useState<string>("");
|
||||
const [currentDocumentTitle, setCurrentDocumentTitle] = useState<string>("");
|
||||
const [pdfError, setPdfError] = useState(false);
|
||||
|
||||
// Fonction pour ouvrir un document dans le modal
|
||||
const openDocumentInModal = (url: string, title: string) => {
|
||||
setCurrentDocumentUrl(url);
|
||||
setCurrentDocumentTitle(title);
|
||||
setPdfError(false);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// Fonction pour fermer le modal
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setCurrentDocumentUrl("");
|
||||
setCurrentDocumentTitle("");
|
||||
setPdfError(false);
|
||||
};
|
||||
|
||||
// Fonction pour ouvrir le contrat PDF original
|
||||
const openOriginalPdf = () => {
|
||||
if (contractData?.pdf_contrat?.url) {
|
||||
window.open(contractData.pdf_contrat.url, '_blank');
|
||||
openDocumentInModal(contractData.pdf_contrat.url, "Contrat CDDU");
|
||||
} else {
|
||||
toast.error("URL du contrat PDF non disponible");
|
||||
}
|
||||
|
|
@ -54,7 +78,7 @@ export default function DocumentsCard({ contractId, contractData }: DocumentsCar
|
|||
// Fonction pour ouvrir le contrat signé
|
||||
const openSignedPdf = async () => {
|
||||
if (signedPdfData?.signedUrl) {
|
||||
window.open(signedPdfData.signedUrl, '_blank');
|
||||
openDocumentInModal(signedPdfData.signedUrl, "Contrat CDDU signé");
|
||||
} else {
|
||||
toast.error("URL du contrat signé non disponible");
|
||||
}
|
||||
|
|
@ -63,12 +87,34 @@ export default function DocumentsCard({ contractId, contractData }: DocumentsCar
|
|||
// Fonction pour ouvrir une fiche de paie
|
||||
const openPayslip = (payslip: PayslipData) => {
|
||||
if (payslip.signedUrl) {
|
||||
window.open(payslip.signedUrl, '_blank');
|
||||
openDocumentInModal(
|
||||
payslip.signedUrl,
|
||||
`Fiche de paie #${payslip.pay_number}`
|
||||
);
|
||||
} else {
|
||||
toast.error("Document de paie non disponible");
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour ouvrir dans un nouvel onglet
|
||||
const openInNewTab = () => {
|
||||
if (currentDocumentUrl) {
|
||||
window.open(currentDocumentUrl, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour télécharger
|
||||
const downloadDocument = () => {
|
||||
if (currentDocumentUrl) {
|
||||
const link = document.createElement('a');
|
||||
link.href = currentDocumentUrl;
|
||||
link.download = `${currentDocumentTitle.replace(/\s+/g, '_')}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
};
|
||||
|
||||
// Récupération du contrat signé
|
||||
useEffect(() => {
|
||||
const fetchSignedPdf = async () => {
|
||||
|
|
@ -98,8 +144,36 @@ export default function DocumentsCard({ contractId, contractData }: DocumentsCar
|
|||
fetchSignedPdf();
|
||||
}, [contractId]);
|
||||
|
||||
// Effet pour bloquer le défilement et gérer la touche Échap quand le modal est ouvert
|
||||
useEffect(() => {
|
||||
if (isModalOpen) {
|
||||
// Bloquer le défilement
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Gérer la touche Échap
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}
|
||||
}, [isModalOpen]);
|
||||
|
||||
// Récupération des fiches de paie
|
||||
useEffect(() => {
|
||||
// Ne charger les fiches de paie que si showPayslips est true
|
||||
if (!showPayslips) {
|
||||
setLoadingPayslips(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchPayslips = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/contrats/${contractId}/payslip-urls`, {
|
||||
|
|
@ -123,7 +197,7 @@ export default function DocumentsCard({ contractId, contractData }: DocumentsCar
|
|||
};
|
||||
|
||||
fetchPayslips();
|
||||
}, [contractId]);
|
||||
}, [contractId, showPayslips]);
|
||||
|
||||
// Formatage des dates
|
||||
const formatDate = (dateStr: string) => {
|
||||
|
|
@ -146,7 +220,7 @@ export default function DocumentsCard({ contractId, contractData }: DocumentsCar
|
|||
const hasSignedContract = signedPdfData?.hasSignedPdf && signedPdfData?.signedUrl;
|
||||
const hasOriginalContract = contractData?.pdf_contrat?.available && contractData?.pdf_contrat?.url;
|
||||
const bothPartiesSigned = contractData?.contrat_signe_employeur === 'oui' && contractData?.contrat_signe_salarie === 'oui';
|
||||
const hasAnyDocument = hasSignedContract || hasOriginalContract || payslips.some(p => p.hasDocument);
|
||||
const hasAnyDocument = hasSignedContract || hasOriginalContract || (showPayslips && payslips.some(p => p.hasDocument));
|
||||
|
||||
return (
|
||||
<Card className="rounded-3xl">
|
||||
|
|
@ -215,7 +289,9 @@ export default function DocumentsCard({ contractId, contractData }: DocumentsCar
|
|||
)
|
||||
) : null}
|
||||
|
||||
{/* Fiches de paie */}
|
||||
{/* Fiches de paie - afficher uniquement si showPayslips est true */}
|
||||
{showPayslips && (
|
||||
<>
|
||||
{loadingPayslips ? (
|
||||
<div className="flex items-center gap-3 p-3 border rounded-lg bg-gray-50">
|
||||
<RefreshCw className="size-5 text-gray-400 animate-spin" />
|
||||
|
|
@ -246,6 +322,8 @@ export default function DocumentsCard({ contractId, contractData }: DocumentsCar
|
|||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Message quand aucun document n'est disponible */}
|
||||
{!loadingSignedPdf && !loadingPayslips && !hasAnyDocument && (
|
||||
|
|
@ -256,6 +334,101 @@ export default function DocumentsCard({ contractId, contractData }: DocumentsCar
|
|||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Modal de visualisation de document */}
|
||||
{isModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
// Fermer si on clique sur le fond (pas sur le contenu)
|
||||
if (e.target === e.currentTarget) {
|
||||
closeModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative w-[95vw] h-[95vh] max-w-7xl bg-white rounded-2xl shadow-2xl flex flex-col overflow-hidden">
|
||||
{/* Header du modal */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b bg-gradient-to-r from-slate-50 to-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="size-5 text-slate-600" />
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900">{currentDocumentTitle}</h2>
|
||||
{contractNumber && (
|
||||
<>
|
||||
<span className="text-slate-400">•</span>
|
||||
<span className="text-sm text-slate-600 font-medium">Contrat n° {contractNumber}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={downloadDocument}
|
||||
className="px-3 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors flex items-center gap-2"
|
||||
title="Télécharger"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Télécharger
|
||||
</button>
|
||||
<button
|
||||
onClick={openInNewTab}
|
||||
className="px-3 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors flex items-center gap-2"
|
||||
title="Ouvrir dans un nouvel onglet"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Nouvel onglet
|
||||
</button>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Fermer"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Corps du modal avec iframe */}
|
||||
<div className="flex-1 relative bg-slate-100">
|
||||
{pdfError ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center p-8 text-center">
|
||||
<AlertCircle className="size-16 text-amber-500 mb-4" />
|
||||
<h3 className="text-xl font-semibold text-slate-900 mb-2">
|
||||
Impossible d'afficher le PDF
|
||||
</h3>
|
||||
<p className="text-slate-600 mb-6 max-w-md">
|
||||
Le document ne peut pas être affiché dans cette fenêtre.
|
||||
Utilisez les boutons ci-dessus pour le télécharger ou l'ouvrir dans un nouvel onglet.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={downloadDocument}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Télécharger le PDF
|
||||
</button>
|
||||
<button
|
||||
onClick={openInNewTab}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Ouvrir dans un nouvel onglet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
src={currentDocumentUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={currentDocumentTitle}
|
||||
onError={() => setPdfError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { useState, useMemo, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { api } from "@/lib/fetcher";
|
||||
import { Loader2, Search, Info } from "lucide-react";
|
||||
import { NotesSection } from "@/components/NotesSection";
|
||||
|
|
@ -308,6 +309,7 @@ export function NouveauCDDUForm({
|
|||
contractIdForNotes?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const posthog = usePostHog();
|
||||
|
||||
// --- form state
|
||||
const [reference, setReference] = useState<string>("");
|
||||
|
|
@ -1198,6 +1200,19 @@ useEffect(() => {
|
|||
const errorData = await res.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Erreur HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
// 🎯 Tracker l'événement de création de contrat avec PostHog
|
||||
const result = await res.json().catch(() => ({}));
|
||||
posthog?.capture('contract_created', {
|
||||
contract_type: isRegimeRG ? 'RG' : 'CDDU',
|
||||
regime: isRegimeRG ? 'Régime Général' : payload.regime,
|
||||
multi_mois: payload.multi_mois,
|
||||
categorie_professionnelle: payload.categorie_professionnelle,
|
||||
contract_id: result?.contract?.id || result?.contract?.contract_number,
|
||||
has_notes: !!payload.notes,
|
||||
validation_immediate: payload.valider_direct,
|
||||
});
|
||||
console.log('📊 PostHog: Événement contract_created envoyé');
|
||||
}
|
||||
|
||||
// Autoriser la navigation après soumission + feedback
|
||||
|
|
|
|||
185
components/surveys/ContractCreationSurvey.tsx
Normal file
185
components/surveys/ContractCreationSurvey.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { usePostHog } from 'posthog-js/react';
|
||||
import { Star, X } from 'lucide-react';
|
||||
|
||||
interface ContractCreationSurveyProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
contractId?: string;
|
||||
contractType?: 'CDDU' | 'RG';
|
||||
}
|
||||
|
||||
/**
|
||||
* Survey de satisfaction après création d'un contrat
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [showSurvey, setShowSurvey] = useState(false);
|
||||
* const [contractId, setContractId] = useState<string>();
|
||||
*
|
||||
* // Après création réussie du contrat :
|
||||
* setContractId(result.contract.id);
|
||||
* setShowSurvey(true);
|
||||
*
|
||||
* // Dans le JSX :
|
||||
* <ContractCreationSurvey
|
||||
* isOpen={showSurvey}
|
||||
* onClose={() => setShowSurvey(false)}
|
||||
* contractId={contractId}
|
||||
* contractType="CDDU"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function ContractCreationSurvey({
|
||||
isOpen,
|
||||
onClose,
|
||||
contractId,
|
||||
contractType,
|
||||
}: ContractCreationSurveyProps) {
|
||||
const posthog = usePostHog();
|
||||
const [rating, setRating] = useState<number | null>(null);
|
||||
const [hoverRating, setHoverRating] = useState<number | null>(null);
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [showFeedback, setShowFeedback] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (rating !== null && rating <= 3) {
|
||||
setShowFeedback(true);
|
||||
} else {
|
||||
setShowFeedback(false);
|
||||
setFeedback('');
|
||||
}
|
||||
}, [rating]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (rating) {
|
||||
posthog?.capture('contract_creation_survey_submitted', {
|
||||
rating,
|
||||
feedback: feedback.trim() || undefined,
|
||||
contract_id: contractId,
|
||||
contract_type: contractType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
console.log('📊 Survey soumis:', { rating, feedback, contractId, contractType });
|
||||
|
||||
setSubmitted(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
// Reset pour la prochaine fois
|
||||
setTimeout(() => {
|
||||
setRating(null);
|
||||
setFeedback('');
|
||||
setSubmitted(false);
|
||||
setShowFeedback(false);
|
||||
}, 500);
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
posthog?.capture('contract_creation_survey_dismissed', {
|
||||
had_rating: rating !== null,
|
||||
contract_id: contractId,
|
||||
contract_type: contractType,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 w-96 bg-white rounded-xl shadow-2xl border border-slate-200 animate-in slide-in-from-bottom-4 duration-300">
|
||||
{!submitted ? (
|
||||
<div className="p-6">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-3 right-3 text-slate-400 hover:text-slate-600 transition"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<h3 className="text-lg font-semibold mb-2 pr-8">📊 Votre avis compte !</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Comment évaluez-vous la facilité du processus de création de contrat ?
|
||||
</p>
|
||||
|
||||
{/* Stars rating */}
|
||||
<div className="flex justify-center gap-2 mb-4">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => setRating(star)}
|
||||
onMouseEnter={() => setHoverRating(star)}
|
||||
onMouseLeave={() => setHoverRating(null)}
|
||||
className="transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 rounded"
|
||||
aria-label={`${star} étoile${star > 1 ? 's' : ''}`}
|
||||
>
|
||||
<Star
|
||||
className={`w-8 h-8 transition-colors ${
|
||||
(hoverRating || rating || 0) >= star
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-slate-300'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-slate-500 mb-4">
|
||||
<span>Très difficile</span>
|
||||
<span>Très facile</span>
|
||||
</div>
|
||||
|
||||
{/* Feedback optionnel si note <= 3 */}
|
||||
{showFeedback && (
|
||||
<div className="mb-4 animate-in slide-in-from-top-2 duration-200">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Que pourrions-nous améliorer ? (optionnel)
|
||||
</label>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
placeholder="Partagez vos suggestions..."
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 focus:border-transparent resize-none"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
{feedback.length}/500 caractères
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-1 py-2 px-4 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition text-sm font-medium"
|
||||
>
|
||||
Plus tard
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!rating}
|
||||
className="flex-1 py-2 px-4 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition text-sm font-medium"
|
||||
>
|
||||
Envoyer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-center animate-in fade-in duration-200">
|
||||
<div className="mb-3 text-4xl">✅</div>
|
||||
<h3 className="text-lg font-semibold mb-1">Merci !</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Votre retour nous aide à améliorer l'application.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue