Initial commit

This commit is contained in:
odentas 2025-10-12 17:05:46 +02:00
parent 5fb2a4c513
commit f27de28bb4
212 changed files with 18207 additions and 2882 deletions

41
.env.local.bak Normal file
View file

@ -0,0 +1,41 @@
# Created by Vercel CLI
# Created by Vercel CLI
VERCEL_OIDC_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im1yay00MzAyZWMxYjY3MGY0OGE5OGFkNjFkYWRlNGEyM2JlNyJ9.eyJpc3MiOiJodHRwczovL29pZGMudmVyY2VsLmNvbS9vZGVudGFzLXByb2plY3RzLTE2ZjRlYWZiIiwic3ViIjoib3duZXI6b2RlbnRhcy1wcm9qZWN0cy0xNmY0ZWFmYjpwcm9qZWN0Om9kZW50YXMtZXNwYWNlLXBhaWU6ZW52aXJvbm1lbnQ6ZGV2ZWxvcG1lbnQiLCJzY29wZSI6Im93bmVyOm9kZW50YXMtcHJvamVjdHMtMTZmNGVhZmI6cHJvamVjdDpvZGVudGFzLWVzcGFjZS1wYWllOmVudmlyb25tZW50OmRldmVsb3BtZW50IiwiYXVkIjoiaHR0cHM6Ly92ZXJjZWwuY29tL29kZW50YXMtcHJvamVjdHMtMTZmNGVhZmIiLCJvd25lciI6Im9kZW50YXMtcHJvamVjdHMtMTZmNGVhZmIiLCJvd25lcl9pZCI6InRlYW1fYkw2RkdOUkc0dFB5VDBmOGhqMFFUdVZuIiwicHJvamVjdCI6Im9kZW50YXMtZXNwYWNlLXBhaWUiLCJwcm9qZWN0X2lkIjoicHJqX3RPNGJObzQ0cldiYkk2UkhENGRZNDlSTEZ4b2EiLCJlbnZpcm9ubWVudCI6ImRldmVsb3BtZW50IiwidXNlcl9pZCI6Ill0QU5YTkhGMVVtcHRUa0llTVB5QlJKdCIsIm5iZiI6MTc1ODExMDE1OSwiaWF0IjoxNzU4MTEwMTU5LCJleHAiOjE3NTgxNTMzNTl9.b9OEMCf8-S6bgJBF8-IEvaZrzo9mChJCis0lbq7HsUB99cGVUUlWaLYe2o5zw3eYYO5rNVXcZ5wlNvNqEAjkaelcFBexE6Qx1yFh9QB9xGsTYltfVio2PxyWFKxsHVcNYf3khOHjHutm84LIoYIx6mxXYr96sfJsilpamw7bN161zYBuu2E4T5RlLhQ6r1DRCTMyvD-R7pLdtyHgpq8VsLzOEsvvCfO92pI57kLveen_itHMpIH7An3J7BjijkSluYWHRg4PkzrCti_3SP0kK8wIs2Jl2pXAKR877nsVl7ZR08Z-sspHfhYAtQWnhMJ7M0l8xpWpHG2U8mpiqLCP_w"
NEXT_PUBLIC_API_BASE=https://0rryyjj6vh.execute-api.eu-west-3.amazonaws.com/default
AUTH_BYPASS=1
NEXT_PUBLIC_AUTH_BYPASS=1
# 🎭 Mode démo - Décommentez pour activer
DEMO_MODE=true
NEXT_PUBLIC_DEMO_MODE=true
NEXT_PUBLIC_SUPABASE_URL=https://fusqtpjififcmgbhmosq.supabase.co
NEXT_PUBLIC_API_BASE=https://0rryyjj6vh.execute-api.eu-west-3.amazonaws.com/default
AUTH_BYPASS=1
NEXT_PUBLIC_AUTH_BYPASS=1
NEXT_PUBLIC_SUPABASE_URL=https://fusqtpjififcmgbhmosq.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ1c3F0cGppZmlmY21nYmhtb3NxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTc1ODI4OTgsImV4cCI6MjA3MzE1ODg5OH0.kkQN9TaMM38WRgMLAUQh77yj71bi1aRGjG7W8KGGfUI
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ1c3F0cGppZmlmY21nYmhtb3NxIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc1NzU4Mjg5OCwiZXhwIjoyMDczMTU4ODk4fQ.hbouhmX_2f3ivGIgTM1qom5q_GBNnbDTr6bcJnnhmOA
N8N_NOTES_WEBHOOK_URL=https://n8n.odentas.fr/webhook-test/ajout-note-contrat
SUPABASE_URL=https://fusqtpjififcmgbhmosq.supabase.co
AWS_REGION=eu-west-3
AWS_SES_FROM="Odentas <paie@odentas.fr>"
AWS_ACCESS_KEY_ID=AKIAUIGDVEFKTEVMDOVG
AWS_SECRET_ACCESS_KEY=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
S3_BUCKET_NAME=odentas-docs
S3_BUCKET_NAME_EMAILS=stockage-logs-emails
UPSTREAM_API_BASE=https://0rryyjj6vh.execute-api.eu-west-3.amazonaws.com/default
AIRTABLE_TABLE_CONTRATS=tblUMqJtsvAvavZNz
AIRTABLE_BASE_ID=appyVHP0ZQdQzXFPn
AIRTABLE_API_KEY=patrNer5Dqyff2KEJ.aeebec95fc07db378dc92867d81ceba64e5e625b76ab0153395172595d8aa50c
DOCUSEAL_API_BASE=https://api.docuseal.eu
DOCUSEAL_TOKEN=s1fDVPiie3HYq2dEYvBX6od5k9p7JMB7VtvihoS8X77
NEXT_PUBLIC_FORCE_ANIMATIONS=1
N8N_VIREMENT_WEBHOOK_URL=
AWS_S3_BUCKET=odentas-docs
PDFMONKEY_URL=https://api.pdfmonkey.io/api/v1/documents
PDFMONKEY_API_KEY=ss-eoykvRJsbzedEP8c-
GOCARDLESS_ENVIRONMENT=live
GOCARDLESS_ACCESS_TOKEN=live_iMcKO4hM4Bezk25GN-owkb52jAI47NlxdjoL4dB_

41
.env.local.bak2 Normal file
View file

@ -0,0 +1,41 @@
# Created by Vercel CLI
# Created by Vercel CLI
VERCEL_OIDC_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im1yay00MzAyZWMxYjY3MGY0OGE5OGFkNjFkYWRlNGEyM2JlNyJ9.eyJpc3MiOiJodHRwczovL29pZGMudmVyY2VsLmNvbS9vZGVudGFzLXByb2plY3RzLTE2ZjRlYWZiIiwic3ViIjoib3duZXI6b2RlbnRhcy1wcm9qZWN0cy0xNmY0ZWFmYjpwcm9qZWN0Om9kZW50YXMtZXNwYWNlLXBhaWU6ZW52aXJvbm1lbnQ6ZGV2ZWxvcG1lbnQiLCJzY29wZSI6Im93bmVyOm9kZW50YXMtcHJvamVjdHMtMTZmNGVhZmI6cHJvamVjdDpvZGVudGFzLWVzcGFjZS1wYWllOmVudmlyb25tZW50OmRldmVsb3BtZW50IiwiYXVkIjoiaHR0cHM6Ly92ZXJjZWwuY29tL29kZW50YXMtcHJvamVjdHMtMTZmNGVhZmIiLCJvd25lciI6Im9kZW50YXMtcHJvamVjdHMtMTZmNGVhZmIiLCJvd25lcl9pZCI6InRlYW1fYkw2RkdOUkc0dFB5VDBmOGhqMFFUdVZuIiwicHJvamVjdCI6Im9kZW50YXMtZXNwYWNlLXBhaWUiLCJwcm9qZWN0X2lkIjoicHJqX3RPNGJObzQ0cldiYkk2UkhENGRZNDlSTEZ4b2EiLCJlbnZpcm9ubWVudCI6ImRldmVsb3BtZW50IiwidXNlcl9pZCI6Ill0QU5YTkhGMVVtcHRUa0llTVB5QlJKdCIsIm5iZiI6MTc1ODExMDE1OSwiaWF0IjoxNzU4MTEwMTU5LCJleHAiOjE3NTgxNTMzNTl9.b9OEMCf8-S6bgJBF8-IEvaZrzo9mChJCis0lbq7HsUB99cGVUUlWaLYe2o5zw3eYYO5rNVXcZ5wlNvNqEAjkaelcFBexE6Qx1yFh9QB9xGsTYltfVio2PxyWFKxsHVcNYf3khOHjHutm84LIoYIx6mxXYr96sfJsilpamw7bN161zYBuu2E4T5RlLhQ6r1DRCTMyvD-R7pLdtyHgpq8VsLzOEsvvCfO92pI57kLveen_itHMpIH7An3J7BjijkSluYWHRg4PkzrCti_3SP0kK8wIs2Jl2pXAKR877nsVl7ZR08Z-sspHfhYAtQWnhMJ7M0l8xpWpHG2U8mpiqLCP_w"
NEXT_PUBLIC_API_BASE=https://0rryyjj6vh.execute-api.eu-west-3.amazonaws.com/default
AUTH_BYPASS=1
NEXT_PUBLIC_AUTH_BYPASS=1
# 🎭 Mode démo - Décommentez pour activer
# DEMO_MODE=true
NEXT_PUBLIC_DEMO_MODE=true
NEXT_PUBLIC_SUPABASE_URL=https://fusqtpjififcmgbhmosq.supabase.co
NEXT_PUBLIC_API_BASE=https://0rryyjj6vh.execute-api.eu-west-3.amazonaws.com/default
AUTH_BYPASS=1
NEXT_PUBLIC_AUTH_BYPASS=1
NEXT_PUBLIC_SUPABASE_URL=https://fusqtpjififcmgbhmosq.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ1c3F0cGppZmlmY21nYmhtb3NxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTc1ODI4OTgsImV4cCI6MjA3MzE1ODg5OH0.kkQN9TaMM38WRgMLAUQh77yj71bi1aRGjG7W8KGGfUI
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ1c3F0cGppZmlmY21nYmhtb3NxIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc1NzU4Mjg5OCwiZXhwIjoyMDczMTU4ODk4fQ.hbouhmX_2f3ivGIgTM1qom5q_GBNnbDTr6bcJnnhmOA
N8N_NOTES_WEBHOOK_URL=https://n8n.odentas.fr/webhook-test/ajout-note-contrat
SUPABASE_URL=https://fusqtpjififcmgbhmosq.supabase.co
AWS_REGION=eu-west-3
AWS_SES_FROM="Odentas <paie@odentas.fr>"
AWS_ACCESS_KEY_ID=AKIAUIGDVEFKTEVMDOVG
AWS_SECRET_ACCESS_KEY=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
S3_BUCKET_NAME=odentas-docs
S3_BUCKET_NAME_EMAILS=stockage-logs-emails
UPSTREAM_API_BASE=https://0rryyjj6vh.execute-api.eu-west-3.amazonaws.com/default
AIRTABLE_TABLE_CONTRATS=tblUMqJtsvAvavZNz
AIRTABLE_BASE_ID=appyVHP0ZQdQzXFPn
AIRTABLE_API_KEY=patrNer5Dqyff2KEJ.aeebec95fc07db378dc92867d81ceba64e5e625b76ab0153395172595d8aa50c
DOCUSEAL_API_BASE=https://api.docuseal.eu
DOCUSEAL_TOKEN=s1fDVPiie3HYq2dEYvBX6od5k9p7JMB7VtvihoS8X77
NEXT_PUBLIC_FORCE_ANIMATIONS=1
N8N_VIREMENT_WEBHOOK_URL=
AWS_S3_BUCKET=odentas-docs
PDFMONKEY_URL=https://api.pdfmonkey.io/api/v1/documents
PDFMONKEY_API_KEY=ss-eoykvRJsbzedEP8c-
GOCARDLESS_ENVIRONMENT=live
GOCARDLESS_ACCESS_TOKEN=live_iMcKO4hM4Bezk25GN-owkb52jAI47NlxdjoL4dB_

53
.gitignore vendored
View file

@ -1,46 +1,21 @@
# Vercel
.vercel
# Environment variables
.env*.local
.env
# Dependencies
# Dépendances & builds
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Next.js
.next/
out/
build/
dist/
build/
# Logs
# Vercel
.vercel/
# Environnement & secrets
.env
.env.local
.env.*.local
# OS / IDE
.DS_Store
*.log
# TypeScript
*.tsbuildinfo
tsconfig.tsbuildinfo
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# IDE
.vscode/
.idea/
*.swp
*.swo
# Temporary files
tmp/
temp/
*.tmp
node_modules
# Tests & coverage
coverage/

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"npm.scriptRunner": "npm"
}

View file

@ -0,0 +1,73 @@
## 🎉 Modal de progression d'envoi d'emails groupés - TERMINÉ !
### ✅ Fonctionnalités implémentées :
**1. Modal de progression en temps réel :**
- Interface similaire au modal de génération de PDFs
- Affichage de la progression en pourcentage avec barre animée
- Statistiques en temps réel (succès, échecs, restants)
- Liste détaillée de chaque email avec statuts
**2. API Streaming pour envoi progressif :**
- Endpoint `/api/staff/bulk-email-stream` avec Server-Sent Events
- Envoi par lots de 5 emails pour respecter les limites SES
- Pause de 1 seconde entre les lots
- Gestion des erreurs individuelles sans arrêter le processus
**3. États de progression visuels :**
- 🕐 **En attente** : Gris, icône horloge
- 🔄 **Envoi en cours** : Bleu, spinner animé
- ✅ **Succès** : Vert, icône de validation + heure d'envoi
- ❌ **Erreur** : Rouge, icône d'erreur + message détaillé
**4. Fonctionnalités avancées :**
- **Annulation** : Bouton pour arrêter l'envoi en cours
- **Logs détaillés** : Sauvegarde des statistiques d'envoi
- **Animations** : Effets visuels pour l'interface
- **Responsive** : Compatible mobile et desktop
### 📊 Interface du modal :
```
┌─────────────────────────────────────────────────┐
│ 📧 Envoi d'emails groupés │
│ Sujet: "Information importante - Espace Paie" │
├─────────────────────────────────────────────────┤
│ Progression: 8/15 (53%) │
│ ████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░ 53% │
├─────────────────────────────────────────────────┤
│ ✅ Envoyés: 7 ❌ Échecs: 1 ⏳ Restants: 7 │
├─────────────────────────────────────────────────┤
│ ✅ user1@example.com (Organization A) 14:32:15 │
│ ✅ user2@example.com (Organization B) 14:32:16 │
│ 🔄 user3@example.com (Organization C) Envoi... │
│ 🕐 user4@example.com (Organization D) En attente │
│ ❌ user5@example.com (Organization E) Erreur SES │
└─────────────────────────────────────────────────┘
```
### 🚀 Comment ça marche :
1. **Clic sur "Envoyer"** → Ouverture du modal
2. **Initialisation** → Tous les emails en "En attente"
3. **Traitement par lots** → 5 emails max simultanés
4. **Mise à jour en temps réel** → Statuts et progression
5. **Gestion des erreurs** → Continuer malgré les échecs
6. **Finalisation** → Statistiques complètes + logs
### 🔧 Fichiers créés/modifiés :
- **`BulkEmailProgressModal.tsx`** - Interface du modal
- **`bulk-email-stream/route.ts`** - API streaming
- **`BulkEmailForm.tsx`** - Intégration du modal
### 🌟 Améliorations apportées :
- Retour visuel immédiat à l'utilisateur
- Transparence totale sur le processus d'envoi
- Possibilité d'annuler en cours de route
- Interface moderne et intuitive
- Respect des bonnes pratiques SES
- Logs pour traçabilité et debug
La fonctionnalité est maintenant complète avec un modal de progression professionnel qui affiche l'avancement en temps réel de l'envoi des emails groupés ! 🎯

78
DEBUG_EMAIL_LOGS.md Normal file
View file

@ -0,0 +1,78 @@
# 🔍 Diagnostic Email SES - Logs de Débogage Activés
## Logs Ajoutés pour Diagnostic
J'ai ajouté des logs détaillés dans 3 fichiers pour identifier exactement d'où vient l'email invalide :
### 1. Dans `app/api/contrats/[id]/route.ts`
- 🔍 **[ROUTE DEBUG]** Organization data retrieved for non-staff user
- 🔍 **[ROUTE DEBUG]** About to send email notifications with organizationData
### 2. Dans `lib/emailService.ts`
- 🔍 **[EMAIL DEBUG]** sendContractUpdateNotifications called with
- 🔍 **[EMAIL DEBUG]** Email addresses found
- 🔍 **[EMAIL DEBUG]** Sending email with validated addresses
### 3. Dans `lib/emailTemplateService.ts`
- 🔍 **[SES DEBUG]** sendUniversalEmailV2 called with
- 🚨 **[SES DEBUG]** Invalid toEmail detected (si erreur)
- 🔍 **[SES DEBUG]** Emails validated, proceeding with send
## Corrections Appliquées
### ✅ Fix Potentiel : Array vs Object
Si `organization_details` est retourné comme un tableau au lieu d'un objet :
```typescript
// Fix: Si organization_details est un tableau, prendre le premier élément
let orgDetails = organizationData?.organization_details;
if (Array.isArray(orgDetails)) {
console.log('🔍 [EMAIL DEBUG] organization_details is array, taking first element');
orgDetails = orgDetails[0] || {};
}
```
## Comment Tester
1. **Modifiez un contrat** en tant qu'utilisateur non-staff
2. **Regardez les logs Vercel** pour voir les messages de debug
3. **Identifiez** exactement quelle valeur d'email cause le problème
## Attendu dans les Logs
```
🔍 [ROUTE DEBUG] Organization data retrieved for non-staff user: {
orgId: "...",
hasOrgDetails: true,
emailNotifs: "...", // ← La valeur problématique sera ici
emailNotifsCC: "..."
}
🔍 [EMAIL DEBUG] Email addresses found: {
emailNotifs: "...", // ← Valeur exacte
emailNotifsType: "string" // ← Type de données
}
🚨 [SES DEBUG] Invalid toEmail detected: {
toEmail: "...", // ← Email invalide détecté
isEmpty: true/false
}
```
## Actions Possibles
### Si `organization_details` est un tableau vide :
```json
"organization_details": []
```
→ Le fix array est appliqué
### Si `emailNotifs` est une chaîne vide :
```json
"emailNotifs": ""
```
→ Validation should catch this
### Si la structure de données est inattendue :
Les logs détaillés révéleront la structure exacte.
**Testez maintenant et regardez les logs Vercel pour identifier le problème !** 🔍

113
DEPLOYMENT.md Normal file
View file

@ -0,0 +1,113 @@
# Déploiement Vercel - Guide Rapide
## 🚀 Configuration pour demo.odentas.fr
### 1. Ajouter le domaine dans Vercel
```bash
# Si vous utilisez Vercel CLI
vercel domains add demo.odentas.fr
# Ou via l'interface web:
# Dashboard → Settings → Domains → Add → demo.odentas.fr
```
### 2. Configuration DNS
Chez votre registraire DNS :
```
Type: CNAME
Name: demo
Value: cname.vercel-dns.com
TTL: 300
```
### 3. Test de la configuration
```bash
# Test local (avec le serveur en marche)
./scripts/test-demo-domains.sh
# Test manuel
curl -H "Host: demo.odentas.fr" http://localhost:3001/api/me
```
### 4. Vérification du déploiement
Une fois déployé, vous devriez avoir :
- **https://demo.odentas.fr** → Mode démo automatique
- **https://paie.odentas.fr** → Mode normal
- **Même codebase** → Comportements différents
### 5. Fonctionnalités automatiques
Le fichier `vercel.json` configure automatiquement :
- ✅ **Détection du domaine** : Auto-activation sur demo.odentas.fr
- ✅ **Header injection** : x-demo-mode: true
- ✅ **Redirection** : / → /contrats pour montrer les données
- ✅ **Optimisations** : Timeouts API adaptés
### 6. Banner démo intelligent
Le système affiche automatiquement :
- **Sur demo.odentas.fr** : Banner complet avec liens contact
- **En développement** : Banner simple de démo
- **Sur paie.odentas.fr** : Aucun banner (mode normal)
## 🔧 Dépannage
### Problème : demo.odentas.fr ne fonctionne pas
1. Vérifier la configuration DNS : `nslookup demo.odentas.fr`
2. Vérifier dans Vercel Dashboard que le domaine est ajouté
3. Attendre la propagation DNS (jusqu'à 24h)
### Problème : Mode démo non détecté
1. Vérifier les logs : `🎭 [DEMO MIDDLEWARE]` doit apparaître
2. Tester avec header : `curl -H "x-demo-mode: true" ...`
3. Vérifier `vercel.json` est bien déployé
### Test complet
```bash
# Lancer le script de test
cd "/Users/renaud/Projet Nouvel Espace Paie"
./scripts/test-demo-domains.sh
```
## 📋 Checklist de déploiement
- [ ] Domaine `demo.odentas.fr` ajouté dans Vercel
- [ ] DNS configuré (CNAME demo → cname.vercel-dns.com)
- [ ] `vercel.json` présent dans le repo
- [ ] Test local passant avec `./scripts/test-demo-domains.sh`
- [ ] Déploiement Vercel effectué
- [ ] Test production : https://demo.odentas.fr/api/me
- [ ] Vérification mode normal : https://paie.odentas.fr
## 🎯 URLs de test
Une fois déployé :
```bash
# Mode démo
curl https://demo.odentas.fr/api/me
curl https://demo.odentas.fr/api/contrats
curl "https://demo.odentas.fr/api/search?q=alice"
# Mode normal
curl https://paie.odentas.fr/api/me
```
## 💡 Avantages de cette configuration
- **Un seul codebase** → Maintenance simplifiée
- **Détection automatique** → Pas de variables d'env
- **SEO friendly** → URLs propres sans paramètres
- **Déploiement atomique** → Cohérence garantie
- **Isolation sécurisée** → Pas de risque de fuite de données

View file

@ -1,7 +1,7 @@
# Variables d'environnement requises pour la signature électronique
## DocuSeal
- `DOCUSEAL_API_TOKEN`: Token d'authentification pour l'API DocuSeal
- `DOCUSEAL_TOKEN`: Token d'authentification pour l'API DocuSeal
## AWS S3 (pour les PDFs et les emails HTML)
- `AWS_REGION`: Région AWS (par défaut: eu-west-3)

View file

@ -107,7 +107,7 @@ import {
await sendAccountActivationEmail('user@example.com', {
firstName: 'Marie',
organizationName: 'Compagnie Théâtrale',
activationUrl: 'https://espace-paie.odentas.fr/activate?token=abc'
activationUrl: 'https://paie.odentas.fr/activate?token=abc'
});
// Modification dhabilitation

114
FIX_EMAIL_SES_ERROR.md Normal file
View file

@ -0,0 +1,114 @@
# 🔧 Fix Email Error: "Missing final '@domain'" - COMPLET
## Problème Résolu ✅
L'erreur AWS SES "Missing final '@domain'" était causée par des adresses email invalides ou vides dans les notifications CDDU + un problème spécifique avec les accès staff.
## 🎯 **Root Cause Identifié**
1. **Emails invalides** dans `organization_details.email_notifs`
2. **Bug staff** : Quand staff modifie/supprime un contrat, `organizationData = { organization_details: {} }` créait un objet vide → `emailNotifs = undefined` → erreur SES
## ✅ Corrections Appliquées
### 1. Validation d'Email Globale
Ajout de validation dans `emailTemplateService.ts` :
```typescript
const isValidEmail = (email: string) => {
return email && typeof email === 'string' && email.includes('@') && email.includes('.') && email.trim().length > 0;
};
```
### 2. Protection dans les Fonctions d'Envoi
- `sendContractNotifications()`
- `sendContractUpdateNotifications()`
- `sendContractCancellationNotifications()`
- `sendUniversalEmailV2()`
### 3. **Correction Critique : Bug Staff dans `/contrats/[id]/route.ts`**
**Avant :**
```typescript
if (org.id === null) {
// Staff avec accès admin
organizationData = { organization_details: {} }; // ❌ BUG : objet vide
}
```
**Après :**
```typescript
if (org.id === null) {
// Pour staff, récupérer l'organisation du contrat depuis la DB
if (contractData.org_id) {
const { data: orgDetails } = await admin
.from("organizations")
.select("*, organization_details(*)")
.eq("id", contractData.org_id)
.single();
organizationData = orgDetails || { organization_details: {} };
} else {
organizationData = null; // Skip notifications
}
}
```
### 4. Gestion Intelligente des Erreurs
- Emails invalides → Warning dans les logs au lieu d'erreur fatale
- Emails CC invalides → Ignorés automatiquement
- Emails principaux invalides → Erreur avec message clair
- **Pas d'organisation** → Skip notifications (pas d'erreur)
## 🔍 Points Corrigés dans le Code
### **1. `lib/emailService.ts`**
- ✅ Validation emails dans `sendContractNotifications()`
- ✅ Validation emails dans `sendContractUpdateNotifications()`
- ✅ Validation emails dans `sendContractCancellationNotifications()`
### **2. `lib/emailTemplateService.ts`**
- ✅ Validation emails dans `sendUniversalEmailV2()`
### **3. `app/api/contrats/[id]/route.ts`** ⭐ **CRITIQUE**
- ✅ Correction bug staff dans `PATCH` (ligne ~350)
- ✅ Correction bug staff dans `PATCH` (ligne ~500)
- ✅ Correction bug staff dans `DELETE` (ligne ~580)
## 📊 Monitoring Post-Fix
Les logs contiendront maintenant :
### ✅ Succès
```
Contract notifications sent successfully (universal system)
```
### ⚠️ Warnings (non-bloquants)
```
No valid notification email configured for organization: {
orgId: "...",
orgName: "...",
emailNotifs: "",
emailNotifsCC: null
}
No organization linked to contract, skipping email notifications
```
### ❌ Erreurs (si email principal complètement invalide)
```
Invalid email address: "invalid-email"
```
## 🚀 Résultat
- ✅ Plus d'erreurs SES "Missing final '@domain'"
- ✅ Création/modification/suppression de contrats qui continue même si email invalide
- ✅ **Staff peut maintenant modifier/supprimer des contrats sans erreurs email**
- ✅ Logs clairs pour identifier les problèmes de configuration
- ✅ Envoi robuste avec gestion des cas d'erreur
## 🔧 Actions de Maintenance
1. **Vérifiez les logs** pour identifier les organisations avec des warnings
2. **Corrigez les emails** dans l'interface d'administration
3. **Testez** la création/modification/suppression de contrats
4. **Surveillez** les métriques SES pour confirmer la résolution
Le système est maintenant **100% résistant aux erreurs d'email** ! 🎯

73
FIX_OTP_EMAIL_LINKS.md Normal file
View file

@ -0,0 +1,73 @@
# 🔧 Fix OTP Email Links - Guide Rapide
## Problème
Les emails OTP reçus contiennent des liens vers `localhost:3000` au lieu de votre domaine Vercel.
## ✅ Solution Implémentée
### 1. Code Corrigé ✅
Le fichier `app/api/auth/send-code/route.ts` a été mis à jour pour détecter automatiquement l'URL correcte :
```typescript
// Détecter automatiquement l'URL de base selon l'environnement
const getBaseUrl = () => {
// En production, utiliser l'URL Vercel ou le domaine custom
if (process.env.VERCEL_URL) {
return `https://${process.env.VERCEL_URL}`;
}
// Fallback vers variable d'environnement personnalisée
if (process.env.NEXT_PUBLIC_SITE_URL) {
return process.env.NEXT_PUBLIC_SITE_URL;
}
// En développement local
return "http://localhost:3000";
};
```
## 🚀 Actions à Faire MAINTENANT
### 1. Variables Vercel (URGENT)
Ajoutez cette variable dans votre dashboard Vercel :
```bash
# Settings → Environment Variables → Production
NEXT_PUBLIC_SITE_URL=https://votre-app.vercel.app
```
### 2. Configuration Supabase (URGENT)
Dans Supabase Dashboard → Authentication → URL Configuration :
```bash
Site URL: https://votre-app.vercel.app
Additional Redirect URLs:
- https://votre-app.vercel.app/**
- https://votre-domaine-custom.com/** (si vous en avez un)
- http://localhost:3000/** (pour le dev)
```
### 3. Template Email Supabase (OPTIONNEL)
Dans Supabase → Authentication → Email Templates → Confirm signup :
Remplacez :
```html
{{ .ConfirmationURL }}
```
Par votre domaine :
```html
https://votre-app.vercel.app/auth/callback?token_hash={{ .TokenHash }}&type=magiclink
```
## ⚡ Test Rapide
1. **Déployez** le code corrigé sur Vercel
2. **Ajoutez** la variable `NEXT_PUBLIC_SITE_URL`
3. **Configurez** les URLs dans Supabase
4. **Testez** l'OTP sur votre site Vercel
## 🎯 Résultat
✅ Les emails OTP contiendront désormais des liens vers votre domaine Vercel, pas localhost.
---
**Temps estimé :** 5 minutes de configuration + 2 minutes de déploiement = **7 minutes total**

114
GUIDE_ERREURS_EMAILS.md Normal file
View file

@ -0,0 +1,114 @@
# 🐛 Guide de résolution des erreurs d'envoi d'emails groupés
## Erreur : "Missing '<'" - CORRIGÉ ✅
### Problème identifié :
L'erreur "Missing '<'" était causée par un mauvais formatage de l'adresse email source dans l'API SES.
### Solution appliquée :
```typescript
// Avant (incorrect)
Source: `Espace Paie Odentas <${fromEmail}>`,
// Après (corrigé)
let sourceEmail = fromEmail;
if (fromEmail && !fromEmail.includes('<')) {
sourceEmail = `Espace Paie Odentas <${fromEmail}>`;
} else {
sourceEmail = fromEmail;
}
Source: sourceEmail,
```
## 📋 Autres erreurs courantes et solutions
### 1. **Adresse email invalide**
- **Cause** : Format d'email incorrect (manque @, domaine invalide, espaces)
- **Solution** : Validation ajoutée côté client et serveur
- **Prévention** : Seuls les emails confirmés et valides sont affichés
### 2. **Quota journalier dépassé**
- **Cause** : Limite AWS SES atteinte (200 emails/jour par défaut)
- **Solution** : Attendre 24h ou demander une augmentation de quota
- **Prévention** : Surveiller le nombre d'emails envoyés
### 3. **Limite de débit dépassée**
- **Cause** : Trop d'emails envoyés simultanément (limite SES : 14/seconde)
- **Solution** : L'API limite automatiquement à 12 emails par lot avec pause de 1s
- **Prévention** : Ne pas cliquer plusieurs fois sur "Envoyer"
### Performance optimisée :
- **Débit** : 12 emails par seconde (sous la limite de 14/sec)
- **Throughput** : ~720 emails par minute
- **Sécurité** : Marge de 2 emails/sec pour éviter les dépassements
### 4. **Configuration SES manquante**
- **Cause** : Variable d'environnement AWS_SES_FROM non définie
- **Solution** : Vérifier la configuration des variables d'environnement
- **Prévention** : Test automatique au démarrage
## 🔧 Améliorations apportées
### Validation côté client :
```typescript
const isValidEmail = user.email &&
user.email.includes('@') &&
user.email.includes('.') &&
user.email.length > 5 &&
!user.email.includes(' ');
```
### Gestion d'erreurs améliorée :
- Messages d'erreur plus clairs et spécifiques
- Distinction entre les types d'erreurs
- Conseils contextuels pour résolution
### Interface utilisateur :
- Warning détaillé en cas d'échecs
- Affichage des statuts en temps réel
- Possibilité d'annulation en cours d'envoi
## 📊 Monitoring et logs
### Logs automatiques :
- Nombre total d'emails traités
- Taux de succès/échec
- Détails des erreurs par email
- Horodatage précis
### Traçabilité :
- Chaque envoi est enregistré en base
- Possibilité de retrouver les échecs
- Statistiques d'utilisation
## 🚀 Bonnes pratiques
1. **Avant l'envoi** :
- Vérifier le contenu HTML (aperçu)
- Tester avec 1-2 destinataires d'abord
- S'assurer que le sujet est clair
2. **Pendant l'envoi** :
- Ne pas fermer le navigateur
- Surveiller les erreurs en temps réel
- Utiliser "Annuler" si nécessaire
3. **Après l'envoi** :
- Vérifier les statistiques finales
- Noter les emails en échec pour retry
- Contrôler la réception sur quelques comptes
## ⚡ Limites techniques
- **Maximum** : 100 destinataires par envoi
- **Débit** : 12 emails simultanés par lot (limite SES : 14/sec)
- **Performance** : ~720 emails/minute
- **Taille** : Contenu HTML < 10MB
- **Quota** : Selon configuration AWS SES
## 🆘 En cas de problème persistant
1. Vérifier les logs AWS SES
2. Contrôler les variables d'environnement
3. Tester avec un email simple
4. Contacter l'administrateur système

62
GUIDE_MODE_DEMO.md Normal file
View file

@ -0,0 +1,62 @@
# Guide : Gestion du Mode Démo
## Désactivation du mode démo (situation actuelle)
Le mode démo a été **désactivé** en local en commentant les variables dans `.env.local` :
```bash
# DEMO_MODE=true
# NEXT_PUBLIC_DEMO_MODE=true
```
## Comment réactiver le mode démo temporairement
### 1. Réactivation rapide
Décommentez les lignes dans `.env.local` :
```bash
DEMO_MODE=true
NEXT_PUBLIC_DEMO_MODE=true
```
### 2. Réactivation temporaire via header HTTP
Pour tester une API spécifique en mode démo sans modifier `.env.local` :
```bash
curl -H "x-demo-mode: true" http://localhost:3001/api/contrats
```
### 3. Réactivation temporaire via variable d'environnement
Pour un test ponctuel :
```bash
DEMO_MODE=true npm run dev
```
## Modes d'utilisation
### Mode Normal (actuel)
- ✅ Accès à tous les comptes staff et client
- ✅ Connexion Supabase réelle
- ✅ Données de production/développement
### Mode Démo
- ⚠️ Données fictives uniquement
- ⚠️ Pas d'accès aux vraies données
- ⚠️ Bannière de démo affichée
## Commandes utiles
```bash
# Activer temporairement le mode démo
echo "DEMO_MODE=true" >> .env.local
echo "NEXT_PUBLIC_DEMO_MODE=true" >> .env.local
# Désactiver le mode démo
sed -i 's/^DEMO_MODE=true$/# DEMO_MODE=true/' .env.local
sed -i 's/^NEXT_PUBLIC_DEMO_MODE=true$/# NEXT_PUBLIC_DEMO_MODE=true/' .env.local
# Vérifier l'état actuel
grep -E "(# )?DEMO_MODE" .env.local
```
## En production
Le mode démo reste actif sur `demo.odentas.fr` grâce à la configuration Vercel automatique via le domaine.
Les modifications locales n'affectent pas la production.

View file

@ -0,0 +1,57 @@
## Test de l'accès aux pages publiques pendant la maintenance
### Pages maintenant accessibles en mode maintenance :
1. **`/auto-declaration`** - Formulaire d'auto-déclaration des salariés
- Permet aux salariés de compléter leurs informations et uploader des justificatifs
- Accessible via un lien avec matricule : `/auto-declaration?matricule=XXX`
2. **`/dl-contrat-signe`** - Téléchargement de contrats signés
- Permet aux salariés de télécharger leurs contrats signés
- Accessible via des liens sécurisés générés par le système
### Pages déjà accessibles en mode maintenance :
- `/api/*` - Toutes les API routes (nécessaires pour le fonctionnement)
- `/maintenance` - Page de maintenance elle-même
- `/signin` - Page de connexion (pour l'accès staff)
- Ressources statiques (`/_next`, `/favicon`, `/public`)
### Comment tester :
1. **Activer le mode maintenance** :
- Se connecter en tant que staff
- Utiliser le bouton "Mode Maintenance" dans l'interface
2. **Tester l'accès aux pages publiques** :
```
# Ces URLs doivent être accessibles même en maintenance
http://localhost:3000/auto-declaration?matricule=TEST123
http://localhost:3000/dl-contrat-signe?token=xxx
```
3. **Tester les autres pages** :
```
# Ces URLs doivent rediriger vers /maintenance
http://localhost:3000/contrats
http://localhost:3000/salaries
http://localhost:3000/
```
4. **Accès staff** :
- Sur la page de maintenance, cliquer sur "Accès équipe"
- Se connecter avec un compte staff
- Accès complet à l'application
### Modification apportée :
Dans `middleware.ts`, la condition de vérification de maintenance exclut maintenant :
```typescript
const isPublicPage = path.startsWith('/auto-declaration') || path.startsWith('/dl-contrat-signe');
if (!isApiOrAssets && !isMaintenancePage && !isSigninPage && !isPublicPage) {
// Vérification du mode maintenance...
}
```
Cela garantit que ces pages restent accessibles aux salariés même lorsque l'Espace Paie est en maintenance pour les utilisateurs connectés.

View file

@ -0,0 +1,46 @@
# Fonctionnalité : Notes automatiques de création de contrats
## Description
Lorsqu'un nouveau contrat est créé via l'Espace Paie (routes `/contrats/nouveau` ou `/contrats/nouveau/saisie-tableau`), le système crée automatiquement une note traçant cette création.
## Comportement
### Pour un utilisateur standard (client)
Quand un contrat est créé par un client via l'Espace Paie, une note automatique est ajoutée avec :
- **Contenu** : "Demande créée via l'Espace Paie par Prénom (ROLE) le dd/mm/aaaa à hh:mm:ss"
- **Source** : "Système"
- **Exemples** :
- "Demande créée via l'Espace Paie par Marie (SUPER_ADMIN) le 09/10/2025 à 14:30:25"
- "Demande créée via l'Espace Paie par Jean (ADMIN) le 09/10/2025 à 14:30:25"
- "Demande créée via l'Espace Paie par Sophie (AGENT) le 09/10/2025 à 14:30:25"
### Pour un membre du Staff Odentas
Quand un contrat est créé par un membre du staff, une note automatique est ajoutée avec :
- **Contenu** : "Demande créée par le Staff Odentas le dd/mm/aaaa à hh:mm:ss"
- **Source** : "Système"
## Notes additionnelles
- Cette note système est créée **en plus** de toute note manuelle saisie par l'utilisateur
- Les notes manuelles gardent la source "Espace Paie"
- La détection du statut Staff se fait via la table `staff_users` ou via les métadonnées utilisateur
- Le prénom est extrait depuis `user_metadata.first_name` ou `user_metadata.display_name`
- Le niveau d'habilitation est récupéré depuis la table `organization_members` pour les clients
- Les niveaux possibles sont : SUPER_ADMIN, ADMIN, AGENT, COMPTA
## Implémentation
### Fichiers modifiés
- `/app/api/cddu-contracts/route.ts` : API principale de création de contrats CDDU
- `/app/api/rg-contracts/route.ts` : API pour les contrats Régime Général
### Routes concernées
- `/contrats/nouveau` : Formulaire de création d'un nouveau contrat CDDU
- `/contrats/nouveau/saisie-tableau` : Interface de saisie en lot
- Les contrats Régime Général via le même formulaire
### Format des dates
- Date : format français (dd/mm/aaaa)
- Heure : format français 24h (hh:mm:ss)
- Timezone : locale du serveur

84
OPTIMISATION_DEBIT_SES.md Normal file
View file

@ -0,0 +1,84 @@
# 🚀 Optimisation du débit d'envoi d'emails - SES 14/sec
## ⚡ Performances améliorées
### Avant l'optimisation :
- **Débit** : 5 emails par lot
- **Pause** : 1 seconde entre lots
- **Performance** : ~300 emails/minute
- **Efficacité** : 35% de la capacité SES
### Après l'optimisation :
- **Débit** : 12 emails par lot
- **Pause** : 1 seconde entre lots
- **Performance** : ~720 emails/minute
- **Efficacité** : 85% de la capacité SES
- **Marge de sécurité** : 2 emails/sec
## 📊 Calculs de performance
```
Limite SES : 14 emails/seconde
Configuration : 12 emails/lot avec 1s de pause
Débit effectif :
- Lot 1 : 12 emails à t=0s
- Pause : 1 seconde
- Lot 2 : 12 emails à t=1s
- Performance : 12 emails/seconde (< 14 emails/seconde )
Temps d'envoi estimés :
- 50 emails : ~5 secondes
- 100 emails : ~9 secondes
- 200 emails : ~17 secondes
- 500 emails : ~42 secondes
```
## 🛡️ Sécurité et fiabilité
### Marge de sécurité :
- **Buffer** : 2 emails/sec sous la limite
- **Protection** : Évite les erreurs de dépassement
- **Robustesse** : Tolérance aux variations réseau
### Gestion d'erreurs améliorée :
- Détection automatique des limites dépassées
- Messages d'erreur spécifiques
- Retry automatique en cas d'erreur temporaire
## 🎯 Impact utilisateur
### Interface mise à jour :
- Affichage du débit en temps réel
- Indicateur "12 emails/lot, respect limite 14/sec"
- Progression plus fluide et rapide
### Expérience améliorée :
- **2.4x plus rapide** qu'avant
- Temps d'attente réduits
- Feedback en temps réel optimisé
## 🔧 Configuration technique
### Variables importantes :
```typescript
const batchSize = 12; // Emails par lot
const batchDelayMs = 1000; // Pause entre lots (ms)
const maxSESRate = 14; // Limite SES (emails/sec)
const safetyMargin = 2; // Marge de sécurité
```
### Adaptabilité :
- Facile à ajuster si limite SES change
- Configuration centralisée
- Monitoring des performances intégré
## 📈 Métriques de suivi
L'API enregistre automatiquement :
- Nombre d'emails traités par minute
- Taux de succès/échec
- Temps de traitement total
- Respect des limites SES
Cette optimisation permet d'utiliser efficacement votre quota SES tout en gardant une marge de sécurité ! 🎉

View file

@ -0,0 +1,103 @@
# Guide d'Accès Staff en Mode Maintenance
## Fonctionnalité Implémentée
Cette implémentation permet aux membres du staff de se connecter à l'Espace Paie même lorsque le site est en mode maintenance.
## Comment ça fonctionne
### 1. Page de Maintenance
- Lorsque le site est en maintenance, un lien discret "Accès équipe" apparaît en bas de la page
- Ce lien redirige vers `/signin?staff_access=true`
### 2. Page de Connexion Staff
- Quand on accède à `/signin` avec le paramètre `staff_access=true`, la page affiche :
- Un titre spécial : "Connexion Staff - Mode Maintenance"
- Un avertissement orange : "⚠️ Site en maintenance - Accès équipe uniquement"
- Tous les modes de connexion habituels (mot de passe et code par email)
### 3. Middleware Ajusté
- Le middleware autorise maintenant l'accès à `/signin` même en mode maintenance
- Après connexion réussie, les staff peuvent accéder normalement à l'application
- Les non-staff restent bloqués et redirigés vers la page de maintenance
## Flux Utilisateur
### Pour un Staff
1. Visite du site en maintenance → page de maintenance
2. Clic sur "Accès équipe" → page de connexion staff
3. Saisie des identifiants → connexion réussie
4. Redirection vers l'application → accès complet
### Pour un Non-Staff
1. Visite du site en maintenance → page de maintenance
2. Même s'il trouve le lien "Accès équipe" → page de connexion
3. Après connexion → redirection vers la page de maintenance (middleware)
## Modifications Effectuées
### 1. `app/maintenance/MaintenancePage.tsx`
```tsx
// Ajout du lien d'accès staff dans le footer
<div className="mt-3">
<a
href="/signin?staff_access=true"
className="text-xs text-gray-400 hover:text-gray-600 transition-colors duration-200"
>
Accès équipe
</a>
</div>
```
### 2. `app/signin/page.tsx`
```tsx
// Détection du paramètre staff_access
const [isStaffAccess, setIsStaffAccess] = useState(false);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
setIsStaffAccess(urlParams.get('staff_access') === 'true');
}, []);
// Affichage conditionnel du titre et message d'avertissement
{isStaffAccess ? (
<>
<Settings size={20} className="inline mr-2 text-orange-500" />
Connexion Staff - Mode Maintenance
</>
) : (
"Connexion à l'Espace Paie"
)}
```
### 3. `middleware.ts`
```typescript
// Exclusion de /signin du blocage de maintenance
if (!isApiOrAssets && !isMaintenancePage && !isSigninPage) {
// Vérification du mode maintenance...
}
```
## Sécurité
- Le lien "Accès équipe" est discret et ne compromet pas la sécurité
- L'authentification reste obligatoire pour tous
- Seuls les vrais staff (vérifiés en base) peuvent accéder à l'app après connexion
- Les non-staff sont toujours bloqués par le middleware après authentification
## Test Manuel
1. **Activer le mode maintenance** (via le bouton staff dans l'interface)
2. **Se déconnecter**
3. **Visiter le site** → vérifier la page de maintenance avec le lien
4. **Cliquer sur "Accès équipe"** → vérifier la page de connexion staff
5. **Se connecter en tant que staff** → vérifier l'accès à l'app
6. **Se connecter en tant que non-staff** → vérifier le blocage
## Avantages
- ✅ Accès d'urgence pour les staff en cas de maintenance
- ✅ Interface claire indiquant le mode spécial
- ✅ Sécurité préservée (authentification + vérification staff)
- ✅ Fonctionnalité discrète pour les utilisateurs normaux
- ✅ Compatible avec tous les modes de connexion existants

View file

@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { KeyRound, Lock, Loader2, Check, X, Eye, EyeOff } from "lucide-react";
import { MfaSetupComponent } from "@/components/auth/MfaSetupComponent";
import { usePageTitle } from "@/hooks/usePageTitle";
/**
* Critères de validation du mot de passe
@ -31,6 +32,8 @@ const isPasswordValid = (validation: ReturnType<typeof validatePassword>) => {
* - 2FA désactivé temporairement
*/
export default function CompteSecuritePage() {
usePageTitle("Sécurité");
const [hasPassword, setHasPassword] = useState<boolean | null>(null);
React.useEffect(() => {
@ -132,11 +135,11 @@ export default function CompteSecuritePage() {
{hasPassword === null ? (
<span className="inline-block text-xs text-slate-500">Vérification du statut</span>
) : hasPassword ? (
<span className="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
<span className="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-emerald-100 text-emerald-700">
Mot de passe défini
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
<span className="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700">
Aucun mot de passe défini
</span>
)}
@ -167,8 +170,8 @@ export default function CompteSecuritePage() {
{/* Critères de validation en temps réel */}
{pw1.length > 0 && (
<div className="mt-2 p-3 bg-slate-50 dark:bg-slate-900 rounded-lg border">
<div className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<div className="mt-2 p-3 bg-slate-50 rounded-lg border">
<div className="text-sm font-medium text-slate-700 mb-2">
Critères requis :
</div>
<div className="space-y-1">
@ -178,7 +181,7 @@ export default function CompteSecuritePage() {
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.minLength ? "text-green-700 dark:text-green-300" : "text-red-700 dark:text-red-300"}>
<span className={passwordValidation.minLength ? "text-green-700" : "text-red-700"}>
Au moins 12 caractères
</span>
</div>
@ -188,7 +191,7 @@ export default function CompteSecuritePage() {
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.hasLowercase ? "text-green-700 dark:text-green-300" : "text-red-700 dark:text-red-300"}>
<span className={passwordValidation.hasLowercase ? "text-green-700" : "text-red-700"}>
Au moins une minuscule (a-z)
</span>
</div>
@ -198,7 +201,7 @@ export default function CompteSecuritePage() {
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.hasUppercase ? "text-green-700 dark:text-green-300" : "text-red-700 dark:text-red-300"}>
<span className={passwordValidation.hasUppercase ? "text-green-700" : "text-red-700"}>
Au moins une majuscule (A-Z)
</span>
</div>
@ -208,7 +211,7 @@ export default function CompteSecuritePage() {
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.hasNumber ? "text-green-700 dark:text-green-300" : "text-red-700 dark:text-red-300"}>
<span className={passwordValidation.hasNumber ? "text-green-700" : "text-red-700"}>
Au moins un chiffre (0-9)
</span>
</div>
@ -218,7 +221,7 @@ export default function CompteSecuritePage() {
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.hasSpecialChar ? "text-green-700 dark:text-green-300" : "text-red-700 dark:text-red-300"}>
<span className={passwordValidation.hasSpecialChar ? "text-green-700" : "text-red-700"}>
Au moins un caractère spécial (!@#$%^&*)
</span>
</div>
@ -255,14 +258,14 @@ export default function CompteSecuritePage() {
{passwordMatch === true ? (
<>
<Check className="h-4 w-4 text-green-600" />
<span className="text-green-700 dark:text-green-300">
<span className="text-green-700">
Les mots de passe correspondent
</span>
</>
) : (
<>
<X className="h-4 w-4 text-red-600" />
<span className="text-red-700 dark:text-red-300">
<span className="text-red-700">
Les mots de passe ne correspondent pas
</span>
</>

View file

@ -9,6 +9,7 @@ import { NotesSection } from "@/components/NotesSection";
import { Button } from "@/components/ui/button";
import { ConfirmationModal } from "@/components/ui/confirmation-modal";
import { toast } from "sonner";
import { usePageTitle } from "@/hooks/usePageTitle";
/* =========================
Types attendus du backend
@ -178,8 +179,8 @@ function usePaies(id: string) {
========= */
function Section({ title, children }: { title: React.ReactNode; children: React.ReactNode }) {
return (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800 font-medium text-slate-700 dark:text-slate-200 bg-slate-50/60 dark:bg-slate-800/40">
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
{title}
</div>
<div className="p-4">{children}</div>
@ -250,14 +251,14 @@ function Badge({
}) {
const cls =
tone === "ok"
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200"
? "bg-emerald-100 text-emerald-800"
: tone === "warn"
? "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200"
? "bg-amber-100 text-amber-800"
: tone === "error"
? "bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-200"
? "bg-rose-100 text-rose-800"
: tone === "info"
? "bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200"
: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300";
? "bg-sky-100 text-sky-800"
: "bg-slate-100 text-slate-700";
return <span className={`text-xs px-2 py-1 rounded-full ${cls}`}>{children}</span>;
}
@ -288,41 +289,41 @@ function stateBadgeDemande(s?: EtatDemande) {
if (normalized.includes("pre") || normalized.includes("demande")) {
// Pré-demande - Gris (neutre)
style = {
bg: "bg-slate-50 dark:bg-slate-800/40",
border: "border-slate-200 dark:border-slate-700/60",
text: "text-slate-700 dark:text-slate-300",
bg: "bg-slate-50",
border: "border-slate-200",
text: "text-slate-700",
label: "Pré-demande"
};
} else if (normalized.includes("recu") || normalized.includes("recue")) {
// Reçue - Bleu (information)
style = {
bg: "bg-blue-50 dark:bg-blue-900/30",
border: "border-blue-200 dark:border-blue-800/60",
text: "text-blue-800 dark:text-blue-200",
bg: "bg-blue-50",
border: "border-blue-200",
text: "text-blue-800",
label: "Reçue"
};
} else if (normalized.includes("cours") || normalized.includes("traitement")) {
// En cours de traitement - Orange (en attente)
style = {
bg: "bg-orange-50 dark:bg-orange-900/20",
border: "border-orange-200 dark:border-orange-800/60",
text: "text-orange-800 dark:text-orange-200",
bg: "bg-orange-50",
border: "border-orange-200",
text: "text-orange-800",
label: "En cours de traitement"
};
} else if (normalized.includes("traitee") || normalized.includes("traite")) {
// Traitée - Vert (succès)
style = {
bg: "bg-emerald-50 dark:bg-emerald-900/20",
border: "border-emerald-200 dark:border-emerald-800/60",
text: "text-emerald-800 dark:text-emerald-200",
bg: "bg-emerald-50",
border: "border-emerald-200",
text: "text-emerald-800",
label: "Traitée"
};
} else {
// Fallback - Affichage de la valeur brute avec style neutre
style = {
bg: "bg-slate-50 dark:bg-slate-800/40",
border: "border-slate-200 dark:border-slate-700/60",
text: "text-slate-700 dark:text-slate-300",
bg: "bg-slate-50",
border: "border-slate-200",
text: "text-slate-700",
label: input || "—"
};
}
@ -363,6 +364,11 @@ export default function ContratMultiPage() {
const { id } = useParams<{ id: string }>();
const { data, isLoading, isError, error } = useContrat(id);
// Titre dynamique basé sur le numéro du contrat
const contractTitle = data?.numero ? `Contrat ${data.numero}` : `Contrat multi-mois`;
usePageTitle(contractTitle);
const { data: paiesData, isLoading: paiesLoading, isError: paiesError, error: paiesErrorObj } = usePaies(id);
// State pour la modale de confirmation de paiement
@ -457,7 +463,7 @@ export default function ContratMultiPage() {
if (isLoading) {
return (
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-10 text-center text-slate-500">
<div className="rounded-2xl border bg-white p-10 text-center text-slate-500">
<Loader2 className="w-4 h-4 inline animate-spin mr-2" />
Chargement du contrat
</div>
@ -466,11 +472,11 @@ export default function ContratMultiPage() {
if (isError || !data) {
return (
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-6">
<div className="rounded-2xl border bg-white p-6">
<div className="text-rose-600 font-medium mb-2">Impossible de charger ce contrat.</div>
<div className="text-sm text-slate-500">{(error as any)?.message || "Erreur inconnue"}</div>
<div className="mt-4">
<Link className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border dark:border-slate-800" href="/contrats">
<Link className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border" href="/contrats">
<ArrowLeft className="w-4 h-4" /> Retour aux contrats
</Link>
</div>
@ -489,10 +495,10 @@ export default function ContratMultiPage() {
</Link>
</div>
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4">
<div className="rounded-2xl border bg-white p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="text-lg font-semibold">{title}</div>
<div className="h-4 w-px bg-slate-200 dark:bg-slate-800 mx-1" />
<div className="h-4 w-px bg-slate-200 mx-1" />
<div className="text-sm text-slate-500">CDDU · Multi-mois</div>
</div>
</div>
@ -593,7 +599,7 @@ export default function ContratMultiPage() {
<Field
label="DPAE"
value={(
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200">
<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>
)}
@ -605,7 +611,7 @@ export default function ContratMultiPage() {
<Field
label="DPAE"
value={(
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200">
<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>
)}
@ -654,7 +660,7 @@ export default function ContratMultiPage() {
const aemPending = aemNorm.includes('a_traiter') || aemNorm.includes('a traiter') || aemNorm.includes('traiter');
const CardInner = (
<div className="h-full rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4 hover:shadow-md transition-shadow relative">
<div className="h-full rounded-2xl border bg-white p-4 hover:shadow-md transition-shadow relative">
{/* Bouton de paiement en position absolue */}
{p.traite === 'oui' && (
<Button
@ -674,18 +680,18 @@ export default function ContratMultiPage() {
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
<div className="flex items-center gap-2">
{/* Numéro de paie */}
<span className="text-[11px] px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-200 border border-indigo-200/60 dark:border-indigo-800/60">
<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)] dark:from-sky-900/20 dark:to-blue-900/20 dark:text-blue-200 dark:border-blue-800/60">
<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)] dark:from-sky-900/20 dark:to-blue-900/20 dark:text-blue-200 dark:border-blue-800/60">
<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
@ -694,22 +700,22 @@ export default function ContratMultiPage() {
<div className="sm:ml-auto flex items-center gap-2">
{/* 1. Traitée */}
{p.traite === 'non' ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200">
<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" /> À traiter
</span>
) : p.traite === 'oui' ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200">
<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" /> Traitée
</span>
) : null}
{/* 2. AEM */}
{aemPending ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200">
<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" /> AEM
</span>
) : aemOk ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200">
<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" /> AEM OK
</span>
) : (
@ -718,11 +724,11 @@ export default function ContratMultiPage() {
{/* 3. Payée avec même style que les autres */}
{p.transfer_done ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200">
<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" /> Payée
</span>
) : (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300">
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-slate-100 text-slate-600">
<Clock className="w-3 h-3" /> Non payée
</span>
)}

View file

@ -9,6 +9,7 @@ import { NotesSection } from "@/components/NotesSection";
import { Button } from "@/components/ui/button";
import { ConfirmationModal } from "@/components/ui/confirmation-modal";
import { toast } from "sonner";
import { usePageTitle } from "@/hooks/usePageTitle";
/* =========================
Types attendus du backend
@ -177,8 +178,8 @@ function usePaies(id: string) {
========= */
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800 font-medium text-slate-700 dark:text-slate-200 bg-slate-50/60 dark:bg-slate-800/40">
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
{title}
</div>
<div className="p-4">{children}</div>
@ -213,14 +214,14 @@ function Badge({
}) {
const cls =
tone === "ok"
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200"
? "bg-emerald-100 text-emerald-800"
: tone === "warn"
? "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200"
? "bg-amber-100 text-amber-800"
: tone === "error"
? "bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-200"
? "bg-rose-100 text-rose-800"
: tone === "info"
? "bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200"
: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300";
? "bg-sky-100 text-sky-800"
: "bg-slate-100 text-slate-700";
return <span className={`text-xs px-2 py-1 rounded-full ${cls}`}>{children}</span>;
}
@ -251,41 +252,41 @@ function stateBadgeDemande(s?: EtatDemande) {
if (normalized.includes("pre") || normalized.includes("demande")) {
// Pré-demande - Gris (neutre)
style = {
bg: "bg-slate-50 dark:bg-slate-800/40",
border: "border-slate-200 dark:border-slate-700/60",
text: "text-slate-700 dark:text-slate-300",
bg: "bg-slate-50",
border: "border-slate-200",
text: "text-slate-700",
label: "Pré-demande"
};
} else if (normalized.includes("recu") || normalized.includes("recue")) {
// Reçue - Bleu (information)
style = {
bg: "bg-blue-50 dark:bg-blue-900/30",
border: "border-blue-200 dark:border-blue-800/60",
text: "text-blue-800 dark:text-blue-200",
bg: "bg-blue-50",
border: "border-blue-200",
text: "text-blue-800",
label: "Reçue"
};
} else if (normalized.includes("cours") || normalized.includes("traitement")) {
// En cours de traitement - Orange (en attente)
style = {
bg: "bg-orange-50 dark:bg-orange-900/20",
border: "border-orange-200 dark:border-orange-800/60",
text: "text-orange-800 dark:text-orange-200",
bg: "bg-orange-50",
border: "border-orange-200",
text: "text-orange-800",
label: "En cours de traitement"
};
} else if (normalized.includes("traitee") || normalized.includes("traite")) {
// Traitée - Vert (succès)
style = {
bg: "bg-emerald-50 dark:bg-emerald-900/20",
border: "border-emerald-200 dark:border-emerald-800/60",
text: "text-emerald-800 dark:text-emerald-200",
bg: "bg-emerald-50",
border: "border-emerald-200",
text: "text-emerald-800",
label: "Traitée"
};
} else {
// Fallback - Affichage de la valeur brute avec style neutre
style = {
bg: "bg-slate-50 dark:bg-slate-800/40",
border: "border-slate-200 dark:border-slate-700/60",
text: "text-slate-700 dark:text-slate-300",
bg: "bg-slate-50",
border: "border-slate-200",
text: "text-slate-700",
label: input || "—"
};
}
@ -331,6 +332,11 @@ export default function ContratMultiPage() {
const { id } = useParams<{ id: string }>();
const { data, isLoading, isError, error } = useContrat(id);
// Titre dynamique basé sur le numéro du contrat
const contractTitle = data?.numero ? `Contrat ${data.numero}` : `Contrat RG`;
usePageTitle(contractTitle);
const { data: paiesData, isLoading: paiesLoading, isError: paiesError, error: paiesErrorObj } = usePaies(id);
const [paiesPage, setPaiesPage] = useState(1);
@ -439,7 +445,7 @@ export default function ContratMultiPage() {
if (isLoading) {
return (
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-10 text-center text-slate-500">
<div className="rounded-2xl border bg-white p-10 text-center text-slate-500">
<Loader2 className="w-4 h-4 inline animate-spin mr-2" />
Chargement du contrat
</div>
@ -448,11 +454,11 @@ export default function ContratMultiPage() {
if (isError || !data) {
return (
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-6">
<div className="rounded-2xl border bg-white p-6">
<div className="text-rose-600 font-medium mb-2">Impossible de charger ce contrat.</div>
<div className="text-sm text-slate-500">{(error as any)?.message || "Erreur inconnue"}</div>
<div className="mt-4">
<Link className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border dark:border-slate-800" href="/contrats">
<Link className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border" href="/contrats">
<ArrowLeft className="w-4 h-4" /> Retour aux contrats
</Link>
</div>
@ -480,10 +486,10 @@ export default function ContratMultiPage() {
</Link>
</div>
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4">
<div className="rounded-2xl border bg-white p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="text-lg font-semibold">{title}</div>
<div className="h-4 w-px bg-slate-200 dark:bg-slate-800 mx-1" />
<div className="h-4 w-px bg-slate-200 mx-1" />
<div className="text-sm text-slate-500">Régime général</div> </div>
</div>
@ -568,15 +574,15 @@ export default function ContratMultiPage() {
label="DPAE"
value={
data.dpae === 'envoyee' || data.dpae === 'retour_ok' ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200">
<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' ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-200">
<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' ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200">
<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>
) : (
@ -616,7 +622,7 @@ export default function ContratMultiPage() {
{pagedPaies.map((p) => {
const label = payLabel(p) || (p.ordre ? `Paie ${p.ordre}` : 'Paie');
const CardInner = (
<div className="h-full rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4 hover:shadow-md transition-shadow relative">
<div className="h-full rounded-2xl border bg-white p-4 hover:shadow-md transition-shadow relative">
{/* Bouton de paiement en position absolue dans le coin bas droit */}
{p.traite === 'oui' && (
<Button
@ -636,12 +642,12 @@ export default function ContratMultiPage() {
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
<div className="flex items-center gap-2">
{/* Numéro de paie */}
<span className="text-[11px] px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-200 border border-indigo-200/60 dark:border-indigo-800/60">
<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 ? (
<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)] dark:from-sky-900/20 dark:to-blue-900/20 dark:text-blue-200 dark:border-blue-800/60">
<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}
</span>
) : null}
@ -649,22 +655,22 @@ export default function ContratMultiPage() {
<div className="sm:ml-auto flex items-center gap-2">
{/* 1. Traitée */}
{p.traite === 'non' ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200">
<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" /> À traiter
</span>
) : p.traite === 'oui' ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200">
<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" /> Traitée
</span>
) : null}
{/* 2. Payée avec même style que les autres */}
{p.transfer_done ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200">
<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" /> Payée
</span>
) : (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300">
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-slate-100 text-slate-600">
<Clock className="w-3 h-3" /> Non payée
</span>
)}
@ -706,7 +712,7 @@ export default function ContratMultiPage() {
type="button"
onClick={() => setPaiesPage((p) => Math.max(1, p - 1))}
disabled={currentPage <= 1}
className="px-2 py-1 text-sm rounded border dark:border-slate-700 disabled:opacity-50"
className="px-2 py-1 text-sm rounded border disabled:opacity-50"
aria-label="Page précédente"
>
Précédent
@ -716,7 +722,7 @@ export default function ContratMultiPage() {
type="button"
onClick={() => setPaiesPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage >= totalPages}
className="px-2 py-1 text-sm rounded border dark:border-slate-700 disabled:opacity-50"
className="px-2 py-1 text-sm rounded border disabled:opacity-50"
aria-label="Page suivante"
>
Suivant

View file

@ -75,13 +75,13 @@ function useContrats(
// --- Mapping état → couleur/texte
const ETATS: Record<Contrat["etat"], { label: string; className: string }> = {
"pre-demande": { label: "Pré-demande", className: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300" },
"Reçue": { label: "Reçue", className: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" },
"envoye": { label: "Envoyé", className: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" },
"signe": { label: "Contrat signé", className: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300" },
"modification": { label: "Modifier la demande", className: "bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300" },
"traitee": { label: "Traitée", className: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300" },
"en_cours": { label: "En cours", className: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" },
"pre-demande": { label: "Pré-demande", className: "bg-slate-100 text-slate-700" },
"Reçue": { label: "Reçue", className: "bg-blue-100 text-blue-800" },
"envoye": { label: "Envoyé", className: "bg-blue-100 text-blue-800" },
"signe": { label: "Contrat signé", className: "bg-emerald-100 text-emerald-800" },
"modification": { label: "Modifier la demande", className: "bg-rose-100 text-rose-800" },
"traitee": { label: "Traitée", className: "bg-emerald-100 text-emerald-800" },
"en_cours": { label: "En cours", className: "bg-blue-100 text-blue-800" },
};
function humanizeEtat(raw?: string){
@ -107,7 +107,7 @@ function safeEtat(etat?: string){
const label = etat ? etat.charAt(0).toUpperCase() + etat.slice(1) : "—";
return {
label,
className: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
className: "bg-slate-100 text-slate-700",
} as { label: string; className: string };
}
@ -159,7 +159,7 @@ export default function ContratsClient({ clientInfo }: { clientInfo: ClientInfo
if (!clientInfo) {
return (
<div className="space-y-5">
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-8 text-center">
<div className="rounded-2xl border bg-white p-8 text-center">
<div className="text-slate-500">Impossible de récupérer les informations de votre organisation.</div>
</div>
</div>
@ -169,11 +169,11 @@ export default function ContratsClient({ clientInfo }: { clientInfo: ClientInfo
return (
<div className="space-y-5">
{/* En-tête + Recherche */}
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4">
<section className="rounded-2xl border bg-white p-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<h1 className="text-xl font-semibold">Contrats & Paies</h1>
<div className="sm:ml-auto flex items-center gap-2 w-full sm:w-auto">
<div className="flex items-center gap-2 px-3 py-2 rounded-xl border dark:border-slate-800 w-full sm:w-80">
<div className="flex items-center gap-2 px-3 py-2 rounded-xl border w-full sm:w-80">
<Search className="w-4 h-4"/>
<input value={q} onChange={(e)=>{ setQ(e.target.value); setPage(1); }} placeholder="Référence, nom, production…" className="bg-transparent outline-none text-sm flex-1"/>
</div>
@ -181,16 +181,16 @@ export default function ContratsClient({ clientInfo }: { clientInfo: ClientInfo
</div>
{/* Régime tabs */}
<div className="mt-3 inline-flex rounded-xl border dark:border-slate-800 p-1 bg-slate-50 dark:bg-slate-800/50">
<button onClick={()=>{ setRegime("CDDU"); setPage(1); }} className={`px-3 py-1.5 text-sm rounded-lg ${regime==='CDDU' ? 'bg-white dark:bg-slate-900 shadow border dark:border-slate-700' : 'opacity-80'}`}>CDDU</button>
<button onClick={()=>{ setRegime("RG"); setPage(1); }} className={`px-3 py-1.5 text-sm rounded-lg ${regime==='RG' ? 'bg-white dark:bg-slate-900 shadow border dark:border-slate-700' : 'opacity-80'}`}>Régime général</button>
<div className="mt-3 inline-flex rounded-xl border p-1 bg-slate-50">
<button onClick={()=>{ setRegime("CDDU"); setPage(1); }} className={`px-3 py-1.5 text-sm rounded-lg ${regime==='CDDU' ? 'bg-white shadow border' : 'opacity-80'}`}>CDDU</button>
<button onClick={()=>{ setRegime("RG"); setPage(1); }} className={`px-3 py-1.5 text-sm rounded-lg ${regime==='RG' ? 'bg-white shadow border' : 'opacity-80'}`}>Régime général</button>
</div>
{/* Onglets + action */}
<div className="mt-4 flex items-center justify-between gap-3">
<div className="inline-flex rounded-xl border dark:border-slate-800 p-1 bg-slate-50 dark:bg-slate-800/50">
<button onClick={()=>switchTab("en_cours")} className={`px-3 py-1.5 text-sm rounded-lg ${status==='en_cours' ? 'bg-white dark:bg-slate-900 shadow border dark:border-slate-700' : 'opacity-80'}`}>En cours</button>
<button onClick={()=>switchTab("termines")} className={`px-3 py-1.5 text-sm rounded-lg ${status==='termines' ? 'bg-white dark:bg-slate-900 shadow border dark:border-slate-700' : 'opacity-80'}`}>Terminés</button>
<div className="inline-flex rounded-xl border p-1 bg-slate-50">
<button onClick={()=>switchTab("en_cours")} className={`px-3 py-1.5 text-sm rounded-lg ${status==='en_cours' ? 'bg-white shadow border' : 'opacity-80'}`}>En cours</button>
<button onClick={()=>switchTab("termines")} className={`px-3 py-1.5 text-sm rounded-lg ${status==='termines' ? 'bg-white shadow border' : 'opacity-80'}`}>Terminés</button>
</div>
<a
href={regime === 'CDDU' ? '/contrats/nouveau' : '/contrats-rg/nouveau'}
@ -201,13 +201,13 @@ export default function ContratsClient({ clientInfo }: { clientInfo: ClientInfo
</div>
{status === "termines" && (
<div className="mt-3 flex flex-col sm:flex-row gap-2 sm:items-center">
<div className="text-sm text-slate-600 dark:text-slate-300">Filtrer par période :</div>
<div className="text-sm text-slate-600">Filtrer par période :</div>
<div className="flex gap-2">
<select
id="period-select"
value={period}
onChange={(e)=>{ setPeriod(e.target.value); setPage(1); }}
className="px-3 py-2 rounded-lg border dark:border-slate-800 bg-white dark:bg-slate-900 text-sm"
className="px-3 py-2 rounded-lg border bg-white text-sm"
>
<optgroup label="Année">
<option value="Y">Toute l'année</option>
@ -240,7 +240,7 @@ export default function ContratsClient({ clientInfo }: { clientInfo: ClientInfo
<select
value={year}
onChange={(e)=>{ setYear(parseInt(e.target.value,10)); setPage(1); }}
className="px-3 py-2 rounded-lg border dark:border-slate-800 bg-white dark:bg-slate-900 text-sm"
className="px-3 py-2 rounded-lg border bg-white text-sm"
>
{yearOptions.map(y => <option key={y} value={y}>{y}</option>)}
</select>
@ -250,11 +250,11 @@ export default function ContratsClient({ clientInfo }: { clientInfo: ClientInfo
</section>
{/* Tableau */}
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<section className="rounded-2xl border bg-white">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b dark:border-slate-800 bg-slate-50/80 dark:bg-slate-800/40">
<tr className="border-b bg-slate-50/80">
<Th>État</Th>
<Th>Référence</Th>
<Th>Nom</Th>
@ -274,7 +274,7 @@ export default function ContratsClient({ clientInfo }: { clientInfo: ClientInfo
<tr><td colSpan={8} className="py-12 text-center text-slate-500">{status==='en_cours' ? 'Aucun contrat en cours.' : 'Aucun contrat terminé.'}</td></tr>
) : (
items.map((c)=> (
<tr key={c.id} className="border-b last:border-b-0 dark:border-slate-800">
<tr key={c.id} className="border-b last:border-b-0">
<Td>
{(() => { const e = safeEtat(c.etat as any); return (
<span className={`px-2 py-1 rounded-full text-xs whitespace-nowrap ${e.className}`}>{e.label}</span>
@ -284,7 +284,7 @@ export default function ContratsClient({ clientInfo }: { clientInfo: ClientInfo
<div className="flex flex-col">
<a href={detailHref(c)} className="underline font-medium">{c.reference}</a>
{(c.is_multi_mois === true || (c.regime && c.regime.toUpperCase() === "CDDU_MULTI")) && (
<span className="mt-1 inline-flex w-fit text-[11px] px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200">Multimois</span>
<span className="mt-1 inline-flex w-fit text-[11px] px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-800">Multimois</span>
)}
</div>
</Td>
@ -304,10 +304,10 @@ export default function ContratsClient({ clientInfo }: { clientInfo: ClientInfo
</div>
{/* Pagination */}
<div className="p-3 flex items-center gap-3 border-t dark:border-slate-800">
<button onClick={()=> setPage(p=> Math.max(1,p-1))} disabled={page===1} className="px-2 py-1 rounded-lg border dark:border-slate-800 disabled:opacity-40"><ChevronLeft className="w-4 h-4"/></button>
<div className="p-3 flex items-center gap-3 border-t">
<button onClick={()=> setPage(p=> Math.max(1,p-1))} disabled={page===1} className="px-2 py-1 rounded-lg border disabled:opacity-40"><ChevronLeft className="w-4 h-4"/></button>
<div className="text-sm">Page <strong>{page}</strong></div>
<button onClick={()=> setPage(p=> p + 1)} disabled={!hasMore} className="px-2 py-1 rounded-lg border dark:border-slate-800 disabled:opacity-40"><ChevronRight className="w-4 h-4"/></button>
<button onClick={()=> setPage(p=> p + 1)} disabled={!hasMore} className="px-2 py-1 rounded-lg border disabled:opacity-40"><ChevronRight className="w-4 h-4"/></button>
<div className="ml-auto text-sm text-slate-500">{isFetching ? 'Mise à jour…' : `${items.length} élément${items.length>1?'s':''}${hasMore ? ' (plus disponibles)' : ''}`}</div>
</div>
</section>

View file

@ -138,9 +138,9 @@ export default function EditFormulairePage() {
return (
<div className="p-4">
<div className="mb-4 rounded-xl border dark:border-slate-800 p-4">
<div className="mb-4 rounded-xl border p-4">
<div className="text-lg font-semibold">Modifier la demande</div>
<div className="text-sm text-slate-600 dark:text-slate-300">
<div className="text-sm text-slate-600">
Contrat <strong>{data.numero || data.id}</strong>
</div>
</div>

View file

@ -5,6 +5,7 @@ import { useParams, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Loader2, RefreshCw, Info } from "lucide-react";
import { api } from "@/lib/fetcher";
import { usePageTitle } from "@/hooks/usePageTitle";
/** Types minimalistes — adapte si besoin à ta réponse API */
type ContratDetail = {
@ -62,12 +63,12 @@ function StatusBadge({ value }: { value: string }) {
const v = value.toLowerCase();
const color =
v.includes("traite") || v.includes("valid")
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200"
? "bg-emerald-100 text-emerald-800"
: v.includes("cours") || v.includes("processing")
? "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-200"
? "bg-amber-100 text-amber-800"
: v.includes("attente") || v.includes("recu") || v.includes("reçue")
? "bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-200"
: "bg-slate-100 text-slate-800 dark:bg-slate-800/60 dark:text-slate-200";
? "bg-sky-100 text-sky-800"
: "bg-slate-100 text-slate-800";
return (
<span className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${color}`}>
{value}
@ -80,6 +81,8 @@ export default function ContratEtatPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
usePageTitle("Édition de contrat");
const [showCancelModal, setShowCancelModal] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
@ -196,9 +199,9 @@ export default function ContratEtatPage() {
logStep("UI loading");
return (
<main className="max-w-3xl mx-auto p-4">
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-6 text-center">
<div className="rounded-2xl border bg-white p-6 text-center">
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
<div className="text-sm text-slate-600 dark:text-slate-300">Chargement du contrat</div>
<div className="text-sm text-slate-600">Chargement du contrat</div>
</div>
</main>
);
@ -208,20 +211,20 @@ export default function ContratEtatPage() {
logStep("UI error", { error });
return (
<main className="max-w-3xl mx-auto p-4">
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-6">
<div className="rounded-2xl border bg-white p-6">
<div className="text-base font-semibold mb-2">Impossible de récupérer le contrat</div>
<p className="text-sm text-slate-600 dark:text-slate-300">
<p className="text-sm text-slate-600">
Merci de réessayer un peu plus tard. Si le problème persiste, contacte le support.
</p>
{error && (
<pre className="mt-3 text-xs overflow-auto p-3 rounded bg-slate-50 dark:bg-slate-800/60">
<pre className="mt-3 text-xs overflow-auto p-3 rounded bg-slate-50">
{String((error as any)?.message ?? error)}
</pre>
)}
<div className="mt-4">
<button
onClick={() => refetch()}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border dark:border-slate-700 text-sm"
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border text-sm"
>
<RefreshCw className={`w-4 h-4 ${isFetching ? "animate-spin" : ""}`} />
Réessayer
@ -235,11 +238,11 @@ export default function ContratEtatPage() {
return (
<main className="max-w-3xl mx-auto p-4 space-y-5">
{/* En-tête */}
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-5 flex items-start justify-between gap-4">
<div className="rounded-2xl border bg-white p-5 flex items-start justify-between gap-4">
<div>
<div className="text-lg font-semibold">Modification de contrat</div>
{data.numero ? (
<div className="mt-1 text-sm text-slate-600 dark:text-slate-300">
<div className="mt-1 text-sm text-slate-600">
Référence : <strong>{data.numero}</strong>
</div>
) : null}
@ -247,7 +250,7 @@ export default function ContratEtatPage() {
<div className="flex items-center gap-2">
<button
onClick={() => refetch()}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border dark:border-slate-700 text-sm"
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border text-sm"
>
<RefreshCw className={`w-4 h-4 ${isFetching ? "animate-spin" : ""}`} />
Rafraîchir
@ -256,26 +259,26 @@ export default function ContratEtatPage() {
</div>
{/* Carte état */}
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-5 space-y-4">
<div className="rounded-2xl border bg-white p-5 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div className="rounded-xl border dark:border-slate-800 p-4">
<div className="rounded-xl border p-4">
<div className="text-slate-500">Salarié</div>
<div className="mt-1 font-medium">
{Array.isArray((data as any).salarie?.nom) ? (data as any).salarie.nom.join(", ") : (data as any).salarie?.nom ?? "—"}
</div>
</div>
<div className="rounded-xl border dark:border-slate-800 p-4">
<div className="rounded-xl border p-4">
<div className="text-slate-500">Production</div>
<div className="mt-1 font-medium">
{data.production ?? "—"}
{data.numero_objet ? <span className="text-slate-500"> n° dobjet {data.numero_objet}</span> : null}
</div>
</div>
<div className="rounded-xl border dark:border-slate-800 p-4">
<div className="rounded-xl border p-4">
<div className="text-slate-500">Profession</div>
<div className="mt-1 font-medium">{data.profession ?? "—"}</div>
</div>
<div className="rounded-xl border dark:border-slate-800 p-4">
<div className="rounded-xl border p-4">
<div className="text-slate-500">Période</div>
<div className="mt-1 font-medium">
{formatDateFr(data.date_debut)} {data.date_fin ? `${formatDateFr(data.date_fin)}` : ""}
@ -284,8 +287,8 @@ export default function ContratEtatPage() {
</div>
{isRecue ? (
<div className="rounded-2xl border border-emerald-300 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-900/20 p-5">
<div className="text-sm text-emerald-800 dark:text-emerald-200">
<div className="rounded-2xl border border-emerald-300 bg-emerald-50 p-5">
<div className="text-sm text-emerald-800">
Le traitement de cette demande par nos services n'ayant pas encore commencé, vous pouvez directement la modifier depuis l'Espace Paie.
Cliquez ci-dessous pour accéder au formulaire de modification.
</div>
@ -307,8 +310,8 @@ export default function ContratEtatPage() {
</div>
</div>
) : (
<div className="rounded-2xl border border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-900/20 p-5">
<div className="text-sm text-amber-800 dark:text-amber-200">
<div className="rounded-2xl border border-amber-300 bg-amber-50 p-5">
<div className="text-sm text-amber-800">
Cette demande n'est plus modifiable directement depuis l'Espace Paie car son traitement par nos services a commencé.
Nous vous invitons à demander une modification manuelle via le bouton ci-dessous.
</div>
@ -328,9 +331,9 @@ export default function ContratEtatPage() {
{showCancelModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={() => setShowCancelModal(false)} />
<div className="relative w-full max-w-md rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-5 shadow-2xl">
<div className="relative w-full max-w-md rounded-2xl border bg-white p-5 shadow-2xl">
<div className="text-base font-semibold">Confirmer l'annulation ?</div>
<p className="text-sm text-slate-600 dark:text-slate-300 mt-2">
<p className="text-sm text-slate-600 mt-2">
Vous êtes sur le point d'annuler cette demande. Le contrat sera supprimé de votre Espace Paie et ne sera pas facturé.
</p>
<div className="mt-4 flex items-center justify-end gap-2">
@ -338,7 +341,7 @@ export default function ContratEtatPage() {
type="button"
onClick={() => setShowCancelModal(false)}
disabled={isCancelling}
className="px-3 py-2 rounded-lg border dark:border-slate-800 disabled:opacity-50"
className="px-3 py-2 rounded-lg border disabled:opacity-50"
>
Fermer
</button>

View file

@ -22,7 +22,7 @@ type ContratDetail = {
function Card({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="rounded-2xl border bg-white">
<div className="p-4">{children}</div>
</div>
);
@ -150,7 +150,7 @@ ${message}
{/* En-tête contrat */}
<Card>
<div className="text-sm text-slate-700 dark:text-slate-300 space-y-1">
<div className="text-sm text-slate-700 space-y-1">
<div>
<span className="font-medium">Contrat :</span> {data.numero} {" "}
{data.regime === "CDDU_MONO"
@ -212,7 +212,7 @@ ${message}
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Expliquez précisément votre demande de modification (dates, rémunération, fonction, etc.)."
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
</div>
@ -221,7 +221,7 @@ ${message}
<div className="flex items-center justify-end gap-3">
<a
href={`/contrats/${data.id}`}
className="px-4 py-2 rounded-lg border dark:border-slate-800"
className="px-4 py-2 rounded-lg border"
>
Annuler
</a>
@ -246,11 +246,11 @@ ${message}
{/* Overlay succès */}
{sent && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/70 dark:bg-slate-900/70">
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-6 text-center shadow-xl">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/70">
<div className="rounded-2xl border bg-white p-6 text-center shadow-xl">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-3" />
<div className="font-medium">Ticket créé avec succès</div>
<p className="text-sm text-slate-600 dark:text-slate-300 mt-1">
<p className="text-sm text-slate-600 mt-1">
Redirection vers votre demande de support
</p>
</div>

View file

@ -20,6 +20,7 @@ function formatEUR(value?: string | number | null): string | undefined {
import Link from "next/link";
import Script from "next/script";
import { usePageTitle } from "@/hooks/usePageTitle";
// ---------- Hook récupération fiches de paie Supabase ----------
// ...existing code...
@ -45,6 +46,54 @@ type Payslip = {
};
function usePayslips(contractId: string) {
// 🎭 Détection directe du mode démo
const isDemoMode = typeof window !== 'undefined' && window.location.hostname === 'demo.odentas.fr';
console.log('🔍 usePayslips debug:', {
isDemoMode,
hostname: typeof window !== 'undefined' ? window.location.hostname : 'server',
contractId
});
// 🎭 Mode démo : utiliser les données fictives pour les contrats demo
if (isDemoMode && contractId === 'demo-cont-001') {
console.log('🎭 Demo mode detected, loading demo payslips...');
const DEMO_PAYSLIPS: Payslip[] = [
{
id: "demo-payslip-001",
contract_id: "demo-cont-001",
period_start: "2024-01-15",
period_end: "2024-06-30",
period_month: "2024-06",
pay_number: 1,
gross_amount: "850.00",
net_amount: "623.45",
net_after_withholding: "623.45",
employer_cost: "1247.50",
pay_date: "2024-07-15",
processed: true,
aem_status: "valide",
transfer_done: true,
analytic_tag: "SPECTACLE-2024",
storage_path: "/demo/payslips/demo-payslip-001.pdf",
source_reference: "DEMO-PAY-001",
created_at: "2024-07-01T10:00:00Z"
}
];
console.log('✅ Demo payslips loaded:', DEMO_PAYSLIPS.length);
return {
data: DEMO_PAYSLIPS,
isLoading: false,
error: null,
isError: false,
isFetching: false
};
}
// Mode normal : récupération via API
return useQuery<Payslip[]>({
queryKey: ["payslips", contractId],
queryFn: async () => {
@ -217,6 +266,88 @@ type ContratDetail = {
// ---------- Data hooks ----------
function useContratDetail(id: string) {
// 🎭 Détection directe du mode démo
const isDemoMode = typeof window !== 'undefined' && window.location.hostname === 'demo.odentas.fr';
console.log('🔍 useContratDetail debug:', {
isDemoMode,
hostname: typeof window !== 'undefined' ? window.location.hostname : 'server',
contractId: id
});
// 🎭 Mode démo : utiliser les données fictives pour l'ID demo
if (isDemoMode && id === 'demo-cont-001') {
console.log('🎭 Demo mode detected, loading demo contract details...');
const DEMO_CONTRACT_DETAIL: ContratDetail = {
id: "demo-cont-001",
numero: "DEMO-2024-001",
regime: "CDDU_MONO",
salarie: {
nom: "MARTIN Alice",
email: "alice.martin@demo.fr"
},
salarie_matricule: "demo-sal-001",
production: "Les Misérables - Tournée 2024",
objet: "PROD-2024-15",
profession: "04201 - Comédien",
categorie_prof: "Artiste interprète",
type_salaire: "Forfait cachet",
salaire_demande: "850,00€",
date_debut: "2024-01-15",
date_fin: "2024-06-30",
panier_repas: "oui",
// PDFs et documents
pdf_contrat: { available: true, url: "/demo/contrat-demo.pdf" },
pdf_avenant: { available: false },
pdf_paie: { available: true, url: "/demo/paie-demo.pdf" },
// États et statuts
etat_traitement: "termine",
virement_effectue: true,
salaire_net_avant_pas: "623,45€",
net_a_payer_rib: "623,45€",
salaire_brut: "850,00€",
cout_employeur: "1.247,50€",
precisions_salaire: "Contrat démo - Tarif spectacle vivant",
// Signatures et contrat
etat_demande: "Traitée",
contrat_signe_employeur: "oui",
contrat_signe_salarie: "oui",
etat_contrat: "termine",
// Déclarations
dpae: "OK",
aem: "OK",
// Temps de travail
jours_travailles: 25,
nb_representations: 18,
nb_services_repetitions: 12,
nb_heures_repetitions: 48,
nb_heures_annexes: 8,
nb_cachets_aem: 18,
nb_heures_aem: 0,
// Métadonnées
created_at: "2024-01-10T09:00:00Z",
updated_at: "2024-07-01T16:30:00Z"
};
console.log('✅ Demo contract details loaded');
return {
data: DEMO_CONTRACT_DETAIL,
isLoading: false,
error: null,
isError: false,
isFetching: false
};
}
// Mode normal : récupération via API
return useQuery<ContratDetail>({
queryKey: ["contrat", id],
queryFn: async () => {
@ -303,9 +434,9 @@ function useToggleVirement(id: string) {
function Section({ title, icon: Icon, children }: { title: string; icon?: React.ElementType; children: React.ReactNode }) {
return (
<Card className="rounded-3xl overflow-hidden">
<CardHeader className="bg-slate-50/60 dark:bg-slate-800/40 border-b dark:border-slate-800">
<CardHeader className="bg-slate-50/60 border-b">
<CardTitle className="flex items-center gap-3">
{Icon && <Icon className="size-5 text-slate-600 dark:text-slate-400" />}
{Icon && <Icon className="size-5 text-slate-600" />}
<span className="text-lg font-semibold">{title}</span>
</CardTitle>
</CardHeader>
@ -334,11 +465,11 @@ function Badge({
tone?: "default" | "ok" | "warn" | "error" | "info";
}) {
const cls =
tone === "ok" ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200" :
tone === "warn" ? "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200" :
tone === "error" ? "bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-200" :
tone === "info" ? "bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200" :
"bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300";
tone === "ok" ? "bg-emerald-100 text-emerald-800" :
tone === "warn" ? "bg-amber-100 text-amber-800" :
tone === "error" ? "bg-rose-100 text-rose-800" :
tone === "info" ? "bg-sky-100 text-sky-800" :
"bg-slate-100 text-slate-700";
return <span className={`text-xs px-2 py-1 rounded-full ${cls}`}>{children}</span>;
}
@ -367,11 +498,11 @@ function stateBadgeDemande(s: EtatDemande | string) {
// - En cours d'envoi: indigo
type Style = { bg: string; border: string; text: string; label: string };
const styles: Record<string, Style> = {
"Reçue": { bg: "bg-sky-50 dark:bg-sky-900/30", border: "border-sky-200 dark:border-sky-800/60", text: "text-sky-800 dark:text-sky-200", label: "Reçue" },
"Pré-demande": { bg: "bg-slate-50 dark:bg-slate-800/40", border: "border-slate-200 dark:border-slate-700/60", text: "text-slate-700 dark:text-slate-300", label: "Pré-demande" },
"En cours de traitement": { bg: "bg-amber-50 dark:bg-amber-900/20", border: "border-amber-200 dark:border-amber-800/60", text: "text-amber-800 dark:text-amber-200", label: "En cours de traitement" },
"Traitée": { bg: "bg-emerald-50 dark:bg-emerald-900/20", border: "border-emerald-200 dark:border-emerald-800/60", text: "text-emerald-800 dark:text-emerald-200", label: "Traitée" },
"En cours d'envoi": { bg: "bg-indigo-50 dark:bg-indigo-900/20", border: "border-indigo-200 dark:border-indigo-800/60", text: "text-indigo-800 dark:text-indigo-200", label: "En cours d'envoi" },
"Reçue": { bg: "bg-sky-50", border: "border-sky-200", text: "text-sky-800", label: "Reçue" },
"Pré-demande": { bg: "bg-slate-50", border: "border-slate-200", text: "text-slate-700", label: "Pré-demande" },
"En cours de traitement": { bg: "bg-amber-50", border: "border-amber-200", text: "text-amber-800", label: "En cours de traitement" },
"Traitée": { bg: "bg-emerald-50", border: "border-emerald-200", text: "text-emerald-800", label: "Traitée" },
"En cours d'envoi": { bg: "bg-indigo-50", border: "border-indigo-200", text: "text-indigo-800", label: "En cours d'envoi" },
};
// Normalisations alternatives depuis l'API
@ -383,7 +514,7 @@ function stateBadgeDemande(s: EtatDemande | string) {
styles[normalized] ||
styles[alt] ||
// fallback générique
{ bg: "bg-slate-50 dark:bg-slate-800/40", border: "border-slate-200 dark:border-slate-700/60", text: "text-slate-700 dark:text-slate-300", label: input || "—" };
{ bg: "bg-slate-50", border: "border-slate-200", text: "text-slate-700", label: input || "—" };
return (
<div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border text-xs ${style.bg} ${style.border} ${style.text}`}>
@ -401,28 +532,28 @@ function formatDateFR(iso?: string) {
// ---------- Composant d'erreur d'accès ----------
function AccessDeniedError({ contractId }: { contractId: string }) {
return (
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-8">
<div className="rounded-2xl border bg-white p-8">
<div className="flex flex-col items-center text-center max-w-md mx-auto space-y-4">
<div className="w-16 h-16 rounded-full bg-rose-100 dark:bg-rose-900/40 flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-rose-600 dark:text-rose-400" />
<div className="w-16 h-16 rounded-full bg-rose-100 flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-rose-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
<h2 className="text-lg font-semibold text-slate-900 mb-2">
Accès non autorisé
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
<p className="text-sm text-slate-600 leading-relaxed">
Vous n'avez pas l'autorisation de consulter ce contrat. Il est possible qu'il appartienne à une autre organisation ou qu'il n'existe pas.
</p>
</div>
<div className="text-xs text-slate-500 dark:text-slate-500 bg-slate-50 dark:bg-slate-800 px-3 py-2 rounded-lg font-mono">
<div className="text-xs text-slate-500 bg-slate-50 px-3 py-2 rounded-lg font-mono">
ID: {contractId}
</div>
<Link
href="/contrats"
className="inline-flex items-center gap-2 px-4 py-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 rounded-lg text-sm font-medium hover:bg-slate-800 dark:hover:bg-slate-200 transition-colors"
className="inline-flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Retour aux contrats
@ -438,6 +569,13 @@ export default function ContratPage() {
const router = useRouter();
const { data, isLoading, isError, error } = useContratDetail(id);
// Définir le titre basé sur les données du contrat
const contractTitle = data?.numero
? `Contrat ${data.numero}`
: `Contrat CDDU`;
usePageTitle(contractTitle);
const payslipsQuery = usePayslips(id);
const [signedPayslipUrls, setSignedPayslipUrls] = useState<Record<string, string>>({});
@ -720,7 +858,7 @@ export default function ContratPage() {
if (isLoading) {
return (
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-10 text-center text-slate-500">
<div className="rounded-2xl border bg-white p-10 text-center text-slate-500">
<Loader2 className="w-4 h-4 inline animate-spin mr-2" />
Chargement du contrat
</div>
@ -737,11 +875,11 @@ export default function ContratPage() {
if (errorMessage === "not_found") {
return (
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-6">
<div className="rounded-2xl border bg-white p-6">
<div className="text-amber-600 font-medium mb-2">Contrat introuvable</div>
<div className="text-sm text-slate-500">Le contrat demandé n'existe pas ou a é supprimé.</div>
<div className="mt-4">
<Link className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border dark:border-slate-800" href="/contrats">
<Link className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border" href="/contrats">
<ArrowLeft className="w-4 h-4" /> Retour aux contrats
</Link>
</div>
@ -751,11 +889,11 @@ export default function ContratPage() {
// Erreur générique
return (
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-6">
<div className="rounded-2xl border bg-white p-6">
<div className="text-rose-600 font-medium mb-2">Impossible de charger ce contrat.</div>
<div className="text-sm text-slate-500">{errorMessage || "Erreur inconnue"}</div>
<div className="mt-4">
<Link className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border dark:border-slate-800" href="/contrats">
<Link className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border" href="/contrats">
<ArrowLeft className="w-4 h-4" /> Retour aux contrats
</Link>
</div>
@ -970,29 +1108,29 @@ return (
router.push(`/contrats/nouveau?dupe=${encodeURIComponent(b64)}`);
}}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border dark:border-slate-800"
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border"
title="Dupliquer ce contrat"
>
<Copy className="w-4 h-4" />
Dupliquer
</button>
<Link href={`/contrats/${data.id}/edit`} className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border dark:border-slate-800">
<Link href={`/contrats/${data.id}/edit`} className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border">
<Pencil className="w-4 h-4" /> Modifier
</Link>
<button className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border dark:border-slate-800 opacity-60 cursor-not-allowed" title="À définir">
<button className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border opacity-60 cursor-not-allowed" title="À définir">
<Check className="w-4 h-4" /> Valider
</button>
</div>
</div>
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-6">
<div className="rounded-2xl border bg-white p-6">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-center">
{/* Titre du contrat - 1 colonne */}
<div className="lg:col-span-1">
<div className="flex flex-wrap items-center gap-2">
<div className="text-lg font-semibold">{title}</div>
<div className="h-4 w-px bg-slate-200 dark:bg-slate-800 mx-1" />
<div className="h-4 w-px bg-slate-200 mx-1" />
<div className="text-sm text-slate-500">CDDU</div>
</div>
</div>
@ -1001,7 +1139,7 @@ return (
<div className="lg:col-span-3">
<div className="flex items-center justify-between relative">
{/* Ligne de progression */}
<div className="absolute top-1/2 left-0 right-0 h-0.5 bg-slate-200 dark:bg-slate-700 -translate-y-1/2 z-0"></div>
<div className="absolute top-1/2 left-0 right-0 h-0.5 bg-slate-200 -translate-y-1/2 z-0"></div>
{(() => {
const steps = getTimelineSteps(data.etat_demande, data.contrat_signe_salarie, payslipsQuery.data);
const completedSteps = steps.filter(s => s.status === "completed").length;
@ -1018,26 +1156,26 @@ return (
{/* Étapes */}
<div className="flex items-center justify-between w-full relative z-10">
{getTimelineSteps(data.etat_demande, data.contrat_signe_salarie, payslipsQuery.data).map((step, index) => (
<div key={step.id} className="flex flex-col items-center bg-white dark:bg-slate-900 px-2">
<div key={step.id} className="flex flex-col items-center bg-white px-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
step.status === "completed"
? "bg-green-500"
: step.status === "current"
? "bg-blue-500"
: "bg-slate-300 dark:bg-slate-600"
: "bg-slate-300"
}`}>
{step.status === "completed" ? (
<Check className="w-4 h-4 text-white" />
) : step.status === "current" ? (
<Clock className="w-4 h-4 text-white" />
) : (
<Clock className="w-4 h-4 text-slate-500 dark:text-slate-400" />
<Clock className="w-4 h-4 text-slate-500" />
)}
</div>
<div className={`text-xs font-medium text-center ${
step.status === "upcoming"
? "text-slate-500 dark:text-slate-400"
: "text-slate-700 dark:text-slate-300"
? "text-slate-500"
: "text-slate-700"
}`}>
{step.label}
</div>
@ -1208,11 +1346,11 @@ return (
label="DPAE"
value={
data.dpae === "OK" ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200">
<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 === "À traiter" ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200">
<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>
) : (
@ -1228,7 +1366,7 @@ return (
<div className="text-slate-400">Chargement des fiches de paie</div>
) : payslipsQuery.data && payslipsQuery.data.length > 0 ? (
payslipsQuery.data.map((slip) => (
<div key={slip.id} className="mb-4 p-4 rounded-lg border bg-slate-50 dark:bg-slate-800 relative">
<div key={slip.id} className="mb-4 p-4 rounded-lg border bg-slate-50 relative">
{/* Bouton de paiement en position absolue dans le coin bas droit */}
{slip.processed && (
<Button
@ -1242,7 +1380,7 @@ return (
)}
<div className="mb-3">
<div className="font-medium text-slate-700 dark:text-slate-200">
<div className="font-medium text-slate-700">
Période : {formatDateFR(slip.period_start)} {formatDateFR(slip.period_end)}
</div>
</div>
@ -1278,14 +1416,14 @@ return (
{/* 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 dark:bg-slate-900 rounded-2xl max-w-md mx-4 p-6 shadow-xl">
<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 dark:bg-red-900/30 rounded-full flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400" />
<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 dark:text-slate-100">Signature non disponible</h2>
<h2 className="text-lg font-semibold text-slate-900">Signature non disponible</h2>
</div>
<div className="text-slate-600 dark:text-slate-400 mb-6">
<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>

View file

@ -1,6 +1,9 @@
"use client";
import { NouveauCDDUForm } from "@/components/contrats/NouveauCDDUForm";
import { usePageTitle } from "@/hooks/usePageTitle";
export default function NouveauCDDUPage() {
usePageTitle("Nouveau contrat CDDU");
return <NouveauCDDUForm />;
}

View file

@ -4,8 +4,24 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import { createPortal } from 'react-dom';
import AccessDeniedCard from "@/components/AccessDeniedCard";
import { usePageTitle } from "@/hooks/usePageTitle";
// --- Utils: debounce ---------------------------------------------------------
function generateReference() {
const letters = "ABCDEFGHIKLMNPQRSTUVWXYZ"; // sans O
const digits = "123456789"; // sans 0
const pool = letters + digits;
const pick = (s: string) => s[Math.floor(Math.random() * s.length)];
while (true) {
let ref = "";
for (let i = 0; i < 8; i++) ref += pick(pool);
if (ref.startsWith("RG")) continue; // ne pas commencer par RG
if (!/[A-Z]/.test(ref)) continue; // au moins une lettre
if (!/[1-9]/.test(ref)) continue; // au moins un chiffre
return ref;
}
}
function useDebouncedValue<T>(value: T, delay = 300) {
const [debounced, setDebounced] = React.useState(value);
React.useEffect(() => {
@ -31,6 +47,8 @@ function useClientInfo() {
React.useEffect(() => {
if (loaded) return;
console.log('🔍 [CLIENT INFO DEBUG] Loading client info...');
(async () => {
try {
const res = await fetch("/api/me", {
@ -38,16 +56,26 @@ function useClientInfo() {
headers: { Accept: "application/json" },
credentials: "include"
});
console.log('🔍 [CLIENT INFO DEBUG] /api/me response status:', res.status);
if (res.ok) {
const me = await res.json();
setClientInfo({
console.log('🔍 [CLIENT INFO DEBUG] /api/me response data:', me);
const info = {
id: me.active_org_id || null,
name: me.active_org_name || "Organisation",
api_name: me.active_org_api_name
});
};
console.log('🔍 [CLIENT INFO DEBUG] Client info constructed:', info);
setClientInfo(info);
} else {
console.log('🔍 [CLIENT INFO DEBUG] /api/me response not ok');
}
} catch (e) {
console.warn('Could not load client info:', e);
console.warn('🔍 [CLIENT INFO DEBUG] Could not load client info:', e);
setClientInfo(null);
}
setLoaded(true);
@ -58,7 +86,7 @@ function useClientInfo() {
}
// Recherche de productions (alignée avec useSearchSpectacles)
async function searchProductions(q: string, clientInfo: ClientInfo): Promise<string[]> {
async function searchProductions(q: string, clientInfo: ClientInfo): Promise<SpectacleOption[]> {
if (!q || q.trim().length < 2 || !clientInfo) return [];
try {
@ -69,11 +97,7 @@ async function searchProductions(q: string, clientInfo: ClientInfo): Promise<str
const result = await api<{ items: SpectacleOption[] }>(`/spectacles?${params.toString()}`, {}, clientInfo);
return (result.items || []).map((s) => {
const nom = s.nom || "";
const numero = s.numero_objet;
return numero ? `${nom}${numero}` : nom;
}).filter(name => name.length > 0);
return (result.items || []).filter(s => s.nom && s.nom.length > 0);
} catch (e) {
console.warn('Search productions failed:', e);
return [];
@ -82,16 +106,31 @@ async function searchProductions(q: string, clientInfo: ClientInfo): Promise<str
// Recherche de salariés (alignée avec useSearchSalaries) — renvoie des objets complets
async function searchSalaries(q: string, clientInfo: ClientInfo): Promise<SalarieOption[]> {
if (!q || q.trim().length < 2 || !clientInfo) return [];
console.log('🔍 [SEARCH DEBUG] searchSalaries called with:', { q, clientInfo });
if (!q || q.trim().length < 2 || !clientInfo) {
console.log('🔍 [SEARCH DEBUG] Search conditions not met');
return [];
}
try {
const params = new URLSearchParams();
params.set("page", "1");
params.set("limit", "10");
params.set("q", q.trim());
const result = await api<{ items: SalarieOption[] }>(`/salaries?${params.toString()}`, {}, clientInfo);
return result.items || [];
const url = `/salaries?${params.toString()}`;
console.log('🔍 [SEARCH DEBUG] Search URL:', url);
const result = await api<{ items: SalarieOption[] }>(url, {}, clientInfo);
console.log('🔍 [SEARCH DEBUG] API result:', result);
const items = result.items || [];
console.log('🔍 [SEARCH DEBUG] Final items:', items);
return items;
} catch (e) {
console.warn('Search salaries failed:', e);
console.warn('🔍 [SEARCH DEBUG] Search salaries failed:', e);
return [];
}
}
@ -140,6 +179,7 @@ export type SalaryType = "BRUT" | "NET_AVT_PAS" | "CTE" | "MINIMA";
export type ContractRow = {
id: string;
salarie: string;
salarieMatricule?: string; // Stocker le matricule séparément
role: RoleType;
profession: string;
dateDebut: string; // YYYY-MM-DD
@ -359,9 +399,9 @@ function ComboBox({
inputProps,
error,
}: {
value: string;
onChange: (v: string) => void;
onSelect: (v: string) => void;
value: string | SpectacleOption | null;
onChange: (v: string | SpectacleOption | null) => void;
onSelect: (v: string | SpectacleOption | null) => void;
placeholder?: string;
searchType: 'productions' | 'salaries' | 'professionsArtiste' | 'professionsTechnicien';
className?: string;
@ -440,23 +480,56 @@ function ComboBox({
}, [open, updatePopupPos]);
const commit = (v: any) => {
console.log('🔍 [COMBOBOX DEBUG] Commit called with:', v);
console.log('🔍 [COMBOBOX DEBUG] Search type:', searchType);
if (searchType === 'salaries' && v && typeof v === 'object') {
onSelect(v.nom || '');
// Pour les salariés, on stocke "MATRICULE | Nom" pour pouvoir extraire le matricule plus tard
const matricule = v.matricule || '';
const nom = v.nom || '';
const finalValue = matricule && nom ? `${matricule} | ${nom}` : nom;
console.log('🔍 [COMBOBOX DEBUG] Salarie object:', v);
console.log('🔍 [COMBOBOX DEBUG] Matricule:', matricule);
console.log('🔍 [COMBOBOX DEBUG] Nom:', nom);
console.log('🔍 [COMBOBOX DEBUG] Final value:', finalValue);
onSelect(finalValue);
} else if (searchType === 'productions' && v && typeof v === 'object') {
// Pour les productions, on passe l'objet complet
console.log('🔍 [COMBOBOX DEBUG] Production object:', v);
onSelect(v);
} else if (typeof v === 'string') {
console.log('🔍 [COMBOBOX DEBUG] String value:', v);
onSelect(v);
} else {
console.log('🔍 [COMBOBOX DEBUG] Other value (converted to string):', v);
onSelect(String(v ?? ''));
}
setOpen(false);
};
// Convertir la valeur pour l'affichage dans l'input
const inputValue = React.useMemo(() => {
if (searchType === 'productions' && value && typeof value === 'object') {
const nom = value.nom || '';
const numero = value.numero_objet;
return numero ? `${nom}${numero}` : nom;
}
return typeof value === 'string' ? value : '';
}, [value, searchType]);
return (
<div ref={anchorRef} className={`relative ${className}`}>
<input
value={value}
value={inputValue}
onChange={(e) => {
onChange(e.target.value);
setQuery(e.target.value);
const newValue = e.target.value;
if (searchType === 'productions') {
// Pour les productions, on garde la valeur string temporairement pendant la frappe
onChange(newValue);
} else {
onChange(newValue);
}
setQuery(newValue);
setOpen(true);
}}
onFocus={() => setOpen(true)}
@ -510,10 +583,10 @@ function ComboBox({
</li>
);
}
// default (productions/professions)
// productions et professions
return (
<li
key={typeof it === 'string' ? it : 'opt-' + i}
key={typeof it === 'string' ? it : (it.id || it.nom || 'opt-' + i)}
role="option"
aria-selected={isActive}
className={`px-3 py-2 cursor-pointer transition border-l-4 ${
@ -525,6 +598,17 @@ function ComboBox({
onMouseDown={(e) => { e.preventDefault(); commit(it); }}
>
{(() => {
if (searchType === 'productions' && typeof it === 'object' && it.nom) {
return (
<>
<div className="font-medium">{it.nom}</div>
{it.numero_objet ? (
<div className="text-xs text-slate-500">{it.numero_objet}</div>
) : null}
</>
);
}
// fallback pour professions et autres
const text = typeof it === 'string' ? it : String(it);
const [main, extra] = text.split(' — ');
return (
@ -549,49 +633,26 @@ function ComboBox({
}
export default function Page() {
usePageTitle("Saisie en tableau - Nouveaux contrats");
// Staff-only guard for this page
const [authChecked, setAuthChecked] = React.useState(false);
const [isStaff, setIsStaff] = React.useState<boolean>(false);
React.useEffect(() => {
let mounted = true;
(async () => {
try {
const res = await fetch('/api/me', { cache: 'no-store', credentials: 'include' });
if (res.ok) {
const me = await res.json();
if (mounted) setIsStaff(!!me?.is_staff);
}
} catch (e) {
// ignore, default is false
} finally {
if (mounted) setAuthChecked(true);
}
})();
return () => { mounted = false; };
}, []);
if (!authChecked) {
return <div className="p-6 text-sm text-slate-500">Chargement</div>;
}
if (!isStaff) {
return (
<div className="p-6">
<AccessDeniedCard
title="Accès réservé au staff"
message="Cette page n'est pas encore disponible pour les clients."
hint="Cette fonction sera bientôt disponible."
/>
</div>
);
}
// Tous les autres hooks DOIVENT être déclarés avant les conditions de retour
const [production, setProduction] = useState<SpectacleOption | null>(null);
const [rows, setRows] = useState<ContractRow[]>([emptyRow()]);
const pasteTargetRef = useRef<HTMLTextAreaElement | null>(null);
// State pour l'organisation sélectionnée (staff uniquement)
const [selectedOrg, setSelectedOrg] = useState<{id: string; name: string} | null>(null);
const [availableOrgs, setAvailableOrgs] = useState<{id: string; name: string; structure_api: string}[]>([]);
// Focus helpers for row/column navigation
const focusCell = useCallback((rowId: string, field: keyof ContractRow) => {
const el = document.querySelector<HTMLElement>(`[data-row="${rowId}"][data-field="${field}"]`);
el?.focus();
}, []);
const [production, setProduction] = useState("");
const [rows, setRows] = useState<ContractRow[]>([emptyRow()]);
const pasteTargetRef = useRef<HTMLTextAreaElement | null>(null);
// Tooltip custom pour le bouton Valider
const validateWrapRef = useRef<HTMLDivElement | null>(null);
@ -604,18 +665,6 @@ export default function Page() {
setValidateTipPos({ top: r.top + window.scrollY, left: r.left + window.scrollX + r.width / 2, width: r.width });
}, []);
React.useEffect(() => {
if (!validateTipOpen) return;
const onScroll = () => computeValidateTipPos();
const onResize = () => computeValidateTipPos();
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
};
}, [validateTipOpen, computeValidateTipPos]);
// Tooltip d'aide pour "Annexe" (icône ou select role)
const annexeIconRef = useRef<HTMLSpanElement | null>(null);
const annexeAnchorEl = useRef<HTMLElement | null>(null);
@ -633,18 +682,74 @@ export default function Page() {
setAnnexeTipPos({ top: r.top + window.scrollY, left: r.left + window.scrollX + r.width / 2 });
setAnnexeTipOpen(true);
}, []);
// États pour les tooltips et la soumission
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitSuccess, setSubmitSuccess] = useState(false);
// Effect pour vérifier le statut staff
React.useEffect(() => {
if (!annexeTipOpen) return;
const onScroll = () => computeAnnexeTipPos();
const onResize = () => computeAnnexeTipPos();
let mounted = true;
(async () => {
try {
const res = await fetch('/api/me', { cache: 'no-store', credentials: 'include' });
if (res.ok) {
const me = await res.json();
if (mounted) setIsStaff(!!me?.is_staff);
}
} catch (e) {
// ignore, default is false
} finally {
if (mounted) setAuthChecked(true);
}
})();
return () => { mounted = false; };
}, []);
// Charger les organisations disponibles pour le staff
React.useEffect(() => {
if (!isStaff) return;
(async () => {
try {
const res = await fetch("/api/organizations", { credentials: "include", cache: "no-store" });
if (res.ok) {
const json: any = await res.json();
const items = json.items || [];
setAvailableOrgs(items);
// Auto-sélectionner l'organisation active si disponible
const meRes = await fetch("/api/me", { credentials: "include", cache: "no-store" });
if (meRes.ok) {
const meData = await meRes.json();
if (meData.active_org_id) {
const activeOrg = items.find((org: any) => org.id === meData.active_org_id);
if (activeOrg) {
setSelectedOrg({ id: activeOrg.id, name: activeOrg.name });
}
}
}
}
} catch (e) {
console.warn('Could not load organizations:', e);
}
})();
}, [isStaff]);
React.useEffect(() => {
if (!validateTipOpen) return;
const onScroll = () => computeValidateTipPos();
const onResize = () => computeValidateTipPos();
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
};
}, [annexeTipOpen, computeAnnexeTipPos]);
}, [validateTipOpen, computeValidateTipPos]);
// Hooks pour CSV
const csvInputRef = useRef<HTMLInputElement | null>(null);
const openCSVDialog = useCallback(() => {
@ -682,6 +787,22 @@ export default function Page() {
reader.readAsText(file);
}, []);
React.useEffect(() => {
if (!annexeTipOpen) return;
const onScroll = () => computeAnnexeTipPos();
const onResize = () => computeAnnexeTipPos();
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
};
}, [annexeTipOpen, computeAnnexeTipPos]);
// ==============================================
// TOUS LES AUTRES HOOKS (AVANT LES CONDITIONS DE RETOUR)
// ==============================================
const downloadCSVTemplate = useCallback(() => {
const headers = Array.from(PASTE_ORDER).join(',');
const blob = new Blob([headers + '\n'], { type: 'text/csv;charset=utf-8;' });
@ -735,7 +856,6 @@ export default function Page() {
setPanelPos(null);
}, []);
// Maintenir la position du panneau et de la flèche au scroll/resize
React.useEffect(() => {
if (!noteEditorOpen || !noteAnchor) return;
@ -811,13 +931,11 @@ export default function Page() {
});
}, []);
// Raccourcis globaux : Ctrl+Shift+D (dupliquer), Ctrl+Shift+N (nouvelle ligne vide), Ctrl+Shift+X (supprimer), Ctrl+Shift+↑/↓ (focus row)
// Raccourcis globaux
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
// Ignorer si focus est dans une zone qui a son propre raccourci (textarea caché de paste, etc.)
const tag = (e.target as HTMLElement)?.tagName?.toLowerCase();
const isTyping = tag === 'input' || tag === 'select' || tag === 'textarea';
// On autorise les raccourcis même en saisie pour fluidité (tableur-like), mais on garde une combinaison peu conflictuelle
if (!(e.ctrlKey && e.shiftKey) || e.metaKey) return;
if (e.key.toLowerCase() === 'd') {
@ -850,13 +968,123 @@ export default function Page() {
return () => window.removeEventListener('keydown', onKey, { capture: true });
}, [activeRowId, duplicateRowById, addEmptyRowAfterId, deleteRowById, moveFocusVertical]);
// --- Validation globale / activation du bouton Valider ---------------------
// Validation globale
const anyErrors = useMemo(() => rows.some(r => Object.keys(validateRow(r)).length > 0), [rows]);
const hasAnyData = useMemo(() => rows.some(r => (
!!r.salarie || !!r.profession || !!r.dateDebut || !!r.dateFin || r.salaire !== "" || r.nbreCachetsRepresentation !== "" || r.nbreServiceRepet !== "" || r.heuresSiTechnicien !== "" || !!r.note
)), [rows]);
const canSubmit = hasAnyData && !anyErrors;
// handleSubmit comme useCallback
const handleSubmit = useCallback(async () => {
if (!canSubmit) return;
// Validation pour staff : une organisation doit être sélectionnée
if (isStaff && !selectedOrg) {
setSubmitError("Veuillez sélectionner une organisation");
return;
}
setSubmitting(true);
setSubmitError(null);
try {
const validRows = rows.filter(r =>
r.salarie && r.profession && r.dateDebut && r.dateFin &&
(r.salaire !== "" || r.typeSalaire === "MINIMA")
);
if (validRows.length === 0) {
throw new Error("Aucun contrat valide à soumettre");
}
// Créer chaque contrat individuellement
const results = [];
for (const row of validRows) {
// Extraire le matricule depuis le format "MATRICULE | Nom" ou utiliser le nom complet
const salarieData = row.salarie.includes(' | ')
? row.salarie.split(' | ')
: [row.salarie, row.salarie];
const [matriculeRaw = "", nomRaw = ""] = salarieData;
const salarieMatricule = matriculeRaw.trim();
const salarieNomComplet = (nomRaw || matriculeRaw).trim();
console.log('🔍 [FRONTEND DEBUG] Row salarie:', row.salarie);
console.log('🔍 [FRONTEND DEBUG] SalarieData split:', salarieData);
console.log('🔍 [FRONTEND DEBUG] Matricule raw:', matriculeRaw);
console.log('🔍 [FRONTEND DEBUG] Nom raw:', nomRaw);
console.log('🔍 [FRONTEND DEBUG] Matricule final:', salarieMatricule);
console.log('🔍 [FRONTEND DEBUG] Nom final:', salarieNomComplet);
console.log('🔍 [FRONTEND DEBUG] Selected org:', selectedOrg);
console.log('🔍 [FRONTEND DEBUG] Is staff:', isStaff);
const contractData = {
salarie_matricule: salarieMatricule || salarieNomComplet,
salarie_nom: salarieNomComplet,
salarie_email: null,
send_email_confirmation: false,
regime: "CDDU_MONO",
production_id: production?.id || null,
spectacle: production?.nom || "Production",
numero_objet: production?.numero_objet || null,
profession_label: row.profession.split(' — ')[0] || row.profession,
profession_code: row.profession.split(' — ')[1] || null,
categorie: row.role === "ARTISTE" ? "Artiste" : "Technicien",
date_debut: row.dateDebut,
date_fin: row.dateFin,
nb_representations: row.role === "ARTISTE" ? (row.nbreCachetsRepresentation || 0) : 0,
nb_services_repetition: row.role === "ARTISTE" ? (row.nbreServiceRepet || 0) : 0,
heures_total: row.role === "TECHNICIEN" ? (row.heuresSiTechnicien || 0) : 0,
minutes_total: 0,
type_salaire: (() => {
switch (row.typeSalaire) {
case "NET_AVT_PAS": return "Net avant PAS";
case "CTE": return "Coût total employeur";
case "MINIMA": return "Minimum conventionnel";
default: return "Brut";
}
})(),
montant: row.typeSalaire !== "MINIMA" ? Number(row.salaire) : undefined,
panier_repas: "Non",
reference: generateReference(),
notes: row.note || undefined,
org_id: isStaff ? selectedOrg?.id : null,
};
console.log('🔍 [FRONTEND DEBUG] Contract data à envoyer:', contractData);
const response = await fetch("/api/cddu-contracts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(contractData)
});
console.log('🔍 [FRONTEND DEBUG] Response status:', response.status);
console.log('🔍 [FRONTEND DEBUG] Response ok:', response.ok);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.log('🔍 [FRONTEND DEBUG] Error data:', errorData);
throw new Error(errorData.error || `Erreur lors de la création du contrat pour ${row.salarie}`);
}
const result = await response.json();
console.log('🔍 [FRONTEND DEBUG] Success result:', result);
results.push(result);
}
setSubmitSuccess(true);
setTimeout(() => {
window.location.href = "/contrats";
}, 2000);
} catch (error: any) {
setSubmitError(error.message || "Erreur lors de la soumission");
} finally {
setSubmitting(false);
}
}, [canSubmit, isStaff, selectedOrg, rows, production]);
const totals = useMemo(() => {
const totalSalaire = rows.reduce((acc, r) => acc + (Number(r.salaire) || 0), 0);
const totalCachets = rows.reduce((acc, r) => acc + (r.role === "ARTISTE" ? Number(r.nbreCachetsRepresentation) || 0 : 0), 0);
@ -865,6 +1093,7 @@ export default function Page() {
return { totalSalaire, totalCachets, totalServices, totalHeures };
}, [rows]);
// Tous les autres useCallback DOIVENT être ici AVANT les conditions de retour
const addRow = useCallback((preset?: Partial<ContractRow>) => {
setRows((prev) => [...prev, emptyRow(preset)]);
}, []);
@ -970,6 +1199,35 @@ export default function Page() {
closeNote();
}, [noteEditorRowId, noteDraft, updateCell, closeNote]);
const exportJSON = useCallback(() => {
const payload = { production, contrats: rows };
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `saisie_cddu_${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
}, [production, rows]);
// Conditions de retour APRÈS tous les hooks
if (!authChecked) {
return <div className="p-6 text-sm text-slate-500">Chargement</div>;
}
if (!isStaff) {
return (
<div className="p-6">
<AccessDeniedCard
title="Accès réservé au staff"
message="Cette page n'est pas encore disponible pour les clients."
hint="Cette fonction sera bientôt disponible."
/>
</div>
);
}
// Fonctions d'aide (pas des hooks)
const onKeyDownCell = (
e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
row: ContractRow,
@ -986,17 +1244,6 @@ export default function Page() {
}
};
const exportJSON = useCallback(() => {
const payload = { production, contrats: rows };
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `saisie_cddu_${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
}, [production, rows]);
return (
<div className="space-y-3">
<header className={`flex items-center justify-between ${headerPad}`}>
@ -1013,11 +1260,34 @@ export default function Page() {
<button
className="inline-flex items-center gap-2 rounded-lg px-4 py-2 bg-emerald-600 text-white shadow hover:bg-emerald-700 transition text-[14px] h-10 text-base font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
disabled={!canSubmit}
disabled={!canSubmit || submitting}
onClick={handleSubmit}
>
Valider la saisie
{submitting ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
Création en cours...
</>
) : (
"Valider la saisie"
)}
</button>
</div>
{/* Messages d'erreur et succès */}
{submitError && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{submitError}
</div>
)}
{submitSuccess && (
<div className="mt-3 p-3 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">
Contrats créés avec succès ! Redirection vers la liste des contrats...
</div>
)}
{validateTipOpen && !canSubmit && validateTipPos && createPortal(
(() => {
const top = validateTipPos.top - 10; // au-dessus
@ -1053,12 +1323,55 @@ export default function Page() {
)}
<section className={`grid grid-cols-1 md:grid-cols-3 gap-2 items-end ${headerPad}`}>
{isStaff && (
<div className="col-span-full mb-3">
<label className="text-[12px] font-medium text-amber-700">
Organisation (obligatoire pour les utilisateurs staff)
</label>
<select
value={selectedOrg?.id || ""}
onChange={(e) => {
const org = availableOrgs.find(o => o.id === e.target.value);
setSelectedOrg(org ? { id: org.id, name: org.name } : null);
}}
className="mt-1 w-full rounded-md border px-3 py-2 text-sm bg-white"
>
<option value="" disabled>Sélectionner une organisation</option>
{availableOrgs.map(org => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
{isStaff && !selectedOrg && (
<p className="text-xs text-amber-600 mt-1">
Une organisation doit être sélectionnée avant de pouvoir valider la saisie.
</p>
)}
</div>
)}
<div className="col-span-2">
<label className="text-[12px] font-medium">Production (commune à toutes les lignes)</label>
<ComboBox
value={production}
onChange={setProduction}
onSelect={setProduction}
onChange={(v) => {
if (typeof v === 'string') {
// Quand l'utilisateur tape, on crée un objet temporaire avec le nom
setProduction({ nom: v, numero_objet: null });
} else {
setProduction(v);
}
}}
onSelect={(v) => {
if (typeof v === 'object' && v !== null) {
setProduction(v);
} else if (typeof v === 'string' && v.trim()) {
// Si l'utilisateur tape une nouvelle production, on crée un objet temporaire
setProduction({ nom: v.trim(), numero_objet: null });
} else {
setProduction(null);
}
}}
placeholder="Rechercher ou saisir une production…"
searchType="productions"
className="mt-1"
@ -1147,7 +1460,7 @@ export default function Page() {
<Td>
<ComboBox
value={row.salarie}
value={row.salarie.includes(' | ') ? row.salarie.split(' | ')[1] : row.salarie}
onChange={(v) => updateCell(row.id, "salarie", v)}
onSelect={(v) => updateCell(row.id, "salarie", v)}
placeholder="Rechercher un salarié…"
@ -1179,9 +1492,10 @@ export default function Page() {
{row.role === 'ARTISTE' ? (
<ComboBox
value={row.profession}
onChange={(v) => updateCell(row.id, 'profession', v)}
onChange={(v) => updateCell(row.id, 'profession', typeof v === 'string' ? v : String(v || ''))}
onSelect={(v) => {
const label = v.includes(' — ') ? v.split(' — ')[0] : v;
const strValue = typeof v === 'string' ? v : String(v || '');
const label = strValue.includes(' — ') ? strValue.split(' — ')[0] : strValue;
updateCell(row.id, 'profession', label);
}}
placeholder="Rechercher une profession (Artiste)…"
@ -1192,9 +1506,10 @@ export default function Page() {
) : (
<ComboBox
value={row.profession}
onChange={(v) => updateCell(row.id, 'profession', v)}
onChange={(v) => updateCell(row.id, 'profession', typeof v === 'string' ? v : String(v || ''))}
onSelect={(v) => {
const label = v.includes(' — ') ? v.split(' — ')[0] : v;
const strValue = typeof v === 'string' ? v : String(v || '');
const label = strValue.includes(' — ') ? strValue.split(' — ')[0] : strValue;
updateCell(row.id, 'profession', label);
}}
placeholder="Rechercher une profession (Technicien)…"

View file

@ -5,6 +5,7 @@ import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/fetcher";
import { Calendar, Loader2 } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
/* =========================
Types attendus du backend
@ -150,9 +151,9 @@ function paymentWindowLabel(annee: number, mois: number) {
function Section({ title, children, actions }: { title: string; children: React.ReactNode; actions?: React.ReactNode }) {
return (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800 bg-slate-50/60 dark:bg-slate-800/40 flex items-center justify-between">
<div className="font-medium text-slate-700 dark:text-slate-200">{title}</div>
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b bg-slate-50/60 flex items-center justify-between">
<div className="font-medium text-slate-700">{title}</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
<div className="p-4">{children}</div>
@ -164,6 +165,8 @@ function Section({ title, children, actions }: { title: string; children: React.
Page
===== */
export default function CotisationsMensuellesPage() {
usePageTitle("Cotisations mensuelles");
const now = new Date();
const handlePeriodChange = (value: Filters["period"]) => {
@ -304,9 +307,9 @@ export default function CotisationsMensuellesPage() {
return (
<div className="space-y-5">
{/* Bandeau titre + aide */}
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4">
<div className="rounded-2xl border bg-white p-4">
<div className="text-lg font-semibold">Vos cotisations mensuelles</div>
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">
<p className="mt-2 text-sm text-slate-600">
Les télépaiements seffectuent entre le 15 et le 30 du mois suivant les salaires concernés.
Par exemple, pour les salaires de septembre, les télépaiements ont lieu entre le 15 et le 30 octobre.
</p>
@ -318,7 +321,7 @@ export default function CotisationsMensuellesPage() {
<div>
<label className="text-xs text-slate-500 block mb-1">Filtrer par année</label>
<select
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
value={filters.year}
onChange={(e) => {
const y = parseInt(e.target.value, 10);
@ -347,7 +350,7 @@ export default function CotisationsMensuellesPage() {
<div>
<label className="text-xs text-slate-500 block mb-1">Filtrer par période</label>
<select
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
value={filters.period}
onChange={(e) => handlePeriodChange(e.target.value as Filters["period"])}
>
@ -384,7 +387,7 @@ export default function CotisationsMensuellesPage() {
<div className="flex items-end">
<button
onClick={handleReset}
className="text-xs px-3 py-2 rounded border dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800"
className="text-xs px-3 py-2 rounded border hover:bg-slate-50"
type="button"
>
Réinitialiser
@ -412,7 +415,7 @@ export default function CotisationsMensuellesPage() {
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b dark:border-slate-800 bg-slate-50/80 dark:bg-slate-800/40">
<tr className="border-b bg-slate-50/80">
<th className="text-left font-medium px-3 py-2">Période</th>
<th className="text-right font-medium px-3 py-2">Total</th>
<th className="text-right font-medium px-3 py-2">URSSAF</th>
@ -427,7 +430,7 @@ export default function CotisationsMensuellesPage() {
<tbody>
{/* Ligne Total */}
{total && (
<tr className="border-b dark:border-slate-800 font-medium">
<tr className="border-b font-medium">
<td className="px-3 py-2 flex items-center gap-2">
<StatusDot s={total.status} />
Total
@ -449,7 +452,7 @@ export default function CotisationsMensuellesPage() {
) : (
<>
{items.map((row) => (
<tr key={`${row.annee}-${row.mois}-${row.segment || 'def'}`} className="border-b last:border-b-0 dark:border-slate-800">
<tr key={`${row.annee}-${row.mois}-${row.segment || 'def'}`} className="border-b last:border-b-0">
<td className="px-3 py-2">
<div className="flex items-center gap-2 group relative">
<StatusDot s={row.status} />
@ -462,11 +465,11 @@ export default function CotisationsMensuellesPage() {
{/* Tooltip custom */}
<div
role="tooltip"
className="pointer-events-none absolute left-full top-1/2 z-10 ml-2 w-64 px-3 py-2 rounded-lg bg-slate-900 text-white dark:bg-slate-800 text-xs shadow-lg opacity-0 group-hover:opacity-100 -translate-y-1/2 group-hover:translate-y-0 transition flex items-center"
className="pointer-events-none absolute left-full top-1/2 z-10 ml-2 w-64 px-3 py-2 rounded-lg bg-slate-900 text-white text-xs shadow-lg opacity-0 group-hover:opacity-100 -translate-y-1/2 group-hover:translate-y-0 transition flex items-center"
style={{ top: '50%', transform: 'translateY(-50%)' }}
>
Fenêtre de paiement : {paymentWindowLabel(row.annee, row.mois)}
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rotate-45 bg-slate-900 dark:bg-slate-800" />
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rotate-45 bg-slate-900" />
</div>
</div>
</td>

View file

@ -1,5 +1,10 @@
// Page de debug - à supprimer après résolution du problème
import DebugSalarieAPI from "@/components/debug-salarie-api";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Debug | Espace Paie Odentas",
};
export default function DebugPage() {
return <DebugSalarieAPI />;

View file

@ -5,6 +5,7 @@ import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/fetcher";
import { Loader2, CheckCircle2, XCircle, FileDown, Edit } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
// ---------------- Types ----------------
type SepaInfo = {
@ -52,19 +53,19 @@ function fmtDateFR(iso?: string) {
function Badge({ tone = "default", children }: { tone?: "ok" | "warn" | "error" | "default"; children: React.ReactNode }) {
const cls =
tone === "ok"
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200"
? "bg-emerald-100 text-emerald-800"
: tone === "warn"
? "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200"
? "bg-amber-100 text-amber-800"
: tone === "error"
? "bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-200"
: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300";
? "bg-rose-100 text-rose-800"
: "bg-slate-100 text-slate-700";
return <span className={`text-xs px-2 py-1 rounded-full ${cls}`}>{children}</span>;
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800 font-medium text-slate-700 dark:text-slate-200 bg-slate-50/60 dark:bg-slate-800/40">
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
{title}
</div>
<div className="p-4">{children}</div>
@ -109,6 +110,8 @@ function useBilling(page: number, limit: number) {
// -------------- Page --------------
export default function FacturationPage() {
usePageTitle("Facturation");
const [page, setPage] = useState(1);
const limit = 10;
const { data, isLoading, isError, error } = useBilling(page, limit);
@ -129,7 +132,7 @@ export default function FacturationPage() {
{data && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Etat SEPA */}
<div className="rounded-xl border dark:border-slate-800 p-4 flex items-center justify-between">
<div className="rounded-xl border p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{data.sepa.enabled ? (
<CheckCircle2 className="w-5 h-5 text-emerald-600"/>
@ -153,7 +156,7 @@ export default function FacturationPage() {
</div>
{/* Coordonnées bancaires */}
<div className="lg:col-span-2 rounded-xl border dark:border-slate-800 p-4">
<div className="lg:col-span-2 rounded-xl border p-4">
<div className="flex items-center justify-between mb-3">
<div className="font-medium">Vos coordonnées bancaires</div>
<button className="inline-flex items-center gap-2 text-sm underline"><Edit className="w-4 h-4"/> Modifier</button>
@ -189,8 +192,8 @@ export default function FacturationPage() {
{data && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left bg-slate-50 dark:bg-slate-800/40">
<tr className="border-b dark:border-slate-800">
<thead className="text-left bg-slate-50">
<tr className="border-b">
<th className="px-3 py-2">Statut</th>
<th className="px-3 py-2">Numéro</th>
<th className="px-3 py-2">Période concernée</th>
@ -207,7 +210,7 @@ export default function FacturationPage() {
</tr>
)}
{items.map((f) => (
<tr key={f.id} className="border-b last:border-b-0 dark:border-slate-800">
<tr key={f.id} className="border-b last:border-b-0">
<td className="px-3 py-2">
{f.statut === "payee" ? (
<span className="inline-flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-emerald-500 inline-block"/> Payée</span>
@ -248,14 +251,14 @@ export default function FacturationPage() {
</div>
<div className="flex items-center gap-2">
<button
className="px-3 py-1 rounded-md border dark:border-slate-800 disabled:opacity-40"
className="px-3 py-1 rounded-md border disabled:opacity-40"
disabled={page === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Précédent
</button>
<button
className="px-3 py-1 rounded-md border dark:border-slate-800 disabled:opacity-40"
className="px-3 py-1 rounded-md border disabled:opacity-40"
disabled={!hasMore}
onClick={() => setPage((p) => p + 1)}
>

View file

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/fetcher";
import { usePageTitle } from "@/hooks/usePageTitle";
type StructureInfos = {
raison_sociale?: string;
@ -48,8 +49,8 @@ type ClientInfo = {
function Line({ label, value }: { label: string; value?: string | number | null }) {
return (
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 dark:border-slate-800 py-2">
<div className="text-slate-500 dark:text-slate-400">{label}</div>
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 py-2">
<div className="text-slate-500">{label}</div>
<div className="col-span-2">{value ?? "—"}</div>
</div>
);
@ -63,6 +64,8 @@ function fmtDateFR(d?: string | null) {
}
export default function InformationsPage() {
usePageTitle("Informations de la structure");
const [page, setPage] = useState(1);
const limit = 10;
@ -123,8 +126,8 @@ export default function InformationsPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-start">
<div className="space-y-4">
{/* Colonne gauche : Votre structure */}
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800">
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b">
<h2 className="font-medium">Votre structure</h2>
</div>
@ -149,8 +152,8 @@ export default function InformationsPage() {
</section>
{/* Vos productions */}
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800">
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b">
<h2 className="font-medium">Vos productions</h2>
</div>
@ -161,7 +164,7 @@ export default function InformationsPage() {
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left border-b dark:border-slate-800">
<tr className="text-left border-b">
<th className="px-3 py-2">Production</th>
<th className="px-3 py-2">Déclaration</th>
<th className="px-3 py-2">N° dobjet</th>
@ -169,7 +172,7 @@ export default function InformationsPage() {
</thead>
<tbody>
{prods.items.map((s, i) => (
<tr key={`${s.nom}-${s.numero_objet ?? i}`} className="border-b last:border-b-0 dark:border-slate-800">
<tr key={`${s.nom}-${s.numero_objet ?? i}`} className="border-b last:border-b-0">
<td className="px-3 py-2">{s.nom}</td>
<td className="px-3 py-2">{fmtDateFR(s.declaration)}</td>
<td className="px-3 py-2">{s.numero_objet ?? "—"}</td>
@ -183,9 +186,9 @@ export default function InformationsPage() {
)}
</div>
<div className="flex items-center justify-end gap-2 p-3 border-t dark:border-slate-800">
<div className="flex items-center justify-end gap-2 p-3 border-t">
<button
className="px-3 py-1.5 rounded-lg border bg-white hover:bg-slate-50 dark:bg-slate-900 dark:border-slate-800"
className="px-3 py-1.5 rounded-lg border bg-white hover:bg-slate-50"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
@ -193,7 +196,7 @@ export default function InformationsPage() {
</button>
<div className="text-sm text-slate-500">Page {page}</div>
<button
className="px-3 py-1.5 rounded-lg border bg-white hover:bg-slate-50 dark:bg-slate-900 dark:border-slate-800 disabled:opacity-50"
className="px-3 py-1.5 rounded-lg border bg-white hover:bg-slate-50 disabled:opacity-50"
onClick={() => setPage((p) => (hasMore ? p + 1 : p))}
disabled={!hasMore}
>
@ -205,10 +208,9 @@ export default function InformationsPage() {
<div className="space-y-4">
{/* Colonne droite : Contact + Caisses */}
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800 flex items-center justify-between">
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b">
<h2 className="font-medium">Informations de contact</h2>
<Link href="/informations/modifier" className="text-sm underline">Modifier</Link>
</div>
<div className="p-4 text-sm">
{!!structure ? (
@ -225,8 +227,8 @@ export default function InformationsPage() {
</div>
</section>
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800 flex items-center justify-between">
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b flex items-center justify-between">
<h2 className="font-medium">Caisses & organismes</h2>
</div>
<div className="p-4 text-sm">

View file

@ -4,10 +4,14 @@ import { MaintenanceButton } from "@/components/MaintenanceButton";
import { ReactNode } from "react";
import { redirect } from "next/navigation";
// import { getAccessToken } from "@/lib/auth"; // Supprimé, on utilise directement Supabase
import { cookies } from "next/headers";
import { cookies, headers } from "next/headers";
import { createSbServer } from "@/lib/supabaseServer";
// app/layout.tsx
import "@/styles/cmdk.css";
import { detectDemoModeFromHeaders } from "@/lib/demo-detector";
import { DEMO_USER, DEMO_ORGANIZATION } from "@/lib/demo-data";
import { DemoBanner } from "@/components/DemoBanner";
import { DemoModeProvider } from "@/hooks/useDemoMode";
type ClientInfo = {
id: string;
@ -23,6 +27,59 @@ type ClientInfo = {
export default async function AppLayout({ children }: { children: ReactNode }) {
const c = cookies();
const h = headers();
// 🎭 Vérification du mode démo AVANT tout traitement d'auth
const isDemoMode = detectDemoModeFromHeaders(h);
if (isDemoMode) {
// Mode démo : utiliser des données fictives sans authentification
const demoClientInfo: ClientInfo = {
id: DEMO_ORGANIZATION.id,
name: DEMO_ORGANIZATION.name,
api_name: DEMO_ORGANIZATION.api_name,
user: {
id: DEMO_USER.id,
email: DEMO_USER.email,
display_name: DEMO_USER.display_name,
first_name: DEMO_USER.first_name
}
};
console.log("🎭 [LAYOUT] Mode démo actif - Utilisation des données fictives");
return (
<DemoModeProvider forceDemoMode={true}>
<div className="min-h-screen">
{/* Demo Banner */}
<DemoBanner isDemoMode={true} isPublicDemo={process.env.NODE_ENV === 'production'} />
<div className="grid md:grid-cols-[calc(var(--sidebar-w)_+_8px)_1fr] min-h-screen">
{/* Sidebar flush left */}
<aside className="hidden md:block sticky top-0 h-screen overflow-y-auto overflow-x-hidden border-r bg-background">
<Sidebar clientInfo={demoClientInfo} isStaff={false} />
</aside>
{/* Main column (header + content) */}
<div className="flex flex-col min-h-screen">
{/* Header aligned with content column */}
<header className="m-0 p-0">
<Header clientInfo={demoClientInfo} isStaff={false} />
<div className="flex items-center justify-end gap-3 mt-2">
<MaintenanceButton isStaff={false} />
</div>
</header>
{/* Main content area */}
<main className="p-4">
{children}
</main>
</div>
</div>
</div>
</DemoModeProvider>
);
}
// ⚠️ DEV ONLY : bypass auth si AUTH_BYPASS=1
if (process.env.NODE_ENV === "development" && process.env.AUTH_BYPASS === "1") {

View file

@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/fetcher";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { usePageTitle } from "@/hooks/usePageTitle";
type ClientInfo = {
id: string;
@ -22,6 +23,9 @@ type ClientInfo = {
export default function Dashboard() {
const [currentPage, setCurrentPage] = useState(1);
// Définir le titre de la page
usePageTitle("Tableau de bord");
const { data, isLoading, isError } = useContrats({
// Pas de restriction de régime - récupère tous les contrats
status: "en_cours",
@ -84,14 +88,14 @@ export default function Dashboard() {
return (
<div className="space-y-6">
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-6">
<section className="rounded-2xl border bg-white p-6">
<h1 className="text-2xl font-semibold">Bonjour{userFirstName ? ` ${userFirstName}` : ''} 👋</h1>
<p className="text-sm text-slate-600 dark:text-slate-400 mt-2">Voici un aperçu rapide de vos contrats et actions à venir.</p>
<p className="text-sm text-slate-600 mt-2">Voici un aperçu rapide de vos contrats et actions à venir.</p>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-6 space-y-4">
<div className="rounded-2xl border bg-white p-6 space-y-4">
<h2 className="text-xl font-semibold">Vos contrats en cours</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
<p className="text-sm text-slate-600">
{countLabel}
</p>
@ -102,21 +106,21 @@ export default function Dashboard() {
<Link
key={c.id}
href={c.regime === 'RG' ? `/contrats-rg/${c.id}` : `/contrats/${c.id}`}
className="block p-4 border rounded-xl bg-gray-50 dark:bg-slate-800 hover:shadow-md transition-shadow focus:outline-none focus:ring-2 focus:ring-blue-500"
className="block p-4 border rounded-xl bg-gray-50 hover:shadow-md transition-shadow focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<h3 className="text-lg font-semibold">{c.salarie_nom}</h3>
<div className="flex flex-wrap items-center gap-2 mt-1">
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{c.profession}
</span>
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300">
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
Réf. {c.reference}
</span>
{c.regime && (
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
c.regime === 'RG'
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
? 'bg-emerald-100 text-emerald-800'
: 'bg-purple-100 text-purple-800'
}`}>
{c.regime === 'RG' ? 'Régime Général' : 'CDDU'}
</span>
@ -131,7 +135,7 @@ export default function Dashboard() {
<span className="flex items-center text-sm">
<svg className="w-4 h-4 mr-1" viewBox="0 0 20 20"><rect x="3" y="4" width="14" height="13" rx="2"/><path d="M16 7H4"/><path d="M7 2v2M13 2v2"/></svg>
{c.date_fin === '2099-01-01' ? (
<span className="italic text-emerald-600 dark:text-emerald-400">CDI en cours</span>
<span className="italic text-emerald-600">CDI en cours</span>
) : (
<>Fin : {formatFR(c.date_fin)}</>
)}
@ -164,12 +168,12 @@ export default function Dashboard() {
</Button>
</div>
</div>
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-6">
<div className="rounded-2xl border bg-white p-6">
<h2 className="text-xl font-semibold mb-4">Vos notifications</h2>
<div className="relative p-6 rounded-xl bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20 border border-blue-100 dark:border-blue-800/30 overflow-hidden">
<div className="relative p-6 rounded-xl bg-gradient-to-br from-blue-50 to-indigo-50 border border-blue-100 overflow-hidden">
{/* Icône décorative en arrière-plan */}
<div className="absolute top-4 right-4 opacity-10 dark:opacity-5">
<div className="absolute top-4 right-4 opacity-10">
<svg className="w-12 h-12 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2L13 8h6l-5 4 2 6-6-4-6 4 2-6-5-4h6l3-6z"/>
</svg>
@ -178,22 +182,22 @@ export default function Dashboard() {
{/* Contenu principal */}
<div className="relative">
<div className="flex items-center gap-3 mb-3">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-5 5v-5zM4 3h11v6H4V3zM4 13h7v7H4v-7z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Bientôt disponible</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">Fonctionnalité en développement</p>
<h3 className="font-semibold text-slate-900">Bientôt disponible</h3>
<p className="text-sm text-slate-600">Fonctionnalité en développement</p>
</div>
</div>
<p className="text-sm text-slate-700 dark:text-slate-300 leading-relaxed">
<p className="text-sm text-slate-700 leading-relaxed">
Le système de notifications vous permettra de recevoir des alertes importantes concernant vos contrats, échéances et documents à traiter, directement dans votre Espace Paie.
</p>
<div className="mt-4 flex items-center gap-2 text-xs text-blue-600 dark:text-blue-400">
<div className="mt-4 flex items-center gap-2 text-xs text-blue-600">
<div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse"></div>
<span>Cette fonctionnalité sera lancée prochainement</span>
</div>

View file

@ -4,6 +4,7 @@ import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState, useMemo } from "react";
import { ArrowLeft, Loader2, ChevronLeft, ChevronRight } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
// Types
type SalarieDetail = {
@ -64,8 +65,8 @@ function Field({ label, value }: { label: string; value: string }) {
// Composant Section
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800 font-medium text-slate-700 dark:text-slate-200 bg-slate-50/60 dark:bg-slate-800/40">
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
{title}
</div>
<div className="p-4">{children}</div>
@ -93,6 +94,11 @@ export default function SalariePage() {
const router = useRouter();
const [salarie, setSalarie] = useState<SalarieDetail | null>(null);
// Titre dynamique basé sur le salarié
const salarieName = salarie ? `${salarie.prenom} ${salarie.nom_usage || salarie.nom_naissance}`.trim() : `Salarié ${matricule}`;
usePageTitle(`${salarieName}`);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -125,6 +131,48 @@ export default function SalariePage() {
setLoading(true);
setError(null);
// 🎭 Détection directe du mode démo
const isDemoMode = typeof window !== 'undefined' && window.location.hostname === 'demo.odentas.fr';
console.log('🔍 fetchSalarie debug:', {
isDemoMode,
hostname: typeof window !== 'undefined' ? window.location.hostname : 'server',
matricule
});
// 🎭 Mode démo : utiliser les données fictives pour l'ID demo
if (isDemoMode && matricule === 'demo-sal-001') {
console.log('🎭 Demo mode detected, loading demo salarie...');
const DEMO_SALARIE: SalarieDetail = {
matricule: "demo-sal-001",
civilite: "Mme",
prenom: "Alice",
nom_naissance: "MARTIN",
nom_usage: "MARTIN",
pseudo: "Alice M.",
date_naissance: "1990-05-15",
lieu_naissance: "Paris (75)",
email: "alice.martin@demo.fr",
telephone: "06 12 34 56 78",
adresse: "123 Rue de la Comédie, 75001 Paris",
nir: "2900515751234",
conges_spectacles: "12345678901",
iban: "FR76 3000 3000 0000 0000 0000 123",
bic: "SOGEFRPP",
transat_connecte: true,
justificatifs: "RIB et pièce d'identité fournis",
mineur_moins_16: false,
resident_fr: true,
rib_pdf: "/demo/rib-demo.pdf"
};
console.log('✅ Demo salarie loaded');
setSalarie(DEMO_SALARIE);
setLoading(false);
return;
}
console.log(`🔍 Fetching salarie: ${matricule}`);
const response = await fetch(`/api/salaries/${matricule}`, {
@ -164,6 +212,66 @@ export default function SalariePage() {
setContratsLoading(true);
setContratsError(null);
// 🎭 Détection directe du mode démo
const isDemoMode = typeof window !== 'undefined' && window.location.hostname === 'demo.odentas.fr';
console.log('🔍 fetchContrats debug:', {
isDemoMode,
hostname: typeof window !== 'undefined' ? window.location.hostname : 'server',
matricule,
year,
page
});
// 🎭 Mode démo : utiliser les données fictives pour l'ID demo
if (isDemoMode && matricule === 'demo-sal-001') {
console.log('🎭 Demo mode detected, loading demo contrats...');
const DEMO_CONTRATS: ContratListItem[] = [
{
id: "demo-cont-001",
reference: "DEMO-2024-001",
profession: "04201 - Comédien",
date_debut: "2024-01-15",
date_fin: "2024-06-30",
is_multi_mois: true,
regime: "CDDU_MULTI"
},
{
id: "demo-cont-002",
reference: "DEMO-2024-002",
profession: "04201 - Comédien",
date_debut: "2024-07-05",
date_fin: "2024-07-28",
is_multi_mois: false,
regime: "CDDU_MONO"
},
{
id: "demo-cont-003",
reference: "DEMO-2023-015",
profession: "04201 - Comédien",
date_debut: "2023-09-01",
date_fin: "2023-12-20",
is_multi_mois: true,
regime: "CDDU_MULTI"
}
];
// Filtrer par année
const filteredContrats = DEMO_CONTRATS.filter(contrat => {
if (!contrat.date_debut) return true;
const contractYear = new Date(contrat.date_debut).getFullYear();
return contractYear === year;
});
console.log('✅ Demo contrats loaded:', filteredContrats.length, 'for year', year);
setContrats(filteredContrats);
setHasMore(false); // Pas de pagination pour les données demo
setContratsLoading(false);
return;
}
console.log(`🔍 Fetching contrats for ${matricule}, year ${year}, page ${page}`);
const response = await fetch(`/api/salaries/${matricule}/contrats?year=${year}&page=${page}&limit=${limit}`, {
@ -229,16 +337,16 @@ export default function SalariePage() {
<ArrowLeft className="w-4 h-4" /> Retour
</Link>
</div>
<div className="rounded-2xl border bg-rose-50 dark:bg-rose-900/20 dark:border-rose-800 p-4">
<h3 className="font-medium text-rose-800 dark:text-rose-200 mb-2">
<div className="rounded-2xl border bg-rose-50 p-4">
<h3 className="font-medium text-rose-800 mb-2">
Erreur lors du chargement
</h3>
<p className="text-rose-700 dark:text-rose-300 text-sm">
<p className="text-rose-700 text-sm">
{error}
</p>
<button
onClick={() => window.location.reload()}
className="mt-3 px-3 py-1 bg-rose-100 dark:bg-rose-800 text-rose-800 dark:text-rose-200 rounded text-sm hover:bg-rose-200 dark:hover:bg-rose-700"
className="mt-3 px-3 py-1 bg-rose-100 text-rose-800 rounded text-sm hover:bg-rose-200"
>
Réessayer
</button>
@ -273,7 +381,7 @@ export default function SalariePage() {
</div>
{/* Titre */}
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4">
<div className="rounded-2xl border bg-white p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="text-lg font-semibold">
{formatValue(salarie.nom_usage).toUpperCase()} {formatValue(salarie.prenom)}
@ -316,7 +424,7 @@ export default function SalariePage() {
<Field label="BIC" value={formatValue(salarie.bic)} />
<Field label="Justificatifs" value={formatValue(salarie.justificatifs)} />
<div className="pt-3 border-t dark:border-slate-800">
<div className="pt-3 border-t">
<Field
label="Espace Transat"
value={salarie.transat_connecte !== undefined
@ -354,7 +462,7 @@ export default function SalariePage() {
setYear(parseInt(e.target.value, 10));
setPage(1);
}}
className="px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="px-3 py-2 rounded-lg border bg-white text-sm"
>
{yearOptions.map((y) => (
<option key={y} value={y}>
@ -368,7 +476,7 @@ export default function SalariePage() {
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1 || contratsLoading}
className="px-2 py-1 rounded-lg border dark:border-slate-800 disabled:opacity-40"
className="px-2 py-1 rounded-lg border disabled:opacity-40"
title="Page précédente"
>
<ChevronLeft className="w-4 h-4" />
@ -377,7 +485,7 @@ export default function SalariePage() {
<button
onClick={() => setPage((p) => p + 1)}
disabled={!hasMore || contratsLoading}
className="px-2 py-1 rounded-lg border dark:border-slate-800 disabled:opacity-40"
className="px-2 py-1 rounded-lg border disabled:opacity-40"
title="Page suivante"
>
<ChevronRight className="w-4 h-4" />
@ -391,17 +499,17 @@ export default function SalariePage() {
{/* Grille des contrats */}
<div className="space-y-3">
{contratsError ? (
<div className="p-6 text-center text-rose-600 bg-rose-50 dark:bg-rose-900/20 rounded-xl">
<div className="p-6 text-center text-rose-600 bg-rose-50 rounded-xl">
<p>Erreur lors du chargement des contrats</p>
<button
onClick={fetchContrats}
className="mt-2 px-3 py-1 bg-rose-100 dark:bg-rose-800 text-rose-800 dark:text-rose-200 rounded text-sm hover:bg-rose-200 dark:hover:bg-rose-700"
className="mt-2 px-3 py-1 bg-rose-100 text-rose-800 rounded text-sm hover:bg-rose-200"
>
Réessayer
</button>
</div>
) : contrats.length === 0 && !contratsLoading ? (
<div className="p-6 text-center text-slate-500 bg-slate-50 dark:bg-slate-800/40 rounded-xl">
<div className="p-6 text-center text-slate-500 bg-slate-50 rounded-xl">
Aucun contrat pour {year}.
</div>
) : (
@ -409,30 +517,30 @@ export default function SalariePage() {
<Link
key={c.id}
href={hrefContrat(c)}
className="block p-4 border rounded-xl bg-gray-50 dark:bg-slate-800 hover:shadow-md transition-shadow focus:outline-none focus:ring-2 focus:ring-blue-500"
className="block p-4 border rounded-xl bg-gray-50 hover:shadow-md transition-shadow focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<h3 className="text-lg font-semibold">{c.reference}</h3>
<div className="flex flex-wrap items-center gap-2 mt-1">
{c.profession && (
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{c.profession}
</span>
)}
{c.regime && (
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300">
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
{c.regime === 'CDDU_MULTI' ? 'Multi-mois' : c.regime === 'CDDU_MONO' ? 'Mono-mois' : c.regime}
</span>
)}
</div>
<div className="flex flex-col sm:flex-row gap-1 sm:gap-4 mt-2">
<span className="flex items-center text-sm text-slate-600 dark:text-slate-400">
<span className="flex items-center text-sm text-slate-600">
<svg className="w-4 h-4 mr-1 stroke-current" fill="none" viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="16" rx="2"/>
<path d="M7 2v2M17 2v2M3 10h18"/>
</svg>
Début : {formatDateFR(c.date_debut)}
</span>
<span className="flex items-center text-sm text-slate-600 dark:text-slate-400">
<span className="flex items-center text-sm text-slate-600">
<svg className="w-4 h-4 mr-1 stroke-current" fill="none" viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="16" rx="2"/>
<path d="M7 2v2M17 2v2M3 10h18"/>
@ -455,7 +563,7 @@ export default function SalariePage() {
/>
{/* Modal */}
<div className="relative z-10 w-full max-w-md rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-5 shadow-xl">
<div className="relative z-10 w-full max-w-md rounded-2xl border bg-white p-5 shadow-xl">
<div className="text-base font-medium mb-1">Créer un nouveau contrat</div>
<p className="text-sm text-slate-500 mb-4">
Pour quel type de contrat souhaitez-vous procéder ?
@ -468,7 +576,7 @@ export default function SalariePage() {
setNewContratOpen(false);
router.push(`/contrats/nouveau?salarie=${encodeURIComponent(matricule)}`);
}}
className="w-full px-3 py-2 rounded-lg border dark:border-slate-700 text-sm hover:bg-slate-50 dark:hover:bg-slate-800 text-left"
className="w-full px-3 py-2 rounded-lg border text-sm hover:bg-slate-50 text-left"
>
CDDU
<div className="text-xs text-slate-500">Contrat à durée déterminée d'usage</div>
@ -480,7 +588,7 @@ export default function SalariePage() {
// Non fonctionnel pour l'instant
setNewContratOpen(false);
}}
className="w-full px-3 py-2 rounded-lg border dark:border-slate-700 text-sm opacity-70 cursor-not-allowed text-left"
className="w-full px-3 py-2 rounded-lg border text-sm opacity-70 cursor-not-allowed text-left"
title="Bientôt disponible"
disabled
>
@ -493,7 +601,7 @@ export default function SalariePage() {
<button
type="button"
onClick={() => setNewContratOpen(false)}
className="text-sm px-3 py-2 rounded-lg border dark:border-slate-700"
className="text-sm px-3 py-2 rounded-lg border"
>
Annuler
</button>

View file

@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Loader2, ArrowLeft, X, Check } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
type ClientInfo = {
id: string;
@ -25,7 +26,7 @@ function Label({ children, required = false }: { children: React.ReactNode; requ
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-5">
<section className="rounded-2xl border bg-white p-5">
<h2 className="text-base font-semibold mb-4">{title}</h2>
{children}
</section>
@ -70,6 +71,8 @@ function isValidIBAN(input: string): boolean {
}
export default function NouveauSalariePage() {
usePageTitle("Nouveau salarié");
const router = useRouter();
const search = useSearchParams();
const embed = (search.get("embed") || "").toLowerCase() === "1" || (search.get("embed") || "").toLowerCase() === "true";
@ -386,27 +389,27 @@ useEffect(() => {
{!embed && <h1 className="text-xl font-semibold">Nouveau salarié</h1>}
<Section title="Enregistrement d'un nouveau salarié">
<p className="text-sm text-slate-600 dark:text-slate-300">
<p className="text-sm text-slate-600">
Pour enregistrer votre nouveau salarié, indiquez les informations cidessous. Nous avons besoin a minima de son
<strong> nom</strong>, son <strong>prénom</strong> et son <strong>adresse email</strong>.
</p>
<p className="text-sm text-slate-600 dark:text-slate-300 mt-2">
<p className="text-sm text-slate-600 mt-2">
Un email lui sera envoyé pour déposer ses justificatifs et, si nécessaire, compléter son étatcivil.
</p>
</Section>
{/* Onglets (design calqué sur /contrats) */}
<div className="inline-flex rounded-xl border dark:border-slate-800 p-1 bg-slate-50 dark:bg-slate-800/50">
<div className="inline-flex rounded-xl border p-1 bg-slate-50">
<button
type="button"
onClick={() => setFormMode("simplifie")}
className={`px-3 py-1.5 text-sm rounded-lg ${formMode==='simplifie' ? 'bg-white dark:bg-slate-900 shadow border dark:border-slate-700' : 'opacity-80'}`}
className={`px-3 py-1.5 text-sm rounded-lg ${formMode==='simplifie' ? 'bg-white shadow border' : 'opacity-80'}`}
>
Formulaire simplifiée
</button>
<button
type="button"
onClick={() => setFormMode("complet")}
className={`px-3 py-1.5 text-sm rounded-lg ${formMode==='complet' ? 'bg-white dark:bg-slate-900 shadow border dark:border-slate-700' : 'opacity-80'}`}
className={`px-3 py-1.5 text-sm rounded-lg ${formMode==='complet' ? 'bg-white shadow border' : 'opacity-80'}`}
>
Formulaire complet
</button>
@ -436,7 +439,7 @@ useEffect(() => {
<input
value={nom}
onChange={(e) => setNom(upper(e.target.value))}
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
<p className="text-[11px] text-slate-500 mt-1">La saisie se fait automatiquement en majuscules.</p>
</div>
@ -446,7 +449,7 @@ useEffect(() => {
ref={prenomInputRef}
value={prenom}
onChange={(e) => setPrenom(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
<p className="text-[11px] text-slate-500 mt-1">Une majuscule est automatiquement ajoutée au début du prénom.</p>
</div>
@ -461,7 +464,7 @@ useEffect(() => {
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
</div>
</Section>
@ -491,7 +494,7 @@ useEffect(() => {
<input
value={nom}
onChange={(e) => setNom(upper(e.target.value))}
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
<p className="text-[11px] text-slate-500 mt-1">La saisie se fait automatiquement en majuscules.</p>
</div>
@ -501,7 +504,7 @@ useEffect(() => {
ref={prenomInputRef}
value={prenom}
onChange={(e) => setPrenom(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
<p className="text-[11px] text-slate-500 mt-1">Une majuscule est automatiquement ajoutée au début du prénom.</p>
</div>
@ -513,7 +516,7 @@ useEffect(() => {
<input
value={nomNaissance}
onChange={(e) => setNomNaissance(upper(e.target.value))}
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
<p className="text-[11px] text-slate-500 mt-1">Remplir uniquement sil diffère du nom dusage (automatiquement en majuscules).</p>
</div>
@ -522,7 +525,7 @@ useEffect(() => {
<input
value={pseudo}
onChange={(e) => setPseudo(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
<p className="text-[11px] text-slate-500 mt-1">Si votre salarié utilise un nom de scène ou de plume, nous le ferons apparaître sur ses contrats.</p>
</div>
@ -538,7 +541,7 @@ useEffect(() => {
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
</div>
<div className="relative">
@ -549,14 +552,14 @@ useEffect(() => {
onChange={(e) => { setAdresse(e.target.value); setAddrQuery(e.target.value); setAddrOpen(false); setAddrMeta(null); }}
onFocus={() => { if (addrResults.length > 0) setAddrOpen(true); }}
placeholder="Saisir au moins 3 caractères (ex : 10 rue de Rivoli, Paris)"
className="w-full pr-9 px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full pr-9 px-3 py-2 rounded-lg border bg-white text-sm"
/>
{adresse && (
<button
type="button"
aria-label="Effacer ladresse"
onClick={() => { setAdresse(""); setAddrQuery(""); setAddrResults([]); setAddrOpen(false); setAddrMeta(null); }}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-500"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-slate-100 text-slate-500"
title="Effacer"
>
<X className="w-4 h-4" />
@ -564,7 +567,7 @@ useEffect(() => {
)}
</div>
{(addrLoading || (addrOpen && addrResults.length > 0)) && (
<div className="absolute z-20 mt-1 w-full rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 shadow-lg max-h-64 overflow-auto">
<div className="absolute z-20 mt-1 w-full rounded-lg border bg-white shadow-lg max-h-64 overflow-auto">
{addrLoading && (
<div className="p-3 text-xs text-slate-500">Recherche dadresses</div>
)}
@ -572,7 +575,7 @@ useEffect(() => {
<div className="p-3 text-xs text-slate-500">Aucun résultat</div>
)}
{!addrLoading && addrResults.length > 0 && (
<ul className="divide-y divide-slate-100 dark:divide-slate-800">
<ul className="divide-y divide-slate-100">
{addrResults.map((f: any, idx: number) => {
const p = f?.properties || {};
const label = formatAddr(f);
@ -580,7 +583,7 @@ useEffect(() => {
<li key={idx}>
<button
type="button"
className="block w-full text-left px-3 py-2 hover:bg-slate-50 dark:hover:bg-slate-800/40 text-sm"
className="block w-full text-left px-3 py-2 hover:bg-slate-50 text-sm"
onClick={() => {
setAdresse(label);
setAddrOpen(false);
@ -614,7 +617,7 @@ useEffect(() => {
<input
value={telephone}
onChange={(e) => setTelephone(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
<p className="text-[11px] text-slate-500 mt-1">Utilisé uniquement pour les SMS de signature (si numéros français).</p>
</div>
@ -624,7 +627,7 @@ useEffect(() => {
value={complementAdresse}
onChange={(e) => setComplementAdresse(e.target.value)}
placeholder="Bâtiment, étage, appartement, digicode…"
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
</div>
</FieldRow>
@ -638,7 +641,7 @@ useEffect(() => {
type="date"
value={dateNaissance}
onChange={(e) => setDateNaissance(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
</div>
<div>
@ -647,7 +650,7 @@ useEffect(() => {
value={lieuNaissance}
onChange={(e) => setLieuNaissance(e.target.value)}
placeholder="Ville, département, pays le cas échéant"
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
</div>
</FieldRow>
@ -659,7 +662,7 @@ useEffect(() => {
value={nir}
onChange={(e) => setNir(e.target.value)}
placeholder="15 chiffres ou numéro provisoire"
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
<p className="text-[11px] text-slate-500 mt-1">Indiquez le NIR complet ou provisoire si pas encore définitif.</p>
</div>
@ -669,7 +672,7 @@ useEffect(() => {
value={congesSpectacles}
onChange={(e) => setCongesSpectacles(e.target.value)}
placeholder="ex : X123456"
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
</div>
</FieldRow>
@ -684,7 +687,7 @@ useEffect(() => {
onChange={(e) => { setIban(e.target.value); if (ibanError) setIbanError(null); }}
onBlur={() => setIbanError(iban && !isValidIBAN(iban) ? "IBAN invalide (vérifiez la clé et le format)." : null)}
placeholder="FR.. .. .. .. .. .. .. .. .. .."
className={`w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 text-sm ${ibanError ? 'border-red-400 dark:border-red-500' : 'dark:border-slate-800'}`}
className={`w-full px-3 py-2 rounded-lg border bg-white text-sm ${ibanError ? 'border-red-400' : 'border-slate-300'}`}
/>
{ibanError ? (
<p className="text-[11px] text-red-600 mt-1" aria-live="polite">{ibanError}</p>
@ -698,7 +701,7 @@ useEffect(() => {
</div>
<div>
<Label>Son BIC</Label>
<input value={bic} onChange={(e) => setBic(e.target.value)} className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm" />
<input value={bic} onChange={(e) => setBic(e.target.value)} className="w-full px-3 py-2 rounded-lg border bg-white text-sm" />
</div>
</FieldRow>
</Section>
@ -710,7 +713,7 @@ useEffect(() => {
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={4}
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
</div>
@ -720,7 +723,7 @@ useEffect(() => {
type="file"
multiple
onChange={(e) => setFiles(e.target.files)}
className="block w-full text-sm text-slate-600 file:mr-3 file:py-2 file:px-3 file:rounded-lg file:border file:bg-white dark:file:bg-slate-900 file:border-slate-300 dark:file:border-slate-700"
className="block w-full text-sm text-slate-600 file:mr-3 file:py-2 file:px-3 file:rounded-lg file:border file:bg-white file:border-slate-300"
/>
<p className="text-[11px] text-slate-500 mt-1">Vous pouvez transmettre tous fichiers que vous jugez utiles (20Mo max.).</p>
</div>
@ -733,7 +736,7 @@ useEffect(() => {
)}
<div className="flex items-center gap-3">
<Link href="/salaries" className="px-4 py-2 rounded-lg border dark:border-slate-700 text-sm">Relire avant envoi</Link>
<Link href="/salaries" className="px-4 py-2 rounded-lg border text-sm">Relire avant envoi</Link>
<button
type="submit"
disabled={!canSubmit || loading}
@ -748,13 +751,13 @@ useEffect(() => {
{showLeaveConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-5 shadow-xl">
<div className="w-full max-w-md rounded-2xl border bg-white p-5 shadow-xl">
<div className="text-base font-semibold">Quitter cette page ?</div>
<p className="text-sm text-slate-600 dark:text-slate-300 mt-2">
<p className="text-sm text-slate-600 mt-2">
Vous avez une saisie en cours. En quittant maintenant, vous perdrez les informations non envoyées.
</p>
<div className="mt-4 flex items-center justify-end gap-3">
<button onClick={cancelLeave} className="px-4 py-2 rounded-lg border dark:border-slate-700 text-sm">Rester</button>
<button onClick={cancelLeave} className="px-4 py-2 rounded-lg border text-sm">Rester</button>
<button onClick={confirmLeave} className="px-4 py-2 rounded-lg bg-rose-600 text-white text-sm hover:bg-rose-700">Quitter sans enregistrer</button>
</div>
</div>
@ -762,11 +765,11 @@ useEffect(() => {
)}
{redirecting && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/70 dark:bg-slate-900/70">
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-6 text-center shadow-xl">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/70">
<div className="rounded-2xl border bg-white p-6 text-center shadow-xl">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-3" />
<div className="font-medium">Envoi réussi</div>
<p className="text-sm text-slate-600 dark:text-slate-300 mt-1">Redirection dans quelques secondes</p>
<p className="text-sm text-slate-600 mt-1">Redirection dans quelques secondes</p>
</div>
</div>
)}

View file

@ -7,6 +7,7 @@ import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { useMemo, useState, useEffect } from "react";
import { api } from "@/lib/fetcher";
import { Loader2, ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
/* ===== Types ===== */
type SalarieRow = {
@ -40,8 +41,8 @@ type ClientInfo = {
/* ===== Helpers ===== */
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800 font-medium text-slate-700 dark:text-slate-200 bg-slate-50/60 dark:bg-slate-800/40">
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
{title}
</div>
<div className="p-4">{children}</div>
@ -57,6 +58,124 @@ function lastContractHref(c?: SalarieRow["dernier_contrat"]) {
/* ===== Data hook ===== */
function useSalaries(page: number, limit: number, search: string, org?: string | null) {
// 🎭 Détection directe du mode démo
const isDemoMode = typeof window !== 'undefined' && window.location.hostname === 'demo.odentas.fr';
console.log('🔍 useSalaries debug:', {
isDemoMode,
hostname: typeof window !== 'undefined' ? window.location.hostname : 'server',
page,
search
});
// 🎭 Mode démo : utiliser les données fictives DIRECTEMENT
if (isDemoMode) {
console.log('🎭 Demo mode detected, loading demo salaries...');
// Données fictives de salariés
const DEMO_SALARIES: SalarieRow[] = [
{
matricule: "demo-sal-001",
nom: "MARTIN Alice",
email: "alice.martin@demo.fr",
transat_connecte: true,
dernier_emploi: "Comédien",
dernier_contrat: {
id: "demo-cont-001",
reference: "DEMO-2024-001",
is_multi_mois: true,
regime: "CDDU_MULTI"
}
},
{
matricule: "demo-sal-002",
nom: "DUBOIS Pierre",
email: "pierre.dubois@demo.fr",
transat_connecte: false,
dernier_emploi: "Metteur en scène",
dernier_contrat: {
id: "demo-cont-002",
reference: "DEMO-2024-002",
is_multi_mois: false,
regime: "CDDU_MONO"
}
},
{
matricule: "demo-sal-003",
nom: "LEROY Sophie",
email: "sophie.leroy@demo.fr",
transat_connecte: true,
dernier_emploi: "Danseur",
dernier_contrat: {
id: "demo-cont-003",
reference: "DEMO-2024-003",
is_multi_mois: true,
regime: "CDDU_MULTI"
}
},
{
matricule: "demo-sal-004",
nom: "BERNARD Marc",
email: "marc.bernard@demo.fr",
transat_connecte: false,
dernier_emploi: "Technicien son",
dernier_contrat: {
id: "demo-cont-004",
reference: "DEMO-2024-004",
is_multi_mois: false,
regime: "CDDU_MONO"
}
},
{
matricule: "demo-sal-005",
nom: "GARCIA Elena",
email: "elena.garcia@demo.fr",
transat_connecte: true,
dernier_emploi: "Costumière",
dernier_contrat: {
id: "demo-cont-005",
reference: "DEMO-2024-005",
is_multi_mois: true,
regime: "CDDU_MULTI"
}
}
];
// Filtrer par recherche si nécessaire
const filteredSalaries = DEMO_SALARIES.filter(salarie => {
if (search.trim()) {
const searchTerm = search.toLowerCase();
return salarie.nom.toLowerCase().includes(searchTerm) ||
salarie.matricule.toLowerCase().includes(searchTerm) ||
salarie.email?.toLowerCase().includes(searchTerm) ||
salarie.dernier_emploi?.toLowerCase().includes(searchTerm);
}
return true;
});
// Pagination simple
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedSalaries = filteredSalaries.slice(startIndex, endIndex);
console.log('✅ Filtered demo salaries:', filteredSalaries.length, 'total,', paginatedSalaries.length, 'on page', page);
return {
data: {
items: paginatedSalaries,
page,
limit,
total: filteredSalaries.length,
hasMore: endIndex < filteredSalaries.length,
},
isLoading: false,
error: null,
isError: false,
isFetching: false
};
}
// Mode normal : récupération via API
// Récupération dynamique des infos client via /api/me
const { data: clientInfo } = useQuery({
queryKey: ["client-info"],
@ -105,6 +224,8 @@ function useSalaries(page: number, limit: number, search: string, org?: string |
/* ===== Page ===== */
export default function SalariesPage() {
usePageTitle("Salariés");
const router = useRouter();
const searchParams = useSearchParams();
@ -178,7 +299,7 @@ export default function SalariesPage() {
});
}}
disabled={page === 1 || isFetching}
className="px-2 py-1 rounded-lg border dark:border-slate-800 disabled:opacity-40"
className="px-2 py-1 rounded-lg border disabled:opacity-40"
title="Page précédente"
>
<ChevronLeft className="w-4 h-4" />
@ -197,7 +318,7 @@ export default function SalariesPage() {
});
}}
disabled={!hasMore || isFetching}
className="px-2 py-1 rounded-lg border dark:border-slate-800 disabled:opacity-40"
className="px-2 py-1 rounded-lg border disabled:opacity-40"
title="Page suivante"
>
<ChevronRight className="w-4 h-4" />
@ -210,7 +331,7 @@ export default function SalariesPage() {
return (
<div className="space-y-5">
{/* Barre de titre + actions */}
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4">
<div className="rounded-2xl border bg-white p-4">
<div className="flex flex-col md:flex-row md:items-center gap-3">
<div className="text-lg font-semibold">Vos salariés</div>
<div className="md:ml-auto w-full md:w-auto">
@ -234,7 +355,7 @@ export default function SalariesPage() {
router.replace(`/salaries?${sp.toString()}`);
}
}}
className="w-full md:w-80 px-3 py-2 rounded-lg border bg-white dark:bg-slate-900 dark:border-slate-800 text-sm"
className="w-full md:w-80 px-3 py-2 rounded-lg border bg-white text-sm"
/>
</div>
{/* Organization filter for staff */}
@ -242,7 +363,7 @@ export default function SalariesPage() {
<select
value={selectedOrg || ""}
onChange={(e) => { setSelectedOrg(e.target.value || null); setPage(1); }}
className="ml-3 px-3 py-2 rounded-lg border dark:border-slate-800 bg-white dark:bg-slate-900 text-sm"
className="ml-3 px-3 py-2 rounded-lg border bg-white text-sm"
>
<option value="">Toutes les structures</option>
{orgs.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
@ -269,7 +390,7 @@ export default function SalariesPage() {
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b dark:border-slate-800 bg-slate-50/80 dark:bg-slate-800/40">
<tr className="border-b bg-slate-50/80">
<th className="text-left font-medium px-3 py-2">Salarié</th>
<th className="text-left font-medium px-3 py-2 hidden md:table-cell">Structure</th>
<th className="text-left font-medium px-3 py-2">Matricule</th>
@ -291,7 +412,7 @@ export default function SalariesPage() {
rows.map((r: SalarieRow) => {
const contratHref = lastContractHref(r.dernier_contrat);
return (
<tr key={r.matricule} className="border-b last:border-0 dark:border-slate-800">
<tr key={r.matricule} className="border-b last:border-0">
<td className="px-3 py-2">
<Link href={`/salaries/${r.matricule}`} className="underline font-medium">
{r.nom}
@ -301,8 +422,8 @@ export default function SalariesPage() {
<td className="px-3 py-2">{r.matricule}</td>
<td className="px-3 py-2">
<span className={`px-2 py-1 rounded-full text-xs whitespace-nowrap ${
r.transat_connecte ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200"
: "bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-200"
r.transat_connecte ? "bg-emerald-100 text-emerald-800"
: "bg-rose-100 text-rose-800"
}`}>
{r.transat_connecte ? "Connecté" : "Non connecté"}
</span>
@ -324,7 +445,7 @@ export default function SalariesPage() {
setSelectedNom(r.nom || r.matricule);
setNewContratOpen(true);
}}
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800"
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border hover:bg-slate-50"
aria-label="Créer un contrat pour ce salarié"
title="Créer un contrat"
>
@ -355,7 +476,7 @@ export default function SalariesPage() {
});
}}
disabled={page === 1 || isFetching}
className="px-3 py-2 rounded-lg border dark:border-slate-800 disabled:opacity-40"
className="px-3 py-2 rounded-lg border disabled:opacity-40"
>
<ChevronLeft className="w-4 h-4" />
</button>
@ -373,7 +494,7 @@ export default function SalariesPage() {
});
}}
disabled={!hasMore || isFetching}
className="px-3 py-2 rounded-lg border dark:border-slate-800 disabled:opacity-40"
className="px-3 py-2 rounded-lg border disabled:opacity-40"
>
<ChevronRight className="w-4 h-4" />
</button>
@ -387,10 +508,10 @@ export default function SalariesPage() {
/>
{/* Modal */}
<div className="relative z-10 w-full max-w-md rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-5 shadow-xl">
<div className="relative z-10 w-full max-w-md rounded-2xl border bg-white p-5 shadow-xl">
<div className="text-base font-medium mb-1">Créer un nouveau contrat</div>
{selectedNom && (
<div className="text-sm text-slate-600 dark:text-slate-400 mb-1">
<div className="text-sm text-slate-600 mb-1">
Pour: <span className="font-medium">{selectedNom}</span>
</div>
)}
@ -407,7 +528,7 @@ export default function SalariesPage() {
router.push(`/contrats/nouveau?salarie=${encodeURIComponent(selectedMatricule)}`);
}
}}
className="w-full px-3 py-2 rounded-lg border dark:border-slate-700 text-sm hover:bg-slate-50 dark:hover:bg-slate-800 text-left"
className="w-full px-3 py-2 rounded-lg border text-sm hover:bg-slate-50 text-left"
>
CDDU
<div className="text-xs text-slate-500">Contrat à durée déterminée d'usage</div>
@ -419,7 +540,7 @@ export default function SalariesPage() {
// Non fonctionnel pour l'instant
setNewContratOpen(false);
}}
className="w-full px-3 py-2 rounded-lg border dark:border-slate-700 text-sm opacity-70 cursor-not-allowed text-left"
className="w-full px-3 py-2 rounded-lg border text-sm opacity-70 cursor-not-allowed text-left"
title="Bientôt disponible"
disabled
>
@ -432,7 +553,7 @@ export default function SalariesPage() {
<button
type="button"
onClick={() => setNewContratOpen(false)}
className="text-sm px-3 py-2 rounded-lg border dark:border-slate-700"
className="text-sm px-3 py-2 rounded-lg border"
>
Annuler
</button>

View file

@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { FileSignature, BellRing, XCircle } from 'lucide-react';
import Script from 'next/script';
import { usePageTitle } from '@/hooks/usePageTitle';
type AirtableRecord = {
id: string;
@ -13,24 +14,44 @@ type ContractsResponse = {
records: AirtableRecord[];
};
type ContratWithSignatures = AirtableRecord & {
fields: {
Reference?: string;
embed_src_employeur?: string;
docuseal_template_id?: string;
[key: string]: any;
};
};
function classNames(...arr: Array<string | false | null | undefined>) {
return arr.filter(Boolean).join(' ');
}
export default function SignaturesElectroniquesPage() {
export default function SignaturesElectroniques() {
usePageTitle("Signatures électroniques");
const [contrats, setContrats] = useState<ContratWithSignatures[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isOnline, setIsOnline] = useState(true);
// États pour les contrats
const [recordsEmployeur, setRecordsEmployeur] = useState<AirtableRecord[]>([]);
const [recordsSalarie, setRecordsSalarie] = useState<AirtableRecord[]>([]);
const [pollActive, setPollActive] = useState(true);
// États pour les modales
const [modalTitle, setModalTitle] = useState('');
const [embedSrc, setEmbedSrc] = useState<string | null>(null);
const [modalTitle, setModalTitle] = useState<string>('Signature');
const [pageEmbedSrc, setPageEmbedSrc] = useState<string | null>(null);
const [pageEmbedTitle, setPageEmbedTitle] = useState<string>('');
const pageIframeRef = useRef<HTMLIFrameElement | null>(null);
const pollTimer = useRef<number | null>(null);
const [pageEmbedTitle, setPageEmbedTitle] = useState('');
const pageIframeRef = useRef<HTMLIFrameElement>(null);
// État pour les relances
const [loadingRelance, setLoadingRelance] = useState<Record<string, boolean>>({});
// Suppression de pollActive et pollTimer car le polling a été retiré
// Load current contracts to sign (server-side API fetches Airtable)
async function load() {
try {
@ -57,24 +78,8 @@ export default function SignaturesElectroniquesPage() {
load();
}, []);
// Very light polling (8s) while page is focused
useEffect(() => {
if (!pollActive) return;
if (pollTimer.current) window.clearInterval(pollTimer.current);
pollTimer.current = window.setInterval(() => {
load();
}, 8000) as any;
return () => {
if (pollTimer.current) window.clearInterval(pollTimer.current);
pollTimer.current = null;
};
}, [pollActive]);
useEffect(() => {
const onVisibility = () => setPollActive(document.visibilityState === 'visible');
document.addEventListener('visibilitychange', onVisibility);
return () => document.removeEventListener('visibilitychange', onVisibility);
}, []);
// Polling supprimé pour éviter l'interférence avec les signatures
// Les données seront rechargées manuellement ou au refresh de la page
const stats = useMemo(() => ({
total: recordsEmployeur.length + recordsSalarie.length,
@ -159,6 +164,13 @@ export default function SignaturesElectroniquesPage() {
// show modal
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
if (dlg) {
// Ajouter un listener pour rafraîchir les données quand le modal se ferme
const handleClose = () => {
load(); // Recharger les données
dlg.removeEventListener('close', handleClose);
};
dlg.addEventListener('close', handleClose);
if (typeof dlg.showModal === 'function') dlg.showModal();
else dlg.setAttribute('open', '');
}
@ -270,7 +282,7 @@ export default function SignaturesElectroniquesPage() {
<>
{/* Table 1: employeur pending */}
<div className="rounded-xl border overflow-hidden shadow-sm">
<div className="flex items-center justify-between gap-3 px-3 py-2 border-b bg-white/60 backdrop-blur supports-[backdrop-filter]:bg-white/60">
<div className="flex items-center justify-between gap-3 px-3 py-2 border-b bg-white/60 backdrop-blur supports-[backdrop-filter]:bg-white/60[backdrop-filter]:bg-slate-900/60">
<div className="text-sm font-medium text-slate-700">Contrats en attente de signature employeur</div>
<div className="text-xs text-slate-500">{recordsEmployeur.length} élément(s)</div>
</div>
@ -338,7 +350,7 @@ export default function SignaturesElectroniquesPage() {
{/* Table 2: salarie pending */}
<div className="rounded-xl border overflow-hidden shadow-sm mt-8">
<div className="flex items-center justify-between gap-3 px-3 py-2 border-b bg-white/60 backdrop-blur supports-[backdrop-filter]:bg-white/60">
<div className="flex items-center justify-between gap-3 px-3 py-2 border-b bg-white/60 backdrop-blur supports-[backdrop-filter]:bg-white/60[backdrop-filter]:bg-slate-900/60">
<div className="text-sm font-medium text-slate-700">Contrats en attente de signature salarié</div>
<div className="text-xs text-slate-500">{recordsSalarie.length} élément(s)</div>
</div>
@ -404,9 +416,9 @@ export default function SignaturesElectroniquesPage() {
</div>
{/* Modal signature with docuseal-form */}
<dialog id="dlg-signature" className="rounded-lg border max-w-4xl w-[92vw]">
<div className="flex items-center justify-between px-4 py-3 border-b">
<strong>{modalTitle}</strong>
<dialog id="dlg-signature" className="rounded-lg border max-w-4xl w-[92vw] max-h-[90vh] overflow-hidden">
<div className="sticky top-0 z-10 flex items-center justify-between px-4 py-3 border-b bg-white">
<strong className="text-slate-900">{modalTitle}</strong>
<button
onClick={() => {
const dlg = document.getElementById('dlg-signature') as HTMLDialogElement | null;
@ -419,7 +431,7 @@ export default function SignaturesElectroniquesPage() {
<XCircle className="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div className="p-0" style={{ height: '70vh', minHeight: 480 }}>
<div className="overflow-auto" style={{ height: 'calc(90vh - 60px)', minHeight: 480 }}>
{embedSrc ? (
<div dangerouslySetInnerHTML={{
__html: `<docuseal-form

View file

@ -68,8 +68,8 @@ type ClientData = {
function Line({ label, value }: { label: string; value?: string | number | null }) {
return (
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 dark:border-slate-800 py-2">
<div className="text-slate-500 dark:text-slate-400">{label}</div>
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 py-2">
<div className="text-slate-500">{label}</div>
<div className="col-span-2">{value ?? "—"}</div>
</div>
);
@ -77,8 +77,8 @@ function Line({ label, value }: { label: string; value?: string | number | null
function LogoLine({ label, value }: { label: string; value?: string | null }) {
return (
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 dark:border-slate-800 py-2">
<div className="text-slate-500 dark:text-slate-400">{label}</div>
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 py-2">
<div className="text-slate-500">{label}</div>
<div className="col-span-2">
{value ? (
<img
@ -108,14 +108,14 @@ function EditableLine({
options?: { value: string; label: string }[];
}) {
return (
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 dark:border-slate-800 py-2">
<div className="text-slate-500 dark:text-slate-400">{label}</div>
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 py-2">
<div className="text-slate-500">{label}</div>
<div className="col-span-2">
{type === "select" && options ? (
<select
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="w-full px-2 py-1 text-sm border rounded dark:border-slate-700 dark:bg-slate-800"
className="w-full px-2 py-1 text-sm border rounded"
>
<option value=""></option>
{options.map((opt) => (
@ -129,7 +129,7 @@ function EditableLine({
type={type}
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="w-full px-2 py-1 text-sm border rounded dark:border-slate-700 dark:bg-slate-800"
className="w-full px-2 py-1 text-sm border rounded"
/>
)}
</div>
@ -194,8 +194,8 @@ function ImageUpload({
};
return (
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 dark:border-slate-800 py-2">
<div className="text-slate-500 dark:text-slate-400">{label}</div>
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 py-2">
<div className="text-slate-500">{label}</div>
<div className="col-span-2 space-y-2">
{preview && (
<div className="relative inline-block">
@ -217,7 +217,7 @@ function ImageUpload({
type="file"
accept="image/*"
onChange={handleFileChange}
className="w-full px-2 py-1 text-sm border rounded dark:border-slate-700 dark:bg-slate-800 file:mr-2 file:py-1 file:px-2 file:border-0 file:text-sm file:bg-slate-100 file:text-slate-700 file:rounded"
className="w-full px-2 py-1 text-sm border rounded file:mr-2 file:py-1 file:px-2 file:border-0 file:text-sm file:bg-slate-100 file:text-slate-700 file:rounded"
/>
<div className="text-xs text-slate-500">
Formats acceptés: JPG, PNG, GIF (max 5MB)
@ -412,7 +412,7 @@ export default function ClientDetailPage() {
<>
<button
onClick={handleCancel}
className="px-3 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800"
className="px-3 py-2 text-sm border rounded-lg hover:bg-slate-50"
disabled={updateMutation.isPending}
>
Annuler
@ -445,8 +445,8 @@ export default function ClientDetailPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-start">
<div className="space-y-4">
{/* Informations principales + Votre structure */}
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800">
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b">
<h2 className="font-medium">Informations principales</h2>
</div>
<div className="p-4 text-sm">
@ -597,8 +597,8 @@ export default function ClientDetailPage() {
</section>
{/* Abonnement */}
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800">
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b">
<h2 className="font-medium">Abonnement</h2>
</div>
<div className="p-4 text-sm">
@ -649,8 +649,8 @@ export default function ClientDetailPage() {
<div className="space-y-4">
{/* Informations de contact */}
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800">
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b">
<h2 className="font-medium">Informations de contact</h2>
</div>
<div className="p-4 text-sm">
@ -731,8 +731,8 @@ export default function ClientDetailPage() {
</section>
{/* Caisses & organismes */}
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800">
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b">
<h2 className="font-medium">Caisses & organismes</h2>
</div>
<div className="p-4 text-sm">

View file

@ -36,29 +36,29 @@ export default async function CreateClientPage() {
<Link href="/staff/utilisateurs" className="text-sm underline"> Retour</Link>
</div>
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4">
<div className="rounded-2xl border bg-white p-4">
<form action="/api/staff/organizations/create" method="post" className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Nom de la structure *</label>
<input name="name" required className="w-full px-3 py-2 rounded-lg border dark:border-slate-800 bg-transparent" placeholder="Ex : Compagnie Les Étoiles" />
<input name="name" required className="w-full px-3 py-2 rounded-lg border bg-transparent" placeholder="Ex : Compagnie Les Étoiles" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Identifiant Structure API</label>
<input name="structure_api" className="w-full px-3 py-2 rounded-lg border dark:border-slate-800 bg-transparent" placeholder="Ex : CIE-ETOILES" />
<input name="structure_api" className="w-full px-3 py-2 rounded-lg border bg-transparent" placeholder="Ex : CIE-ETOILES" />
</div>
<div>
<label className="block text-sm font-medium mb-1">SIRET (optionnel)</label>
<input name="siret" className="w-full px-3 py-2 rounded-lg border dark:border-slate-800 bg-transparent" placeholder="Ex : 123 456 789 00012" />
<input name="siret" className="w-full px-3 py-2 rounded-lg border bg-transparent" placeholder="Ex : 123 456 789 00012" />
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Email de contact (optionnel)</label>
<input type="email" name="contact_email" className="w-full px-3 py-2 rounded-lg border dark:border-slate-800 bg-transparent" placeholder="contact@exemple.fr" />
<input type="email" name="contact_email" className="w-full px-3 py-2 rounded-lg border bg-transparent" placeholder="contact@exemple.fr" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Notes (interne)</label>
<textarea name="notes" rows={3} className="w-full px-3 py-2 rounded-lg border dark:border-slate-800 bg-transparent" placeholder="Informations internes…" />
<textarea name="notes" rows={3} className="w-full px-3 py-2 rounded-lg border bg-transparent" placeholder="Informations internes…" />
</div>
<button type="submit" className="px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm hover:bg-emerald-700">Créer lorganisation</button>
</form>

View file

@ -65,7 +65,7 @@ export default async function StaffClientsPage({ searchParams }: { searchParams?
name="q"
defaultValue={q}
placeholder="Rechercher…"
className="px-3 py-2 rounded-lg border dark:border-slate-800 bg-transparent text-sm"
className="px-3 py-2 rounded-lg border bg-transparent text-sm"
/>
</form>
<Link href="/staff/clients/nouveau" className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm bg-emerald-600 text-white hover:bg-emerald-700">
@ -74,9 +74,9 @@ export default async function StaffClientsPage({ searchParams }: { searchParams?
</div>
</div>
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 overflow-hidden">
<div className="rounded-2xl border bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800/40 text-slate-600 dark:text-slate-300">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-4 py-2 font-medium">Nom</th>
<th className="text-left px-4 py-2 font-medium">Structure API</th>
@ -87,7 +87,7 @@ export default async function StaffClientsPage({ searchParams }: { searchParams?
<tbody>
{orgs?.length ? (
orgs.map((o) => (
<tr key={o.id} className="border-t dark:border-slate-800">
<tr key={o.id} className="border-t">
<td className="px-4 py-2">{o.name}</td>
<td className="px-4 py-2">{o.structure_api || "—"}</td>
<td className="px-4 py-2">{formatDate(o.created_at)}</td>

View file

@ -35,7 +35,7 @@ export default async function StaffContractsPage() {
const { data: contracts, error } = await sb
.from("cddu_contracts")
.select(
`id, contract_number, employee_name, structure, type_de_contrat, start_date, end_date, created_at, etat_de_la_demande, etat_de_la_paie`
`id, contract_number, employee_name, structure, type_de_contrat, start_date, end_date, created_at, etat_de_la_demande, etat_de_la_paie, dpae, gross_pay`
)
.eq("type_de_contrat", "CDD d'usage")
.order("start_date", { ascending: false })
@ -73,7 +73,7 @@ export default async function StaffContractsPage() {
<h1 className="text-lg font-semibold">Contrats (Staff)</h1>
</div>
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4">
<div className="rounded-2xl border bg-white p-4">
{(!contracts || contracts.length === 0) && (
<div className="mb-4 p-3 rounded bg-yellow-50 text-sm text-slate-800 border">
<div><strong>Debug:</strong> Aucun contrat trouvé côté serveur (initialData vide).</div>

View file

@ -0,0 +1,438 @@
// app/(app)/staff/email-logs/EmailLogsClient.tsx
'use client';
import React, { useState, useEffect } from 'react';
import { PageTitle } from '@/components/PageTitle';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { RefreshCw, Search, Download, Mail, Clock, CheckCircle, XCircle, Zap, Eye } from 'lucide-react';
import EmailLogDetailModal from '@/components/staff/EmailLogDetailModal';
interface EmailLog {
id: string;
sender_email: string;
recipient_email: string;
cc_emails: string[] | null;
subject: string;
email_type: string;
template_name: string | null;
email_status: 'sending' | 'sent' | 'failed' | 'bounced' | 'complained';
ses_message_id: string | null;
failure_reason: string | null;
sent_at: string | null;
created_at: string;
organization_id: string | null;
contract_id: string | null;
tags: Record<string, any> | null;
context: Record<string, any> | null;
}
interface EmailStats {
total: number;
sent: number;
failed: number;
sending: number;
successRate: number;
}
interface EmailLogsClientProps {
initialLogs: EmailLog[];
initialCount: number;
initialStats: EmailStats;
}
export default function EmailLogsClient({ initialLogs, initialCount, initialStats }: EmailLogsClientProps) {
const [logs, setLogs] = useState<EmailLog[]>(initialLogs);
const [stats, setStats] = useState<EmailStats>(initialStats);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(Math.ceil(initialCount / 20));
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const itemsPerPage = 20;
// Charger les données au montage
useEffect(() => {
// Charger les données seulement si on a des filtres ou une pagination différente
if (searchTerm || statusFilter !== 'all' || typeFilter !== 'all' || currentPage !== 1) {
loadEmailLogs();
}
}, [searchTerm, statusFilter, typeFilter, currentPage]);
const loadEmailLogs = async () => {
try {
setLoading(true);
const params = new URLSearchParams({
page: currentPage.toString(),
limit: itemsPerPage.toString(),
...(searchTerm && { search: searchTerm }),
...(statusFilter !== 'all' && { status: statusFilter }),
...(typeFilter !== 'all' && { type: typeFilter })
});
const response = await fetch(`/api/staff/email-logs?${params}`);
const data = await response.json();
if (!response.ok) {
console.error('Erreur lors du chargement des logs:', data.error);
return;
}
setLogs(data.logs || []);
setTotalPages(Math.ceil((data.count || 0) / itemsPerPage));
} catch (error) {
console.error('Erreur lors du chargement des logs:', error);
} finally {
setLoading(false);
}
};
const refreshData = async () => {
await Promise.all([loadEmailLogs()]);
};
const openLogDetail = (log: EmailLog) => {
setSelectedLog(log);
setIsModalOpen(true);
};
const closeLogDetail = () => {
setSelectedLog(null);
setIsModalOpen(false);
};
// Recharger les logs quand les filtres changent
useEffect(() => {
if (logs.length > 0 || !loading) { // Éviter le double chargement initial
setCurrentPage(1); // Reset à la première page
loadEmailLogs();
}
}, [searchTerm, statusFilter, typeFilter]);
// Recharger les logs quand la page change
useEffect(() => {
if (logs.length > 0 || !loading) { // Éviter le double chargement initial
loadEmailLogs();
}
}, [currentPage]);
const exportLogs = async () => {
try {
const params = new URLSearchParams({
page: '1',
limit: '10000', // Grande limite pour l'export
...(searchTerm && { search: searchTerm }),
...(statusFilter !== 'all' && { status: statusFilter }),
...(typeFilter !== 'all' && { type: typeFilter })
});
const response = await fetch(`/api/staff/email-logs?${params}`);
const data = await response.json();
if (!response.ok) {
console.error('Erreur lors de l\'export des logs:', data.error);
return;
}
const allLogs = data.logs || [];
// Créer le CSV
const csvHeader = [
'Date création', 'Date envoi', 'Expéditeur', 'Destinataire', 'CC',
'Sujet', 'Type', 'Template', 'Statut', 'ID SES', 'Erreur',
'Organisation', 'Contrat'
].join(',');
const csvRows = allLogs.map((log: EmailLog) => [
new Date(log.created_at).toLocaleString('fr-FR'),
log.sent_at ? new Date(log.sent_at).toLocaleString('fr-FR') : '',
log.sender_email,
log.recipient_email,
log.cc_emails?.join('; ') || '',
`"${log.subject.replace(/"/g, '""')}"`, // Échapper les guillemets
log.email_type,
log.template_name || '',
log.email_status,
log.ses_message_id || '',
log.failure_reason ? `"${log.failure_reason.replace(/"/g, '""')}"` : '',
log.organization_id || '',
log.contract_id || ''
].join(','));
const csvContent = [csvHeader, ...csvRows].join('\n');
// Télécharger le fichier
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `email-logs-${new Date().toISOString().split('T')[0]}.csv`;
link.click();
} catch (error) {
console.error('Erreur lors de l\'export:', error);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'sent': return <CheckCircle className="h-4 w-4 text-green-500" />;
case 'failed': return <XCircle className="h-4 w-4 text-red-500" />;
case 'sending': return <Zap className="h-4 w-4 text-yellow-500" />;
default: return <Clock className="h-4 w-4 text-gray-500" />;
}
};
const getStatusBadge = (status: string) => {
const statusStyles: Record<string, string> = {
sent: 'bg-green-100 text-green-800 border-green-200',
failed: 'bg-red-100 text-red-800 border-red-200',
sending: 'bg-yellow-100 text-yellow-800 border-yellow-200',
bounced: 'bg-red-100 text-red-800 border-red-200',
complained: 'bg-gray-100 text-gray-800 border-gray-200'
};
return (
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-xs font-semibold rounded-full border ${statusStyles[status] || 'bg-gray-100 text-gray-800 border-gray-200'}`}>
{getStatusIcon(status)}
{status}
</span>
);
};
return (
<div className="space-y-6">
<PageTitle
title="Logs des emails"
/>
{/* Statistiques */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total</CardTitle>
<Mail className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total.toLocaleString()}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Envoyés</CardTitle>
<CheckCircle className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{stats.sent.toLocaleString()}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Échecs</CardTitle>
<XCircle className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{stats.failed.toLocaleString()}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Taux de succès</CardTitle>
<Zap className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">{stats.successRate.toFixed(1)}%</div>
</CardContent>
</Card>
</div>
{/* Filtres et actions */}
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="flex flex-col sm:flex-row gap-2 flex-1">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Rechercher par email, sujet..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-10 w-40 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="all">Tous les statuts</option>
<option value="sending">En cours</option>
<option value="sent">Envoyés</option>
<option value="failed">Échecs</option>
<option value="bounced">Rebonds</option>
<option value="complained">Plaintes</option>
</select>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="h-10 w-48 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="all">Tous les types</option>
<option value="bulk_communication">Communications groupées</option>
<option value="contract_signature">Signature contrat</option>
<option value="password_reset">Réinitialisation mot de passe</option>
<option value="account_activation">Activation compte</option>
<option value="invoice_generated">Facture générée</option>
<option value="system_notification">Notification système</option>
</select>
</div>
<div className="flex gap-2">
<Button onClick={refreshData} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Actualiser
</Button>
<Button onClick={exportLogs} variant="outline" size="sm">
<Download className="h-4 w-4 mr-2" />
Exporter CSV
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin" />
<span className="ml-2">Chargement...</span>
</div>
) : logs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
Aucun log trouvé pour les critères sélectionnés
</div>
) : (
<div className="space-y-4">
{/* Liste des logs */}
<div className="space-y-2">
{logs.map((log) => (
<div
key={log.id}
className="group border rounded-lg p-4 hover:bg-muted/50 cursor-pointer transition-all hover:shadow-md"
onClick={() => openLogDetail(log)}
>
<div className="flex flex-col sm:flex-row gap-4 items-start">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
{getStatusBadge(log.email_status)}
<span className="text-sm font-medium">{log.email_type}</span>
{log.template_name && (
<span className="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full border bg-gray-100 text-gray-800 border-gray-200">
{log.template_name}
</span>
)}
</div>
<div className="text-sm">
<span className="font-medium">À:</span> {log.recipient_email}
{log.cc_emails && log.cc_emails.length > 0 && (
<>
<span className="ml-2 font-medium">CC:</span> {log.cc_emails.join(', ')}
</>
)}
</div>
<div className="text-sm text-muted-foreground">
<span className="font-medium">Sujet:</span> {log.subject}
</div>
{log.failure_reason && (
<div className="text-sm text-red-600">
<span className="font-medium">Erreur:</span> {log.failure_reason}
</div>
)}
</div>
<div className="text-right text-sm text-muted-foreground space-y-1">
<div>
<span className="font-medium">Créé:</span>{' '}
{new Date(log.created_at).toLocaleString('fr-FR')}
</div>
{log.sent_at && (
<div>
<span className="font-medium">Envoyé:</span>{' '}
{new Date(log.sent_at).toLocaleString('fr-FR')}
</div>
)}
{log.ses_message_id && (
<div className="text-xs">
<span className="font-medium">SES ID:</span> {log.ses_message_id.substring(0, 12)}...
</div>
)}
<div className="pt-2">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
openLogDetail(log);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
<Eye className="h-4 w-4 mr-1" />
Voir détails
</Button>
</div>
</div>
</div>
</div>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Page {currentPage} sur {totalPages}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
Précédent
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
Suivant
</Button>
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Modal de détails */}
<EmailLogDetailModal
log={selectedLog}
isOpen={isModalOpen}
onClose={closeLogDetail}
/>
</div>
);
}

View file

@ -0,0 +1,102 @@
// app/(app)/staff/email-logs/page.tsx
import { createSbServer, createSbServiceRole } from "@/lib/supabaseServer";
import { redirect } from "next/navigation";
import { Metadata } from "next";
import EmailLogsClient from "./EmailLogsClient";
export const metadata: Metadata = {
title: "Logs des emails | Staff | Espace Paie Odentas",
};
export const dynamic = "force-dynamic";
async function checkStaffPermissions() {
const sb = createSbServer();
try {
const { data: { user }, error: authError } = await sb.auth.getUser();
if (authError || !user) {
redirect('/signin');
}
const { data: staffMember, error: staffError } = await sb
.from('staff_users')
.select('is_staff')
.eq('user_id', user.id)
.maybeSingle();
if (staffError || !staffMember?.is_staff) {
redirect('/');
}
return user;
} catch (error) {
console.error('Erreur lors de la vérification des permissions:', error);
redirect('/');
}
}
async function getEmailLogs() {
// Utiliser le service role pour bypasser RLS
const sb = createSbServiceRole();
try {
// Récupérer les logs récents (première page)
const { data: logs, error: logsError, count } = await sb
.from('email_logs')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false })
.range(0, 19); // 20 premiers éléments
if (logsError) {
console.error('Erreur lors de la récupération des logs:', logsError);
return {
logs: [],
count: 0,
stats: { total: 0, sent: 0, failed: 0, sending: 0, successRate: 0 }
};
}
// Récupérer les statistiques
const { data: stats, error: statsError } = await sb
.from('email_logs')
.select('email_status')
.gte('created_at', new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString());
let emailStats = {
total: count || 0,
sent: 0,
failed: 0,
sending: 0,
successRate: 0
};
if (stats && !statsError) {
emailStats.sent = stats.filter(s => s.email_status === 'sent').length;
emailStats.failed = stats.filter(s => ['failed', 'bounced', 'complained'].includes(s.email_status)).length;
emailStats.sending = stats.filter(s => s.email_status === 'sending').length;
emailStats.successRate = emailStats.total > 0 ? (emailStats.sent / emailStats.total) * 100 : 0;
}
return {
logs: logs || [],
count: count || 0,
stats: emailStats
};
} catch (error) {
console.error('Erreur lors de la récupération des données:', error);
return { logs: [], count: 0, stats: { total: 0, sent: 0, failed: 0, sending: 0, successRate: 0 } };
}
}
export default async function EmailLogsPage() {
// Vérifier les permissions côté serveur
await checkStaffPermissions();
// Récupérer les données côté serveur avec les bonnes permissions
const { logs, count, stats } = await getEmailLogs();
// Rendre le composant client avec les données pré-chargées
return <EmailLogsClient initialLogs={logs} initialCount={count} initialStats={stats} />;
}

View file

@ -0,0 +1,126 @@
// app/(app)/staff/emails-groupes/page.tsx
import { createSbServer } from "@/lib/supabaseServer";
import { createClient } from "@supabase/supabase-js";
import { Metadata } from "next";
import BulkEmailForm from "@/components/staff/BulkEmailForm";
export const metadata: Metadata = {
title: "Envoi d'emails groupés | Staff | Espace Paie Odentas",
};
export const dynamic = "force-dynamic";
async function fetchAllUsers() {
try {
const sb = createSbServer();
// Utiliser le service role pour récupérer tous les utilisateurs
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { autoRefreshToken: false, persistSession: false } }
);
// Récupérer tous les membres avec leurs organisations
const { data: members, error: membersError } = await sb
.from("organization_members")
.select(`
user_id,
role,
revoked,
created_at,
org_id,
organizations(id, name, structure_api)
`)
.eq("revoked", false)
.order("created_at", { ascending: false });
if (membersError) {
console.error('Erreur récupération members:', membersError);
return [];
}
if (!members || members.length === 0) {
return [];
}
// Récupérer les informations utilisateur pour chaque membre
const userIds = [...new Set(members.map((m: any) => m.user_id).filter(Boolean))];
const usersData: any[] = [];
for (const userId of userIds) {
try {
const { data: userData, error: userError } = await admin.auth.admin.getUserById(userId);
if (!userError && userData?.user) {
const userMembers = members.filter(m => m.user_id === userId);
usersData.push({
id: userData.user.id,
email: userData.user.email,
emailConfirmed: userData.user.email_confirmed_at !== null,
createdAt: userData.user.created_at,
lastSignIn: userData.user.last_sign_in_at,
organizations: userMembers.map(m => ({
id: m.org_id,
name: (m.organizations as any)?.name || 'N/A',
role: m.role,
structure_api: (m.organizations as any)?.structure_api
}))
});
}
} catch (error) {
console.error(`Erreur récupération utilisateur ${userId}:`, error);
}
}
return usersData;
} catch (error) {
console.error('Erreur générale:', error);
return [];
}
}
export default async function BulkEmailPage() {
const sb = createSbServer();
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return (
<main className="p-6">
<h1 className="text-lg font-semibold">Accès refusé</h1>
<p className="text-sm text-slate-600">Vous devez être connecté.</p>
</main>
);
}
const { data: me } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!me?.is_staff) {
return (
<main className="p-6">
<h1 className="text-lg font-semibold">Accès refusé</h1>
<p className="text-sm text-slate-600">Cette page est réservée au Staff.</p>
</main>
);
}
const users = await fetchAllUsers();
return (
<div className="container mx-auto px-4 py-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 mb-2">
Envoi d'emails groupés
</h1>
<p className="text-gray-600">
Composez et envoyez des emails à plusieurs utilisateurs de l'Espace Paie
</p>
</div>
<BulkEmailForm users={users} />
</div>
);
}

View file

@ -65,19 +65,19 @@ function calculateDueDate(emissionDate?: string): string {
function Badge({ tone = "default", children }: { tone?: "ok" | "warn" | "error" | "default"; children: React.ReactNode }) {
const cls =
tone === "ok"
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200"
? "bg-emerald-100 text-emerald-800"
: tone === "warn"
? "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200"
? "bg-amber-100 text-amber-800"
: tone === "error"
? "bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-200"
: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300";
? "bg-rose-100 text-rose-800"
: "bg-slate-100 text-slate-700";
return <span className={`text-xs px-2 py-1 rounded-full ${cls}`}>{children}</span>;
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800 font-medium text-slate-700 dark:text-slate-200 bg-slate-50/60 dark:bg-slate-800/40">
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
{title}
</div>
<div className="p-4">{children}</div>
@ -397,7 +397,7 @@ export default function StaffFacturationDetailPage() {
Retour aux factures
</Link>
<div className="text-slate-400">/</div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">
<h1 className="text-xl font-bold text-slate-900">
{invoice.numero || `Facture ${invoice.id.slice(0, 8)}`}
</h1>
</div>
@ -488,7 +488,7 @@ export default function StaffFacturationDetailPage() {
type="text"
value={editForm.numero}
onChange={(e) => setEditForm(prev => ({ ...prev, numero: e.target.value }))}
className="px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="px-3 py-2 border rounded-lg bg-white"
placeholder="Numéro de facture"
/>
) : (
@ -508,7 +508,7 @@ export default function StaffFacturationDetailPage() {
type="text"
value={editForm.periode}
onChange={(e) => setEditForm(prev => ({ ...prev, periode: e.target.value }))}
className="px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="px-3 py-2 border rounded-lg bg-white"
placeholder="ex: Août 2025"
/>
) : (
@ -523,7 +523,7 @@ export default function StaffFacturationDetailPage() {
type="date"
value={editForm.date}
onChange={(e) => setEditForm(prev => ({ ...prev, date: e.target.value }))}
className="px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="px-3 py-2 border rounded-lg bg-white"
/>
) : (
<div>{fmtDateFR(invoice.date || undefined)}</div>
@ -536,7 +536,7 @@ export default function StaffFacturationDetailPage() {
<select
value={editForm.payment_method}
onChange={(e) => setEditForm(prev => ({ ...prev, payment_method: e.target.value }))}
className="px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="px-3 py-2 border rounded-lg bg-white"
>
<option value="sepa">Prélèvement SEPA</option>
<option value="cb">CB</option>
@ -559,7 +559,7 @@ export default function StaffFacturationDetailPage() {
type="date"
value={editForm.due_date}
onChange={(e) => setEditForm(prev => ({ ...prev, due_date: e.target.value }))}
className="px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="px-3 py-2 border rounded-lg bg-white"
/>
) : (
<div>{fmtDateFR(invoice.due_date || undefined)}</div>
@ -573,7 +573,7 @@ export default function StaffFacturationDetailPage() {
type="date"
value={editForm.sepa_day}
onChange={(e) => setEditForm(prev => ({ ...prev, sepa_day: e.target.value }))}
className="px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="px-3 py-2 border rounded-lg bg-white"
/>
) : (
<div>{fmtDateFR(invoice.sepa_day || undefined)}</div>
@ -586,7 +586,7 @@ export default function StaffFacturationDetailPage() {
<select
value={editForm.invoice_type}
onChange={(e) => setEditForm(prev => ({ ...prev, invoice_type: e.target.value }))}
className="px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="px-3 py-2 border rounded-lg bg-white"
>
<option value="paie_mensuelle">Paie - mensuelle</option>
<option value="paie_ouverture">Paie - ouverture</option>
@ -614,7 +614,7 @@ export default function StaffFacturationDetailPage() {
type="text"
value={editForm.site_name}
onChange={(e) => setEditForm(prev => ({ ...prev, site_name: e.target.value }))}
className="px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="px-3 py-2 border rounded-lg bg-white"
placeholder="Nom du site web"
/>
) : (
@ -631,7 +631,7 @@ export default function StaffFacturationDetailPage() {
<select
value={editForm.statut}
onChange={(e) => setEditForm(prev => ({ ...prev, statut: e.target.value }))}
className="px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="px-3 py-2 border rounded-lg bg-white"
>
<option value="emise">Émise</option>
<option value="en_cours">En cours</option>
@ -660,7 +660,7 @@ export default function StaffFacturationDetailPage() {
<select
value={editForm.notified ? "true" : "false"}
onChange={(e) => setEditForm(prev => ({ ...prev, notified: e.target.value === "true" }))}
className="px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="px-3 py-2 border rounded-lg bg-white"
>
<option value="false">Non</option>
<option value="true">Oui</option>
@ -683,7 +683,7 @@ export default function StaffFacturationDetailPage() {
step="0.01"
value={editForm.montant_ht}
onChange={(e) => setEditForm(prev => ({ ...prev, montant_ht: e.target.value }))}
className="px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="px-3 py-2 border rounded-lg bg-white"
/>
) : (
<div className="text-lg font-semibold">{fmtEUR.format(invoice.montant_ht || 0)}</div>
@ -698,7 +698,7 @@ export default function StaffFacturationDetailPage() {
step="0.01"
value={editForm.montant_ttc}
onChange={(e) => setEditForm(prev => ({ ...prev, montant_ttc: e.target.value }))}
className="px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="px-3 py-2 border rounded-lg bg-white"
/>
) : (
<div className="text-lg font-semibold text-blue-600">{fmtEUR.format(invoice.montant_ttc || 0)}</div>
@ -712,7 +712,7 @@ export default function StaffFacturationDetailPage() {
type="date"
value={editForm.payment_date}
onChange={(e) => setEditForm(prev => ({ ...prev, payment_date: e.target.value }))}
className="px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="px-3 py-2 border rounded-lg bg-white"
/>
) : (
<div>{fmtDateFR(invoice.payment_date || undefined)}</div>
@ -790,11 +790,11 @@ export default function StaffFacturationDetailPage() {
<textarea
value={editForm.notes}
onChange={(e) => setEditForm(prev => ({ ...prev, notes: e.target.value }))}
className="w-full px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 min-h-[100px]"
className="w-full px-3 py-2 border rounded-lg bg-white min-h-[100px]"
placeholder="Notes sur la facture..."
/>
) : (
<div className="text-slate-700 dark:text-slate-300 whitespace-pre-wrap">
<div className="text-slate-700 whitespace-pre-wrap">
{invoice.notes || "Aucune note."}
</div>
)}
@ -835,32 +835,32 @@ export default function StaffFacturationDetailPage() {
</DialogHeader>
<div className="space-y-4">
<div className="text-sm text-slate-600 dark:text-slate-400">
<div className="text-sm text-slate-600">
<p className="mb-3">
Êtes-vous sûr de vouloir supprimer cette facture ?
</p>
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-3 space-y-2">
<div className="font-medium text-slate-900 dark:text-slate-100">
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="font-medium text-slate-900">
Facture : {invoice?.numero || `#${invoice?.id.slice(0, 8)}`}
</div>
<div className="text-slate-600 dark:text-slate-400">
<div className="text-slate-600">
Client : {invoice?.organization_name || "—"}
</div>
<div className="text-slate-600 dark:text-slate-400">
<div className="text-slate-600">
Montant : {fmtEUR.format(invoice?.montant_ttc || 0)}
</div>
</div>
<div className="mt-4 p-3 bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800 rounded-lg">
<div className="text-rose-800 dark:text-rose-200 text-sm font-medium mb-2">
<div className="mt-4 p-3 bg-rose-50 border border-rose-200 rounded-lg">
<div className="text-rose-800 text-sm font-medium mb-2">
Cette action supprimera définitivement :
</div>
<ul className="text-rose-700 dark:text-rose-300 text-sm space-y-1 list-disc list-inside">
<ul className="text-rose-700 text-sm space-y-1 list-disc list-inside">
<li>La facture de la base de données</li>
<li>Le fichier PDF associé (si présent)</li>
</ul>
<div className="mt-2 text-rose-800 dark:text-rose-200 text-sm font-medium">
<div className="mt-2 text-rose-800 text-sm font-medium">
Cette action est irréversible.
</div>
</div>
@ -907,33 +907,33 @@ export default function StaffFacturationDetailPage() {
</DialogHeader>
<div className="space-y-4">
<div className="text-sm text-slate-600 dark:text-slate-400">
<div className="text-sm text-slate-600">
<p className="mb-3">
Êtes-vous sûr de vouloir lancer cette facture ?
</p>
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-3 space-y-2">
<div className="font-medium text-slate-900 dark:text-slate-100">
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="font-medium text-slate-900">
Facture : {invoice?.numero || `#${invoice?.id.slice(0, 8)}`}
</div>
<div className="text-slate-600 dark:text-slate-400">
<div className="text-slate-600">
Client : {invoice?.organization_name || "—"}
</div>
<div className="text-slate-600 dark:text-slate-400">
<div className="text-slate-600">
Montant : {fmtEUR.format(invoice?.montant_ttc || 0)}
</div>
{invoice?.sepa_day && (
<div className="text-slate-600 dark:text-slate-400">
<div className="text-slate-600">
Prélèvement SEPA : {fmtDateFR(invoice.sepa_day)}
</div>
)}
</div>
<div className="mt-4 p-3 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-lg">
<div className="text-emerald-800 dark:text-emerald-200 text-sm font-medium mb-2">
<div className="mt-4 p-3 bg-emerald-50 border border-emerald-200 rounded-lg">
<div className="text-emerald-800 text-sm font-medium mb-2">
Cette action va :
</div>
<ul className="text-emerald-700 dark:text-emerald-300 text-sm space-y-1 list-disc list-inside">
<ul className="text-emerald-700 text-sm space-y-1 list-disc list-inside">
<li>Créer le prélèvement dans GoCardless</li>
<li>Envoyer une notification par email au client</li>
<li>Marquer la facture comme notifiée</li>
@ -982,25 +982,25 @@ export default function StaffFacturationDetailPage() {
</DialogHeader>
<div className="space-y-4">
<div className="text-sm text-slate-600 dark:text-slate-400">
<div className="text-sm text-slate-600">
<p className="mb-3">
Êtes-vous sûr de vouloir envoyer une notification pour cette facture ?
</p>
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-3 space-y-2">
<div className="font-medium text-slate-900 dark:text-slate-100">
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="font-medium text-slate-900">
Client : {invoice.organization_name}
</div>
<div className="text-slate-700 dark:text-slate-300">
<div className="text-slate-700">
Montant : {invoice.montant_ttc?.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}
</div>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3 mt-3">
<div className="text-blue-800 dark:text-blue-200 text-sm font-medium mb-2">
<div className="bg-blue-50 rounded-lg p-3 mt-3">
<div className="text-blue-800 text-sm font-medium mb-2">
📧 Cette action va :
</div>
<ul className="text-blue-700 dark:text-blue-300 text-sm space-y-1 list-disc list-inside">
<ul className="text-blue-700 text-sm space-y-1 list-disc list-inside">
<li>Envoyer une notification par email au client</li>
<li>Marquer la facture comme notifiée</li>
<li>Ne PAS créer de prélèvement GoCardless</li>
@ -1049,25 +1049,25 @@ export default function StaffFacturationDetailPage() {
</DialogHeader>
<div className="space-y-4">
<div className="text-sm text-slate-600 dark:text-slate-400">
<div className="text-sm text-slate-600">
<p className="mb-3">
Êtes-vous sûr de vouloir créer un paiement GoCardless pour cette facture ?
</p>
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-3 space-y-2">
<div className="font-medium text-slate-900 dark:text-slate-100">
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="font-medium text-slate-900">
Client : {invoice.organization_name}
</div>
<div className="text-slate-700 dark:text-slate-300">
<div className="text-slate-700">
Montant : {invoice.montant_ttc?.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}
</div>
</div>
<div className="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-3 mt-3">
<div className="text-orange-800 dark:text-orange-200 text-sm font-medium mb-2">
<div className="bg-orange-50 rounded-lg p-3 mt-3">
<div className="text-orange-800 text-sm font-medium mb-2">
💳 Cette action va :
</div>
<ul className="text-orange-700 dark:text-orange-300 text-sm space-y-1 list-disc list-inside">
<ul className="text-orange-700 text-sm space-y-1 list-disc list-inside">
<li>Créer le prélèvement dans GoCardless</li>
<li>Marquer la facture comme émise</li>
<li>Ne PAS envoyer de notification email</li>

View file

@ -26,8 +26,8 @@ type CreateInvoiceForm = {
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800 font-medium text-slate-700 dark:text-slate-200 bg-slate-50/60 dark:bg-slate-800/40">
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
{title}
</div>
<div className="p-4">{children}</div>
@ -154,7 +154,7 @@ export default function CreateInvoicePage() {
Retour aux factures
</Link>
<div className="text-slate-400">/</div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">
<h1 className="text-xl font-bold text-slate-900">
Créer une nouvelle facture
</h1>
</div>
@ -166,13 +166,13 @@ export default function CreateInvoicePage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
Organisation cliente *
</label>
<select
value={form.org_id}
onChange={(e) => updateForm("org_id", e.target.value)}
className="w-full px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="w-full px-3 py-2 border rounded-lg bg-white"
required
>
<option value="">Sélectionner une organisation</option>
@ -185,47 +185,47 @@ export default function CreateInvoicePage() {
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
Numéro de facture
</label>
<input
type="text"
value={form.numero}
onChange={(e) => updateForm("numero", e.target.value)}
className="w-full px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="w-full px-3 py-2 border rounded-lg bg-white"
placeholder="ex: FAC-2025-001"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
Période concernée
</label>
<input
type="text"
value={form.periode}
onChange={(e) => updateForm("periode", e.target.value)}
className="w-full px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="w-full px-3 py-2 border rounded-lg bg-white"
placeholder="ex: Janvier 2025"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
Date d'émission
</label>
<input
type="date"
value={form.date}
onChange={(e) => updateForm("date", e.target.value)}
className="w-full px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="w-full px-3 py-2 border rounded-lg bg-white"
/>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
Montant HT ()
</label>
<input
@ -233,13 +233,13 @@ export default function CreateInvoicePage() {
step="0.01"
value={form.montant_ht}
onChange={(e) => updateForm("montant_ht", e.target.value)}
className="w-full px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="w-full px-3 py-2 border rounded-lg bg-white"
placeholder="0.00"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
Montant TTC () *
</label>
<input
@ -247,20 +247,20 @@ export default function CreateInvoicePage() {
step="0.01"
value={form.montant_ttc}
onChange={(e) => updateForm("montant_ttc", e.target.value)}
className="w-full px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="w-full px-3 py-2 border rounded-lg bg-white"
placeholder="0.00"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
Statut
</label>
<select
value={form.statut}
onChange={(e) => updateForm("statut", e.target.value)}
className="w-full px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="w-full px-3 py-2 border rounded-lg bg-white"
>
<option value="emise">Émise</option>
<option value="en_cours">En cours</option>
@ -277,19 +277,19 @@ export default function CreateInvoicePage() {
<Section title="Notes et PDF">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
Notes sur la facture
</label>
<textarea
value={form.notes}
onChange={(e) => updateForm("notes", e.target.value)}
className="w-full px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 min-h-[100px]"
className="w-full px-3 py-2 border rounded-lg bg-white min-h-[100px]"
placeholder="Notes additionnelles..."
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
PDF de la facture
</label>
<div className="space-y-2">
@ -318,7 +318,7 @@ export default function CreateInvoicePage() {
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingPdf || !form.org_id || !form.numero}
className="inline-flex items-center gap-2 px-4 py-2 border border-dashed border-slate-300 dark:border-slate-600 rounded-lg hover:border-slate-400 transition-colors disabled:opacity-50"
className="inline-flex items-center gap-2 px-4 py-2 border border-dashed border-slate-300 rounded-lg hover:border-slate-400 transition-colors disabled:opacity-50"
>
<Upload className="w-4 h-4" />
{isUploadingPdf ? "Upload en cours..." : "Choisir un fichier PDF"}

View file

@ -7,6 +7,7 @@ import { api } from "@/lib/fetcher";
import { Loader2, CheckCircle2, XCircle, FileDown, Edit, Plus, Eye, ExternalLink, Filter, X, ChevronUp, ChevronDown, Calendar, CreditCard, Send } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { usePageTitle } from "@/hooks/usePageTitle";
// ---------------- Types ----------------
type Invoice = {
@ -42,19 +43,19 @@ function fmtDateFR(iso?: string) {
function Badge({ tone = "default", children }: { tone?: "ok" | "warn" | "error" | "default"; children: React.ReactNode }) {
const cls =
tone === "ok"
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200"
? "bg-emerald-100 text-emerald-800"
: tone === "warn"
? "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200"
? "bg-amber-100 text-amber-800"
: tone === "error"
? "bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-200"
: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300";
? "bg-rose-100 text-rose-800"
: "bg-slate-100 text-slate-700";
return <span className={`text-xs px-2 py-1 rounded-full ${cls}`}>{children}</span>;
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800 font-medium text-slate-700 dark:text-slate-200 bg-slate-50/60 dark:bg-slate-800/40">
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
{title}
</div>
<div className="p-4">{children}</div>
@ -73,6 +74,8 @@ function useStaffBilling(page: number, limit: number) {
// -------------- Page --------------
export default function StaffFacturationPage() {
usePageTitle("Facturation (Staff)");
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>("");
const [clientFilter, setClientFilter] = useState<string>("");
@ -428,8 +431,8 @@ export default function StaffFacturationPage() {
{/* En-tête avec bouton de création */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Facturation</h1>
<p className="text-slate-600 dark:text-slate-400">Gestion des factures de tous les clients</p>
<h1 className="text-2xl font-bold text-slate-900">Facturation</h1>
<p className="text-slate-600">Gestion des factures de tous les clients</p>
</div>
<Link
href="/staff/facturation/create"
@ -441,14 +444,14 @@ export default function StaffFacturationPage() {
</div>
{/* Filtres */}
<div className="bg-white dark:bg-slate-900 rounded-xl border dark:border-slate-800">
<div className="px-4 py-3 border-b dark:border-slate-800 flex items-center justify-between">
<h3 className="font-medium text-slate-700 dark:text-slate-200">Filtres</h3>
<div className="bg-white rounded-xl border">
<div className="px-4 py-3 border-b flex items-center justify-between">
<h3 className="font-medium text-slate-700">Filtres</h3>
<div className="flex items-center gap-2">
{hasActiveFilters && (
<button
onClick={clearFilters}
className="inline-flex items-center gap-1 text-xs px-2 py-1 text-slate-600 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
className="inline-flex items-center gap-1 text-xs px-2 py-1 text-slate-600 hover:text-slate-800"
>
<X className="w-3 h-3" />
Effacer
@ -456,7 +459,7 @@ export default function StaffFacturationPage() {
)}
<button
onClick={() => setShowFilters(!showFilters)}
className="inline-flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
className="inline-flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800"
>
<Filter className="w-4 h-4" />
{showFilters ? "Masquer" : "Afficher"} les filtres
@ -469,13 +472,13 @@ export default function StaffFacturationPage() {
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{/* Filtre par statut */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
Statut
</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-full px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-sm"
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
>
<option value="">Tous les statuts</option>
<option value="emise">Émise</option>
@ -488,13 +491,13 @@ export default function StaffFacturationPage() {
{/* Filtre par client */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
Client
</label>
<select
value={clientFilter}
onChange={(e) => setClientFilter(e.target.value)}
className="w-full px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-sm"
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
>
<option value="">Tous les clients</option>
{uniqueClients.map(client => (
@ -505,13 +508,13 @@ export default function StaffFacturationPage() {
{/* Filtre par période */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
Période
</label>
<select
value={periodFilter}
onChange={(e) => setPeriodFilter(e.target.value)}
className="w-full px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-sm"
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
>
<option value="">Toutes les périodes</option>
{uniquePeriods.map(period => (
@ -522,27 +525,27 @@ export default function StaffFacturationPage() {
{/* Filtre date de début */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
Date de début
</label>
<input
type="date"
value={dateFromFilter}
onChange={(e) => setDateFromFilter(e.target.value)}
className="w-full px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-sm"
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
/>
</div>
{/* Filtre date de fin */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
Date de fin
</label>
<input
type="date"
value={dateToFilter}
onChange={(e) => setDateToFilter(e.target.value)}
className="w-full px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-sm"
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
/>
</div>
</div>
@ -550,9 +553,9 @@ export default function StaffFacturationPage() {
{/* Indicateur de filtres actifs */}
{hasActiveFilters && (
<div className="mt-4 flex flex-wrap items-center gap-2">
<span className="text-sm text-slate-600 dark:text-slate-400">Filtres actifs :</span>
<span className="text-sm text-slate-600">Filtres actifs :</span>
{statusFilter && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200 rounded text-xs">
<span className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">
Statut: {statusFilter}
<button onClick={() => setStatusFilter("")} className="hover:text-blue-600">
<X className="w-3 h-3" />
@ -560,7 +563,7 @@ export default function StaffFacturationPage() {
</span>
)}
{clientFilter && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200 rounded text-xs">
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded text-xs">
Client: {clientFilter}
<button onClick={() => setClientFilter("")} className="hover:text-green-600">
<X className="w-3 h-3" />
@ -568,7 +571,7 @@ export default function StaffFacturationPage() {
</span>
)}
{periodFilter && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200 rounded text-xs">
<span className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded text-xs">
Période: {periodFilter}
<button onClick={() => setPeriodFilter("")} className="hover:text-purple-600">
<X className="w-3 h-3" />
@ -576,7 +579,7 @@ export default function StaffFacturationPage() {
</span>
)}
{dateFromFilter && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-200 rounded text-xs">
<span className="inline-flex items-center gap-1 px-2 py-1 bg-orange-100 text-orange-800 rounded text-xs">
Depuis: {new Date(dateFromFilter).toLocaleDateString("fr-FR")}
<button onClick={() => setDateFromFilter("")} className="hover:text-orange-600">
<X className="w-3 h-3" />
@ -584,7 +587,7 @@ export default function StaffFacturationPage() {
</span>
)}
{dateToFilter && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-200 rounded text-xs">
<span className="inline-flex items-center gap-1 px-2 py-1 bg-cyan-100 text-cyan-800 rounded text-xs">
Jusqu'au: {new Date(dateToFilter).toLocaleDateString("fr-FR")}
<button onClick={() => setDateToFilter("")} className="hover:text-cyan-600">
<X className="w-3 h-3" />
@ -600,21 +603,21 @@ export default function StaffFacturationPage() {
{/* Statistiques rapides */}
{data && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white dark:bg-slate-900 rounded-xl border dark:border-slate-800 p-4">
<div className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.total}</div>
<div className="text-sm text-slate-600 dark:text-slate-400">Total factures</div>
<div className="bg-white rounded-xl border p-4">
<div className="text-2xl font-bold text-slate-900">{stats.total}</div>
<div className="text-sm text-slate-600">Total factures</div>
</div>
<div className="bg-white dark:bg-slate-900 rounded-xl border dark:border-slate-800 p-4">
<div className="bg-white rounded-xl border p-4">
<div className="text-2xl font-bold text-blue-600">{stats.enCours}</div>
<div className="text-sm text-slate-600 dark:text-slate-400">En cours</div>
<div className="text-sm text-slate-600">En cours</div>
</div>
<div className="bg-white dark:bg-slate-900 rounded-xl border dark:border-slate-800 p-4">
<div className="bg-white rounded-xl border p-4">
<div className="text-2xl font-bold text-emerald-600">{stats.emises}</div>
<div className="text-sm text-slate-600 dark:text-slate-400">Émises</div>
<div className="text-sm text-slate-600">Émises</div>
</div>
<div className="bg-white dark:bg-slate-900 rounded-xl border dark:border-slate-800 p-4">
<div className="bg-white rounded-xl border p-4">
<div className="text-2xl font-bold text-rose-600">{stats.impayes}</div>
<div className="text-sm text-slate-600 dark:text-slate-400">Impayées</div>
<div className="text-sm text-slate-600">Impayées</div>
</div>
</div>
)}
@ -640,9 +643,9 @@ export default function StaffFacturationPage() {
<div className="overflow-x-auto">
{/* Bouton d'action en masse */}
{selectedInvoices.size > 0 && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center justify-between">
<div className="text-sm text-blue-800 dark:text-blue-200">
<div className="text-sm text-blue-800">
{selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""}
</div>
<div className="flex items-center gap-2">
@ -688,8 +691,8 @@ export default function StaffFacturationPage() {
)}
<table className="w-full text-sm">
<thead className="text-left bg-slate-50 dark:bg-slate-800/40">
<tr className="border-b dark:border-slate-800">
<thead className="text-left bg-slate-50">
<tr className="border-b">
<th className="px-3 py-2 w-12">
<input
type="checkbox"
@ -702,7 +705,7 @@ export default function StaffFacturationPage() {
<th className="px-3 py-2 w-32">
<button
onClick={() => handleSort("numero")}
className="flex items-center gap-1 hover:text-blue-600 dark:hover:text-blue-400"
className="flex items-center gap-1 hover:text-blue-600"
>
Numéro
{sortField === "numero" && (
@ -715,7 +718,7 @@ export default function StaffFacturationPage() {
<th className="px-3 py-2">
<button
onClick={() => handleSort("client")}
className="flex items-center gap-1 hover:text-blue-600 dark:hover:text-blue-400"
className="flex items-center gap-1 hover:text-blue-600"
>
Client
{sortField === "client" && (
@ -729,7 +732,7 @@ export default function StaffFacturationPage() {
<th className="px-3 py-2">
<button
onClick={() => handleSort("date")}
className="flex items-center gap-1 hover:text-blue-600 dark:hover:text-blue-400"
className="flex items-center gap-1 hover:text-blue-600"
>
Date
{sortField === "date" && (
@ -746,15 +749,15 @@ export default function StaffFacturationPage() {
</tr>
{/* Sous-header avec les totaux */}
{filteredAndSortedItems.length > 0 && (
<tr className="border-b dark:border-slate-800 bg-blue-50 dark:bg-blue-900/20">
<tr className="border-b bg-blue-50">
<td className="px-3 py-2"></td>
<td colSpan={5} className="px-3 py-2 text-sm font-medium text-blue-900 dark:text-blue-100">
<td colSpan={5} className="px-3 py-2 text-sm font-medium text-blue-900">
Total affiché ({filteredAndSortedItems.length} facture{filteredAndSortedItems.length > 1 ? "s" : ""})
</td>
<td className="px-3 py-2 text-right font-bold text-blue-900 dark:text-blue-100">
<td className="px-3 py-2 text-right font-bold text-blue-900">
{fmtEUR.format(filteredTotals.totalHT)}
</td>
<td className="px-3 py-2 text-right font-bold text-blue-900 dark:text-blue-100">
<td className="px-3 py-2 text-right font-bold text-blue-900">
{fmtEUR.format(filteredTotals.totalTTC)}
</td>
<td colSpan={2} className="px-3 py-2"></td>
@ -770,7 +773,7 @@ export default function StaffFacturationPage() {
</tr>
)}
{filteredAndSortedItems.map((f: Invoice) => (
<tr key={f.id} className="border-b last:border-b-0 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50">
<tr key={f.id} className="border-b last:border-b-0 hover:bg-slate-50">
<td className="px-3 py-2">
<input
type="checkbox"
@ -842,21 +845,21 @@ export default function StaffFacturationPage() {
Page {data.factures.page} {items.length} facture{items.length > 1 ? "s" : ""} total{items.length > 1 ? "es" : "e"}
</div>
{hasActiveFilters && (
<div className="text-blue-600 dark:text-blue-400">
<div className="text-blue-600">
{filteredAndSortedItems.length} facture{filteredAndSortedItems.length > 1 ? "s" : ""} après filtrage
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
className="px-3 py-1 rounded-md border dark:border-slate-800 disabled:opacity-40 hover:bg-slate-50 dark:hover:bg-slate-800"
className="px-3 py-1 rounded-md border disabled:opacity-40 hover:bg-slate-50"
disabled={page === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Précédent
</button>
<button
className="px-3 py-1 rounded-md border dark:border-slate-800 disabled:opacity-40 hover:bg-slate-50 dark:hover:bg-slate-800"
className="px-3 py-1 rounded-md border disabled:opacity-40 hover:bg-slate-50"
disabled={!hasMore}
onClick={() => setPage((p) => p + 1)}
>
@ -870,22 +873,22 @@ export default function StaffFacturationPage() {
{/* Modal pour modification en masse de la date SEPA */}
{showSepaModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-slate-900 rounded-lg p-6 w-full max-w-md mx-4">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold mb-4">Modifier la date de prélèvement SEPA</h3>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
<p className="text-sm text-slate-600 mb-4">
Cette action va modifier la date de prélèvement SEPA pour {selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""}.
</p>
<div className="mb-6">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
Nouvelle date de prélèvement
</label>
<input
type="date"
value={newSepaDate}
onChange={(e) => setNewSepaDate(e.target.value)}
className="w-full px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="w-full px-3 py-2 border rounded-lg bg-white"
min={new Date().toISOString().split('T')[0]}
/>
</div>
@ -896,7 +899,7 @@ export default function StaffFacturationPage() {
setShowSepaModal(false);
setNewSepaDate("");
}}
className="px-4 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200"
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={updateSepaDatesMutation.isPending}
>
Annuler
@ -917,22 +920,22 @@ export default function StaffFacturationPage() {
{/* Modal pour modification en masse de la date de facture */}
{showInvoiceDateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-slate-900 rounded-lg p-6 w-full max-w-md mx-4">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold mb-4">Modifier la date de facture</h3>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
<p className="text-sm text-slate-600 mb-4">
Cette action va modifier la date de facture pour {selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""}.
</p>
<div className="mb-6">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
Nouvelle date de facture
</label>
<input
type="date"
value={newInvoiceDate}
onChange={(e) => setNewInvoiceDate(e.target.value)}
className="w-full px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="w-full px-3 py-2 border rounded-lg bg-white"
/>
</div>
@ -942,7 +945,7 @@ export default function StaffFacturationPage() {
setShowInvoiceDateModal(false);
setNewInvoiceDate("");
}}
className="px-4 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200"
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={updateInvoiceDatesMutation.isPending}
>
Annuler
@ -963,22 +966,22 @@ export default function StaffFacturationPage() {
{/* Modal pour modification en masse de la date d'échéance */}
{showDueDateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-slate-900 rounded-lg p-6 w-full max-w-md mx-4">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold mb-4">Modifier la date d'échéance</h3>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
<p className="text-sm text-slate-600 mb-4">
Cette action va modifier la date d'échéance pour {selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""}.
</p>
<div className="mb-6">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
Nouvelle date d'échéance
</label>
<input
type="date"
value={newDueDate}
onChange={(e) => setNewDueDate(e.target.value)}
className="w-full px-3 py-2 border dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800"
className="w-full px-3 py-2 border rounded-lg bg-white"
/>
</div>
@ -988,7 +991,7 @@ export default function StaffFacturationPage() {
setShowDueDateModal(false);
setNewDueDate("");
}}
className="px-4 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200"
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={updateDueDatesMutation.isPending}
>
Annuler
@ -1017,16 +1020,16 @@ export default function StaffFacturationPage() {
</DialogHeader>
<div className="space-y-4">
<div className="text-sm text-slate-600 dark:text-slate-400">
<div className="text-sm text-slate-600">
<p className="mb-3">
Êtes-vous sûr de vouloir créer des paiements GoCardless pour {selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""} ?
</p>
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-3 space-y-2">
<div className="font-medium text-slate-900 dark:text-slate-100">
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="font-medium text-slate-900">
{selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""}
</div>
<div className="text-slate-700 dark:text-slate-300">
<div className="text-slate-700">
{(() => {
const selectedArray = items.filter(invoice => selectedInvoices.has(invoice.id));
const totalTTC = selectedArray.reduce((sum, invoice) => sum + (invoice.montant_ttc || 0), 0);
@ -1035,11 +1038,11 @@ export default function StaffFacturationPage() {
</div>
</div>
<div className="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-3 mt-3">
<div className="text-orange-800 dark:text-orange-200 text-sm font-medium mb-2">
<div className="bg-orange-50 rounded-lg p-3 mt-3">
<div className="text-orange-800 text-sm font-medium mb-2">
💳 Cette action va :
</div>
<ul className="text-orange-700 dark:text-orange-300 text-sm space-y-1 list-disc list-inside">
<ul className="text-orange-700 text-sm space-y-1 list-disc list-inside">
<li>Créer les prélèvements dans GoCardless</li>
<li>Marquer les factures comme émises</li>
<li>Ignorer les factures sans mandat SEPA</li>

View file

@ -83,7 +83,7 @@ export default async function StaffSalariesPage() {
<h1 className="text-lg font-semibold">Salariés (Staff)</h1>
</div>
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4">
<div className="rounded-2xl border bg-white p-4">
{(!salaries || salaries.length === 0) && (
<div className="mb-4 p-3 rounded bg-yellow-50 text-sm text-slate-800 border">
<div><strong>Debug:</strong> Aucun salarié trouvé côté serveur (initialData vide).</div>

View file

@ -39,12 +39,12 @@ export default async function StaffTicketDetail({ params }: { params: { id: stri
<StaffTicketActions ticketId={ticket.id} status={ticket.status} mode="status" />
</div>
<div className="text-sm text-slate-600 dark:text-slate-300">Priorité: <strong>{ticket.priority}</strong> Dernier message: {formatDate(ticket.last_message_at)} Non lus (client/staff): {ticket.unread_by_client || 0} / {ticket.unread_by_staff || 0}</div>
<div className="text-sm text-slate-600">Priorité: <strong>{ticket.priority}</strong> Dernier message: {formatDate(ticket.last_message_at)} Non lus (client/staff): {ticket.unread_by_client || 0} / {ticket.unread_by_staff || 0}</div>
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4 space-y-4">
<div className="rounded-2xl border bg-white p-4 space-y-4">
<div className="space-y-3">
{(messages || []).map((m) => (
<div key={m.id} className={`p-3 rounded-lg border dark:border-slate-800 ${m.internal ? 'bg-slate-50 dark:bg-slate-800/40' : 'bg-white dark:bg-transparent'}`}>
<div key={m.id} className={`p-3 rounded-lg border ${m.internal ? 'bg-slate-50' : 'bg-white'}`}>
<div className="text-[11px] text-slate-500 flex items-center gap-2">
<span>{formatDate(m.created_at)}</span>
<span></span>

View file

@ -2,6 +2,11 @@ export const dynamic = "force-dynamic";
import { cookies } from "next/headers";
import { createSbServer } from "@/lib/supabaseServer";
import NewStaffTicketForm from "@/components/staff/NewStaffTicketForm";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Nouveau ticket | Espace Paie Odentas",
};
export default async function StaffNewTicketPage() {
const sb = createSbServer();

View file

@ -2,6 +2,11 @@ export const dynamic = "force-dynamic";
import Link from "next/link";
import { cookies } from "next/headers";
import { createSbServer } from "@/lib/supabaseServer";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Tickets support | Espace Paie Odentas",
};
function formatDate(d?: string | null) {
if (!d) return "—";
@ -55,9 +60,9 @@ export default async function StaffTicketsPage() {
<Link href="/staff/tickets/nouveau" className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm bg-emerald-600 text-white hover:bg-emerald-700">+ Nouveau ticket</Link>
</div>
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 overflow-hidden">
<div className="rounded-2xl border bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800/40 text-slate-600 dark:text-slate-300">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-4 py-2 font-medium">Sujet</th>
<th className="text-left px-4 py-2 font-medium">Statut</th>
@ -70,7 +75,7 @@ export default async function StaffTicketsPage() {
<tbody>
{(items || []).length ? (
items!.map((t) => (
<tr key={t.id} className="border-t dark:border-slate-800">
<tr key={t.id} className="border-t">
<td className="px-4 py-2">{t.subject}</td>
<td className="px-4 py-2">{t.status}</td>
<td className="px-4 py-2">{t.priority}</td>

View file

@ -3,6 +3,11 @@ import { createSbServer } from "@/lib/supabaseServer";
import Link from "next/link";
import { Suspense } from "react";
import InviteForm from "@/components/staff/InviteForm";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Nouvel utilisateur | Espace Paie Odentas",
};
async function getContext() {
const sb = createSbServer();

View file

@ -76,9 +76,9 @@ export default async function StaffUsersListPage() {
</Link>
</div>
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4">
<section className="rounded-2xl border bg-white p-4">
<h2 className="text-sm font-semibold mb-2">Niveaux d'habilitation</h2>
<ul className="text-sm leading-6 text-slate-700 dark:text-slate-300 list-disc pl-5 space-y-1">
<ul className="text-sm leading-6 text-slate-700 list-disc pl-5 space-y-1">
<li>
<span className="font-medium">Super Admin</span> accès total : gestion des utilisateurs (création, modification de niveau, révocation),
toutes les données (contrats, paies, salarié·es, facturation). Ne peut être modifié que par le support Odentas.
@ -98,9 +98,9 @@ export default async function StaffUsersListPage() {
</ul>
</section>
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 overflow-hidden">
<div className="rounded-2xl border bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800/60 text-slate-600 dark:text-slate-300">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-4 py-3">Prénom</th>
<th className="text-left px-4 py-3">Email</th>
@ -132,7 +132,7 @@ export default async function StaffUsersListPage() {
const status = m.revoked ? "Révoqué" : "Actif";
const disabled = !!m.revoked;
return (
<tr key={m.user_id} className="border-t dark:border-slate-800 align-top">
<tr key={m.user_id} className="border-t align-top">
<td className="px-4 py-3 whitespace-nowrap">{m.first_name || "—"}</td>
<td className="px-4 py-3">{m.email}</td>
<td className="px-4 py-3 uppercase tracking-wide text-xs">{m.role || "—"}</td>
@ -154,7 +154,7 @@ export default async function StaffUsersListPage() {
name="role"
defaultValue={m.role || "ADMIN"}
disabled={disabled}
className="px-2 py-1 rounded border dark:border-slate-800"
className="px-2 py-1 rounded border"
>
<option value="SUPER_ADMIN">Super Admin</option>
<option value="ADMIN">Admin</option>

View file

@ -3,6 +3,11 @@ import Link from "next/link";
import ConfirmableForm from "@/components/ConfirmableForm";
import { createSbServer } from "@/lib/supabaseServer";
import { createClient } from "@supabase/supabase-js";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Gestion des utilisateurs | Espace Paie Odentas",
};
export const dynamic = "force-dynamic";
@ -147,9 +152,9 @@ export default async function StaffUsersListPage() {
</Link>
</div>
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4">
<section className="rounded-2xl border bg-white p-4">
<h2 className="text-sm font-semibold mb-2">Niveaux d'habilitation</h2>
<ul className="text-sm leading-6 text-slate-700 dark:text-slate-300 list-disc pl-5 space-y-1">
<ul className="text-sm leading-6 text-slate-700 list-disc pl-5 space-y-1">
<li>
<span className="font-medium">Super Admin</span> accès total : gestion des utilisateurs (création, modification de niveau, révocation),
toutes les données (contrats, paies, salarié·es, facturation). Ne peut être modifié que par le support Odentas.
@ -169,9 +174,9 @@ export default async function StaffUsersListPage() {
</ul>
</section>
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 overflow-hidden">
<div className="rounded-2xl border bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800/60 text-slate-600 dark:text-slate-300">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-4 py-3">Prénom</th>
<th className="text-left px-4 py-3">Email</th>
@ -204,7 +209,7 @@ export default async function StaffUsersListPage() {
const status = m.revoked ? "Révoqué" : "Actif";
const disabled = !!m.revoked;
return (
<tr key={`${m.user_id}-${m.org_id}`} className="border-t dark:border-slate-800 align-top">
<tr key={`${m.user_id}-${m.org_id}`} className="border-t align-top">
<td className="px-4 py-3 whitespace-nowrap">{m.first_name || "—"}</td>
<td className="px-4 py-3">{m.email}</td>
<td className="px-4 py-3">{m.organization_name}</td>
@ -227,7 +232,7 @@ export default async function StaffUsersListPage() {
name="role"
defaultValue={m.role || "ADMIN"}
disabled={disabled}
className="px-2 py-1 rounded border dark:border-slate-800"
className="px-2 py-1 rounded border"
>
<option value="SUPER_ADMIN">Super Admin</option>
<option value="ADMIN">Admin</option>

View file

@ -2,6 +2,11 @@
import { cookies } from "next/headers";
import NextDynamic from "next/dynamic";
import { createSbServer } from "@/lib/supabaseServer";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Virements salaires (Staff) | Espace Paie Odentas",
};
export const dynamic = "force-dynamic";
@ -70,7 +75,7 @@ export default async function StaffSalaryTransfersPage() {
<h1 className="text-lg font-semibold">Virements de Salaires (Staff)</h1>
</div>
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4">
<div className="rounded-2xl border bg-white p-4">
{(!salaryTransfers || salaryTransfers.length === 0) && (
<div className="mb-4 p-3 rounded bg-yellow-50 text-sm text-slate-800 border">
<div><strong>Debug:</strong> Aucun virement de salaire trouvé côté serveur (initialData vide).</div>

View file

@ -1,5 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { usePageTitle } from "@/hooks/usePageTitle";
type Ticket = {
id: string;
@ -21,9 +22,17 @@ type Message = {
export default function TicketDetailPage({ params }: { params: { id: string } }) {
const { id } = params;
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [ticket, setTicket] = useState<Ticket | null>(null);
// Titre dynamique basé sur le ticket
const ticketTitle = ticket?.subject
? `${ticket.subject.substring(0, 50)}${ticket.subject.length > 50 ? '...' : ''}`
: "Ticket support";
usePageTitle(ticketTitle);
const [messages, setMessages] = useState<Message[]>([]);
const [body, setBody] = useState("");
const [posting, setPosting] = useState(false);
@ -89,13 +98,13 @@ export default function TicketDetailPage({ params }: { params: { id: string } })
) : ticket ? (
<>
<h1 className="text-lg font-semibold">{ticket.subject}</h1>
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4 space-y-4">
<div className="text-sm text-slate-600 dark:text-slate-300">
<div className="rounded-2xl border bg-white p-4 space-y-4">
<div className="text-sm text-slate-600">
Statut: <strong>{ticket.status}</strong> Priorité: <strong>{ticket.priority}</strong>
</div>
<div className="space-y-3">
{messages.map((m) => (
<div key={m.id} className={`p-3 rounded-lg border dark:border-slate-800 ${m.internal ? 'bg-slate-50 dark:bg-slate-800/40' : 'bg-white dark:bg-transparent'}`}>
<div key={m.id} className={`p-3 rounded-lg border ${m.internal ? 'bg-slate-50' : 'bg-white'}`}>
<div className="text-[11px] text-slate-500 flex items-center gap-2">
<span>{new Date(m.created_at).toLocaleString('fr-FR')}</span>
<span></span>
@ -106,7 +115,7 @@ export default function TicketDetailPage({ params }: { params: { id: string } })
))}
</div>
<form onSubmit={onSubmit} className="space-y-2">
<textarea value={body} onChange={(e) => setBody(e.target.value)} className="w-full px-3 py-2 rounded-lg border dark:border-slate-800 bg-transparent text-sm min-h-[100px]" placeholder="Votre réponse…" />
<textarea value={body} onChange={(e) => setBody(e.target.value)} className="w-full px-3 py-2 rounded-lg border bg-transparent text-sm min-h-[100px]" placeholder="Votre réponse…" />
<div className="flex items-center">
<button disabled={posting} className="ml-auto inline-flex items-center px-3 py-2 rounded-lg bg-emerald-600 text-white text-sm hover:bg-emerald-700 disabled:opacity-50">Envoyer</button>
</div>

View file

@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { MessageCircle } from "lucide-react";
import TicketConversation from "@/components/TicketConversation";
import { usePageTitle } from "@/hooks/usePageTitle";
type Ticket = {
id: string;
@ -27,15 +28,15 @@ const STATUS_LABEL: Record<string, string> = {
function getStatusClass(status: string) {
switch (status?.toLowerCase()) {
case "open":
return "bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/40 dark:text-blue-200 dark:border-blue-800/60";
return "bg-blue-100 text-blue-800 border-blue-200";
case "waiting_client":
return "bg-orange-100 text-orange-800 border-orange-200 dark:bg-orange-900/40 dark:text-orange-200 dark:border-orange-800/60";
return "bg-orange-100 text-orange-800 border-orange-200";
case "waiting_staff":
return "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:border-amber-800/60";
return "bg-amber-100 text-amber-800 border-amber-200";
case "closed":
return "bg-emerald-100 text-emerald-800 border-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-200 dark:border-emerald-800/60";
return "bg-emerald-100 text-emerald-800 border-emerald-200";
default:
return "bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700";
return "bg-slate-100 text-slate-600 border-slate-200";
}
}
@ -43,19 +44,21 @@ function getStatusClass(status: string) {
function getPriorityClass(priority: string) {
switch (priority?.toLowerCase()) {
case "urgent":
return "bg-red-100 text-red-800 border-red-200 dark:bg-red-900/40 dark:text-red-200 dark:border-red-800/60";
return "bg-red-100 text-red-800 border-red-200";
case "high":
return "bg-orange-100 text-orange-800 border-orange-200 dark:bg-orange-900/40 dark:text-orange-200 dark:border-orange-800/60";
return "bg-orange-100 text-orange-800 border-orange-200";
case "normal":
return "bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/40 dark:text-blue-200 dark:border-blue-800/60";
return "bg-blue-100 text-blue-800 border-blue-200";
case "low":
return "bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700";
return "bg-slate-100 text-slate-600 border-slate-200";
default:
return "bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700";
return "bg-slate-100 text-slate-600 border-slate-200";
}
}
export default function SupportPage() {
usePageTitle("Support");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [items, setItems] = useState<Ticket[]>([]);
@ -244,12 +247,12 @@ export default function SupportPage() {
<main className="p-6 max-w-7xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold">Support</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">Centre d'assistance Odentas</p>
<p className="text-slate-600 mt-1">Centre d'assistance Odentas</p>
</div>
{/* Mode conversation */}
{viewMode === "conversation" && selectedTicket ? (
<div className="bg-white dark:bg-slate-900 rounded-2xl border dark:border-slate-800 p-6">
<div className="bg-white rounded-2xl border p-6">
<TicketConversation
ticket={selectedTicket}
onClose={closeConversation}
@ -260,10 +263,10 @@ export default function SupportPage() {
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Colonne de gauche: formulaire de création - 2 colonnes */}
<div className="lg:col-span-2">
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4 sticky top-6">
<div className="rounded-2xl border bg-white p-4 sticky top-6">
<h2 className="text-lg font-semibold mb-4">Ouvrir un ticket</h2>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 text-sm text-red-700 dark:text-red-300">
<div className="mb-4 p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700">
{error}
</div>
)}
@ -271,7 +274,7 @@ export default function SupportPage() {
{/* Catégorie */}
<div>
<label className="block text-sm font-medium mb-1">Catégorie</label>
<select value={category} onChange={(e) => { setCategory(e.target.value); }} className="w-full px-3 py-2 rounded-lg border dark:border-slate-800 bg-transparent text-sm">
<select value={category} onChange={(e) => { setCategory(e.target.value); }} className="w-full px-3 py-2 rounded-lg border bg-transparent text-sm">
<option value="general">Général</option>
<option value="contrat">Contrat</option>
<option value="salarie">Salarié·e</option>
@ -287,7 +290,7 @@ export default function SupportPage() {
<input
value={contractInput}
onChange={(e) => setContractInput(e.target.value)}
className="w-full px-3 py-2 rounded-lg border dark:border-slate-800 bg-transparent text-sm"
className="w-full px-3 py-2 rounded-lg border bg-transparent text-sm"
placeholder="Nom du contrat ou référence..."
/>
</div>
@ -299,24 +302,24 @@ export default function SupportPage() {
<input
value={salarieInput}
onChange={(e) => setSalarieInput(e.target.value)}
className="w-full px-3 py-2 rounded-lg border dark:border-slate-800 bg-transparent text-sm"
className="w-full px-3 py-2 rounded-lg border bg-transparent text-sm"
placeholder="Nom du/de la salarié·e ou matricule..."
/>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">Sujet</label>
<input value={subject} onChange={(e) => setSubject(e.target.value)} className="w-full px-3 py-2 rounded-lg border dark:border-slate-800 bg-transparent text-sm" placeholder="Ex: Question sur la paie daoût" />
<input value={subject} onChange={(e) => setSubject(e.target.value)} className="w-full px-3 py-2 rounded-lg border bg-transparent text-sm" placeholder="Ex: Question sur la paie daoût" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Message</label>
<textarea value={message} onChange={(e) => setMessage(e.target.value)} className="w-full px-3 py-2 rounded-lg border dark:border-slate-800 bg-transparent text-sm min-h-[100px]" placeholder="Décrivez votre demande…" />
<textarea value={message} onChange={(e) => setMessage(e.target.value)} className="w-full px-3 py-2 rounded-lg border bg-transparent text-sm min-h-[100px]" placeholder="Décrivez votre demande…" />
</div>
{/* Pièces jointes */}
<div>
<label className="block text-sm font-medium mb-1">Pièces jointes (20 Mo max/fichier)</label>
<div className="flex items-center gap-3">
<label className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border dark:border-slate-800 cursor-pointer">
<label className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer">
<span className="text-sm">Choisir des fichiers</span>
<input type="file" multiple className="hidden" onChange={(e) => onFilesSelected(e.target.files)} />
</label>
@ -339,7 +342,7 @@ export default function SupportPage() {
</div>
<div className="flex items-center gap-3">
<label className="text-sm">Priorité</label>
<select value={priority} onChange={(e) => setPriority(e.target.value as any)} className="px-2 py-1 rounded-md border text-sm dark:border-slate-800 bg-transparent">
<select value={priority} onChange={(e) => setPriority(e.target.value as any)} className="px-2 py-1 rounded-md border text-sm bg-transparent">
<option value="low">Basse</option>
<option value="normal">Normale</option>
<option value="high">Haute</option>
@ -354,8 +357,8 @@ export default function SupportPage() {
</div>
{/* Colonne droite: liste des tickets + pagination - 2 colonnes */}
<div className="lg:col-span-2 rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 overflow-hidden">
<div className="px-4 py-3 border-b dark:border-slate-800">
<div className="lg:col-span-2 rounded-2xl border bg-white overflow-hidden">
<div className="px-4 py-3 border-b">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">Vos tickets</h2>
{hasTickets ? (
@ -367,10 +370,10 @@ export default function SupportPage() {
{/* Première ligne : Recherche + Filtres principaux */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-3 text-sm">
<div className="lg:col-span-2">
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Rechercher un sujet…" className="w-full px-3 py-2 rounded-lg border dark:border-slate-800 bg-transparent" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Rechercher un sujet…" className="w-full px-3 py-2 rounded-lg border bg-transparent" />
</div>
<div>
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="w-full px-2 py-2 rounded-lg border dark:border-slate-800 bg-transparent">
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="w-full px-2 py-2 rounded-lg border bg-transparent">
<option value="all">Tous statuts</option>
<option value="open">Ouvert</option>
<option value="waiting_client">En attente client</option>
@ -379,7 +382,7 @@ export default function SupportPage() {
</select>
</div>
<div>
<select value={filterPriority} onChange={(e) => setFilterPriority(e.target.value)} className="w-full px-2 py-2 rounded-lg border dark:border-slate-800 bg-transparent">
<select value={filterPriority} onChange={(e) => setFilterPriority(e.target.value)} className="w-full px-2 py-2 rounded-lg border bg-transparent">
<option value="all">Toutes priorités</option>
<option value="urgent">Urgente</option>
<option value="high">Haute</option>
@ -391,12 +394,12 @@ export default function SupportPage() {
{/* Deuxième ligne : Options avancées + Tri */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-3 text-sm">
<label className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border dark:border-slate-800 bg-transparent cursor-pointer">
<label className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border bg-transparent cursor-pointer">
<input type="checkbox" checked={unreadOnly} onChange={(e) => setUnreadOnly(e.target.checked)} />
<span>Non lus uniquement</span>
</label>
<div className="lg:col-span-2">
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as any)} className="w-full px-2 py-2 rounded-lg border dark:border-slate-800 bg-transparent">
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as any)} className="w-full px-2 py-2 rounded-lg border bg-transparent">
<option value="last_message_at_desc">Tri: Dernier message (récent ancien)</option>
<option value="created_at_desc">Tri: Création (récent ancien)</option>
<option value="priority_desc">Tri: Priorité (haut bas)</option>
@ -417,7 +420,7 @@ export default function SupportPage() {
{/* Affichage des tickets en cards - Disposition en 1 colonne pour plus de lisibilité */}
<div className="space-y-4">
{displayed.map((t) => (
<div key={t.id} className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4 hover:shadow-md transition-shadow cursor-pointer" onClick={() => openTicket(t)}>
<div key={t.id} className="rounded-2xl border bg-white p-4 hover:shadow-md transition-shadow cursor-pointer" onClick={() => openTicket(t)}>
<div className="flex flex-col gap-3">
{/* En-tête avec sujet et badges statut/priorité */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
@ -441,7 +444,7 @@ export default function SupportPage() {
</div>
{/* Métadonnées du ticket */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-sm text-slate-600 dark:text-slate-300">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-sm text-slate-600">
<div className="flex items-center gap-4">
{typeof t.message_count === 'number' && (
<span className="flex items-center gap-1">
@ -478,11 +481,11 @@ export default function SupportPage() {
{/* Pagination */}
{totalPages > 1 ? (
<div className="flex items-center justify-between px-4 py-4 border-t dark:border-slate-800 text-sm mt-6">
<div className="flex items-center justify-between px-4 py-4 border-t text-sm mt-6">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-2 rounded-lg border dark:border-slate-800 disabled:opacity-50 hover:bg-slate-50 dark:hover:bg-slate-800"
className="px-3 py-2 rounded-lg border disabled:opacity-50 hover:bg-slate-50"
>
Précédent
</button>
@ -494,10 +497,10 @@ export default function SupportPage() {
<button
key={n}
onClick={() => setPage(n)}
className={`h-8 w-8 rounded-lg border dark:border-slate-800 text-sm font-medium transition-colors ${
className={`h-8 w-8 rounded-lg border text-sm font-medium transition-colors ${
active
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
: 'hover:bg-slate-50 dark:hover:bg-slate-800'
? 'bg-slate-900 text-white'
: 'hover:bg-slate-50'
}`}
>
{n}
@ -508,7 +511,7 @@ export default function SupportPage() {
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-3 py-2 rounded-lg border dark:border-slate-800 disabled:opacity-50 hover:bg-slate-50 dark:hover:bg-slate-800"
className="px-3 py-2 rounded-lg border disabled:opacity-50 hover:bg-slate-50"
>
Suivant
</button>

View file

@ -2,8 +2,9 @@
"use client";
import React, { useMemo, useState, useEffect } from "react";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Search, Download, ExternalLink, Info, Copy, Check } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
// --- Types ---
type PeriodKey =
@ -51,8 +52,10 @@ type OrgSummary = {
type ClientVirementItem = {
id: string;
kind: 'CDDU_MONO' | 'CDDU_MULTI' | 'RG';
source: 'contrat' | 'paie_multi' | 'paie_rg';
source: 'contrat' | 'paie_multi' | 'paie_rg' | 'payslip';
contract_id?: string;
salarie?: string | null;
salarie_matricule?: string | null;
reference?: string | null;
profession?: string | null;
date_debut?: string | null;
@ -91,6 +94,46 @@ function formatFR(iso: string) {
return `${dd}/${mm}/${yyyy}`;
}
function maskEndDate(iso?: string | null) {
if (!iso) return '—';
// Masquer la date 2099-01-01 utilisée comme fin indéterminée
if (iso.startsWith('2099-01-01')) return '—';
return formatFR(iso);
}
function capitalize(str: string) {
if (!str) return str;
return str.charAt(0).toUpperCase() + str.slice(1);
}
// Affiche une période au format "Mois YYYY" (ex: "Septembre 2025")
function formatPeriode(per?: string | null) {
if (!per) return '—';
const s = String(per).trim();
// ISO: YYYY-MM or YYYY-MM-DD
const mIso = s.match(/^(\d{4})-(\d{2})(?:-(\d{2}))?$/);
if (mIso) {
const y = parseInt(mIso[1], 10);
const m = parseInt(mIso[2], 10);
const d = new Date(y, (isNaN(m) ? 1 : m) - 1, 1);
return capitalize(d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }));
}
// MM/YYYY
const mMY = s.match(/^(\d{1,2})\/(\d{4})$/);
if (mMY) {
const m = parseInt(mMY[1], 10);
const y = parseInt(mMY[2], 10);
const d = new Date(y, (isNaN(m) ? 1 : m) - 1, 1);
return capitalize(d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }));
}
// Fallback: essayer Date()
const d = new Date(s);
if (!isNaN(d.getTime())) {
return capitalize(d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }));
}
return s;
}
function formatCurrency(amount?: number) {
if (amount == null) return "—";
return new Intl.NumberFormat('fr-FR', {
@ -213,6 +256,8 @@ function useVirements(filters: Filters, selectedOrgId?: string) {
}
export default function VirementsPage() {
usePageTitle("Virements salaires");
const now = new Date();
const [filters, setFilters] = useState<Filters>({
year: now.getFullYear(),
@ -226,6 +271,7 @@ export default function VirementsPage() {
// Récupération des informations utilisateur et des organisations (pour le staff)
const { data: userInfo } = useUserInfo();
const { data: organizations } = useOrganizations();
const queryClient = useQueryClient();
const years = useMemo(() => {
const base = now.getFullYear();
@ -360,6 +406,25 @@ export default function VirementsPage() {
const clientUnpaid = clientFilter(clientUnpaidAll);
const clientRecent = clientFilter(clientRecentAll);
// Mutation: marquer un payslip comme viré
async function markPayslipDone(payslipId: string) {
try {
// Optimistic UI: masquer l'élément avant refetch
// (on ne modifie pas le cache TanStack ici, on refetch directement après)
await fetch(`/api/payslips/${payslipId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({ transfer_done: true })
});
// Invalider les requêtes liées pour recharger la liste
queryClient.invalidateQueries({ queryKey: ["virements-salaires"] });
} catch (e) {
console.error('Erreur marquage payslip:', e);
alert('Erreur lors du marquage du virement.');
}
}
// Filtrage local pour la recherche ET la période
const filteredItems = useMemo((): VirementItem[] => {
let result: VirementItem[] = items;
@ -417,17 +482,17 @@ export default function VirementsPage() {
return (
<div className="space-y-5">
{/* En-tête + Recherche */}
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4">
<section className="rounded-2xl border bg-white p-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div>
<h1 className="text-xl font-semibold">Virements de salaires</h1>
<p className="text-sm text-slate-600 dark:text-slate-400 mt-1">
<p className="text-sm text-slate-600 mt-1">
Suivez vos appels à virement, l'état des virements reçus et les salaires payés.
</p>
</div>
{isOdentas && (
<div className="sm:ml-auto flex items-center gap-2 w-full sm:w-auto">
<div className="flex items-center gap-2 px-3 py-2 rounded-xl border dark:border-slate-800 w-full sm:w-80">
<div className="flex items-center gap-2 px-3 py-2 rounded-xl border w-full sm:w-80">
<Search className="w-4 h-4" />
<input
value={searchQuery}
@ -445,7 +510,7 @@ export default function VirementsPage() {
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Année :</label>
<select
className="px-3 py-2 rounded-lg border dark:border-slate-800 bg-white dark:bg-slate-900 text-sm"
className="px-3 py-2 rounded-lg border bg-white text-sm"
value={filters.year}
onChange={(e) => setFilters(f => ({ ...f, year: parseInt(e.target.value, 10) }))}
>
@ -460,7 +525,7 @@ export default function VirementsPage() {
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Organisation :</label>
<select
className="px-3 py-2 rounded-lg border dark:border-slate-800 bg-white dark:bg-slate-900 text-sm min-w-[200px]"
className="px-3 py-2 rounded-lg border bg-white text-sm min-w-[200px]"
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
>
@ -476,7 +541,7 @@ export default function VirementsPage() {
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Période :</label>
<select
className="px-3 py-2 rounded-lg border dark:border-slate-800 bg-white dark:bg-slate-900 text-sm"
className="px-3 py-2 rounded-lg border bg-white text-sm"
value={filters.period}
onChange={(e) => setFilters(f => ({ ...f, period: e.target.value as PeriodKey }))}
>
@ -502,11 +567,11 @@ export default function VirementsPage() {
</div>
)}
<div className="sm:ml-auto min-w-[280px] max-w-sm flex-1">
<div className="h-full rounded-xl border dark:border-slate-800 bg-white dark:bg-slate-900 p-3">
<div className="h-full rounded-xl border bg-white p-3">
<div className="flex items-start justify-between">
<div>
<div className="text-sm font-semibold">Gestion des virements</div>
<div className="text-xs text-slate-600 dark:text-slate-400">Les virements de salaires sont effectués par</div>
<div className="text-xs text-slate-600">Les virements de salaires sont effectués par</div>
</div>
<div className="flex items-center gap-2">
<div className="relative group inline-block">
@ -514,22 +579,22 @@ export default function VirementsPage() {
type="button"
disabled
aria-disabled="true"
className="text-xs px-2 py-1 rounded-md border dark:border-slate-700 opacity-60 cursor-not-allowed"
className="text-xs px-2 py-1 rounded-md border opacity-60 cursor-not-allowed"
>
Modifier
</button>
<div
role="tooltip"
className="pointer-events-none absolute right-0 mt-2 w-64 px-3 py-2 rounded-lg bg-slate-900 text-white dark:bg-slate-800 text-xs shadow-lg opacity-0 group-hover:opacity-100 translate-y-1 group-hover:translate-y-0 transition"
className="pointer-events-none absolute right-0 mt-2 w-64 px-3 py-2 rounded-lg bg-slate-900 text-white text-xs shadow-lg opacity-0 group-hover:opacity-100 translate-y-1 group-hover:translate-y-0 transition"
>
Bientôt disponible, veuillez nous contacter.
<div className="absolute -top-1 right-6 w-2 h-2 rotate-45 bg-slate-900 dark:bg-slate-800" />
<div className="absolute -top-1 right-6 w-2 h-2 rotate-45 bg-slate-900" />
</div>
</div>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<div className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg border text-sm ${loadingOrg ? 'bg-slate-50 border-slate-200 text-slate-400 dark:bg-slate-800/40 dark:border-slate-700 dark:text-slate-400' : (isOdentas ? 'bg-emerald-50 border-emerald-200 text-emerald-700 dark:bg-emerald-900/30 dark:border-emerald-900 dark:text-emerald-300' : 'bg-slate-50 border-slate-200 text-slate-700 dark:bg-slate-800/40 dark:border-slate-700 dark:text-slate-300')}`}>
<div className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg border text-sm ${loadingOrg ? 'bg-slate-50 border-slate-200 text-slate-400' : (isOdentas ? 'bg-emerald-50 border-emerald-200 text-emerald-700' : 'bg-slate-50 border-slate-200 text-slate-700')}`}>
<span className="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: isOdentas ? '#10b981' : '#64748b', opacity: loadingOrg ? 0.6 : 1 }} />
<span className="font-medium">{gestionLabel}</span>
</div>
@ -537,7 +602,7 @@ export default function VirementsPage() {
type="button"
onClick={() => !loadingOrg && setAboutOpen(true)}
disabled={loadingOrg}
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md border dark:border-slate-700 transition-colors ${loadingOrg ? 'opacity-50 cursor-not-allowed' : 'hover:bg-slate-50 dark:hover:bg-slate-800'}`}
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md border transition-colors ${loadingOrg ? 'opacity-50 cursor-not-allowed' : 'hover:bg-slate-50'}`}
aria-label="En savoir plus sur les virements"
>
<Info className="w-3.5 h-3.5" />
@ -551,11 +616,11 @@ export default function VirementsPage() {
{/* Tableau principal */}
{isOdentas ? (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<section className="rounded-2xl border bg-white">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b dark:border-slate-800 bg-slate-50/80 dark:bg-slate-800/40">
<tr className="border-b bg-slate-50/80">
<Th>Période</Th>
<Th>N° d'appel</Th>
<Th>Date d'appel</Th>
@ -587,8 +652,8 @@ export default function VirementsPage() {
</tr>
) : (
filteredItems.map((row: VirementItem) => (
<tr key={row.id} className="border-b last:border-b-0 dark:border-slate-800 hover:bg-slate-50/50 dark:hover:bg-slate-800/30">
<Td>{row.periode_label || row.periode || "—"}</Td>
<tr key={row.id} className="border-b last:border-b-0 hover:bg-slate-50/50">
<Td>{row.periode_label || formatPeriode(row.periode) || "—"}</Td>
<Td>
<span className="font-medium">
{row.callsheet || row.num_appel || "—"}
@ -610,7 +675,7 @@ export default function VirementsPage() {
href={row.pdf_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/40 dark:text-pink-300 dark:hover:bg-pink-900/60 transition-colors text-xs"
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-pink-100 text-pink-700 hover:bg-pink-200 transition-colors text-xs"
>
<Download className="w-3 h-3" />
PDF
@ -629,7 +694,7 @@ export default function VirementsPage() {
{/* Footer avec indicateur de chargement */}
{filteredItems.length > 0 && (
<div className="p-3 border-t dark:border-slate-800 text-right">
<div className="p-3 border-t text-right">
<div className="text-sm text-slate-500">
{isFetching ? (
<span className="inline-flex items-center gap-1">
@ -644,11 +709,11 @@ export default function VirementsPage() {
)}
</section>
) : (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800">
<section className="rounded-2xl border bg-white">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b dark:border-slate-800 bg-slate-50/80 dark:bg-slate-800/40">
<tr className="border-b bg-slate-50/80">
<Th>Salarié·e</Th>
<Th>Contrat</Th>
<Th>Profession</Th>
@ -670,35 +735,88 @@ export default function VirementsPage() {
) : (
<>
{clientUnpaid.map((it) => (
<tr key={`unpaid-${it.source}-${it.id}`} className="border-b last:border-b-0 dark:border-slate-800 hover:bg-slate-50/50 dark:hover:bg-slate-800/30">
<Td>{it.salarie || '—'}</Td>
<Td><span className="font-medium">{it.reference || '—'}</span></Td>
<tr key={`unpaid-${it.source}-${it.id}`} className="border-b last:border-b-0 hover:bg-slate-50/50">
<Td>
{it.salarie_matricule ? (
<a href={`/salaries/${encodeURIComponent(it.salarie_matricule)}`} target="_blank" rel="noreferrer" className="text-blue-600 hover:underline font-medium">
{it.salarie || it.salarie_matricule}
</a>
) : (
it.salarie || '—'
)}
</Td>
<Td>
{it.contract_id && it.reference ? (
<a
href={it.kind === 'RG' ? `/contrats-rg/${encodeURIComponent(it.contract_id)}` : (it.kind === 'CDDU_MULTI' ? `/contrats-multi/${encodeURIComponent(it.contract_id)}` : `/contrats/${encodeURIComponent(it.contract_id)}`)}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:underline font-medium"
>
{it.reference}
</a>
) : (
<span className="font-medium">{it.reference || '—'}</span>
)}
</Td>
<Td>{it.profession || '—'}</Td>
<Td>{formatFR(it.date_debut || '')}</Td>
<Td>{formatFR(it.date_fin || '')}</Td>
<Td>{it.periode || '—'}</Td>
<Td>{maskEndDate(it.date_fin || '')}</Td>
<Td>{formatPeriode(it.periode)}</Td>
<Td className="text-right font-medium">{it.net_a_payer != null ? formatCurrency(it.net_a_payer) : '—'}</Td>
<Td className="text-center">
<button type="button" disabled className="px-2 py-1 text-xs rounded-md border dark:border-slate-700 opacity-60 cursor-not-allowed" title="Bientôt disponible">Marquer</button>
{it.source === 'payslip' ? (
<button
type="button"
onClick={() => markPayslipDone(it.id)}
className="px-2 py-1 text-xs rounded-md border hover:bg-emerald-50 hover:border-emerald-300 text-emerald-700"
title="Marquer le virement comme effectué"
>
Marquer
</button>
) : (
<button type="button" disabled className="px-2 py-1 text-xs rounded-md border opacity-60 cursor-not-allowed" title="Non disponible">Marquer</button>
)}
</Td>
</tr>
))}
{clientRecent.length > 0 && (
<tr className="bg-slate-50/50 dark:bg-slate-800/30">
<tr className="bg-slate-50/50">
<td colSpan={8} className="px-3 py-2 text-xs text-slate-500">Récemment virés ( 30 jours)</td>
</tr>
)}
{clientRecent.map((it) => (
<tr key={`recent-${it.source}-${it.id}`} className="border-b last:border-b-0 dark:border-slate-800 hover:bg-slate-50/50 dark:hover:bg-slate-800/30">
<Td>{it.salarie || '—'}</Td>
<Td><span className="font-medium">{it.reference || '—'}</span></Td>
<tr key={`recent-${it.source}-${it.id}`} className="border-b last:border-b-0 hover:bg-slate-50/50">
<Td>
{it.salarie_matricule ? (
<a href={`/salaries/${encodeURIComponent(it.salarie_matricule)}`} target="_blank" rel="noreferrer" className="text-blue-600 hover:underline font-medium">
{it.salarie || it.salarie_matricule}
</a>
) : (
it.salarie || '—'
)}
</Td>
<Td>
{it.contract_id && it.reference ? (
<a
href={it.kind === 'RG' ? `/contrats-rg/${encodeURIComponent(it.contract_id)}` : (it.kind === 'CDDU_MULTI' ? `/contrats-multi/${encodeURIComponent(it.contract_id)}` : `/contrats/${encodeURIComponent(it.contract_id)}`)}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:underline font-medium"
>
{it.reference}
</a>
) : (
<span className="font-medium">{it.reference || '—'}</span>
)}
</Td>
<Td>{it.profession || '—'}</Td>
<Td>{formatFR(it.date_debut || '')}</Td>
<Td>{formatFR(it.date_fin || '')}</Td>
<Td>{it.periode || '—'}</Td>
<Td>{maskEndDate(it.date_fin || '')}</Td>
<Td>{formatPeriode(it.periode)}</Td>
<Td className="text-right font-medium">{it.net_a_payer != null ? formatCurrency(it.net_a_payer) : '—'}</Td>
<Td className="text-center">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300">Oui</span>
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-emerald-100 text-emerald-800">Oui</span>
</Td>
</tr>
))}
@ -716,12 +834,12 @@ export default function VirementsPage() {
onClick={() => setAboutOpen(false)}
/>
<div className="absolute inset-0 flex items-center justify-center p-4">
<div role="dialog" aria-modal="true" aria-labelledby="about-virements-title" className="w-full max-w-lg rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 shadow-xl">
<div className="p-5 border-b dark:border-slate-800 flex items-start justify-between">
<div role="dialog" aria-modal="true" aria-labelledby="about-virements-title" className="w-full max-w-lg rounded-2xl border bg-white shadow-xl">
<div className="p-5 border-b flex items-start justify-between">
<div>
<h2 id="about-virements-title" className="text-base font-semibold">Gestion des virements de salaires</h2>
</div>
<button onClick={() => setAboutOpen(false)} className="text-slate-500 hover:text-slate-900 dark:hover:text-white"></button>
<button onClick={() => setAboutOpen(false)} className="text-slate-500 hover:text-slate-900"></button>
</div>
<div className="p-5 space-y-4 text-sm">
<p>
@ -730,10 +848,10 @@ export default function VirementsPage() {
<p>
Dès réception de votre virement, nous <strong>redistribuons les salaires à vos salariés</strong>. Vous pouvez suivre létat dans le tableau ci-dessous (colonnes « Virement reçu » et « Salaires payés »).
</p>
<div className="rounded-lg border dark:border-slate-800 p-3">
<div className="rounded-lg border p-3">
<div className="text-xs uppercase tracking-wide text-slate-500 mb-2">Coordonnées bancaires (salaires)</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2 rounded-md bg-slate-50 dark:bg-slate-800/50 px-2 py-2">
<div className="flex items-center justify-between gap-2 rounded-md bg-slate-50 px-2 py-2">
<div>
<div className="text-[11px] text-slate-500">Bénéficiaire</div>
<div className="font-mono text-xs break-all">ODENTAS MEDIA SAS</div>
@ -741,13 +859,13 @@ export default function VirementsPage() {
<button
type="button"
onClick={() => { navigator.clipboard?.writeText('ODENTAS MEDIA SAS').then(()=>{ setCopiedField('benef'); setTimeout(()=>setCopiedField(null), 1400); }); }}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md border dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800"
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md border hover:bg-slate-100"
aria-label="Copier le bénéficiaire"
>
{copiedField === 'benef' ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} Copier
</button>
</div>
<div className="flex items-center justify-between gap-2 rounded-md bg-slate-50 dark:bg-slate-800/50 px-2 py-2">
<div className="flex items-center justify-between gap-2 rounded-md bg-slate-50 px-2 py-2">
<div>
<div className="text-[11px] text-slate-500">IBAN</div>
<div className="font-mono text-xs break-all">FR76 1695 8000 0141 0850 9729 813</div>
@ -756,14 +874,14 @@ export default function VirementsPage() {
<button
type="button"
onClick={() => { navigator.clipboard?.writeText("FR76 1695 8000 0141 0850 9729 813" as string).then(()=>{ setCopiedField('iban'); setTimeout(()=>setCopiedField(null), 1400); }); }}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md border dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800"
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md border hover:bg-slate-100"
aria-label="Copier lIBAN"
>
{copiedField === 'iban' ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} Copier
</button>
)}
</div>
<div className="flex items-center justify-between gap-2 rounded-md bg-slate-50 dark:bg-slate-800/50 px-2 py-2">
<div className="flex items-center justify-between gap-2 rounded-md bg-slate-50 px-2 py-2">
<div>
<div className="text-[11px] text-slate-500">BIC</div>
<div className="font-mono text-xs break-all">QNTOFRP1XXX</div>
@ -772,7 +890,7 @@ export default function VirementsPage() {
<button
type="button"
onClick={() => { navigator.clipboard?.writeText("QNTOFRP1XXX" as string).then(()=>{ setCopiedField('bic'); setTimeout(()=>setCopiedField(null), 1400); }); }}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md border dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800"
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md border hover:bg-slate-100"
aria-label="Copier le BIC"
>
{copiedField === 'bic' ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} Copier
@ -783,8 +901,8 @@ export default function VirementsPage() {
</div>
</div>
</div>
<div className="p-4 border-t dark:border-slate-800 flex justify-end">
<button onClick={() => setAboutOpen(false)} className="px-3 py-2 rounded-md border dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 text-sm">Fermer</button>
<div className="p-4 border-t flex justify-end">
<button onClick={() => setAboutOpen(false)} className="px-3 py-2 rounded-md border hover:bg-slate-50 text-sm">Fermer</button>
</div>
</div>
</div>
@ -816,7 +934,7 @@ function StatusBadge({ status, date }: { status?: boolean | string; date?: strin
if (val === true) {
return (
<div className="flex flex-col items-center gap-1">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-emerald-100 text-emerald-800">
Oui
</span>
{date && (
@ -828,7 +946,7 @@ function StatusBadge({ status, date }: { status?: boolean | string; date?: strin
);
} else if (val === false) {
return (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-slate-100 text-slate-600">
Non
</span>
);

View file

@ -6,6 +6,7 @@ import Link from "next/link";
import ConfirmableForm from "@/components/ConfirmableForm";
import { api } from "@/lib/fetcher";
import AccessDeniedCard from "@/components/AccessDeniedCard";
import { usePageTitle } from "@/hooks/usePageTitle";
// Types
type Member = {
@ -133,6 +134,8 @@ function useOrganizationMembers(clientInfo: ClientInfo) {
}
export default function StaffUsersListPage() {
usePageTitle("Vos accès");
// Récupération des infos client
const { data: clientInfo = null, isLoading: isLoadingClient, error: clientError } = useClientInfo();
@ -213,9 +216,9 @@ export default function StaffUsersListPage() {
</Link>
</div>
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 p-4">
<section className="rounded-2xl border bg-white p-4">
<h2 className="text-sm font-semibold mb-2">Niveaux d'habilitation</h2>
<ul className="text-sm leading-6 text-slate-700 dark:text-slate-300 list-disc pl-5 space-y-1">
<ul className="text-sm leading-6 text-slate-700 list-disc pl-5 space-y-1">
<li>
<span className="font-medium uppercase tracking-wide text-xs">Super Admin</span> accès principal avec un accès total : gestion des utilisateurs (création, modification de niveau, révocation),
toutes les données (contrats, paies, salarié·es, facturation). Ne peut être modifié que par le support Odentas.
@ -235,9 +238,9 @@ export default function StaffUsersListPage() {
</ul>
</section>
<div className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800 overflow-hidden">
<div className="rounded-2xl border bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800/60 text-slate-600 dark:text-slate-300">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-4 py-3">Prénom</th>
<th className="text-left px-4 py-3">Email</th>
@ -273,7 +276,7 @@ export default function StaffUsersListPage() {
(currentUserEmail && typeof m.email === "string" && m.email.toLowerCase() === currentUserEmail.toLowerCase());
// console.debug("ROW SELF CHECK", { currentUserId, rowUserId: m.user_id, currentUserEmail, rowEmail: m.email, isSelf });
return (
<tr key={m.user_id} className="border-t dark:border-slate-800 align-top">
<tr key={m.user_id} className="border-t align-top">
<td className="px-4 py-3 whitespace-nowrap">{m.first_name || "—"}</td>
<td className="px-4 py-3">{m.email}</td>
<td className="px-4 py-3 uppercase tracking-wide text-xs">{m.role || "—"}</td>
@ -283,10 +286,10 @@ export default function StaffUsersListPage() {
{
m.role === "SUPER_ADMIN" ? (
// Cas SUPER_ADMIN : on garde la card actuelle
<div className="rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-800/40 p-3 shadow-sm">
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 shadow-sm">
<div className="flex items-start gap-3">
<div className="space-y-1">
<p className="text-xs text-slate-600 dark:text-slate-400">
<p className="text-xs text-slate-600">
Contactez l'équipe Odentas pour modifier le <span className="font-medium uppercase tracking-wide text-xs">SUPER ADMIN</span>.
</p>
</div>
@ -294,9 +297,9 @@ export default function StaffUsersListPage() {
</div>
) : isSelf ? (
// Cas utilisateur connecté (non SUPER_ADMIN) : petite card informative
<div className="rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-800/40 p-3 shadow-sm">
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 shadow-sm">
<div className="space-y-1">
<p className="text-xs text-slate-600 dark:text-slate-400">
<p className="text-xs text-slate-600">
Contactez votre <span className="font-medium uppercase tracking-wide text-xs">SUPER ADMIN</span> ou le Staff Odentas pour supprimer votre propre accès.
</p>
</div>
@ -404,7 +407,7 @@ function RoleUpdateForm({
name="role"
defaultValue={member.role || "ADMIN"}
disabled={disabled || isSubmitting}
className="px-2 py-1 rounded border dark:border-slate-800"
className="px-2 py-1 rounded border"
>
<option value="ADMIN">Admin</option>
<option value="AGENT">Agent</option>

View file

@ -2,31 +2,24 @@
import React from 'react'
import { useQuery } from '@tanstack/react-query'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Download, FileText, Upload, Folder, Building2 } from 'lucide-react'
import { Download, FileText, Upload, Folder, Building2, ChevronDown, ChevronRight } from 'lucide-react'
import { Textarea } from '@/components/ui/textarea'
// -----------------------------
import { usePageTitle } from '@/hooks/usePageTitle'
import { Tooltip } from '@/components/ui/tooltip'
type DocumentItem = {
id: string
title: string
url?: string // lien S3 signé ou route API
updatedAt?: string // ISO
url?: string
updatedAt?: string
sizeBytes?: number
meta?: Record<string, any>
period_label?: string | null
}
type ClientInfo = {
id: string
name: string
api_name?: string
} | null
// -----------------------------
// Utils
// -----------------------------
function formatBytes(bytes?: number) {
if (!bytes && bytes !== 0) return ''
const sizes = ['o', 'Ko', 'Mo', 'Go', 'To']
@ -34,453 +27,423 @@ function formatBytes(bytes?: number) {
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`
}
// -----------------------------
// UI Atomes
// -----------------------------
function DownloadButton({ href, filename }: { href?: string, filename?: string }) {
return (
<Button variant="secondary" size="sm" disabled={!href}>
<a href={href} download={filename} target="_blank" rel="noreferrer" className="flex items-center gap-1">
<Download className="h-4 w-4" /><span>Télécharger</span>
</a>
</Button>
)
function formatDateLast(dateStr?: string) {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
function DocumentCard({ item, icon }: { item: DocumentItem, icon?: React.ReactNode }) {
return (
<Card className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-base flex items-center gap-2">
{icon ?? <FileText className="h-4 w-4" />} {item.title}
</CardTitle>
<DownloadButton href={item.url} filename={item.title} />
</div>
{item.updatedAt && (
<CardDescription>Mis à jour le {new Date(item.updatedAt).toLocaleDateString('fr-FR')}</CardDescription>
)}
</CardHeader>
</Card>
)
}
function DocumentGrid({ items, icon }: { items: DocumentItem[]; icon?: React.ReactNode }) {
if (!items?.length) return <EmptyState />
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{items.map((d) => (
<DocumentCard key={d.id} item={d} icon={icon} />
))}
</div>
)
}
function DocumentList({ items, icon }: { items: DocumentItem[]; icon?: React.ReactNode }) {
if (!items?.length) return <EmptyState />
return (
<div className="flex flex-col space-y-4">
{items.map((d) => (
<DocumentCard key={d.id} item={d} icon={icon} />
))}
</div>
)
}
function EmptyState() {
return (
<div className="rounded-2xl border p-8 text-center text-muted-foreground">
Aucun document pour le moment.
</div>
)
}
// -----------------------------
// Upload (optionnel, à brancher)
// -----------------------------
function UploadPanel() {
const [description, setDescription] = React.useState('')
const handleSubmit = () => {
alert('Fonctionnalité de transmission de document en cours de développement')
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2"><Upload className="h-4 w-4"/> Transmettre un document</CardTitle>
<CardDescription>Formats acceptés : pdf, docx, xlsx, jpg, png</CardDescription>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-4 w-4" />
Transmettre un document
</CardTitle>
<CardDescription>
Envoyez-nous directement vos documents
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Input placeholder="Nom du fichier (optionnel)" />
<CardContent className="space-y-4">
<Input type="file" />
<Textarea placeholder="Commentaire (optionnel)" />
<div className="flex justify-end">
<Button>
<Upload className="mr-2 h-4 w-4"/> Envoyer
<Textarea
placeholder="Description du document (optionnel)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Button onClick={handleSubmit} className="w-full">
Envoyer
</Button>
</div>
</CardContent>
</Card>
)
}
// -----------------------------
// Data hooks (avec logique server-side)
// -----------------------------
function useClientInfo() {
return useQuery({
queryKey: ["client-info"],
queryFn: async () => {
try {
const res = await fetch("/api/me", {
cache: "no-store",
headers: { Accept: "application/json" },
credentials: "include"
});
if (!res.ok) return null;
const me = await res.json();
return {
id: me.active_org_id || null,
name: me.active_org_name || "Organisation",
api_name: me.active_org_api_name
} as ClientInfo;
} catch {
return null;
}
},
staleTime: 30_000, // Cache 30s
});
}
function useDocumentsGeneraux() {
const { data: clientInfo } = useClientInfo();
return useQuery({
queryKey: ['documents-generaux', clientInfo?.id],
queryFn: async () => {
if (!clientInfo) return [];
const res = await fetch('/api/documents?type=generaux', {
cache: 'no-store',
headers: {
'accept': 'application/json',
'x-company-name': clientInfo.name,
'x-company-name-b64': btoa(unescape(encodeURIComponent(clientInfo.name))),
'x-active-org-id': clientInfo.id,
},
credentials: 'include',
});
if (!res.ok) {
const msg = await res.text();
throw new Error(`HTTP ${res.status} ${msg || ''}`);
}
const json = await res.json();
return Array.isArray(json.items) ? json.items : [];
},
enabled: !!clientInfo,
staleTime: 15_000,
});
}
function useDocumentsCaisses() {
const { data: clientInfo } = useClientInfo();
return useQuery({
queryKey: ['documents-caisses', clientInfo?.id],
queryFn: async () => {
if (!clientInfo) return [];
// Mock data pour l'instant - à remplacer par un vrai appel API
await new Promise(resolve => setTimeout(resolve, 300));
return [
{ id: 'thalie', title: 'Thalie Santé.pdf', url: '#', sizeBytes: 88_000 },
{ id: 'afdas', title: 'AFDAS.pdf', url: '#', sizeBytes: 76_000 },
{ id: 'fnas', title: 'FNAS.pdf', url: '#', sizeBytes: 64_000 },
];
},
enabled: !!clientInfo,
staleTime: 15_000,
});
}
function useDocumentsComptables() {
const { data: clientInfo } = useClientInfo();
return useQuery({
queryKey: ['documents-comptables', clientInfo?.id],
queryFn: async () => {
if (!clientInfo) return [];
const res = await fetch('/api/documents?type=comptables', {
cache: 'no-store',
headers: {
'accept': 'application/json',
'x-company-name': clientInfo.name,
'x-company-name-b64': btoa(unescape(encodeURIComponent(clientInfo.name))),
'x-active-org-id': clientInfo.id,
},
credentials: 'include',
});
if (!res.ok) {
const msg = await res.text();
throw new Error(`HTTP ${res.status} ${msg || ''}`);
}
const json = await res.json();
return Array.isArray(json?.comptables) ? json.comptables : [];
},
enabled: !!clientInfo,
staleTime: 15_000,
});
}
// -----------------------------
// Helpers Comptables (grouping by période)
// -----------------------------
type ComptableByPeriode = Record<string, DocumentItem[]>;
function groupByPeriode(items: DocumentItem[]): ComptableByPeriode {
const out: ComptableByPeriode = {}
for (const it of items) {
const key = (it as any).periode || 'Sans période'
if (!out[key]) out[key] = []
out[key].push(it)
}
return out
}
// -----------------------------
// Helpers Année (extraction + groupement)
// -----------------------------
function extractYearFromPeriode(periode?: string, fallback?: string): string {
if (!periode) return fallback || 'Autres'
const m = String(periode).match(/(20\d{2}|19\d{2})/)
if (m) return m[1]
return fallback || 'Autres'
}
function buildYearIndex(items: DocumentItem[]) {
const byPeriode = groupByPeriode(items)
const entries = Object.entries(byPeriode)
const byYear: Record<string, { periode: string; docs: DocumentItem[] }[]> = {}
for (const [periode, docs] of entries) {
const y = extractYearFromPeriode((docs[0] as any)?.periode, (docs[0] as any)?.updatedAt?.slice(0,4))
const key = y || 'Autres'
if (!byYear[key]) byYear[key] = []
byYear[key].push({ periode, docs })
}
// tri des périodes à l'intérieur d'une année du plus récent au plus ancien (en gardant l'ordre déjà déterminé)
for (const y of Object.keys(byYear)) {
byYear[y].sort((a, b) => (a.periode > b.periode ? -1 : a.periode < b.periode ? 1 : 0))
}
// liste des années triées desc numériquement si possible
const years = Object.keys(byYear).sort((a, b) => {
const na = parseInt(a, 10); const nb = parseInt(b, 10)
if (!Number.isNaN(na) && !Number.isNaN(nb)) return nb - na
if (!Number.isNaN(na)) return -1
if (!Number.isNaN(nb)) return 1
return a > b ? -1 : a < b ? 1 : 0
})
return { byYear, years }
}
// -----------------------------
// Sections donglets
// -----------------------------
function SectionGeneraux() {
const { data, isLoading, error } = useDocumentsGeneraux();
if (isLoading) return <SkeletonGrid />;
if (error) return <div className="rounded-2xl border p-6 text-center text-rose-600 text-sm">{error.message}</div>;
return <DocumentList items={data || []} icon={<Folder className="h-4 w-4"/>} />;
const { data: documentsGeneraux, isLoading, error } = useQuery<DocumentItem[]>({
queryKey: ['documents', 'generaux'],
queryFn: async () => {
const res = await fetch('/api/documents?category=generaux')
const data = await res.json()
console.log('📄 Documents Généraux - Response:', data)
console.log('📄 Documents Généraux - Is Array:', Array.isArray(data))
// Si la réponse est un objet avec une propriété documents
if (data && typeof data === 'object' && !Array.isArray(data)) {
console.log('📄 Documents Généraux - Keys:', Object.keys(data))
// Essayer différentes propriétés possibles
if (data.documents) return data.documents
if (data.data) return data.data
if (data.items) return data.items
}
return Array.isArray(data) ? data : []
}
})
console.log('📄 Documents Généraux - Final Data:', documentsGeneraux)
console.log('📄 Documents Généraux - Loading:', isLoading)
console.log('📄 Documents Généraux - Error:', error)
const handleDownload = (item: DocumentItem) => {
if (item.url) {
window.open(item.url, '_blank')
} else {
alert('Document non disponible')
}
}
if (isLoading) {
return <p className="text-center text-muted-foreground py-8">Chargement...</p>
}
if (error) {
return <p className="text-center text-red-500 py-8">Erreur: {String(error)}</p>
}
return (
<div className="space-y-2">
{documentsGeneraux && documentsGeneraux.length > 0 ? (
documentsGeneraux.map((item) => (
<div key={item.id} className="flex items-center justify-between p-3 border rounded-lg">
<div>
<h4 className="font-medium">{item.title}</h4>
<p className="text-sm text-muted-foreground">
{formatDateLast(item.updatedAt)} {formatBytes(item.sizeBytes)}
</p>
</div>
<Button
size="sm"
onClick={() => handleDownload(item)}
disabled={!item.url}
>
<Download className="h-4 w-4 mr-2" />
Télécharger
</Button>
</div>
))
) : (
<p className="text-center text-muted-foreground py-8">
Aucun document disponible
</p>
)}
</div>
)
}
function SectionCaisses() {
const { data, isLoading } = useDocumentsCaisses();
if (isLoading) return <SkeletonGrid />;
return <DocumentGrid items={data || []} icon={<Building2 className="h-4 w-4"/>} />;
const { data: documentsOrganismes, isLoading, error } = useQuery<DocumentItem[]>({
queryKey: ['documents', 'caisses'],
queryFn: async () => {
const res = await fetch('/api/documents?category=caisses')
const data = await res.json()
console.log('📄 Documents Caisses - Response:', data)
if (data && typeof data === 'object' && !Array.isArray(data)) {
if (data.documents) return data.documents
if (data.data) return data.data
if (data.items) return data.items
}
return Array.isArray(data) ? data : []
}
})
const handleDownload = (item: DocumentItem) => {
if (item.url) {
window.open(item.url, '_blank')
} else {
alert('Document non disponible')
}
}
if (isLoading) {
return <p className="text-center text-muted-foreground py-8">Chargement...</p>
}
if (error) {
return <p className="text-center text-red-500 py-8">Erreur: {String(error)}</p>
}
return (
<div className="space-y-2">
{documentsOrganismes && documentsOrganismes.length > 0 ? (
documentsOrganismes.map((item) => (
<div key={item.id} className="flex items-center justify-between p-3 border rounded-lg">
<div>
<h4 className="font-medium">{item.title}</h4>
<p className="text-sm text-muted-foreground">
{formatDateLast(item.updatedAt)} {formatBytes(item.sizeBytes)}
</p>
</div>
<Button
size="sm"
onClick={() => handleDownload(item)}
disabled={!item.url}
>
<Download className="h-4 w-4 mr-2" />
Télécharger
</Button>
</div>
))
) : (
<p className="text-center text-muted-foreground py-8">
Aucun document disponible
</p>
)}
</div>
)
}
function SectionComptables() {
const { isLoading, isError, data, error } = useDocumentsComptables()
const [expandedPeriods, setExpandedPeriods] = React.useState<Set<string>>(new Set())
// Hooks MUST be called unconditionally across renders
const [open, setOpen] = React.useState<string | null>(null)
const [selectedYear, setSelectedYear] = React.useState<string>('Autres')
const { data: documentsCompta, isLoading, error } = useQuery<DocumentItem[]>({
queryKey: ['documents', 'comptables'],
queryFn: async () => {
console.log('📄 Fetching comptables with category=docs_comptables')
const res = await fetch('/api/documents?category=docs_comptables')
const data = await res.json()
console.log('📄 Documents Comptables - Raw Response:', data)
console.log('📄 Documents Comptables - Is Array?', Array.isArray(data))
const { byYear, years } = React.useMemo(() => buildYearIndex(data || []), [data])
// Ensure selectedYear stays valid when years changes
React.useEffect(() => {
if (!years?.length) {
setSelectedYear('Autres')
return
if (data && typeof data === 'object' && !Array.isArray(data)) {
console.log('📄 Documents Comptables - Object keys:', Object.keys(data))
if (data.documents) {
console.log('📄 Using data.documents:', data.documents)
return data.documents
}
if (data.data) {
console.log('📄 Using data.data:', data.data)
return data.data
}
if (data.items) {
console.log('📄 Using data.items:', data.items)
return data.items
}
if (!years.includes(selectedYear)) {
setSelectedYear(years[0])
}
}, [years, selectedYear])
if (isLoading) return <SkeletonGrid />
if (isError) return <div className="rounded-2xl border p-6 text-center text-rose-600 text-sm">{error?.message}</div>
if (!data?.length) return <EmptyState />
const result = Array.isArray(data) ? data : []
console.log('📄 Final result:', result)
return result
}
})
const currentIndexRaw = years.indexOf(selectedYear)
const currentIndex = currentIndexRaw === -1 ? 0 : currentIndexRaw
const hasPrev = currentIndex < years.length - 1
const hasNext = currentIndex > 0
const effectiveYear = years[currentIndex] || 'Autres'
const periodsForYear = byYear[effectiveYear] || []
// Grouper les documents par période
const documentsByPeriod = React.useMemo(() => {
if (!documentsCompta || documentsCompta.length === 0) return new Map()
const grouped = new Map<string, DocumentItem[]>()
documentsCompta.forEach(doc => {
const period = doc.period_label || 'Sans période'
if (!grouped.has(period)) {
grouped.set(period, [])
}
grouped.get(period)!.push(doc)
})
// Trier les périodes par ordre décroissant (plus récent en premier)
const sortedEntries = Array.from(grouped.entries()).sort((a, b) => {
// Si "Sans période", mettre à la fin
if (a[0] === 'Sans période') return 1
if (b[0] === 'Sans période') return -1
// Sinon, tri décroissant (plus récent en premier)
return b[0].localeCompare(a[0])
})
return new Map(sortedEntries)
}, [documentsCompta])
const togglePeriod = (period: string) => {
setExpandedPeriods(prev => {
const next = new Set(prev)
if (next.has(period)) {
next.delete(period)
} else {
next.add(period)
}
return next
})
}
const handleDownload = (item: DocumentItem) => {
if (item.url) {
window.open(item.url, '_blank')
} else {
alert('Document non disponible')
}
}
if (isLoading) {
return <p className="text-center text-muted-foreground py-8">Chargement...</p>
}
if (error) {
return <p className="text-center text-red-500 py-8">Erreur: {String(error)}</p>
}
if (!documentsCompta || documentsCompta.length === 0) {
return (
<p className="text-center text-muted-foreground py-8">
Aucun document comptable disponible
</p>
)
}
return (
<div className="flex flex-col space-y-4">
{/* Pagination par année */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
type="button"
disabled={!hasPrev}
onClick={() => hasPrev && setSelectedYear(years[currentIndex + 1])}
className={`px-3 py-1.5 rounded-lg border text-sm ${hasPrev ? 'hover:bg-slate-50 dark:hover:bg-slate-800' : 'opacity-50 cursor-not-allowed'}`}
>
Année précédente
</button>
</div>
<div className="text-sm font-medium">{effectiveYear}</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={!hasNext}
onClick={() => hasNext && setSelectedYear(years[currentIndex - 1])}
className={`px-3 py-1.5 rounded-lg border text-sm ${hasNext ? 'hover:bg-slate-50 dark:hover:bg-slate-800' : 'opacity-50 cursor-not-allowed'}`}
>
Année suivante
</button>
</div>
</div>
<div className="space-y-3">
{Array.from(documentsByPeriod.entries()).map(([period, docs]) => {
const isExpanded = expandedPeriods.has(period)
{/* Cards par période pour l'année sélectionnée */}
{periodsForYear.length === 0 ? (
<EmptyState />
return (
<div key={period} className="border rounded-lg overflow-hidden">
{/* Header de la période - cliquable */}
<button
onClick={() => togglePeriod(period)}
className="w-full flex items-center justify-between p-4 bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-5 w-5 text-muted-foreground" />
) : (
<div className="flex flex-col space-y-4">
{periodsForYear.map(({ periode, docs }) => {
const isOpen = open === periode
return (
<Card key={periode} className="hover:shadow-md transition-shadow">
<CardHeader className="cursor-pointer select-none" onClick={() => setOpen(isOpen ? null : periode)}>
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Folder className="h-4 w-4" /> {periode}
</CardTitle>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span>{docs.length} document{docs.length > 1 ? 's' : ''}</span>
<svg className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} viewBox="0 0 20 20" fill="currentColor"><path d="M5.23 7.21a.75.75 0 011.06.02L10 11.116l3.71-3.885a.75.75 0 111.08 1.04l-4.25 4.455a.75.75 0 01-1.08 0L5.25 8.27a.75.75 0 01-.02-1.06z"/></svg>
<ChevronRight className="h-5 w-5 text-muted-foreground" />
)}
<div className="text-left">
<h3 className="font-semibold text-base">{period}</h3>
<p className="text-sm text-muted-foreground">
{docs.length} document{docs.length > 1 ? 's' : ''}
</p>
</div>
</div>
</CardHeader>
{isOpen && (
<CardContent className="pt-0 pb-4">
<div className="flex flex-col space-y-3">
{docs.map((d) => (
<DocumentCard key={d.id} item={d} icon={<FileText className="h-4 w-4" />} />
</button>
{/* Liste des documents - affichée si expanded */}
{isExpanded && (
<div className="p-2 space-y-2 bg-background">
{docs.map((item: DocumentItem) => (
<div
key={item.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/30 transition-colors"
>
<div className="flex-1 min-w-0">
<h4 className="font-medium truncate">{item.title}</h4>
<p className="text-sm text-muted-foreground">
{formatDateLast(item.updatedAt)} {formatBytes(item.sizeBytes)}
</p>
</div>
<Button
size="sm"
onClick={() => handleDownload(item)}
disabled={!item.url}
className="ml-4"
>
<Download className="h-4 w-4 mr-2" />
Télécharger
</Button>
</div>
))}
</div>
</CardContent>
)}
</Card>
</div>
)
})}
</div>
)}
{/* Sélecteur direct d'année (optionnel) */}
<div className="flex flex-wrap items-center gap-2 pt-2">
{years.map((y) => (
<button
key={y}
type="button"
onClick={() => setSelectedYear(y)}
className={`px-2.5 py-1 rounded-md text-xs border ${effectiveYear === y ? 'bg-slate-900 text-white dark:bg-white dark:text-slate-900' : 'hover:bg-slate-50 dark:hover:bg-slate-800'}`}
>
{y}
</button>
))}
</div>
</div>
)
}
// -----------------------------
// Skeletons
// -----------------------------
function SkeletonCard() {
return (
<div className="rounded-2xl border p-4 animate-pulse space-y-3">
<div className="h-4 w-1/2 bg-muted rounded" />
<div className="h-3 w-1/3 bg-muted rounded" />
<div className="h-8 w-28 bg-muted rounded ml-auto" />
</div>
)
}
function SkeletonGrid() {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
)
}
// -----------------------------
// Page
// -----------------------------
export default function VosDocumentsPage() {
usePageTitle("Vos documents");
const [activeTab, setActiveTab] = React.useState('generaux');
return (
<div className="space-y-6">
<header className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">Vos documents</h2>
{/* Optionnel : panneau dupload dans un slide-over plus tard */}
</header>
<Tabs defaultValue="generaux" className="w-full">
<TabsList className="grid grid-cols-3 w-full md:w-auto">
<TabsTrigger value="generaux">Documents généraux</TabsTrigger>
<TabsTrigger value="caisses">Caisses & organismes</TabsTrigger>
<TabsTrigger value="comptables">Documents comptables</TabsTrigger>
</TabsList>
<TabsContent value="generaux" className="mt-4">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Colonne gauche : Documents disponibles */}
<div className="lg:col-span-2">
<SectionGeneraux />
</div>
<div className="lg:col-span-1 space-y-4">
<UploadPanel />
<Card>
<CardHeader>
<CardTitle className="text-base">Besoin dun document particulier ?</CardTitle>
<CardDescription>Nhésitez pas à nous contacter si vous avez besoin dune attestation spécifique.</CardDescription>
<CardTitle>Documents disponibles</CardTitle>
<CardDescription>
{activeTab === 'generaux' && 'Téléchargez vos documents généraux'}
{activeTab === 'comptables' && 'Téléchargez vos documents comptables et sociaux'}
</CardDescription>
</CardHeader>
<CardContent>
{activeTab === 'generaux' && <SectionGeneraux />}
{activeTab === 'comptables' && <SectionComptables />}
</CardContent>
</Card>
</div>
{/* Colonne droite : Onglets + Transmettre un document */}
<div className="lg:col-span-1 space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Catégories</CardTitle>
<CardDescription>Sélectionnez une catégorie</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Button
variant={activeTab === 'generaux' ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => setActiveTab('generaux')}
>
<FileText className="h-4 w-4 mr-2" />
Documents généraux
</Button>
<Tooltip
content="Les documents des caisses seront de nouveau disponibles dans quelques jours"
side="left"
>
<Button
variant="outline"
className="w-full justify-start opacity-50 cursor-not-allowed"
disabled
>
<Building2 className="h-4 w-4 mr-2" />
Caisses & organismes
</Button>
</Tooltip>
<Button
variant={activeTab === 'comptables' ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => setActiveTab('comptables')}
>
<Folder className="h-4 w-4 mr-2" />
Documents comptables
</Button>
</CardContent>
</Card>
<UploadPanel />
<Card>
<CardHeader>
<CardTitle className="text-base">Besoin d'aide ?</CardTitle>
<CardDescription>
N'hésitez pas à nous contacter si vous avez besoin d'une attestation spécifique.
</CardDescription>
</CardHeader>
</Card>
</div>
</div>
</TabsContent>
<TabsContent value="caisses" className="mt-4">
<SectionCaisses />
</TabsContent>
<TabsContent value="comptables" className="mt-4">
<SectionComptables />
</TabsContent>
</Tabs>
</div>
)
}
// =============================================================
// Notes dintégration Supabase
// - Remplacer les hooks useDocuments* par des appels réels
// ex. via une route API /api/documents?type=general|caisses|comptables
// - Les URLs doivent être des liens S3 pré-signés (via Lambda / Supabase Edge)
// - Penser à filtrer par organization/active_org_id côté API
// =============================================================

View file

@ -2,8 +2,11 @@
import { Suspense } from "react";
import ActivateContent from "./ActivateContent";
import { usePageTitle } from "@/hooks/usePageTitle";
export default function ActivatePage() {
usePageTitle("Activation de compte");
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-gray-50">

View file

@ -472,7 +472,7 @@ export async function POST(req: Request) {
newRole: role,
updatedBy,
updateDate: new Date().toLocaleString('fr-FR'),
ctaUrl: `${process.env.NEXT_PUBLIC_BASE_URL || 'https://espace-paie.odentas.fr'}/vos-acces`,
ctaUrl: `${process.env.NEXT_PUBLIC_BASE_URL || 'https://paie.odentas.fr'}/vos-acces`,
});
}
}

View file

@ -14,7 +14,7 @@ export async function POST(req: Request) {
{ auth: { autoRefreshToken: false, persistSession: false } }
);
// 1) Retrouver le user par email pour savoir si cest un Staff
// 1) Retrouver le user par email pour savoir si c'est un Staff
const { data: users, error: listErr } = await (srv.auth.admin as any).listUsers();
if (listErr) return new NextResponse(listErr.message, { status: 500 });
const user = (users?.users || []).find(
@ -32,15 +32,33 @@ export async function POST(req: Request) {
}
if (!isStaff) {
// 2) Si pas Staff, vérifier quil a bien une org active (non révoquée)
// 2) Si pas Staff, vérifier qu'il a bien une org active (non révoquée)
const { data: ok, error: rpcErr } = await srv.rpc("user_has_active_org", { p_email: email });
if (rpcErr) return new NextResponse(rpcErr.message, { status: 500 });
if (!ok) return new NextResponse("revoked", { status: 403 });
}
// 3) Envoyer lOTP
// 3) Envoyer l'OTP avec détection automatique de l'URL
const supabase = createRouteHandlerClient({ cookies });
const origin = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";
// Détecter automatiquement l'URL de base selon la requête (évite les soucis de sous-domaine)
const getBaseUrl = (req: Request) => {
try {
// 1) Privilégier les en-têtes proxy
const proto = req.headers.get('x-forwarded-proto') || 'https';
const host = req.headers.get('x-forwarded-host') || req.headers.get('host');
if (host) return `${proto}://${host}`;
} catch {}
// 2) Variable d'env explicite
if (process.env.NEXT_PUBLIC_SITE_URL) return process.env.NEXT_PUBLIC_SITE_URL;
// 3) Vercel fallback
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
// 4) Local dev
return "http://localhost:3000";
};
const origin = getBaseUrl(req);
console.log('[send-code] Using redirect origin:', origin);
const { error } = await supabase.auth.signInWithOtp({
email,
options: { shouldCreateUser: false, emailRedirectTo: `${origin}/auth/callback` },

View file

@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSbServiceRole } from '@/lib/supabaseServer';
import crypto from 'crypto';
import { sendUniversalEmailV2 } from '@/lib/emailTemplateService';
export async function POST(request: NextRequest) {
try {
const { salarie_id } = await request.json();
if (!salarie_id) {
return NextResponse.json(
{ error: 'ID du salarié requis' },
{ status: 400 }
);
}
const supabase = createSbServiceRole();
// Récupérer les informations du salarié
const { data: salarie, error: salarieError } = await supabase
.from('salaries')
.select('id, nom, prenom, adresse_mail, code_salarie, civilite, employer_id')
.eq('id', salarie_id)
.single();
if (salarieError || !salarie) {
console.error('Erreur récupération salarié:', salarieError);
return NextResponse.json(
{ error: 'Salarié non trouvé' },
{ status: 404 }
);
}
// Récupérer les informations de l'employeur séparément
let organizationName = 'votre employeur';
if (salarie.employer_id) {
const { data: organization, error: orgError } = await supabase
.from('organizations')
.select('name')
.eq('id', salarie.employer_id)
.single();
console.log('🏢 [TOKEN] Organization query result:', { organization, orgError, employer_id: salarie.employer_id });
if (organization?.name) {
organizationName = organization.name;
console.log('✅ [TOKEN] Organization name found:', organizationName);
} else {
console.log('⚠️ [TOKEN] No organization name found, using default');
}
} else {
console.log('⚠️ [TOKEN] No employer_id found for salarie');
}
if (!salarie.adresse_mail) {
return NextResponse.json(
{ error: 'Email du salarié requis' },
{ status: 400 }
);
}
// Supprimer d'éventuels tokens existants
await supabase
.from('auto_declaration_tokens')
.delete()
.eq('salarie_id', salarie_id);
// Générer un nouveau token sécurisé
const token = crypto.randomBytes(32).toString('hex');
// Créer le token en base avec expiration dans 72h
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 72);
const { error: tokenError } = await supabase
.from('auto_declaration_tokens')
.insert({
token,
salarie_id: salarie_id,
expires_at: expiresAt.toISOString(),
used: false
});
if (tokenError) {
console.error('Erreur création token:', tokenError);
return NextResponse.json(
{ error: 'Erreur lors de la génération du token' },
{ status: 500 }
);
}
// Préparer l'URL sécurisée pour l'auto-déclaration
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
const autoDeclarationUrl = `${baseUrl}/auto-declaration?token=${token}`;
// Envoyer l'email d'invitation
try {
const emailResult = await sendUniversalEmailV2({
type: 'auto-declaration-invitation',
toEmail: salarie.adresse_mail,
data: {
firstName: salarie.prenom || 'Cher collaborateur',
organizationName: organizationName, // Utilisé à la fois dans le texte ET dans la card
matricule: salarie.code_salarie || 'Non défini',
ctaUrl: autoDeclarationUrl
}
});
return NextResponse.json({
success: true,
message: 'Token généré et invitation envoyée avec succès',
token: token, // Pour debug uniquement, retirer en production
email_sent: true,
messageId: emailResult
});
} catch (emailError) {
console.error('Erreur envoi email invitation:', emailError);
return NextResponse.json(
{ error: 'Token créé mais erreur lors de l\'envoi de l\'email' },
{ status: 207 }
);
}
} catch (error) {
console.error('Erreur génération token:', error);
return NextResponse.json(
{ error: 'Erreur serveur' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,199 @@
import { NextRequest, NextResponse } from "next/server";
import { createSbServiceRole } from "@/lib/supabaseServer";
export const dynamic = "force-dynamic";
// GET - Récupérer les données d'un salarié par token sécurisé
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const token = searchParams.get('token');
if (!token) {
return NextResponse.json(
{ error: 'Token d\'accès manquant' },
{ status: 400 }
);
}
const sb = createSbServiceRole();
// Vérifier le token et récupérer les données du salarié
const { data: tokenData, error: tokenError } = await sb
.from('auto_declaration_tokens')
.select(`
id,
salarie_id,
expires_at,
used,
salaries (
id,
code_salarie,
num_salarie,
salarie,
nom,
nom_de_naissance,
prenom,
civilite,
pseudonyme,
adresse_mail,
tel,
adresse,
date_naissance,
lieu_de_naissance,
nir,
iban,
bic,
conges_spectacles,
derniere_profession,
employer_id,
justificatifs_personnels,
organizations(name)
)
`)
.eq('token', token)
.single();
if (tokenError || !tokenData) {
return NextResponse.json(
{ error: 'Token d\'accès invalide' },
{ status: 403 }
);
}
// Vérifier l'expiration du token
if (new Date() > new Date(tokenData.expires_at)) {
return NextResponse.json(
{ error: 'Token d\'accès expiré. Demandez un nouveau lien à votre employeur.' },
{ status: 403 }
);
}
const salarie = tokenData.salaries;
if (!salarie) {
return NextResponse.json(
{ error: 'Salarié non trouvé' },
{ status: 404 }
);
}
return NextResponse.json(salarie);
} catch (error) {
console.error('Erreur API:', error);
return NextResponse.json(
{ error: 'Erreur interne du serveur' },
{ status: 500 }
);
}
}
// PATCH - Mettre à jour les données d'un salarié
export async function PATCH(request: NextRequest) {
try {
const body = await request.json();
const { token, ...updateData } = body;
if (!token) {
return NextResponse.json(
{ error: 'Token d\'accès manquant' },
{ status: 400 }
);
}
const sb = createSbServiceRole();
// Vérifier le token et récupérer l'ID du salarié
const { data: tokenData, error: tokenError } = await sb
.from('auto_declaration_tokens')
.select('salarie_id, expires_at, used')
.eq('token', token)
.single();
if (tokenError || !tokenData) {
return NextResponse.json(
{ error: 'Token d\'accès invalide' },
{ status: 403 }
);
}
// Vérifier l'expiration du token
if (new Date() > new Date(tokenData.expires_at)) {
return NextResponse.json(
{ error: 'Token d\'accès expiré. Demandez un nouveau lien à votre employeur.' },
{ status: 403 }
);
}
// Préparer les données de mise à jour
const dataToUpdate: any = {
updated_at: new Date().toISOString()
};
// Mapper les champs autorisés à mettre à jour
if (updateData.civilite) dataToUpdate.civilite = updateData.civilite;
if (updateData.nom) dataToUpdate.nom = updateData.nom;
if (updateData.prenom) dataToUpdate.prenom = updateData.prenom;
if (updateData.nom_de_naissance !== undefined) dataToUpdate.nom_de_naissance = updateData.nom_de_naissance;
if (updateData.pseudonyme !== undefined) dataToUpdate.pseudonyme = updateData.pseudonyme;
if (updateData.adresse_mail) dataToUpdate.adresse_mail = updateData.adresse_mail;
if (updateData.tel !== undefined) dataToUpdate.tel = updateData.tel;
if (updateData.adresse !== undefined) dataToUpdate.adresse = updateData.adresse;
if (updateData.date_naissance !== undefined) dataToUpdate.date_naissance = updateData.date_naissance;
if (updateData.lieu_de_naissance !== undefined) dataToUpdate.lieu_de_naissance = updateData.lieu_de_naissance;
if (updateData.nir !== undefined) dataToUpdate.nir = updateData.nir;
if (updateData.conges_spectacles !== undefined) dataToUpdate.conges_spectacles = updateData.conges_spectacles;
if (updateData.iban !== undefined) dataToUpdate.iban = updateData.iban;
if (updateData.bic !== undefined) dataToUpdate.bic = updateData.bic;
if (updateData.derniere_profession !== undefined) dataToUpdate.derniere_profession = updateData.derniere_profession;
if (updateData.notes) {
// Ajouter les notes aux justificatifs personnels (format JSON)
const currentDate = new Date().toISOString().split('T')[0];
const noteEntry = {
date: currentDate,
source: 'auto-declaration',
notes: updateData.notes
};
dataToUpdate.justificatifs_personnels = JSON.stringify([noteEntry]);
}
// Mettre à jour le salarié
const { data, error } = await sb
.from('salaries')
.update(dataToUpdate)
.eq('id', tokenData.salarie_id)
.select()
.single();
if (error) {
console.error('Erreur Supabase update:', error);
return NextResponse.json(
{ error: 'Erreur lors de la mise à jour' },
{ status: 500 }
);
}
// Marquer le token comme utilisé (optionnel)
await sb
.from('auto_declaration_tokens')
.update({
used: true,
used_at: new Date().toISOString()
})
.eq('token', token);
return NextResponse.json({
success: true,
data: data
});
} catch (error) {
console.error('Erreur API:', error);
return NextResponse.json(
{ error: 'Erreur interne du serveur' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from "next/server";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { randomUUID } from "crypto";
import { createSbServiceRole } from "@/lib/supabaseServer";
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
},
});
const BUCKET_NAME = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
// Types de fichiers autorisés et leurs tailles max
const ALLOWED_FILE_TYPES = {
'image/jpeg': 10 * 1024 * 1024, // 10MB
'image/jpg': 10 * 1024 * 1024, // 10MB
'image/png': 10 * 1024 * 1024, // 10MB
'application/pdf': 10 * 1024 * 1024 // 10MB
};
const FILE_TYPE_NAMES = {
piece_identite: "piece-identite",
attestation_secu: "attestation-secu",
rib: "rib",
medecine_travail: "medecine-travail",
autre: "autre"
};
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const file = formData.get('file') as File;
const token = formData.get('token') as string; // Changé de 'matricule' à 'token'
const type = formData.get('type') as string;
if (!file) {
return NextResponse.json({ error: "Aucun fichier fourni" }, { status: 400 });
}
if (!token) {
return NextResponse.json({ error: "Token d'accès manquant" }, { status: 400 });
}
if (!type || !FILE_TYPE_NAMES[type as keyof typeof FILE_TYPE_NAMES]) {
return NextResponse.json({ error: "Type de fichier invalide" }, { status: 400 });
}
// Vérifier le token et récupérer les infos du salarié
const supabase = createSbServiceRole();
const { data: tokenData, error: tokenError } = await supabase
.from('auto_declaration_tokens')
.select(`
salarie_id,
expires_at,
used,
salaries (
code_salarie
)
`)
.eq('token', token)
.single();
if (tokenError || !tokenData) {
return NextResponse.json({ error: "Token d'accès invalide" }, { status: 403 });
}
// Vérifier l'expiration du token
if (new Date() > new Date(tokenData.expires_at)) {
return NextResponse.json({ error: "Token d'accès expiré" }, { status: 403 });
}
const matricule = (tokenData.salaries as any)?.code_salarie;
if (!matricule) {
return NextResponse.json({ error: "Matricule du salarié non trouvé" }, { status: 404 });
}
// Vérifier le type de fichier
if (!ALLOWED_FILE_TYPES[file.type as keyof typeof ALLOWED_FILE_TYPES]) {
return NextResponse.json({
error: "Type de fichier non autorisé. Seuls les fichiers PDF, JPG et PNG sont acceptés"
}, { status: 400 });
}
// Vérifier la taille du fichier
const maxSize = ALLOWED_FILE_TYPES[file.type as keyof typeof ALLOWED_FILE_TYPES];
if (file.size > maxSize) {
return NextResponse.json({
error: `Fichier trop volumineux. Taille maximum autorisée : ${maxSize / (1024 * 1024)}MB`
}, { status: 400 });
}
// Générer un nom de fichier unique
const fileExtension = file.name.split('.').pop() || '';
const fileName = `${FILE_TYPE_NAMES[type as keyof typeof FILE_TYPE_NAMES]}-${Date.now()}.${fileExtension}`;
const s3Key = `justif-salaries/${matricule}/${fileName}`;
// Convertir le fichier en buffer
const buffer = Buffer.from(await file.arrayBuffer());
// Upload vers S3
const uploadCommand = new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: s3Key,
Body: buffer,
ContentType: file.type,
Metadata: {
'matricule': matricule,
'type': type,
'original-filename': file.name,
'uploaded-at': new Date().toISOString()
}
});
await s3Client.send(uploadCommand);
// Construire l'URL du fichier
const fileUrl = `https://${BUCKET_NAME}.s3.eu-west-3.amazonaws.com/${s3Key}`;
return NextResponse.json({
success: true,
key: s3Key,
url: fileUrl,
filename: fileName,
originalName: file.name,
type: type,
matricule: matricule
});
} catch (error) {
console.error("Erreur upload S3:", error);
return NextResponse.json(
{ error: "Erreur lors du téléchargement du fichier" },
{ status: 500 }
);
}
}

View file

@ -1,20 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSbServer } from '@/lib/supabaseServer';
import { createClient } from '@supabase/supabase-js';
import { createClient, SupabaseClient, PostgrestError } from '@supabase/supabase-js';
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { v4 as uuidv4 } from 'uuid';
import { sendContractNotifications } from '@/lib/emailService';
import { resolveActiveOrg } from '@/lib/resolveActiveOrg';
async function assertStaff(sb: ReturnType<typeof createSbServer>, userId: string) {
const { data: me } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", userId)
.maybeSingle();
return !!me?.is_staff;
}
type QueryResult<T> = { data: T | null; error: PostgrestError | null };
type EmployeeRow = {
id: string;
code_salarie: string;
nom: string;
prenom: string;
adresse_mail: string | null;
employer_id?: string | null;
};
type OrganizationRow = {
name: string;
organization_details?: {
email_notifs?: string | null;
email_notifs_cc?: string | null;
prenom_contact?: string | null;
code_employeur?: string | null;
} | null;
};
type ProductionRow = {
id: string;
name: string;
reference: string | null;
org_id?: string | null;
};
type NoteInsert = {
contract_id: string;
organization_id: string;
content: string;
source: string;
};
export async function POST(request: NextRequest) {
const supabase = createRouteHandlerClient({ cookies });
@ -28,47 +53,189 @@ export async function POST(request: NextRequest) {
// Générer un identifiant unique pour le contrat
const contractId = uuidv4();
const contractNumber = body.reference || generateContractReference();
const providedReference = typeof body.reference === "string" ? body.reference.trim().toUpperCase() : "";
const contractNumber = providedReference || generateContractReference();
// Récupérer les informations de l'employé
console.log("Recherche employé avec matricule:", body.salarie_matricule);
// Détecter si l'utilisateur est staff et préparer un client service_role si disponible
let isStaff = false;
console.log('🔍 [DEBUG] Début détection staff pour user:', user.id);
const { data: employee, error: empError } = await supabase
try {
const { data: staffRow } = await supabase
.from('staff_users')
.select('is_staff')
.eq('user_id', user.id)
.maybeSingle();
console.log('🔍 [DEBUG] Résultat query staff_users:', staffRow);
isStaff = !!staffRow?.is_staff;
console.log('🔍 [DEBUG] isStaff depuis DB:', isStaff);
} catch (err) {
console.log('🔍 [DEBUG] Erreur query staff_users, fallback metadata:', err);
const userMeta = user.user_metadata || {};
const appMeta = user.app_metadata || {};
console.log('🔍 [DEBUG] user_metadata:', userMeta);
console.log('🔍 [DEBUG] app_metadata:', appMeta);
isStaff = Boolean(
userMeta.is_staff === true ||
userMeta.role === 'staff' ||
(Array.isArray(appMeta?.roles) && appMeta.roles.includes('staff'))
);
console.log('🔍 [DEBUG] isStaff depuis metadata:', isStaff);
}
const serviceSupabase: SupabaseClient | null =
process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE_KEY
? createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
})
: null;
if (isStaff && !serviceSupabase) {
console.error('Service role non configuré : impossible de traiter une création staff.');
return NextResponse.json(
{ error: 'Configuration Supabase incomplète pour le mode staff' },
{ status: 500 }
);
}
const clients: SupabaseClient[] = [];
if (isStaff && serviceSupabase) {
clients.push(serviceSupabase);
}
clients.push(supabase);
if (!isStaff && serviceSupabase) {
clients.push(serviceSupabase);
}
const runOnClients = async <T>(fn: (client: SupabaseClient, index: number) => Promise<QueryResult<T>>): Promise<QueryResult<T>> => {
let lastResult: QueryResult<T> = { data: null, error: null };
console.log(`🔍 [DEBUG] runOnClients démarré avec ${clients.length} clients`);
for (let i = 0; i < clients.length; i++) {
const client = clients[i];
console.log(`🔍 [DEBUG] Essai avec client ${i + 1}/${clients.length}`);
const result = await fn(client, i);
lastResult = result;
console.log(`🔍 [DEBUG] Client ${i + 1} résultat:`, { hasData: !!result.data, error: result.error });
if (result.data) {
console.log(`✅ [DEBUG] Données trouvées avec client ${i + 1}`);
return result;
}
if (result.error) {
const code = result.error.code;
console.log(`🔍 [DEBUG] Client ${i + 1} erreur code:`, code);
if (code && code !== 'PGRST116' && code !== 'PGRST108') {
console.log(`❌ [DEBUG] Erreur bloquante avec client ${i + 1}, arrêt`);
return result;
}
}
}
console.log(`❌ [DEBUG] Aucun client n'a trouvé de données`);
return lastResult;
};
// Récupérer les informations de l'organisation en premier
let orgId = typeof body.org_id === 'string' && body.org_id.trim().length > 0 ? body.org_id.trim() : null;
let orgName: string | null = null;
console.log('🔍 [DEBUG] org_id depuis body:', body.org_id);
console.log('🔍 [DEBUG] orgId après traitement:', orgId);
if (!orgId) {
console.log('🔍 [DEBUG] Pas d\'orgId, utilisation de resolveActiveOrg...');
// Utiliser resolveActiveOrg pour obtenir l'organisation active de l'utilisateur
orgId = await resolveActiveOrg(supabase);
console.log('🔍 [DEBUG] Organisation résolue via resolveActiveOrg:', orgId);
} else {
console.log('🔍 [DEBUG] OrgId fourni dans le body, pas besoin de résolution');
}
if (!orgId) {
console.error("❌ [DEBUG] Aucune organisation trouvée pour l'utilisateur:", user.id);
return NextResponse.json({ error: 'Organisation non trouvée' }, { status: 400 });
}
console.log('✅ [DEBUG] Organisation finale:', orgId);
const salarieMatricule = (body.salarie_matricule ?? '').toString().trim();
console.log('🔍 [DEBUG] Body original salarie_matricule:', body.salarie_matricule);
console.log('🔍 [DEBUG] Matricule après trim:', salarieMatricule);
console.log('🔍 [DEBUG] Type de matricule:', typeof salarieMatricule);
console.log('🔍 [DEBUG] Longueur matricule:', salarieMatricule.length);
console.log('🔍 [DEBUG] Staff user:', isStaff);
console.log('🔍 [DEBUG] OrgId à utiliser:', orgId);
console.log('🔍 [DEBUG] Nombre de clients Supabase:', clients.length);
console.log('🔍 [DEBUG] Service role disponible:', !!serviceSupabase);
if (!salarieMatricule) {
console.error('❌ [DEBUG] Matricule vide après trim');
return NextResponse.json({ error: 'Matricule salarié manquant' }, { status: 400 });
}
console.log('🔍 [DEBUG] Début recherche employé...');
const { data: employee, error: empError } = await runOnClients<EmployeeRow>(async (client, index) => {
console.log(`🔍 [DEBUG] Essai client ${index + 1}/${clients.length}`);
const isServiceRole = client === serviceSupabase;
console.log(`🔍 [DEBUG] Client service role: ${isServiceRole}`);
const { data, error } = await client
.from('salaries')
.select('id, code_salarie, nom, prenom, adresse_mail')
.eq('code_salarie', body.salarie_matricule)
.single();
.select('id, code_salarie, nom, prenom, adresse_mail, employer_id')
.eq('code_salarie', salarieMatricule)
.eq('employer_id', orgId)
.maybeSingle();
console.log("Résultat recherche employé:", { employee, empError });
console.log(`🔍 [DEBUG] Client ${index + 1} - Data:`, data);
console.log(`🔍 [DEBUG] Client ${index + 1} - Error:`, error);
return { data: (data as EmployeeRow | null), error };
});
console.log('🔍 [DEBUG] Résultat final recherche employé:', { employee, empError });
if (empError || !employee) {
// Essayons de voir quels employés existent
const { data: allEmployees } = await supabase
console.log('❌ [DEBUG] Employé non trouvé, recherche d\'exemples...');
// Essayer avec chaque client pour voir ce qui est disponible
for (let i = 0; i < clients.length; i++) {
const client = clients[i];
const isServiceRole = client === serviceSupabase;
console.log(`🔍 [DEBUG] Recherche exemples avec client ${i + 1} (service role: ${isServiceRole})`);
// Tous les employés de cette org
const { data: allEmployeesInOrg, error: allEmpError } = await client
.from('salaries')
.select('code_salarie, nom, prenom')
.select('code_salarie, nom, prenom, employer_id')
.eq('employer_id', orgId)
.limit(10);
console.log(`🔍 [DEBUG] Client ${i + 1} - Employés dans org ${orgId}:`, allEmployeesInOrg);
console.log(`🔍 [DEBUG] Client ${i + 1} - Erreur:`, allEmpError);
// Recherche par matricule sans filtrer par org
const { data: globalSearch, error: globalError } = await client
.from('salaries')
.select('code_salarie, nom, prenom, employer_id')
.eq('code_salarie', salarieMatricule)
.limit(5);
console.log("Exemple d'employés dans la base:", allEmployees);
console.log(`🔍 [DEBUG] Client ${i + 1} - Recherche globale matricule ${salarieMatricule}:`, globalSearch);
console.log(`🔍 [DEBUG] Client ${i + 1} - Erreur recherche globale:`, globalError);
// Premier employé de n'importe quelle org pour voir la structure
const { data: firstEmployee, error: firstError } = await client
.from('salaries')
.select('code_salarie, nom, prenom, employer_id')
.limit(1)
.maybeSingle();
console.log(`🔍 [DEBUG] Client ${i + 1} - Premier employé (structure):`, firstEmployee);
console.log(`🔍 [DEBUG] Client ${i + 1} - Erreur premier employé:`, firstError);
}
return NextResponse.json({ error: 'Employé non trouvé' }, { status: 404 });
}
// Récupérer les informations de l'organisation en premier
let orgId = body.org_id;
let orgName = null;
if (!orgId) {
// Utiliser resolveActiveOrg pour obtenir l'organisation active de l'utilisateur
orgId = await resolveActiveOrg(supabase);
console.log("Organisation résolue via resolveActiveOrg:", orgId);
}
if (!orgId) {
console.error("Aucune organisation trouvée pour l'utilisateur:", user.id);
return NextResponse.json({ error: 'Organisation non trouvée' }, { status: 400 });
}
// Récupérer le nom de l'organisation avec les détails de notification
const { data: organization, error: orgError } = await supabase
const { data: organization, error: orgError } = await runOnClients<OrganizationRow>(async (client) => {
const { data, error } = await client
.from('organizations')
.select(`
name,
@ -80,7 +247,9 @@ export async function POST(request: NextRequest) {
)
`)
.eq('id', orgId)
.single();
.maybeSingle();
return { data: (data as OrganizationRow | null), error };
});
if (orgError || !organization) {
console.error('Erreur récupération organisation:', orgError);
@ -90,33 +259,41 @@ export async function POST(request: NextRequest) {
orgName = organization.name;
// Récupérer les informations de la production
let production: any = null;
let prodError: any = null;
let production: ProductionRow | null = null;
let prodError: PostgrestError | null = null;
if (body.production_id) {
// Si un ID de production est fourni, récupérer par ID (production existante)
console.log("Recherche production par ID:", body.production_id);
const result = await supabase
console.log('Recherche production par ID:', body.production_id);
const result = await runOnClients<ProductionRow>(async (client) => {
const { data, error } = await client
.from('productions')
.select('id, name, reference')
.select('id, name, reference, org_id')
.eq('id', body.production_id)
.single();
.eq('org_id', orgId)
.maybeSingle();
return { data: (data as ProductionRow | null), error };
});
production = result.data;
prodError = result.error;
console.log("Résultat recherche production par ID:", { production, prodError });
} else {
console.log('Résultat recherche production par ID:', { production, prodError });
} else if (body.spectacle) {
// Sinon, rechercher par nom (pour créer une nouvelle production)
console.log("Recherche production par nom:", body.spectacle);
const result = await supabase
console.log('Recherche production par nom:', body.spectacle);
const result = await runOnClients<ProductionRow>(async (client) => {
const { data, error } = await client
.from('productions')
.select('id, name, reference')
.select('id, name, reference, org_id')
.eq('org_id', orgId)
.eq('name', body.spectacle)
.single();
.maybeSingle();
return { data: (data as ProductionRow | null), error };
});
production = result.data;
prodError = result.error;
console.log("Résultat recherche production par nom:", { production, prodError });
console.log('Résultat recherche production par nom:', { production, prodError });
}
// Si un ID de production était fourni mais que la production n'existe pas, retourner une erreur
@ -125,9 +302,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Production introuvable' }, { status: 404 });
}
// Si la production n'existe pas (recherche par nom uniquement), la créer automatiquement
if (!body.production_id && (prodError || !production)) {
console.log("Production non trouvée, création automatique...");
if (!body.production_id && (!production || prodError)) {
if (!body.spectacle) {
return NextResponse.json({ error: 'Nom du spectacle requis' }, { status: 400 });
}
console.log('Production non trouvée, création automatique...');
// Générer une référence unique pour la nouvelle production
const generateReference = () => {
@ -144,46 +324,42 @@ export async function POST(request: NextRequest) {
reference: body.numero_objet || generateReference(),
declaration_date: new Date().toISOString().split('T')[0], // YYYY-MM-DD
sent_date: null,
prod_type: "Spectacle vivant", // Valeur par défaut
prod_type: 'Spectacle vivant', // Valeur par défaut
director: null,
created_at: new Date().toISOString()
};
console.log("Données de la nouvelle production:", newProductionData);
console.log('Données de la nouvelle production:', newProductionData);
// Créer la nouvelle production
const { data: newProduction, error: createError } = await supabase
const writeClient: SupabaseClient = serviceSupabase ?? supabase;
let creationResult = await writeClient
.from('productions')
.insert(newProductionData)
.select('id, name, reference')
.select('id, name, reference, org_id')
.single();
if (createError) {
console.error('Erreur création nouvelle production:', createError);
// Si échec avec le client normal, essayer avec service_role
const serviceSupabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const { data: serviceProduction, error: serviceCreateError } = await serviceSupabase
if (creationResult.error && serviceSupabase && writeClient !== serviceSupabase) {
console.error('Erreur création nouvelle production:', creationResult.error);
creationResult = await serviceSupabase
.from('productions')
.insert(newProductionData)
.select('id, name, reference')
.select('id, name, reference, org_id')
.single();
}
if (serviceCreateError) {
console.error('Erreur création production avec service_role:', serviceCreateError);
if (creationResult.error || !creationResult.data) {
console.error('Erreur création production avec service_role:', creationResult.error);
return NextResponse.json({ error: 'Impossible de créer la production' }, { status: 500 });
}
production = serviceProduction;
console.log("Production créée avec service_role:", production);
} else {
production = newProduction;
console.log("Production créée avec succès:", production);
production = creationResult.data as ProductionRow;
console.log('Production créée avec succès:', production);
}
if (!production) {
console.error('Production introuvable après résolution/creation.');
return NextResponse.json({ error: 'Production introuvable' }, { status: 500 });
}
// Préparer les données du contrat selon la structure réelle de cddu_contracts
@ -287,9 +463,104 @@ export async function POST(request: NextRequest) {
finalContract = serviceContract;
}
// Créer une note système automatique pour tracer la création du contrat
try {
const now = new Date();
const dateStr = now.toLocaleDateString('fr-FR');
const timeStr = now.toLocaleTimeString('fr-FR');
// Récupérer le prénom et le rôle de l'utilisateur
const userFirstName = user.user_metadata?.first_name || user.user_metadata?.display_name?.split(' ')[0] || 'Utilisateur';
let userRole = null;
if (!isStaff) {
// Pour les clients, récupérer leur rôle dans l'organisation
try {
const { data: memberData } = await supabase
.from('organization_members')
.select('role')
.eq('org_id', orgId)
.eq('user_id', user.id)
.eq('revoked', false)
.maybeSingle();
userRole = memberData?.role || null;
} catch (err) {
console.warn('Erreur récupération rôle membre:', err);
}
}
const systemNoteContent = isStaff
? `Demande créée par le Staff Odentas le ${dateStr} à ${timeStr}`
: `Demande créée via l'Espace Paie par ${userFirstName}${userRole ? ` (${userRole})` : ''} le ${dateStr} à ${timeStr}`;
const systemNotePayload: NoteInsert = {
contract_id: contractId,
organization_id: orgId,
content: systemNoteContent,
source: 'Système',
};
const { error: systemNoteError } = await supabase
.from('notes')
.insert([systemNotePayload]);
if (systemNoteError) {
console.warn('Erreur insertion note système avec client standard, tentative service_role:', systemNoteError);
const serviceSupabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const { error: srSystemNoteError } = await serviceSupabase
.from('notes')
.insert([systemNotePayload]);
if (srSystemNoteError) {
console.error('Échec insertion note système même avec service_role:', srSystemNoteError);
}
}
} catch (systemNoteCatchErr) {
console.error('Exception lors de la création de la note système:', systemNoteCatchErr);
}
// Si une note a été fournie lors de la création, créer également une entrée dans la table `notes`
try {
const rawNote = typeof body.notes === 'string' ? body.notes.trim() : '';
if (rawNote) {
const notePayload: NoteInsert = {
contract_id: contractId,
organization_id: orgId,
content: rawNote,
source: 'Espace Paie',
};
const { error: noteError } = await supabase
.from('notes')
.insert([notePayload]);
if (noteError) {
console.warn('Erreur insertion note avec client standard, tentative service_role:', noteError);
const serviceSupabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const { error: srNoteError } = await serviceSupabase
.from('notes')
.insert([notePayload]);
if (srNoteError) {
console.error('Échec insertion note même avec service_role:', srNoteError);
}
}
}
} catch (noteCatchErr) {
console.error('Exception lors de la création de la note liée au contrat:', noteCatchErr);
}
// Envoyer les notifications par email après la création réussie du contrat
try {
const shouldSendEmail = body.send_email_confirmation !== false; // envoi par défaut, sauf si explicitement à false
if (shouldSendEmail) {
await sendContractNotifications(contractData, organization);
} else {
console.log('Email confirmation disabled by user choice, skipping notifications.');
}
} catch (emailError) {
console.error('Erreur envoi notifications email:', emailError);
// Ne pas faire échouer la création du contrat si l'envoi d'email échoue
@ -308,10 +579,19 @@ export async function POST(request: NextRequest) {
}
function generateContractReference(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
const letters = 'ABCDEFGHIKLMNPQRSTUVWXYZ'; // sans O
const digits = '123456789'; // sans 0
const pool = letters + digits;
const pick = (source: string) => source[Math.floor(Math.random() * source.length)];
while (true) {
let ref = '';
for (let i = 0; i < 8; i += 1) {
ref += pick(pool);
}
if (ref.startsWith('RG')) continue;
if (!/[A-Z]/.test(ref)) continue;
if (!/[1-9]/.test(ref)) continue;
return ref;
}
return result;
}

View file

@ -1,6 +1,7 @@
// app/api/contrats/[id]/generate-pdf/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
import { createClient } from "@supabase/supabase-js";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { PROFESSIONS_ARTISTE, ProfessionOption } from "@/components/constants/ProfessionsArtiste";
import { promises as fs } from "fs";
@ -25,12 +26,29 @@ async function getTechniciensData(): Promise<ProfessionOption[]> {
}
}
// Fonction pour récupérer les féminisations
// Fonction pour récupérer les féminisations depuis Supabase
async function getFeminisations(): Promise<ProfessionFeminisation[]> {
try {
const filePath = path.join(process.cwd(), 'public/data/professions-feminisations.json');
const fileContent = await fs.readFile(filePath, 'utf8');
return JSON.parse(fileContent);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
persistSession: false
}
});
const { data: feminisations, error } = await supabase
.from('professions_feminisations')
.select('profession_code, profession_label, profession_feminine');
if (error) {
console.warn("Erreur lors du chargement des féminisations depuis Supabase:", error);
return [];
}
return feminisations || [];
} catch (error) {
console.warn("Erreur lors du chargement des féminisations:", error);
return [];
@ -416,7 +434,7 @@ export async function POST(
console.log("Upload du fichier dans S3 sous la clé:", s3Key);
const uploadCommand = new PutObjectCommand({
Bucket: "odentas-docs",
Bucket: (process.env.AWS_S3_BUCKET || "odentas-docs").trim(),
Key: s3Key,
Body: pdfBuffer,
ContentType: "application/pdf",
@ -426,7 +444,8 @@ export async function POST(
console.log("Fichier PDF uploadé sur S3 avec succès.");
// URL S3 pour accéder au fichier
const s3Url = `https://odentas-docs.s3.eu-west-3.amazonaws.com/${s3Key}`;
const bucketName = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
const s3Url = `https://${bucketName}.s3.eu-west-3.amazonaws.com/${s3Key}`;
// Mettre à jour le contrat avec l'URL du PDF
console.log("Mise à jour du contrat avec:", {

View file

@ -36,13 +36,30 @@ export async function GET(
// Récupération du contrat et de l'URL du PDF
const { data: contract, error: contractError } = await sb
.from("cddu_contracts")
.select("contract_pdf_filename")
.select("contract_pdf_filename, contract_number, employee_name")
.eq("id", params.id)
.single();
if (contractError || !contract?.contract_pdf_filename) {
if (contractError) {
return NextResponse.json(
{ error: "PDF non trouvé pour ce contrat" },
{ error: "Contrat non trouvé", details: contractError },
{ status: 404 }
);
}
if (!contract?.contract_pdf_filename) {
// Diagnostic : renvoyer des infos sur le contrat
return NextResponse.json(
{
error: "PDF non trouvé pour ce contrat",
debug: {
contractId: params.id,
contractNumber: contract?.contract_number,
employeeName: contract?.employee_name,
contract_pdf_filename: contract?.contract_pdf_filename,
hasFilename: !!contract?.contract_pdf_filename
}
},
{ status: 404 }
);
}
@ -58,7 +75,7 @@ export async function GET(
// Génération de l'URL pré-signée (valide 1 heure)
const command = new GetObjectCommand({
Bucket: "odentas-docs",
Bucket: (process.env.AWS_S3_BUCKET || "odentas-docs").trim(),
Key: `unsigned-contracts/${contract.contract_pdf_filename}`,
});
@ -67,7 +84,8 @@ export async function GET(
});
return NextResponse.json({
signedUrl: signedUrl,
pdfUrl: signedUrl,
signedUrl: signedUrl, // Garde la compatibilité
filename: contract.contract_pdf_filename
});

View file

@ -352,19 +352,56 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
// Récupérer les données d'organisation avec tous les détails
let organizationData;
if (org.id === null) {
// Pour staff, utiliser l'organisation du contrat
organizationData = { organization_details: {} };
} else {
// Pour staff, récupérer l'organisation du contrat depuis la DB
if (contractData.org_id) {
const { data: orgDetails } = await supabase
.from("organizations")
.select("*, organization_details(*)")
.eq("id", contractData.org_id)
.single();
organizationData = orgDetails || { organization_details: {} };
} else {
// Pas d'organisation liée, skip les notifications
console.log("⚠️ No organization linked to contract, skipping email notifications");
organizationData = null;
}
} else {
const { data: orgDetails, error: orgError } = await supabase
.from("organizations")
.select("*, organization_details(*)")
.eq("id", org.id)
.single();
console.log("🔍 [ROUTE DEBUG] Organization data retrieved for non-staff user:", {
orgId: org.id,
orgName: org.name,
orgError,
hasOrgDetails: !!orgDetails,
orgDetailsStructure: orgDetails ? {
id: orgDetails.id,
name: orgDetails.name,
hasOrganizationDetails: !!orgDetails.organization_details,
organizationDetailsIsArray: Array.isArray(orgDetails.organization_details),
organizationDetailsKeys: orgDetails.organization_details ? Object.keys(orgDetails.organization_details) : null,
emailNotifs: orgDetails.organization_details?.email_notifs,
emailNotifsCC: orgDetails.organization_details?.email_notifs_cc
} : null
});
organizationData = orgDetails;
}
if (organizationData) {
console.log("🔍 [ROUTE DEBUG] About to send email notifications with organizationData:", {
orgId: organizationData.id,
orgName: organizationData.name,
hasOrgDetails: !!organizationData.organization_details,
emailNotifs: organizationData.organization_details?.email_notifs,
emailNotifsCC: organizationData.organization_details?.email_notifs_cc
});
await sendContractUpdateNotifications(contractData, organizationData);
}
}
} catch (emailError) {
console.error("⚠️ EMAIL NOTIFICATION ERROR (non-blocking):", { contractId, error: emailError, requestId });
}
@ -488,19 +525,57 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
// Récupérer les données d'organisation avec tous les détails
let organizationData;
if (org.id === null) {
// Pour staff, utiliser l'organisation du contrat
organizationData = { organization_details: {} };
// 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 || "");
const { data: orgDetails } = await admin
.from("organizations")
.select("*, organization_details(*)")
.eq("id", contractData.org_id)
.single();
organizationData = orgDetails || { organization_details: {} };
} else {
const { data: orgDetails } = await supabase
// Pas d'organisation liée, skip les notifications
console.log("⚠️ No organization linked to contract, skipping email notifications");
organizationData = null;
}
} else {
const { data: orgDetails, error: orgError } = await supabase
.from("organizations")
.select("*, organization_details(*)")
.eq("id", org.id)
.single();
console.log("🔍 [ROUTE DEBUG] Organization data retrieved for non-staff user (upstream sync):", {
orgId: org.id,
orgName: org.name,
orgError,
hasOrgDetails: !!orgDetails,
orgDetailsStructure: orgDetails ? {
id: orgDetails.id,
name: orgDetails.name,
hasOrganizationDetails: !!orgDetails.organization_details,
organizationDetailsIsArray: Array.isArray(orgDetails.organization_details),
organizationDetailsKeys: orgDetails.organization_details ? Object.keys(orgDetails.organization_details) : null,
emailNotifs: orgDetails.organization_details?.email_notifs,
emailNotifsCC: orgDetails.organization_details?.email_notifs_cc
} : null
});
organizationData = orgDetails;
}
if (organizationData) {
console.log("🔍 [ROUTE DEBUG] About to send email notifications with organizationData (upstream sync):", {
orgId: organizationData.id,
orgName: organizationData.name,
hasOrgDetails: !!organizationData.organization_details,
emailNotifs: organizationData.organization_details?.email_notifs,
emailNotifsCC: organizationData.organization_details?.email_notifs_cc
});
await sendContractUpdateNotifications(contractData, organizationData);
}
}
} catch (emailError) {
console.error("⚠️ EMAIL NOTIFICATION ERROR (non-blocking):", { contractId, error: emailError, requestId });
}
@ -555,7 +630,20 @@ export async function DELETE(req: NextRequest, { params }: { params: { id: strin
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;
organizationData = { organization_details: {} };
// Pour staff, récupérer l'organisation du contrat depuis la DB
if (contractData?.org_id) {
const { data: orgDetails } = await admin
.from("organizations")
.select("*, organization_details(*)")
.eq("id", contractData.org_id)
.single();
organizationData = orgDetails || { organization_details: {} };
} else {
// Pas d'organisation liée, skip les notifications
console.log("⚠️ No organization linked to contract, skipping email notifications");
organizationData = null;
}
} else {
// Client avec accès limité à son organisation
const { data } = await supabase.from("cddu_contracts").select("*").eq("id", contractId).eq("org_id", org.id).single();
@ -611,7 +699,11 @@ export async function DELETE(req: NextRequest, { params }: { params: { id: strin
// 4. Envoyer les notifications email d'annulation
try {
if (organizationData) {
await sendContractCancellationNotifications(contractData, organizationData);
} else {
console.log("⚠️ No organization data available, skipping cancellation email notifications");
}
} catch (emailError) {
console.error("⚠️ EMAIL NOTIFICATION ERROR (non-blocking):", { contractId, error: emailError, requestId });
}

View file

@ -0,0 +1,127 @@
// app/api/contrats/generate-batch-pdf/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
export async function POST(request: NextRequest) {
try {
const sb = createSbServer();
// Vérification de l'authentification et du staff
const { data: { user }, error: authError } = await sb.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "Authentification requise" },
{ status: 401 }
);
}
const { data: staffUser } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staffUser?.is_staff) {
return NextResponse.json(
{ error: "Accès refusé - réservé au staff" },
{ status: 403 }
);
}
// Parse du body pour récupérer les IDs des contrats
const { contractIds }: { contractIds: string[] } = await request.json();
if (!contractIds || !Array.isArray(contractIds) || contractIds.length === 0) {
return NextResponse.json(
{ error: "Liste des contrats requise" },
{ status: 400 }
);
}
// Limiter le nombre de contrats à traiter en une fois pour éviter les timeouts (30s réels)
// Estimation : ~2-3s par PDF + délai réduit = ~2-3s par contrat
// Avec 30s de timeout réel, on peut traiter 10-12 contrats maximum
if (contractIds.length > 10) {
return NextResponse.json(
{ error: "Maximum 10 contrats peuvent être traités en une fois à cause du timeout de 30s. Les contrats supplémentaires seront traités automatiquement par groupes." },
{ status: 400 }
);
}
const results = [];
const errors = [];
// Traiter chaque contrat séquentiellement pour éviter de surcharger les APIs externes
for (const contractId of contractIds) {
try {
console.log(`[BATCH PDF] Traitement du contrat ${contractId}`);
// Appeler l'endpoint individuel pour chaque contrat
const response = await fetch(`${request.nextUrl.origin}/api/contrats/${contractId}/generate-pdf`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Forward les cookies pour l'authentification
'Cookie': request.headers.get('cookie') || '',
},
});
if (response.ok) {
const result = await response.json();
results.push({
contractId,
success: true,
filename: result.filename,
message: result.message
});
console.log(`[BATCH PDF] ✅ Succès pour le contrat ${contractId}`);
} else {
const errorData = await response.json();
errors.push({
contractId,
success: false,
error: errorData.error || `Erreur ${response.status}`
});
console.log(`[BATCH PDF] ❌ Erreur pour le contrat ${contractId}: ${errorData.error}`);
}
} catch (error) {
errors.push({
contractId,
success: false,
error: error instanceof Error ? error.message : "Erreur inconnue"
});
console.log(`[BATCH PDF] ❌ Exception pour le contrat ${contractId}:`, error);
}
// Réduire le délai entre les traitements pour optimiser le temps dans la limite de 30s
await new Promise(resolve => setTimeout(resolve, 200));
}
// Résumé des résultats
const totalProcessed = contractIds.length;
const successCount = results.length;
const errorCount = errors.length;
return NextResponse.json({
success: true,
message: `Traitement terminé: ${successCount}/${totalProcessed} PDFs créés avec succès`,
summary: {
totalProcessed,
successCount,
errorCount
},
results,
errors
});
} catch (error) {
console.error("Erreur lors de la génération batch de PDFs:", error);
return NextResponse.json(
{
error: "Erreur interne du serveur",
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}

View file

@ -1,9 +1,11 @@
// app/api/contrats/route.ts
import { NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { cookies, headers } from "next/headers";
import { resolveActiveOrg } from "@/lib/resolveActiveOrg";
import { createClient } from '@supabase/supabase-js';
import { detectDemoModeFromHeaders } from "@/lib/demo-detector";
import { DEMO_CONTRACTS, DEMO_ORGANIZATION } from "@/lib/demo-data";
// Force dynamic rendering and disable revalidation cache for this proxy
export const dynamic = 'force-dynamic';
@ -24,6 +26,54 @@ function buildUpstreamUrl(req: Request) {
}
export async function GET(req: Request) {
// 🎭 Vérification du mode démo en premier
const h = headers();
const isDemoMode = detectDemoModeFromHeaders(h);
if (isDemoMode) {
console.log("🎭 [API CONTRATS] Mode démo détecté - renvoi de données fictives");
const url = new URL(req.url);
const regime = url.searchParams.get("regime");
const status = url.searchParams.get("status");
const page = parseInt(url.searchParams.get("page") || "1");
const limit = parseInt(url.searchParams.get("limit") || "10");
// Filtrer les contrats selon les paramètres
let filteredContracts = DEMO_CONTRACTS;
if (regime === "CDDU") {
filteredContracts = filteredContracts.filter(c =>
c.regime === "CDDU_MONO" || c.regime === "CDDU_MULTI"
);
} else if (regime === "RG") {
filteredContracts = filteredContracts.filter(c => c.regime === "RG");
}
if (status === "en_cours") {
filteredContracts = filteredContracts.filter(c =>
c.etat === "en_cours" || c.etat === "signe"
);
} else if (status === "termines") {
filteredContracts = filteredContracts.filter(c =>
c.etat === "traitee"
);
}
// Pagination
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedContracts = filteredContracts.slice(startIndex, endIndex);
return NextResponse.json({
contrats: paginatedContracts,
total: filteredContracts.length,
page,
limit,
totalPages: Math.ceil(filteredContracts.length / limit)
});
}
try {
const url = new URL(req.url);
const regime = url.searchParams.get("regime");

View file

@ -0,0 +1,32 @@
// app/api/debug-documents/route.ts
export const dynamic = "force-dynamic";
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET(req: Request) {
const c = cookies();
const { searchParams } = new URL(req.url);
const cookieInfo = {
active_org_key: c.get("active_org_key")?.value || null,
active_org_name: c.get("active_org_name")?.value || null,
active_org_id: c.get("active_org_id")?.value || null,
};
const params = {
category: searchParams.get('category'),
all_params: Object.fromEntries(searchParams.entries())
};
const apiBase = process.env.NEXT_PUBLIC_API_BASE || process.env.LAMBDA_API_BASE;
return NextResponse.json({
message: "Debug Documents API",
cookies: cookieInfo,
params,
env: {
apiBase,
hasApiBase: !!apiBase
}
});
}

View file

@ -0,0 +1,75 @@
// Debug endpoint pour vérifier toutes les variables d'environnement
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
try {
console.log('🔍 [ENV DEBUG] Analyse des variables d\'environnement');
// Liste des variables importantes à vérifier
const envVars = [
'AWS_REGION',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_S3_BUCKET',
'AWS_SES_FROM',
'DOCUSEAL_TOKEN',
'DOCUSEAL_API_BASE',
'PDFMONKEY_URL',
'PDFMONKEY_API_KEY',
'GOCARDLESS_ACCESS_TOKEN',
'GOCARDLESS_ENVIRONMENT',
'NEXT_PUBLIC_SUPABASE_URL',
'SUPABASE_SERVICE_ROLE_KEY',
'S3_BUCKET_NAME',
'S3_BUCKET_NAME_EMAILS',
'UPSTREAM_API_BASE'
];
const envCheck: Record<string, any> = {};
envVars.forEach(varName => {
const rawValue = process.env[varName];
const trimmedValue = rawValue?.trim();
envCheck[varName] = {
exists: !!rawValue,
rawLength: rawValue?.length || 0,
trimmedLength: trimmedValue?.length || 0,
hasDifference: rawValue !== trimmedValue,
hasNewlines: rawValue?.includes('\n') || false,
hasCarriageReturn: rawValue?.includes('\r') || false,
hasSpaces: rawValue?.startsWith(' ') || rawValue?.endsWith(' ') || false,
preview: rawValue ? rawValue.substring(0, 20) + '...' : 'undefined'
};
if (envCheck[varName].hasDifference) {
console.log(`⚠️ [ENV DEBUG] Variable ${varName} contient des caractères parasites!`, {
raw: JSON.stringify(rawValue),
trimmed: JSON.stringify(trimmedValue)
});
}
});
// Compter les variables avec des problèmes
const problematicVars = Object.entries(envCheck)
.filter(([_, info]) => info.hasDifference)
.map(([name]) => name);
return NextResponse.json({
status: 'Environment Variables Debug',
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV,
totalVarsChecked: envVars.length,
problematicVars,
problematicCount: problematicVars.length,
details: envCheck
});
} catch (error) {
console.error('❌ [ENV DEBUG] Global error:', error);
return NextResponse.json({
error: 'Global environment debug error',
details: error instanceof Error ? error.message : String(error)
}, { status: 500 });
}
}

61
app/api/debug-s3/route.ts Normal file
View file

@ -0,0 +1,61 @@
// Debug endpoint pour tester S3 en production
import { NextRequest, NextResponse } from "next/server";
import { s3ObjectExists, getS3SignedUrl } from "@/lib/aws-s3";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const testKey = searchParams.get('key') || 'test-key-that-does-not-exist';
console.log('🔍 [S3 TEST] Testing S3 configuration in production');
// Test des variables d'environnement
const envCheck = {
AWS_REGION: process.env.AWS_REGION,
AWS_S3_BUCKET: process.env.AWS_S3_BUCKET,
hasAccessKey: !!process.env.AWS_ACCESS_KEY_ID,
hasSecretKey: !!process.env.AWS_SECRET_ACCESS_KEY,
accessKeyLength: process.env.AWS_ACCESS_KEY_ID?.length,
secretKeyLength: process.env.AWS_SECRET_ACCESS_KEY?.length,
isProduction: process.env.NODE_ENV === 'production'
};
console.log('🔍 [S3 TEST] Environment variables:', envCheck);
// Test d'existence d'un objet (qui n'existe probablement pas)
let existsResult;
try {
existsResult = await s3ObjectExists(testKey);
console.log('✅ [S3 TEST] Object exists test completed:', existsResult);
} catch (error) {
console.error('❌ [S3 TEST] Error checking object existence:', error);
existsResult = `ERROR: ${error}`;
}
// Test de génération d'URL (pour un objet qui n'existe pas - ça devrait quand même générer l'URL)
let urlResult;
try {
urlResult = await getS3SignedUrl(testKey, 60);
console.log('✅ [S3 TEST] URL generation completed');
} catch (error) {
console.error('❌ [S3 TEST] Error generating URL:', error);
urlResult = `ERROR: ${error}`;
}
return NextResponse.json({
status: 'S3 Debug Test',
environment: envCheck,
testKey,
exists: existsResult,
signedUrl: typeof urlResult === 'string' ? 'Generated successfully' : urlResult,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('❌ [S3 TEST] Global error:', error);
return NextResponse.json({
error: 'Global S3 test error',
details: error instanceof Error ? error.message : String(error)
}, { status: 500 });
}
}

View file

@ -0,0 +1,106 @@
// app/api/debug/pdf-diagnosis/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";
export async function GET(request: NextRequest) {
try {
const sb = createSbServer();
// Vérification de l'authentification et du staff
const { data: { user }, error: authError } = await sb.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "Authentification requise" },
{ status: 401 }
);
}
const { data: staffUser } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staffUser?.is_staff) {
return NextResponse.json(
{ error: "Accès refusé - réservé au staff" },
{ status: 403 }
);
}
// Get a sample of contracts with their PDF filenames
const { data: contracts, error: contractsError } = await sb
.from("cddu_contracts")
.select(`
id,
contract_number,
contract_pdf_filename,
employee_name
`)
.order("created_at", { ascending: false })
.limit(10);
if (contractsError) {
return NextResponse.json(
{ error: "Erreur lors de la récupération des contrats", details: contractsError },
{ status: 500 }
);
}
// Configuration S3
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
// List some files in the unsigned-contracts folder
const listCommand = new ListObjectsV2Command({
Bucket: (process.env.AWS_S3_BUCKET || "odentas-docs").trim(),
Prefix: "unsigned-contracts/",
MaxKeys: 20
});
const s3Objects = await s3Client.send(listCommand);
const analysis = {
database_contracts: contracts?.map(c => ({
id: c.id,
contract_number: c.contract_number,
employee_name: c.employee_name,
contract_pdf_filename: c.contract_pdf_filename,
has_filename: !!c.contract_pdf_filename,
filename_length: c.contract_pdf_filename?.length || 0
})) || [],
s3_files: s3Objects.Contents?.map(obj => ({
key: obj.Key,
size: obj.Size,
lastModified: obj.LastModified
})) || [],
statistics: {
total_contracts_checked: contracts?.length || 0,
contracts_with_filename: contracts?.filter(c => c.contract_pdf_filename).length || 0,
contracts_without_filename: contracts?.filter(c => !c.contract_pdf_filename).length || 0,
s3_files_count: s3Objects.Contents?.length || 0
}
};
return NextResponse.json({
success: true,
data: analysis
});
} catch (error) {
console.error("Erreur lors du diagnostic PDF:", error);
return NextResponse.json(
{
error: "Erreur interne du serveur",
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,28 @@
// Test rapide pour vérifier l'URL détectée
// À lancer temporairement en local pour debug
export async function GET() {
const getBaseUrl = () => {
console.log('NEXT_PUBLIC_SITE_URL:', process.env.NEXT_PUBLIC_SITE_URL);
console.log('VERCEL_URL:', process.env.VERCEL_URL);
if (process.env.NEXT_PUBLIC_SITE_URL) {
return process.env.NEXT_PUBLIC_SITE_URL;
}
if (process.env.VERCEL_URL) {
return `https://${process.env.VERCEL_URL}`;
}
return "http://localhost:3000";
};
const detectedUrl = getBaseUrl();
return Response.json({
detectedUrl,
env: {
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
VERCEL_URL: process.env.VERCEL_URL,
NODE_ENV: process.env.NODE_ENV
}
});
}

View file

@ -0,0 +1,221 @@
// app/api/dl-contrat-signe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import { getS3SignedUrlIfExists } from "@/lib/aws-s3";
import AWS from "aws-sdk";
// Configuration AWS pour DynamoDB
const dynamoDB = new AWS.DynamoDB.DocumentClient({
region: process.env.AWS_REGION || 'eu-west-3',
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
});
// Fonction pour récupérer le docusealSubID depuis DynamoDB
async function getDocusealSubIdFromDynamoDB(submissionId: string): Promise<string | null> {
try {
console.log("🔍 Recherche dans DynamoDB pour submission_id:", submissionId);
const params = {
TableName: 'DocuSealNotification',
Key: { submission_id: submissionId }
};
const result = await dynamoDB.get(params).promise();
if (result.Item) {
const docusealSubID = result.Item.docusealSubID || result.Item.docuseal_submission_id;
console.log("✅ DocusealSubID trouvé dans DynamoDB:", docusealSubID);
return docusealSubID;
} else {
console.log("❌ Aucune entrée trouvée dans DynamoDB pour:", submissionId);
return null;
}
} catch (error) {
console.error("❌ Erreur lors de la recherche DynamoDB:", error);
return null;
}
}
export async function GET(request: NextRequest) {
try {
const url = new URL(request.url);
const contractId = url.searchParams.get("contract_id");
if (!contractId) {
return NextResponse.json(
{ error: "Le paramètre contract_id est requis" },
{ status: 400 }
);
}
// Utiliser le service role pour accéder aux données sans authentification
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseServiceRole = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseServiceRole) {
console.error("Configuration Supabase manquante");
return NextResponse.json(
{ error: "Configuration serveur incomplète" },
{ status: 500 }
);
}
const sb = createClient(supabaseUrl, supabaseServiceRole, {
auth: {
autoRefreshToken: false,
persistSession: false
}
});
// Rechercher le contrat par différentes méthodes
let contract = null;
let contractError = null;
let searchMethod = "";
// 1. Essayer par docuseal_submission_id direct
console.log("🔍 Recherche par docuseal_submission_id:", contractId);
const { data: contractBySubmission, error: errorBySubmission } = await sb
.from("cddu_contracts")
.select("id, contract_pdf_s3_key, contract_number, docuseal_submission_id")
.eq("docuseal_submission_id", contractId)
.single();
if (contractBySubmission) {
contract = contractBySubmission;
searchMethod = "docuseal_submission_id_direct";
console.log("✅ Contrat trouvé par docuseal_submission_id direct");
} else {
console.log("❌ Pas de contrat trouvé par docuseal_submission_id direct:", errorBySubmission?.message);
// 2. Si ça ressemble à un submission_id DynamoDB (format: contrats/contrat_cddu_XXX_YYY)
if (contractId.includes('contrats/contrat_cddu_')) {
console.log("🔍 Format DynamoDB détecté, recherche dans DynamoDB...");
const docusealSubID = await getDocusealSubIdFromDynamoDB(contractId);
if (docusealSubID) {
console.log("🔍 Recherche par docusealSubID depuis DynamoDB:", docusealSubID);
const { data: contractByDynamoDB, error: errorByDynamoDB } = await sb
.from("cddu_contracts")
.select("id, contract_pdf_s3_key, contract_number, docuseal_submission_id")
.eq("docuseal_submission_id", docusealSubID)
.single();
if (contractByDynamoDB) {
contract = contractByDynamoDB;
searchMethod = "dynamodb_lookup";
console.log("✅ Contrat trouvé via DynamoDB lookup");
} else {
console.log("❌ Pas de contrat trouvé avec docusealSubID depuis DynamoDB:", errorByDynamoDB?.message);
}
}
}
// 3. Si ça ressemble à une clé S3, essayer par contract_pdf_s3_key
if (!contract && contractId.includes('/') && contractId.includes('.')) {
console.log("🔍 Recherche par contract_pdf_s3_key:", contractId);
const { data: contractByS3Key, error: errorByS3Key } = await sb
.from("cddu_contracts")
.select("id, contract_pdf_s3_key, contract_number, docuseal_submission_id")
.eq("contract_pdf_s3_key", contractId)
.single();
if (contractByS3Key) {
contract = contractByS3Key;
searchMethod = "contract_pdf_s3_key";
console.log("✅ Contrat trouvé par contract_pdf_s3_key");
} else {
console.log("❌ Pas de contrat trouvé par contract_pdf_s3_key:", errorByS3Key?.message);
}
}
// 4. Essayer par contract_number si c'est un simple string
if (!contract) {
console.log("🔍 Recherche par contract_number:", contractId);
const { data: contractByNumber, error: errorByNumber } = await sb
.from("cddu_contracts")
.select("id, contract_pdf_s3_key, contract_number, docuseal_submission_id")
.eq("contract_number", contractId)
.single();
if (contractByNumber) {
contract = contractByNumber;
searchMethod = "contract_number";
console.log("✅ Contrat trouvé par contract_number");
} else {
console.log("❌ Pas de contrat trouvé par contract_number:", errorByNumber?.message);
}
}
}
if (!contract) {
console.error("❌ Contrat introuvable par aucune méthode pour:", contractId);
return NextResponse.json(
{
error: "Contrat introuvable",
debug: {
searchedValue: contractId,
searchMethods: ["docuseal_submission_id_direct", "dynamodb_lookup", "contract_pdf_s3_key", "contract_number"]
}
},
{ status: 404 }
);
}
// Vérification de la présence de la clé S3
if (!contract.contract_pdf_s3_key) {
console.log("❌ Aucune clé S3 trouvée pour le contrat:", contract.id);
return NextResponse.json(
{
error: "Aucun contrat signé disponible",
debug: {
contractId: contract.id,
contractNumber: contract.contract_number,
hasS3Key: false
}
},
{ status: 404 }
);
}
console.log("🔑 Clé S3 trouvée:", contract.contract_pdf_s3_key);
// Génération de l'URL pré-signée
const presignedUrl = await getS3SignedUrlIfExists(contract.contract_pdf_s3_key, 3600);
if (!presignedUrl) {
console.log("❌ Fichier non trouvé dans S3 pour la clé:", contract.contract_pdf_s3_key);
return NextResponse.json(
{
error: "Contrat signé non trouvé dans le stockage",
debug: {
s3Key: contract.contract_pdf_s3_key,
contractNumber: contract.contract_number
}
},
{ status: 404 }
);
}
console.log("✅ URL pré-signée générée avec succès");
return NextResponse.json({
body: {
presignedUrl,
contractNumber: contract.contract_number
},
debug: {
foundBy: searchMethod,
originalSearchValue: contractId,
s3Key: contract.contract_pdf_s3_key,
docusealSubmissionId: contract.docuseal_submission_id
}
});
} catch (error) {
console.error("Erreur lors de la récupération du contrat:", error);
return NextResponse.json(
{ error: "Erreur interne du serveur" },
{ status: 500 }
);
}
}

View file

@ -1,78 +1,122 @@
// app/api/documents/route.ts
export const dynamic = "force-dynamic";
import { NextResponse } from "next/server";
import { cookies, headers } from "next/headers";
import { cookies } from "next/headers";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE || process.env.LAMBDA_API_BASE; // ex: https://XXXX.execute-api.eu-west-3.amazonaws.com/default
function json(status: number, body: any) {
return NextResponse.json(body, { status });
}
export async function GET(req: Request) {
try {
if (!API_BASE) return json(500, { error: "missing_env_NEXT_PUBLIC_API_BASE" });
const c = cookies();
const h = headers();
const sb = createRouteHandlerClient({ cookies });
// 1) Sources possibles pour l'orga
// a) cookies (prioritaires côté app)
let orgKey = c.get("active_org_key")?.value || "";
let orgName = c.get("active_org_name")?.value || "";
// 1) Récupérer la catégorie depuis les query params
const { searchParams } = new URL(req.url);
const category = searchParams.get("category");
if (!category) {
return json(400, { error: "missing_category_parameter" });
}
console.log('📄 Documents API - Category:', category);
// 2) Déterminer l'organisation active
let orgId = c.get("active_org_id")?.value || "";
// b) headers (si le client envoie manuellement)
if (!orgKey) orgKey = h.get("x-company-key") || orgKey;
if (!orgName) orgName = h.get("x-company-name") || orgName;
// On accepte aussi une version base64 (au cas où)
if (!orgName) {
const b64 = h.get("x-company-name-b64");
if (b64) {
try { orgName = Buffer.from(b64, "base64").toString("utf8"); } catch {}
}
}
// c) fallback: si on a l'ID mais pas le nom/clé, on va chercher dans Supabase
if (!orgKey && !orgName && orgId) {
const sb = createRouteHandlerClient({ cookies });
const { data } = await sb.from("organizations").select("name").eq("id", orgId).maybeSingle();
if (data?.name) orgName = data.name;
}
// If we don't have company key/name, continue anyway.
// For staff users (null-org semantics) we want to allow calls without an active org.
// Only the Lambda endpoint may require company headers; we only forward them when present.
// 2) Construire l'URL Lambda (/documents) et propager les query params
const lambdaURL = new URL(API_BASE.replace(/\/$/, "") + "/documents");
const src = new URL(req.url);
src.searchParams.forEach((v, k) => lambdaURL.searchParams.set(k, v));
// 3) Préparer les headers pour la Lambda
const outHeaders: Record<string, string> = { accept: "application/json" };
if (orgKey) {
outHeaders["x-company-key"] = orgKey;
} else if (orgName) {
const b64 = Buffer.from(orgName, "utf8").toString("base64");
outHeaders["x-company-name-b64"] = b64;
}
const res = await fetch(lambdaURL.toString(), {
method: "GET",
headers: outHeaders,
cache: "no-store",
console.log('📄 Documents API - Org ID from cookie:', orgId);
console.log('📄 Documents API - All cookies:', {
active_org_id: c.get("active_org_id")?.value,
active_org_name: c.get("active_org_name")?.value,
active_org_key: c.get("active_org_key")?.value,
});
if (!res.ok) {
const text = await res.text();
return json(502, { error: "lambda_error", status: res.status, body: text });
// 3) Si pas d'orgId dans les cookies, vérifier si c'est un client authentifié
if (!orgId) {
const { data: { user }, error: userError } = await sb.auth.getUser();
console.log('📄 Documents API - User:', user?.id, 'Error:', userError);
if (!user) {
return json(401, { error: "unauthorized", details: "No user found" });
}
const data = await res.json();
return json(200, data);
} catch (e: any) {
return json(500, { error: "internal_error", message: e?.message || String(e) });
// Vérifier si c'est un staff
const { data: staffUser } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
console.log('📄 Documents API - Is staff?', staffUser?.is_staff);
// Si c'est un staff sans org sélectionnée, retourner une erreur explicite
if (staffUser?.is_staff) {
return json(400, {
error: "no_organization_selected",
details: "Staff user must select an organization first"
});
}
// Récupérer l'organisation du client via organization_members
const { data: member, error: memberError } = await sb
.from("organization_members")
.select("org_id")
.eq("user_id", user.id)
.eq("revoked", false)
.maybeSingle();
console.log('📄 Documents API - Member:', member, 'Error:', memberError);
if (member?.org_id) {
orgId = member.org_id;
console.log('📄 Documents API - Org ID from member:', orgId);
}
}
if (!orgId) {
return json(400, { error: "no_organization_found" });
}
// 4) Récupérer les documents depuis Supabase avec RLS
console.log('📄 Documents API - Fetching from Supabase with org_id:', orgId, 'category:', category);
const { data: documents, error } = await sb
.from("documents")
.select("*")
.eq("org_id", orgId)
.eq("category", category)
.order("date_added", { ascending: false });
if (error) {
console.error('📄 Documents API - Supabase Error:', error);
return json(500, { error: "supabase_error", details: error.message });
}
console.log('📄 Documents API - Found documents:', documents?.length || 0);
// 5) Transformer les documents au format attendé par le frontend
const formattedDocuments = (documents || []).map(doc => ({
id: doc.id,
title: doc.filename || doc.type_label || 'Document',
url: doc.storage_path, // L'URL S3 présignée sera générée côté client si nécessaire
updatedAt: doc.date_added,
sizeBytes: doc.size_bytes || 0,
period_label: doc.period_label,
meta: {
category: doc.category,
type_label: doc.type_label,
}
}));
console.log('📄 Documents API - Returning formatted documents:', formattedDocuments.length);
return json(200, formattedDocuments);
} catch (err: any) {
console.error('📄 Documents API - Unexpected error:', err);
return json(500, { error: "server_error", message: err.message });
}
}

View file

@ -3,25 +3,26 @@ import { createSbServiceRole } from '@/lib/supabaseServer';
import { DynamoDBClient, PutItemCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb';
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { sendUniversalEmailV2, renderUniversalEmailV2, EmailDataV2 } from '@/lib/emailTemplateService';
import { ENV } from '@/lib/cleanEnv';
import axios from 'axios';
// Configuration AWS
const region = process.env.AWS_REGION || 'eu-west-3';
const region = ENV.AWS_REGION;
const dynamoDBClient = new DynamoDBClient({
region,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!
accessKeyId: ENV.AWS_ACCESS_KEY_ID,
secretAccessKey: ENV.AWS_SECRET_ACCESS_KEY
}
});
// Vérification des variables d'environnement au démarrage
console.log('🔧 Configuration DynamoDB:', {
region,
hasAccessKey: !!process.env.AWS_ACCESS_KEY_ID,
hasSecretKey: !!process.env.AWS_SECRET_ACCESS_KEY,
accessKeyLength: process.env.AWS_ACCESS_KEY_ID?.length,
secretKeyLength: process.env.AWS_SECRET_ACCESS_KEY?.length
hasAccessKey: !!ENV.AWS_ACCESS_KEY_ID,
hasSecretKey: !!ENV.AWS_SECRET_ACCESS_KEY,
accessKeyLength: ENV.AWS_ACCESS_KEY_ID?.length,
secretKeyLength: ENV.AWS_SECRET_ACCESS_KEY?.length
});
const s3Client = new S3Client({ region });
// Envoi d'e-mails géré via le service de templates universels
@ -102,10 +103,10 @@ export async function POST(request: NextRequest) {
// Continue le processus même si DynamoDB échoue
}
// Étape 2 : Récupération du fichier PDF depuis S3
// Étape 2 : Récupération du PDF depuis S3
const getObjectCommand = new GetObjectCommand({
Bucket: 'odentas-docs', // Bucket utilisé pour les PDFs de contrats
Key: pdfS3Key
Bucket: ENV.AWS_S3_BUCKET,
Key: pdfS3Key,
});
const s3Object = await s3Client.send(getObjectCommand);
@ -122,13 +123,23 @@ export async function POST(request: NextRequest) {
const docusealFileName = pdfS3Key.split('/').pop() || `contrat_${submissionId}.pdf`;
console.log('Nom de fichier extrait pour DocuSeal:', docusealFileName);
// Debug de la configuration DocuSeal
console.log('🔧 [DOCUSEAL] Configuration:', {
hasToken: !!ENV.DOCUSEAL_TOKEN,
tokenLength: ENV.DOCUSEAL_TOKEN.length,
tokenPreview: ENV.DOCUSEAL_TOKEN.substring(0, 10) + '...',
apiBase: ENV.DOCUSEAL_API_BASE,
apiBaseRaw: process.env.DOCUSEAL_API_BASE,
rawTokenLength: process.env.DOCUSEAL_TOKEN?.length
});
// Étape 3 : Création du template DocuSeal
const templateResponse = await axios.post('https://api.docuseal.eu/templates/pdf', {
const templateResponse = await axios.post(`${ENV.DOCUSEAL_API_BASE}/templates/pdf`, {
name: `CDDU - ${reference}`,
documents: [{ name: docusealFileName, file: pdfBase64 }]
}, {
headers: {
'X-Auth-Token': process.env.DOCUSEAL_API_TOKEN!,
'X-Auth-Token': ENV.DOCUSEAL_TOKEN,
'Content-Type': 'application/json'
}
});
@ -137,7 +148,7 @@ export async function POST(request: NextRequest) {
console.log('Template DocuSeal créé:', templateResponse.data);
// Étape 4 : Création de la soumission DocuSeal
const submissionResponse = await axios.post('https://api.docuseal.eu/submissions', {
const submissionResponse = await axios.post(`${ENV.DOCUSEAL_API_BASE}/submissions`, {
template_id: templateId,
send_email: false,
submitters: [
@ -146,7 +157,7 @@ export async function POST(request: NextRequest) {
]
}, {
headers: {
'X-Auth-Token': process.env.DOCUSEAL_API_TOKEN!,
'X-Auth-Token': ENV.DOCUSEAL_TOKEN,
'Content-Type': 'application/json'
}
});
@ -368,7 +379,7 @@ async function updateContractWithDocusealId(submissionId: string, docusealSubID:
// Fonction pour uploader l'email HTML sur S3
async function uploadEmailToS3(emailHtml: string, key: string): Promise<string> {
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME_EMAILS!,
Bucket: ENV.S3_BUCKET_NAME_EMAILS,
Key: `${key}.html`,
Body: emailHtml,
ContentType: 'text/html',
@ -377,7 +388,7 @@ async function uploadEmailToS3(emailHtml: string, key: string): Promise<string>
try {
await s3Client.send(command);
return `https://${process.env.S3_BUCKET_NAME_EMAILS}.s3.amazonaws.com/${key}.html`;
return `https://${ENV.S3_BUCKET_NAME_EMAILS}.s3.amazonaws.com/${key}.html`;
} catch (error) {
console.error('Erreur lors de l\'upload de l\'email sur S3:', error);
throw new Error('Échec de l\'upload de l\'email HTML sur S3.');

View file

@ -138,7 +138,7 @@ export async function GET(req: Request) {
// 3) Presign S3 URLs for PDFs
let signer: any = null;
const bucket = process.env.AWS_S3_BUCKET || 'odentas-docs';
const bucket = (process.env.AWS_S3_BUCKET || 'odentas-docs').trim();
const region = process.env.AWS_REGION || 'eu-west-3';
const expireSeconds = Math.max(60, Math.min(60 * 60, Number(process.env.INVOICE_URL_EXPIRES ?? 900)));

View file

@ -1,8 +1,23 @@
import { NextResponse } from 'next/server';
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies, headers } from 'next/headers';
import { detectDemoModeFromHeaders } from '@/lib/demo-detector';
import { DEMO_ORGANIZATION } from '@/lib/demo-data';
export async function GET() {
// 🎭 Vérification du mode démo en premier
const h = headers();
const isDemoMode = detectDemoModeFromHeaders(h);
if (isDemoMode) {
console.log("🎭 [API ME/ROLE] Mode démo détecté - renvoi de données fictives");
return NextResponse.json({
is_staff: false,
org_id: DEMO_ORGANIZATION.id
});
}
try {
const sb = createRouteHandlerClient({ cookies });
const { data: { user } } = await sb.auth.getUser();

View file

@ -2,8 +2,10 @@
export const dynamic = "force-dynamic";
import { NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { cookies, headers } from "next/headers";
import { resolveActiveOrg } from "@/lib/resolveActiveOrg";
import { detectDemoModeFromHeaders } from "@/lib/demo-detector";
import { DEMO_USER, DEMO_ORGANIZATION } from "@/lib/demo-data";
/**
* API simplifiée /api/me
@ -13,6 +15,25 @@ import { resolveActiveOrg } from "@/lib/resolveActiveOrg";
export async function GET() {
console.log("📊 [API ME] Début de la requête /api/me");
// 🎭 Vérification du mode démo en premier
const h = headers();
const isDemoMode = detectDemoModeFromHeaders(h);
if (isDemoMode) {
console.log("🎭 [API ME] Mode démo détecté - renvoi de données fictives");
return NextResponse.json({
user_id: DEMO_USER.id,
email: DEMO_USER.email,
display_name: DEMO_USER.display_name,
first_name: DEMO_USER.first_name,
active_org_id: DEMO_ORGANIZATION.id,
active_org_name: DEMO_ORGANIZATION.name,
active_org_api_name: DEMO_ORGANIZATION.api_name,
is_staff: false
});
}
try {
// Vérifier les cookies reçus
const cookiesStore = cookies();

View file

@ -1,8 +1,22 @@
// app/api/organizations/route.ts
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { cookies, headers } from "next/headers";
import { detectDemoModeFromHeaders } from "@/lib/demo-detector";
import { DEMO_ORGANIZATION } from "@/lib/demo-data";
export async function GET() {
// 🎭 Vérification du mode démo en premier
const h = headers();
const isDemoMode = detectDemoModeFromHeaders(h);
if (isDemoMode) {
console.log("🎭 [API ORGANIZATIONS] Mode démo détecté - renvoi de données fictives");
return Response.json({
items: [DEMO_ORGANIZATION]
});
}
try {
const supabase = createRouteHandlerClient({ cookies });

View file

@ -1,9 +1,11 @@
// app/api/professions-feminisations/route.ts
import { NextRequest, NextResponse } from "next/server";
import { promises as fs } from "fs";
import path from "path";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { createClient } from "@supabase/supabase-js";
import { cookies } from "next/headers";
type ProfessionFeminisation = {
id?: string;
profession_code: string;
profession_label: string;
profession_feminine: string;
@ -11,25 +13,17 @@ type ProfessionFeminisation = {
updated_at?: string;
};
const FEMINISATIONS_FILE = path.join(process.cwd(), 'public/data/professions-feminisations.json');
// Client Supabase avec service role pour contourner RLS si nécessaire
function getServiceRoleClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
async function loadFeminisations(): Promise<ProfessionFeminisation[]> {
try {
const fileContent = await fs.readFile(FEMINISATIONS_FILE, 'utf8');
return JSON.parse(fileContent);
} catch (error) {
console.warn("Erreur lors du chargement des féminisations:", error);
return [];
}
}
async function saveFeminisations(feminisations: ProfessionFeminisation[]): Promise<void> {
try {
await fs.writeFile(FEMINISATIONS_FILE, JSON.stringify(feminisations, null, 2));
} catch (error) {
console.error("Erreur lors de la sauvegarde des féminisations:", error);
throw error;
return createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
persistSession: false
}
});
}
// GET - Récupérer toutes les féminisations ou une spécifique
@ -38,13 +32,35 @@ export async function GET(request: NextRequest) {
const url = new URL(request.url);
const professionCode = url.searchParams.get('code');
const feminisations = await loadFeminisations();
// Utiliser le service role client pour la lecture car les féminisations doivent être accessibles à tous
const supabase = getServiceRoleClient();
if (professionCode) {
const feminisation = feminisations.find(f => f.profession_code === professionCode);
const { data: feminisation, error } = await supabase
.from('professions_feminisations')
.select('*')
.eq('profession_code', professionCode)
.maybeSingle();
if (error) {
console.error("Erreur lors de la récupération de la féminisation:", error);
return NextResponse.json({ error: "Erreur lors de la récupération de la féminisation" }, { status: 500 });
}
// Retourner null explicitement si pas de féminisation trouvée
return NextResponse.json(feminisation || null);
}
const { data: feminisations, error } = await supabase
.from('professions_feminisations')
.select('*')
.order('profession_label');
if (error) {
console.error("Erreur lors de la récupération des féminisations:", error);
return NextResponse.json({ error: "Erreur lors de la récupération des féminisations" }, { status: 500 });
}
return NextResponse.json(feminisations);
} catch (error) {
console.error("Erreur GET féminisations:", error);
@ -65,29 +81,26 @@ export async function POST(request: NextRequest) {
);
}
const feminisations = await loadFeminisations();
const existingIndex = feminisations.findIndex(f => f.profession_code === profession_code);
// Utiliser le client avec service role pour écrire
const supabase = getServiceRoleClient();
const now = new Date().toISOString();
const feminisation: ProfessionFeminisation = {
const { data: feminisation, error } = await supabase
.from('professions_feminisations')
.upsert({
profession_code,
profession_label,
profession_feminine,
updated_at: now
};
profession_feminine: profession_feminine.trim()
}, {
onConflict: 'profession_code'
})
.select('*')
.single();
if (existingIndex >= 0) {
// Mise à jour
feminisation.created_at = feminisations[existingIndex].created_at || now;
feminisations[existingIndex] = feminisation;
} else {
// Création
feminisation.created_at = now;
feminisations.push(feminisation);
if (error) {
console.error("Erreur lors de la sauvegarde de la féminisation:", error);
return NextResponse.json({ error: "Erreur lors de la sauvegarde de la féminisation" }, { status: 500 });
}
await saveFeminisations(feminisations);
return NextResponse.json(feminisation);
} catch (error) {
console.error("Erreur POST féminisation:", error);
@ -105,15 +118,19 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: "Le code de profession est requis" }, { status: 400 });
}
const feminisations = await loadFeminisations();
const filteredFeminisations = feminisations.filter(f => f.profession_code !== professionCode);
// Utiliser le client avec service role pour supprimer
const supabase = getServiceRoleClient();
if (filteredFeminisations.length === feminisations.length) {
return NextResponse.json({ error: "Féminisation non trouvée" }, { status: 404 });
const { error } = await supabase
.from('professions_feminisations')
.delete()
.eq('profession_code', professionCode);
if (error) {
console.error("Erreur lors de la suppression de la féminisation:", error);
return NextResponse.json({ error: "Erreur lors de la suppression de la féminisation" }, { status: 500 });
}
await saveFeminisations(filteredFeminisations);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Erreur DELETE féminisation:", error);

View file

@ -1,13 +1,68 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { createClient } from "@supabase/supabase-js";
import { cookies } from "next/headers";
import { v4 as uuidv4 } from "uuid";
type NoteInsert = {
contract_id: string;
organization_id: string;
content: string;
source: string;
};
function generateContractReference(): string {
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const digits = "0123456789";
const chars = letters + digits;
return "RG" + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
}
export async function POST(request: NextRequest) {
const supabase = createRouteHandlerClient({ cookies });
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
export async function POST(request: Request) {
try {
const data = await request.json();
const body = await request.json();
// Pour l'instant, nous redirigeons vers l'endpoint CDDU existant
// avec des adaptations pour le régime général
console.log("Body reçu pour création contrat RG:", body);
// Générer un identifiant unique pour le contrat
const contractId = uuidv4();
const providedReference = typeof body.reference === "string" ? body.reference.trim().toUpperCase() : "";
const contractNumber = providedReference || generateContractReference();
// Détecter si l'utilisateur est staff
let isStaff = false;
console.log('🔍 [DEBUG RG] Début détection staff pour user:', user.id);
try {
const { data: staffRow } = await supabase
.from('staff_users')
.select('is_staff')
.eq('user_id', user.id)
.maybeSingle();
console.log('🔍 [DEBUG RG] Résultat query staff_users:', staffRow);
isStaff = !!staffRow?.is_staff;
console.log('🔍 [DEBUG RG] isStaff depuis DB:', isStaff);
} catch (err) {
console.log('🔍 [DEBUG RG] Erreur query staff_users, fallback metadata:', err);
const userMeta = user.user_metadata || {};
const appMeta = user.app_metadata || {};
isStaff = Boolean(
userMeta.is_staff === true ||
userMeta.role === 'staff' ||
(Array.isArray(appMeta?.roles) && appMeta.roles.includes('staff'))
);
console.log('🔍 [DEBUG RG] isStaff depuis metadata:', isStaff);
}
// Appeler l'endpoint CDDU existant avec les données adaptées pour RG
const adaptedData = {
...data,
...body,
// Propager la préférence d'envoi d'e-mail si fournie
send_email_confirmation: body.send_email_confirmation !== false,
// Marquer explicitement que c'est un contrat RG
regime: "RG",
// Les champs production ne sont pas utilisés en RG
@ -26,22 +81,23 @@ export async function POST(request: Request) {
jours_travail: null,
};
// Utiliser l'endpoint CDDU existant pour l'instant
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3001'}/api/cddu-contracts`, {
// Pour contourner le problème de l'appel HTTP externe qui perd la session,
// on fait l'appel direct à l'API CDDU en interne
const apiUrl = new URL('/api/cddu-contracts', request.url);
const cdduRequest = new NextRequest(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': request.headers.get('Cookie') || '',
},
body: JSON.stringify(adaptedData),
});
if (!response.ok) {
const error = await response.text();
return NextResponse.json({ error: `Erreur lors de la création du contrat RG: ${error}` }, { status: response.status });
}
// Import de la fonction POST de l'API CDDU pour l'appeler directement
const { POST: cdduPost } = await import('../cddu-contracts/route');
const result = await cdduPost(cdduRequest);
const result = await response.json();
return NextResponse.json(result);
return result;
} catch (error) {
console.error('Erreur API RG contracts:', error);

View file

@ -11,7 +11,7 @@ const s3Client = new S3Client({
},
});
const BUCKET_NAME = 'odentas-docs';
const BUCKET_NAME = (process.env.AWS_S3_BUCKET || 'odentas-docs').trim();
export async function POST(request: NextRequest) {
try {

View file

@ -266,7 +266,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ ok: false, error: 'db_error', detail: error }, { status: 500 });
}
// Envoyer l'e-mail de notification après la création réussie
// Envoyer les e-mails de notification après la création réussie
try {
const sbAuth = createSbServer();
const { data: { user } } = await sbAuth.auth.getUser();
@ -281,6 +281,7 @@ export async function POST(req: NextRequest) {
const orgDetails = orgId ? await supabase.from('organizations').select('name, code_employeur').eq('id', orgId).single() : { data: null };
// 1. Email de notification à l'équipe (existant)
if (user && orgDetails?.data) {
await sendUniversalEmailV2({
type: 'employee-created',
@ -296,9 +297,37 @@ export async function POST(req: NextRequest) {
}
});
}
// 2. Générer token et envoyer invitation au salarié (nouveau)
if (data.adresse_mail) {
try {
const tokenResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/auto-declaration/generate-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
salarie_id: data.id,
send_email: true
})
});
if (tokenResponse.ok) {
console.log('✅ [API /salaries POST] Token généré et invitation envoyée au salarié');
} else {
const errorText = await tokenResponse.text();
console.error('❌ [API /salaries POST] Erreur génération token:', errorText);
}
} catch (tokenError) {
console.error('❌ [API /salaries POST] Erreur lors de la génération du token:', tokenError);
}
} else {
console.warn('⚠️ [API /salaries POST] Pas d\'email pour le salarié, invitation non envoyée');
}
} catch (emailError) {
console.error('📧 [API /salaries POST] Failed to send email notification:', emailError);
// Ne pas bloquer la réponse en cas d'échec de l'e-mail
console.error('📧 [API /salaries POST] Failed to send email notifications:', emailError);
// Ne pas bloquer la réponse en cas d'échec des e-mails
}
return NextResponse.json({ ok: true, data }, { status: 201 });

View file

@ -1,13 +1,20 @@
// app/api/search/route.ts - Version debug
// app/api/search/route.ts - Production-safe
import { NextRequest, NextResponse } from "next/server";
import { createMiddlewareClient } from "@supabase/auth-helpers-nextjs";
import { cookies, headers } from "next/headers";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { detectDemoModeFromHeaders } from "@/lib/demo-detector";
import { searchDemoData } from "@/lib/demo-data";
// Avoid static optimization/caching in production for authenticated search
export const dynamic = 'force-dynamic';
export const revalidate = 0;
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const q = searchParams.get("q")?.trim() ?? "";
const limitParam = searchParams.get("limit");
const limit = limitParam ? Math.max(1, Math.min(50, parseInt(limitParam, 10))) : 20;
const debugMode = searchParams.get("debug") === "true";
if (q.length < 2) {
return NextResponse.json(
@ -16,123 +23,25 @@ export async function GET(request: NextRequest) {
);
}
// Mode développement avec AUTH_BYPASS
if (process.env.AUTH_BYPASS === '1') {
const { createClient } = await import("@supabase/supabase-js");
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
// 🎭 Vérification du mode démo en premier
const h = headers();
const isDemoMode = detectDemoModeFromHeaders(h);
console.log("Mode bypass - Recherche pour:", q);
if (isDemoMode) {
console.log("🎭 [API SEARCH] Mode démo détecté - recherche dans les données fictives");
let userOrgId: string | null = null;
try {
const res = NextResponse.next();
const supabase = createMiddlewareClient({ req: request, res });
const { data: { session } } = await supabase.auth.getSession();
const results = searchDemoData(q);
if (session?.user?.id) {
const meUrl = new URL('/api/me', request.nextUrl.origin);
const meResponse = await fetch(meUrl.toString(), {
headers: {
'Cookie': request.headers.get('cookie') || '',
'Authorization': request.headers.get('authorization') || '',
},
return NextResponse.json({
results: results.slice(0, limit),
total: results.length,
query: q,
demo: true
});
if (meResponse.ok) {
const meData = await meResponse.json();
userOrgId = meData.active_org_id;
console.log("Mode bypass - org_id utilisateur:", userOrgId);
}
}
} catch (err) {
console.warn("Mode bypass - Impossible de récupérer l'org_id:", err);
}
if (!userOrgId) {
console.warn("⚠️ Mode bypass SANS filtrage org_id - Tous les résultats seront visibles !");
}
let query = supabaseAdmin
.from("search_index")
.select("id, org_id, entity_type, entity_id, title, subtitle, url, icon, updated_at, meta")
.textSearch("searchable", q, { config: "french", type: "plain" });
if (userOrgId) {
query = query.eq("org_id", userOrgId);
}
let { data, error } = await query
.order("updated_at", { ascending: false })
.limit(limit);
if (!error && (!data || data.length === 0)) {
const searchTerms = q.split(/\s+/).map(term => term.trim()).filter(term => term.length > 0);
const prefixQuery = searchTerms.map(term => `${term}:*`).join(' & ');
let query2 = supabaseAdmin
.from("search_index")
.select("id, org_id, entity_type, entity_id, title, subtitle, url, icon, updated_at, meta")
.textSearch("searchable", prefixQuery, { config: "french", type: "websearch" });
if (userOrgId) {
query2 = query2.eq("org_id", userOrgId);
}
({ data, error } = await query2
.order("updated_at", { ascending: false })
.limit(limit));
}
if (!error && (!data || data.length === 0)) {
const searchTerms = q.split(/\s+/).map(term => term.trim()).filter(term => term.length > 0);
const prefixQuery = searchTerms.map(term => `${term}:*`).join(' & ');
let query3 = supabaseAdmin
.from("search_index")
.select("id, org_id, entity_type, entity_id, title, subtitle, url, icon, updated_at, meta")
.textSearch("searchable", prefixQuery, { config: "simple", type: "websearch" });
if (userOrgId) {
query3 = query3.eq("org_id", userOrgId);
}
({ data, error } = await query3
.order("updated_at", { ascending: false })
.limit(limit));
}
if (!error && (!data || data.length === 0)) {
let query4 = supabaseAdmin
.from("search_index")
.select("id, org_id, entity_type, entity_id, title, subtitle, url, icon, updated_at, meta")
.ilike("title", `%${q}%`);
if (userOrgId) {
query4 = query4.eq("org_id", userOrgId);
}
({ data, error } = await query4
.order("updated_at", { ascending: false })
.limit(limit));
}
console.log("Mode bypass - Résultats:", data?.length || 0);
if (error) {
console.error("Search error (bypass mode):", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(data ?? []);
}
// Mode production
const res = NextResponse.next();
const supabase = createMiddlewareClient({ req: request, res });
try {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
if (sessionError || !session) {
@ -142,6 +51,8 @@ export async function GET(request: NextRequest) {
// Récupérer l'organisation de l'utilisateur
let userOrgId: string | null = null;
console.log("User ID:", session.user.id);
try {
const { data: member, error: mErr } = await supabase
.from("organization_members")
@ -149,13 +60,18 @@ export async function GET(request: NextRequest) {
.eq("user_id", session.user.id)
.single();
console.log("Organization member query result:", { member, error: mErr });
if (!mErr && member?.org_id) {
userOrgId = member.org_id;
console.log("Found user org_id:", userOrgId);
} else {
// If no membership, check if the user is a staff user and allow global access for staff
try {
const { data: s } = await supabase.from('staff_users').select('is_staff').eq('user_id', session.user.id).maybeSingle();
const isStaff = !!s?.is_staff;
console.log("Staff check result:", { staff_data: s, isStaff });
if (!isStaff) {
console.warn("Utilisateur sans organisation associée:", session.user.id);
return NextResponse.json({ error: "No organization found" }, { status: 403 });
@ -177,16 +93,69 @@ export async function GET(request: NextRequest) {
// Debug: Vérifier qu'il y a bien des données pour cette org
const { data: sampleData, error: sampleError } = await supabase
.from("search_index")
.select("id, entity_type, title, searchable")
.select("id, entity_type, title, searchable, org_id")
.eq("org_id", userOrgId)
.limit(5);
console.log("Échantillon de données pour cette org:", sampleData?.length, "résultats");
if (sampleError) {
console.error("Erreur échantillon:", sampleError);
}
if (sampleData && sampleData.length > 0) {
console.log("Premier échantillon:", {
id: sampleData[0].id,
org_id: sampleData[0].org_id,
title: sampleData[0].title,
searchable: sampleData[0].searchable?.substring(0, 100) + "..."
});
} else {
// Si pas de données pour cette org, vérifions toutes les orgs
console.log("Pas de données pour org_id:", userOrgId);
// Test avec service role pour voir si c'est un problème de RLS
const { createClient } = await import('@supabase/supabase-js');
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const { data: adminData, error: adminError } = await supabaseAdmin
.from("search_index")
.select("id, org_id, title")
.limit(5);
console.log("Test avec service role - Données disponibles:", adminData?.length || 0);
if (adminError) {
console.error("Erreur service role:", adminError);
}
if (adminData && adminData.length > 0) {
const uniqueOrgs = [...new Set(adminData.map(d => d.org_id))];
console.log("Organisations trouvées avec service role:", uniqueOrgs);
// Chercher spécifiquement DEVALAN avec service role
const { data: devalanAdmin } = await supabaseAdmin
.from("search_index")
.select("id, org_id, title")
.ilike("title", "%devalan%")
.limit(3);
console.log("DEVALAN trouvé avec service role:", devalanAdmin?.map(d => ({ title: d.title, org_id: d.org_id })));
}
// Test normal
const { data: allOrgs } = await supabase
.from("search_index")
.select("org_id")
.limit(10);
const uniqueOrgs = [...new Set(allOrgs?.map(d => d.org_id) || [])];
console.log("Organisations disponibles dans search_index:", uniqueOrgs);
// Test spécifique : chercher les entrées qui contiennent "devalan"
const { data: devalanTest } = await supabase
.from("search_index")
.select("id, org_id, title")
.ilike("title", "%devalan%")
.limit(3);
console.log("Entrées contenant 'devalan' (toutes orgs):", devalanTest?.map(d => ({ title: d.title, org_id: d.org_id })));
}
// Debug: Test ILIKE simple dès le début pour voir si le problème vient du FTS
@ -204,7 +173,16 @@ export async function GET(request: NextRequest) {
}
// Helper to apply org filter only when present
const applyOrg = (q: any) => (userOrgId ? q.eq("org_id", userOrgId) : q);
const applyOrg = (q: any) => {
console.log("applyOrg called with userOrgId:", userOrgId);
if (userOrgId) {
console.log("Applying org filter: org_id =", userOrgId);
return q.eq("org_id", userOrgId);
} else {
console.log("No org filter applied (global search)");
return q;
}
};
// Approche 1: Recherche FTS simple
console.log("1. Tentative FTS simple:", q);

View file

@ -46,30 +46,31 @@ export async function POST(req: NextRequest) {
}
} catch {}
// Récupération des données du contrat depuis Supabase
const { data: contract, error: contractError } = await sb
.from('contrats')
// Récupération des données du contrat depuis Supabase (cddu_contracts)
let query = sb
.from('cddu_contracts')
.select(`
id,
reference,
contract_number,
employee_name,
employee_email,
employee_matricule,
production_name,
role,
date_debut,
analytique,
start_date,
end_date,
docuseal_template_id,
docuseal_submission_id,
signature_link,
embed_src_employeur,
organizations!inner (
id,
name,
structure_api
)
org_id
`)
.eq('id', contractId)
.single();
.eq('id', contractId);
if (orgId) {
query = query.eq('org_id', orgId);
}
const { data: contract, error: contractError } = await query.single();
if (contractError || !contract) {
console.error('Erreur récupération contrat:', contractError);
@ -77,12 +78,11 @@ export async function POST(req: NextRequest) {
}
// Vérifier que c'est bien un contrat en attente de signature salarié
if (!contract.employee_email) {
return NextResponse.json({ error: 'Email du salarié manquant' }, { status: 400 });
}
// Email: récupéré depuis la table salaries (colonne adresse_mail) à partir du matricule
// Récupération du slug du salarié via DocuSeal API
let employeeSlug: string | null = null;
let docusealEmail: string | null = null;
if (contract.docuseal_submission_id) {
try {
const docusealResponse = await fetch(`${process.env.NEXT_PUBLIC_API_BASE || ''}/api/docuseal/submissions/${contract.docuseal_submission_id}`);
@ -91,6 +91,10 @@ export async function POST(req: NextRequest) {
const submitters = docusealData?.submitters || [];
const employeeSubmitter = submitters.find((s: any) => s.role === 'Salarié');
employeeSlug = employeeSubmitter?.slug || null;
// Fallback email via DocuSeal si introuvable en base salaires (voir plus bas)
if (employeeSubmitter?.email) {
docusealEmail = String(employeeSubmitter.email);
}
}
} catch (error) {
console.error('Erreur récupération DocuSeal:', error);
@ -98,9 +102,10 @@ export async function POST(req: NextRequest) {
}
// Construction du lien de signature
let signatureLink = contract.signature_link;
let signatureLink = contract.signature_link as string | null;
if (!signatureLink && employeeSlug) {
signatureLink = `https://staging.paie.odentas.fr/odentas-sign?docuseal_id=${employeeSlug}`;
const siteBase = process.env.NEXT_PUBLIC_SITE_URL || (process.env.VERCEL_ENV === 'production' ? 'https://paie.odentas.fr' : 'https://staging.paie.odentas.fr');
signatureLink = `${siteBase}/odentas-sign?docuseal_id=${employeeSlug}`;
}
if (!signatureLink) {
@ -108,31 +113,75 @@ export async function POST(req: NextRequest) {
}
// Formatage des données pour l'email
const formattedDate = formatDate(contract.date_debut);
const structure = (contract as any).organizations?.name || 'Employeur';
const formattedDate = formatDate((contract as any).start_date);
// Récupérer le nom d'employeur depuis organizations.name
let employerName = 'Employeur';
try {
const targetOrgId = (contract as any).org_id || orgId;
if (targetOrgId) {
const { data: org } = await sb
.from('organizations')
.select('name')
.eq('id', targetOrgId)
.maybeSingle();
employerName = org?.name || (contract as any).structure || 'Employeur';
} else {
employerName = (contract as any).structure || 'Employeur';
}
} catch (e) {
employerName = (contract as any).structure || 'Employeur';
}
const prenom_salarie = contract.employee_name?.split(' ')[0] || 'Salarié';
// Récupérer l'email du salarié depuis salaries.adresse_mail
let toEmail: string | null = null;
if (contract.employee_matricule) {
try {
let salQ = sb
.from('salaries')
.select('adresse_mail')
.or(`code_salarie.eq.${contract.employee_matricule},num_salarie.eq.${contract.employee_matricule}`)
.limit(1);
if (orgId) salQ = salQ.eq('employer_id', orgId);
const { data: salData, error: salErr } = await salQ;
if (!salErr && salData && salData[0]?.adresse_mail) {
toEmail = salData[0].adresse_mail as string;
}
} catch (e) {
console.warn('Impossible de récupérer adresse_mail depuis salaries:', e);
}
}
// Fallback via DocuSeal si disponible
if (!toEmail && docusealEmail) {
toEmail = docusealEmail;
}
if (!toEmail) {
return NextResponse.json({ error: 'Email du salarié manquant' }, { status: 400 });
}
// Envoi de l'email via le template universel (variant salarié)
const emailData: EmailDataV2 = {
firstName: prenom_salarie,
organizationName: structure,
employerCode: contract.employee_matricule || '',
organizationName: employerName,
matricule: contract.employee_matricule || (contract as any).matricule || '',
profession: contract.role || 'Contrat',
startDate: formattedDate,
productionName: (contract as any).production_name || '',
documentType: contract.role || 'Contrat',
contractReference: contract.reference || String(contract.id),
status: 'En attente',
ctaUrl: signatureLink,
};
const messageId = await sendUniversalEmailV2({
type: 'signature-request-employee',
toEmail: contract.employee_email,
subject: `[Rappel] Signez votre contrat ${structure}`,
toEmail,
subject: `[Rappel] Signez votre contrat ${employerName}`,
data: emailData,
});
console.log('Email de relance envoyé:', {
contractId,
email: contract.employee_email,
email: toEmail,
messageId
});

View file

@ -0,0 +1,348 @@
// app/api/staff/bulk-email-stream/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
import { emailLogger, EmailType } from "@/lib/emailLoggingService";
interface Recipient {
id: string;
email: string;
organizations: Array<{
id: string;
name: string;
role: string;
structure_api: string | null;
}>;
}
interface BulkEmailRequest {
subject: string;
htmlContent: string;
recipients: Recipient[];
}
export async function POST(request: NextRequest) {
try {
const sb = createSbServer();
// Vérifier que l'utilisateur est authentifié et a le rôle staff
const { data: { user }, error: authError } = await sb.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "Non authentifié" },
{ status: 401 }
);
}
// Vérifier les permissions staff
const { data: staffMember, error: staffError } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (staffError || !staffMember?.is_staff) {
return NextResponse.json(
{ error: "Accès refusé - permissions insuffisantes" },
{ status: 403 }
);
}
const body: BulkEmailRequest = await request.json();
const { subject, htmlContent, recipients } = body;
// Validation des données
if (!subject?.trim() || !htmlContent?.trim() || !recipients?.length) {
return NextResponse.json(
{ error: "Tous les champs sont requis" },
{ status: 400 }
);
}
if (recipients.length > 100) {
return NextResponse.json(
{ error: "Maximum 100 destinataires par envoi" },
{ status: 400 }
);
}
// Configuration SES
const region = process.env.AWS_REGION || "eu-west-3";
const fromEmail = process.env.AWS_SES_FROM;
if (!fromEmail) {
return NextResponse.json(
{ error: "Configuration SES manquante" },
{ status: 500 }
);
}
// Configuration du streaming response
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const ses = new SESClient({ region });
// Créer le contenu texte simple à partir du HTML
const textContent = htmlContent
.replace(/<[^>]*>/g, '') // Supprimer les balises HTML
.replace(/\s+/g, ' ') // Normaliser les espaces
.trim();
let sentCount = 0;
const failedEmails: Array<{email: string, error: string}> = [];
// Fonction pour envoyer un update
const sendUpdate = (data: any) => {
const chunk = encoder.encode(`data: ${JSON.stringify(data)}\n\n`);
controller.enqueue(chunk);
};
// Envoyer l'état initial
sendUpdate({
type: 'init',
total: recipients.length,
completed: 0,
success: 0,
errors: 0
});
try {
// Configuration optimisée pour limite SES de 14 emails/seconde
// Stratégie : 12 emails par lot avec pause de 1 seconde
// Calcul : 12 emails/sec < 14 emails/sec (marge de sécurité de 2 emails/sec)
// Performance résultante : ~720 emails/minute
const batchSize = 12;
const batchDelayMs = 1000; // 1 seconde entre les lots
for (let i = 0; i < recipients.length; i += batchSize) {
const batch = recipients.slice(i, i + batchSize);
const promises = batch.map(async (recipient, batchIndex) => {
const globalIndex = i + batchIndex;
let logId: string | null = null;
try {
// Validation de l'adresse email
if (!recipient.email || !recipient.email.includes('@') || !recipient.email.includes('.')) {
throw new Error('Adresse email invalide');
}
// Notifier le début de l'envoi
sendUpdate({
type: 'progress',
recipientId: recipient.id,
email: recipient.email,
status: 'sending',
index: globalIndex
});
// Personnaliser le contenu si nécessaire
let personalizedHtml = htmlContent;
let personalizedSubject = subject;
// Créer le log d'email AVANT l'envoi
logId = await emailLogger.logEmail({
senderEmail: fromEmail || 'paie@odentas.fr',
recipientEmail: recipient.email,
subject: personalizedSubject,
htmlContent: personalizedHtml,
textContent: textContent,
emailType: 'bulk_communication' as EmailType,
templateName: 'bulk-email-stream',
templateData: {
recipients_count: recipients.length,
batch_index: Math.floor(globalIndex / batchSize),
global_index: globalIndex
},
emailStatus: 'sending',
organizationId: recipient.organizations?.[0]?.id,
tags: {
email_system: 'bulk_stream',
batch_size: batchSize.toString(),
total_recipients: recipients.length.toString()
},
context: {
recipient_id: recipient.id,
recipient_orgs: recipient.organizations?.map(org => org.name).join(', ') || 'No organizations',
subject_preview: personalizedSubject.substring(0, 100)
}
});
// Formater l'adresse email source correctement
let sourceEmail = fromEmail;
if (fromEmail && !fromEmail.includes('<')) {
// Si fromEmail ne contient pas déjà un format "Nom <email>", on l'ajoute
sourceEmail = `Espace Paie Odentas <${fromEmail}>`;
} else {
// Si fromEmail contient déjà un format, on l'utilise tel quel
sourceEmail = fromEmail;
}
const cmd = new SendEmailCommand({
Destination: {
ToAddresses: [recipient.email]
},
Source: sourceEmail,
Message: {
Subject: {
Data: personalizedSubject,
Charset: "UTF-8"
},
Body: {
Html: {
Data: personalizedHtml,
Charset: "UTF-8"
},
Text: {
Data: textContent,
Charset: "UTF-8"
}
}
}
});
const response = await ses.send(cmd);
const messageId = (response as any)?.MessageId || '';
sentCount++;
// Mettre à jour le log avec le succès
if (logId) {
await emailLogger.updateEmailLog(logId, {
emailStatus: 'sent',
sesMessageId: messageId,
sentAt: new Date()
});
}
// Notifier le succès
sendUpdate({
type: 'progress',
recipientId: recipient.id,
email: recipient.email,
status: 'success',
index: globalIndex,
sentAt: new Date().toISOString(),
messageId: messageId
});
console.log(`Email envoyé avec succès à ${recipient.email} - MessageId: ${messageId}`);
} catch (error: any) {
let errorMessage = 'Erreur SES inconnue';
// Améliorer les messages d'erreur
if (error.message) {
if (error.message.includes('Missing')) {
errorMessage = 'Format email source invalide';
} else if (error.message.includes('InvalidParameterValue')) {
errorMessage = 'Adresse email invalide';
} else if (error.message.includes('MessageRejected')) {
errorMessage = 'Email rejeté par SES';
} else if (error.message.includes('Daily sending quota exceeded')) {
errorMessage = 'Quota journalier dépassé';
} else if (error.message.includes('Sending rate exceeded')) {
errorMessage = 'Limite de débit dépassée';
} else {
errorMessage = error.message;
}
}
// Mettre à jour le log avec l'erreur
if (logId) {
await emailLogger.updateEmailLog(logId, {
emailStatus: 'failed',
failureReason: errorMessage
});
}
failedEmails.push({ email: recipient.email, error: errorMessage });
// Notifier l'erreur
sendUpdate({
type: 'progress',
recipientId: recipient.id,
email: recipient.email,
status: 'error',
index: globalIndex,
error: errorMessage
});
console.error(`Erreur envoi email à ${recipient.email}:`, error);
}
});
await Promise.all(promises);
// Envoyer une mise à jour globale
sendUpdate({
type: 'batch_complete',
completed: Math.min(i + batchSize, recipients.length),
success: sentCount,
errors: failedEmails.length,
total: recipients.length
});
// Pause entre les lots pour respecter les limites SES (14 emails/seconde max)
if (i + batchSize < recipients.length) {
await new Promise(resolve => setTimeout(resolve, batchDelayMs));
}
}
// Log de l'activité pour traçabilité
try {
await sb
.from("email_logs")
.insert({
user_id: user.id,
type: "bulk_email_stream",
subject: subject,
recipients_count: recipients.length,
sent_count: sentCount,
failed_count: failedEmails.length,
failed_emails: failedEmails.length > 0 ? failedEmails : null,
created_at: new Date().toISOString()
});
} catch (logError) {
console.error("Erreur lors de l'enregistrement du log:", logError);
}
// Envoyer le résultat final
sendUpdate({
type: 'complete',
total: recipients.length,
success: sentCount,
errors: failedEmails.length,
failedEmails: failedEmails,
message: `${sentCount} email(s) envoyé(s) avec succès${failedEmails.length > 0 ? `, ${failedEmails.length} échec(s)` : ''}`
});
} catch (error) {
console.error("Erreur dans le streaming d'emails:", error);
sendUpdate({
type: 'error',
error: 'Erreur interne du serveur'
});
} finally {
controller.close();
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
} catch (error) {
console.error("Erreur dans l'API bulk-email-stream:", error);
return NextResponse.json(
{ error: "Erreur interne du serveur" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,191 @@
// app/api/staff/bulk-email/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
interface Recipient {
id: string;
email: string;
organizations: Array<{
id: string;
name: string;
role: string;
structure_api: string | null;
}>;
}
interface BulkEmailRequest {
subject: string;
htmlContent: string;
recipients: Recipient[];
}
export async function POST(request: NextRequest) {
try {
const sb = createSbServer();
// Vérifier que l'utilisateur est authentifié et a le rôle staff
const { data: { user }, error: authError } = await sb.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "Non authentifié" },
{ status: 401 }
);
}
// Vérifier les permissions staff
const { data: staffMember, error: staffError } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (staffError || !staffMember?.is_staff) {
return NextResponse.json(
{ error: "Accès refusé - permissions insuffisantes" },
{ status: 403 }
);
}
const body: BulkEmailRequest = await request.json();
const { subject, htmlContent, recipients } = body;
// Validation des données
if (!subject?.trim() || !htmlContent?.trim() || !recipients?.length) {
return NextResponse.json(
{ error: "Tous les champs sont requis" },
{ status: 400 }
);
}
if (recipients.length > 100) {
return NextResponse.json(
{ error: "Maximum 100 destinataires par envoi" },
{ status: 400 }
);
}
// Configuration SES
const region = process.env.AWS_REGION || "eu-west-3";
const fromEmail = process.env.AWS_SES_FROM;
if (!fromEmail) {
return NextResponse.json(
{ error: "Configuration SES manquante" },
{ status: 500 }
);
}
const ses = new SESClient({ region });
// Créer le contenu texte simple à partir du HTML
const textContent = htmlContent
.replace(/<[^>]*>/g, '') // Supprimer les balises HTML
.replace(/\s+/g, ' ') // Normaliser les espaces
.trim();
let sentCount = 0;
const failedEmails: string[] = [];
// Configuration optimisée pour 14 emails/seconde
// Envoyer par lots de 12 emails avec pause de 1 seconde
const batchSize = 12;
for (let i = 0; i < recipients.length; i += batchSize) {
const batch = recipients.slice(i, i + batchSize);
const promises = batch.map(async (recipient) => {
try {
// Personnaliser le contenu si nécessaire
let personalizedHtml = htmlContent;
let personalizedSubject = subject;
// Vous pouvez ajouter des remplacements personnalisés ici
// Par exemple : personalizedHtml = personalizedHtml.replace('{{name}}', recipient.name);
// Formater l'adresse email source correctement
let sourceEmail = fromEmail;
if (fromEmail && !fromEmail.includes('<')) {
// Si fromEmail ne contient pas déjà un format "Nom <email>", on l'ajoute
sourceEmail = `Espace Paie Odentas <${fromEmail}>`;
} else {
// Si fromEmail contient déjà un format, on l'utilise tel quel
sourceEmail = fromEmail;
}
const cmd = new SendEmailCommand({
Destination: {
ToAddresses: [recipient.email]
},
Source: sourceEmail,
Message: {
Subject: {
Data: personalizedSubject,
Charset: "UTF-8"
},
Body: {
Html: {
Data: personalizedHtml,
Charset: "UTF-8"
},
Text: {
Data: textContent,
Charset: "UTF-8"
}
}
}
});
await ses.send(cmd);
sentCount++;
console.log(`Email envoyé avec succès à ${recipient.email}`);
} catch (error) {
console.error(`Erreur envoi email à ${recipient.email}:`, error);
failedEmails.push(recipient.email);
}
});
await Promise.all(promises);
// Pause entre les lots pour respecter les limites SES
if (i + batchSize < recipients.length) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// Log de l'activité pour traçabilité
try {
await sb
.from("email_logs") // Créez cette table si elle n'existe pas
.insert({
user_id: user.id,
type: "bulk_email",
subject: subject,
recipients_count: recipients.length,
sent_count: sentCount,
failed_count: failedEmails.length,
failed_emails: failedEmails.length > 0 ? failedEmails : null,
created_at: new Date().toISOString()
});
} catch (logError) {
console.error("Erreur lors de l'enregistrement du log:", logError);
// Ne pas faire échouer la requête pour une erreur de log
}
return NextResponse.json({
sent: sentCount,
failed: failedEmails.length,
failedEmails: failedEmails.length > 0 ? failedEmails : undefined,
message: `${sentCount} email(s) envoyé(s) avec succès${failedEmails.length > 0 ? `, ${failedEmails.length} échec(s)` : ''}`
});
} catch (error) {
console.error("Erreur dans l'API bulk-email:", error);
return NextResponse.json(
{ error: "Erreur interne du serveur" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
export async function POST(req: Request) {
try {
const sb = createSbServer();
const { data: { user } } = await sb.auth.getUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { data: me } = await sb.from("staff_users").select("is_staff").eq("user_id", user.id).maybeSingle();
if (!me?.is_staff) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { contractIds, dpaeStatus } = await req.json();
if (!contractIds || !Array.isArray(contractIds) || contractIds.length === 0) {
return NextResponse.json({ error: "Contract IDs are required" }, { status: 400 });
}
if (!dpaeStatus || !['À faire', 'Faite'].includes(dpaeStatus)) {
return NextResponse.json({ error: "Valid DPAE status is required" }, { status: 400 });
}
// Mettre à jour tous les contrats sélectionnés
const { data: updatedContracts, error } = await sb
.from("cddu_contracts")
.update({ dpae: dpaeStatus })
.in("id", contractIds)
.select("id, dpae");
if (error) {
console.error("Error updating DPAE:", error);
return NextResponse.json({ error: "Failed to update contracts" }, { status: 500 });
}
return NextResponse.json({
success: true,
contracts: updatedContracts,
message: `${updatedContracts?.length || 0} contrat(s) mis à jour`
});
} catch (err: any) {
console.error("Bulk DPAE update error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
export async function POST(req: Request) {
try {
const sb = createSbServer();
const { data: { user } } = await sb.auth.getUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { data: me } = await sb.from("staff_users").select("is_staff").eq("user_id", user.id).maybeSingle();
if (!me?.is_staff) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { updates } = await req.json();
if (!updates || !Array.isArray(updates) || updates.length === 0) {
return NextResponse.json({ error: "Updates are required" }, { status: 400 });
}
// Valider les données
for (const update of updates) {
if (!update.contractId || typeof update.grossPay !== 'number' || update.grossPay < 0) {
return NextResponse.json({ error: "Invalid update data" }, { status: 400 });
}
}
const updatedContracts = [];
// Mettre à jour chaque contrat individuellement pour avoir un meilleur contrôle
for (const update of updates) {
const { data: updatedContract, error } = await sb
.from("cddu_contracts")
.update({ gross_pay: update.grossPay })
.eq("id", update.contractId)
.select("id, gross_pay")
.single();
if (error) {
console.error(`Error updating contract ${update.contractId}:`, error);
continue; // Continue avec les autres même si un échec
}
if (updatedContract) {
updatedContracts.push(updatedContract);
}
}
return NextResponse.json({
success: true,
contracts: updatedContracts,
message: `${updatedContracts.length} salaire(s) mis à jour`
});
} catch (err: any) {
console.error("Bulk salary update error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -16,6 +16,7 @@ export async function GET(req: Request) {
const type_de_contrat = url.searchParams.get("type_de_contrat");
const etat_de_la_demande = url.searchParams.get("etat_de_la_demande");
const etat_de_la_paie = url.searchParams.get("etat_de_la_paie");
const dpae = url.searchParams.get("dpae");
const employee_matricule = url.searchParams.get("employee_matricule");
const start_from = url.searchParams.get("start_from");
const start_to = url.searchParams.get("start_to");
@ -25,7 +26,7 @@ export async function GET(req: Request) {
const offset = Math.max(0, parseInt(url.searchParams.get("offset") || "0", 10));
// Build base query
let query = sb.from("cddu_contracts").select("id, contract_number, employee_name, employee_matricule, structure, type_de_contrat, start_date, end_date, created_at, etat_de_la_demande, etat_de_la_paie", { count: "exact" });
let query = sb.from("cddu_contracts").select("id, contract_number, employee_name, employee_matricule, employee_id, structure, type_de_contrat, start_date, end_date, created_at, etat_de_la_demande, etat_de_la_paie, dpae, gross_pay", { count: "exact" });
if (q) {
// simple ilike search on a few columns
@ -36,12 +37,58 @@ export async function GET(req: Request) {
if (type_de_contrat) query = query.eq("type_de_contrat", type_de_contrat);
if (etat_de_la_demande) query = query.eq("etat_de_la_demande", etat_de_la_demande);
if (etat_de_la_paie) query = query.eq("etat_de_la_paie", etat_de_la_paie);
if (dpae) query = query.eq("dpae", dpae);
if (start_from) query = query.gte("start_date", start_from);
if (start_to) query = query.lte("start_date", start_to);
// allow sort by start_date or end_date or created_at or employee_name
const allowedSorts = new Set(["start_date", "end_date", "created_at", "contract_number", "employee_name"]);
const sortCol = allowedSorts.has(sort) ? sort : "created_at";
// Pour le tri par nom, on doit traiter différemment
if (sortCol === "employee_name") {
// D'abord récupérer les données sans tri
query = query.range(offset, offset + limit - 1);
const { data: contractsData, error: contractsError, count } = await query;
if (contractsError) return NextResponse.json({ error: contractsError.message }, { status: 500 });
if (!contractsData || contractsData.length === 0) {
return NextResponse.json({ rows: [], count: count ?? 0 });
}
// Récupérer les informations des salariés pour le tri
const employeeIds = contractsData.map(c => c.employee_id).filter(Boolean);
const { data: salariesData, error: salariesError } = await sb
.from("salaries")
.select("id, nom, prenom")
.in("id", employeeIds);
if (salariesError) return NextResponse.json({ error: salariesError.message }, { status: 500 });
// Créer une map pour le tri
const salariesMap = new Map();
salariesData?.forEach(s => {
salariesMap.set(s.id, s.nom);
});
console.log("DEBUG TRI - Mapping salaries:", Array.from(salariesMap.entries()));
// Trier les contrats par nom de famille
const sortedContracts = contractsData.sort((a, b) => {
const nomA = salariesMap.get(a.employee_id) || '';
const nomB = salariesMap.get(b.employee_id) || '';
console.log(`DEBUG TRI - Comparaison: ${nomA} vs ${nomB} (employee_ids: ${a.employee_id} vs ${b.employee_id})`);
if (order === "asc") {
return nomA.localeCompare(nomB);
} else {
return nomB.localeCompare(nomA);
}
});
return NextResponse.json({ rows: sortedContracts, count: count ?? sortedContracts.length });
} else {
// Tri normal pour les autres colonnes
query = query.order(sortCol, { ascending: order === "asc" });
query = query.range(offset, offset + limit - 1);
@ -49,6 +96,7 @@ export async function GET(req: Request) {
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ rows: data ?? [], count: count ?? (data ? data.length : 0) });
}
} catch (err: any) {
console.error(err);
return NextResponse.json({ error: "Internal" }, { status: 500 });

View file

@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server';
import { createSbServer, createSbServiceRole } from '@/lib/supabaseServer';
export const dynamic = 'force-dynamic';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
if (!id) {
return NextResponse.json(
{ error: 'ID du log manquant' },
{ status: 400 }
);
}
// Vérifier l'authentification et les permissions staff
const supabaseServer = createSbServer();
const { data: { user }, error: authError } = await supabaseServer.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: 'Non authentifié' },
{ status: 401 }
);
}
// Vérifier si l'utilisateur est staff
const { data: staffData, error: staffError } = await supabaseServer
.from('staff_users')
.select('is_staff')
.eq('user_id', user.id)
.single();
if (staffError || !staffData?.is_staff) {
return NextResponse.json(
{ error: 'Accès refusé - permissions staff requises' },
{ status: 403 }
);
}
// Utiliser le service role pour récupérer le contenu de l'email
const supabaseServiceRole = createSbServiceRole();
const { data: emailLog, error: logError } = await supabaseServiceRole
.from('email_logs')
.select('html_content, email_type, template_name, subject')
.eq('id', id)
.single();
if (logError) {
console.error('Erreur lors de la récupération du log:', logError);
return NextResponse.json(
{ error: 'Log non trouvé' },
{ status: 404 }
);
}
return NextResponse.json({
html_content: emailLog.html_content,
email_type: emailLog.email_type,
template_name: emailLog.template_name,
subject: emailLog.subject
});
} catch (error) {
console.error('Erreur lors de la récupération du contenu de l\'email:', error);
return NextResponse.json(
{ error: 'Erreur interne du serveur' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,80 @@
// app/api/staff/email-logs/route.ts
import { createSbServer, createSbServiceRole } from "@/lib/supabaseServer";
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) {
try {
// Vérifier que l'utilisateur est staff
const sb = createSbServer();
const { data: { user }, error: authError } = await sb.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
}
const { data: staffMember, error: staffError } = await sb
.from('staff_users')
.select('is_staff')
.eq('user_id', user.id)
.maybeSingle();
if (staffError || !staffMember?.is_staff) {
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
}
// Utiliser le service role pour récupérer les données
const sbService = createSbServiceRole();
// Récupérer les paramètres de requête
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '20');
const search = searchParams.get('search');
const status = searchParams.get('status');
const type = searchParams.get('type');
// Construire la requête
let query = sbService
.from('email_logs')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false });
// Appliquer les filtres
if (search) {
query = query.or(`recipient_email.ilike.%${search}%,subject.ilike.%${search}%,sender_email.ilike.%${search}%`);
}
if (status && status !== 'all') {
query = query.eq('email_status', status);
}
if (type && type !== 'all') {
query = query.eq('email_type', type);
}
// Appliquer la pagination
const from = (page - 1) * limit;
const to = from + limit - 1;
query = query.range(from, to);
const { data: logs, error: logsError, count } = await query;
if (logsError) {
console.error('Erreur lors de la récupération des logs:', logsError);
return NextResponse.json({ error: 'Erreur lors de la récupération des logs' }, { status: 500 });
}
return NextResponse.json({
logs: logs || [],
count: count || 0,
page,
limit
});
} catch (error) {
console.error('Erreur dans l\'API email-logs:', error);
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
}
}

View file

@ -94,7 +94,7 @@ export async function GET(req: Request, { params }: { params: { id: string } })
if (invoice.pdf_s3_key) {
try {
const { S3Client, GetObjectCommand, getSignedUrl } = await getS3Presigner();
const bucket = process.env.AWS_S3_BUCKET || 'odentas-docs';
const bucket = (process.env.AWS_S3_BUCKET || 'odentas-docs').trim();
const region = process.env.AWS_REGION || 'eu-west-3';
const expireSeconds = Math.max(60, Math.min(60 * 60, Number(process.env.INVOICE_URL_EXPIRES ?? 900)));
@ -256,7 +256,7 @@ export async function DELETE(req: Request, { params }: { params: { id: string }
});
const deleteCommand = new DeleteObjectCommand({
Bucket: process.env.AWS_S3_BUCKET || 'odentas-docs',
Bucket: (process.env.AWS_S3_BUCKET || 'odentas-docs').trim(),
Key: invoice.pdf_s3_key,
});

View file

@ -91,7 +91,7 @@ export async function POST(req: Request, { params }: { params: { id: string } })
const buffer = Buffer.from(await file.arrayBuffer());
const uploadCommand = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET || 'odentas-docs',
Bucket: (process.env.AWS_S3_BUCKET || 'odentas-docs').trim(),
Key: s3Key,
Body: buffer,
ContentType: 'application/pdf',
@ -158,7 +158,7 @@ export async function DELETE(req: Request, { params }: { params: { id: string }
});
const deleteCommand = new DeleteObjectCommand({
Bucket: process.env.AWS_S3_BUCKET || 'odentas-docs',
Bucket: (process.env.AWS_S3_BUCKET || 'odentas-docs').trim(),
Key: invoice.pdf_s3_key,
});

View file

@ -51,7 +51,7 @@ export async function GET(req: Request, { params }: { params: { id: string } })
});
const getCommand = new GetObjectCommand({
Bucket: process.env.AWS_S3_BUCKET || 'odentas-docs',
Bucket: (process.env.AWS_S3_BUCKET || 'odentas-docs').trim(),
Key: invoice.pdf_s3_key,
});

Some files were not shown because too many files have changed in this diff Show more