Ajout cards contrats multi et RG + survey nouveau contrat

This commit is contained in:
odentas 2025-10-15 11:37:53 +02:00
parent 98f694fc3f
commit 4694b87a18
13 changed files with 2364 additions and 146 deletions

View file

@ -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
View 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
View 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

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

View file

@ -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 ? (
<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)}
</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
}
{/* 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)]">
{label}
</span>
)}
</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, '&quot;')}"` : '';
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>
);
}

View file

@ -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, '&quot;')}"` : '';
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>
);
}

View file

@ -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 }) => {
@ -1227,7 +1254,8 @@ return (
<div className="space-y-5 order-1">
{/* Card Documents */}
<DocumentsCard
contractId={id}
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: (() => {

View file

@ -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;

View file

@ -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);

View file

@ -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
.from("cddu_contracts")
.select("id, contract_pdf_s3_key, employee_email")
.eq("id", params.id)
.single();
// 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 }

View file

@ -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,25 +289,27 @@ export default function DocumentsCard({ contractId, contractData }: DocumentsCar
)
) : null}
{/* Fiches de paie */}
{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" />
<div className="flex-1">
<p className="font-medium text-gray-600">Chargement des fiches de paie...</p>
</div>
</div>
) : payslips.filter(p => p.hasDocument).map((payslip) => (
<div
key={payslip.id}
onClick={() => openPayslip(payslip)}
className="flex items-center gap-3 p-3 border-2 border-blue-200 bg-blue-50 rounded-lg cursor-pointer hover:bg-blue-100 transition-colors"
>
<Download className="size-5 text-blue-600" />
<div className="flex-1">
<p className="font-medium text-blue-800">
Fiche de paie #{payslip.pay_number}
</p>
{/* 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" />
<div className="flex-1">
<p className="font-medium text-gray-600">Chargement des fiches de paie...</p>
</div>
</div>
) : payslips.filter(p => p.hasDocument).map((payslip) => (
<div
key={payslip.id}
onClick={() => openPayslip(payslip)}
className="flex items-center gap-3 p-3 border-2 border-blue-200 bg-blue-50 rounded-lg cursor-pointer hover:bg-blue-100 transition-colors"
>
<Download className="size-5 text-blue-600" />
<div className="flex-1">
<p className="font-medium text-blue-800">
Fiche de paie #{payslip.pay_number}
</p>
<p className="text-sm text-blue-600">
Période: {formatDate(payslip.period_start)} - {formatDate(payslip.period_end)}
</p>
@ -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>
);
}

View file

@ -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

View 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>
);
}