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 # Dépendances & builds
.vercel
# Environment variables
.env*.local
.env
# Dependencies
node_modules/ node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Next.js
.next/ .next/
out/ out/
build/
dist/ dist/
build/
# Logs # Vercel
.vercel/
# Environnement & secrets
.env
.env.local
.env.*.local
# OS / IDE
.DS_Store
*.log *.log
# TypeScript # Tests & coverage
*.tsbuildinfo coverage/
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

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 # Variables d'environnement requises pour la signature électronique
## DocuSeal ## 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 S3 (pour les PDFs et les emails HTML)
- `AWS_REGION`: Région AWS (par défaut: eu-west-3) - `AWS_REGION`: Région AWS (par défaut: eu-west-3)

View file

@ -107,7 +107,7 @@ import {
await sendAccountActivationEmail('user@example.com', { await sendAccountActivationEmail('user@example.com', {
firstName: 'Marie', firstName: 'Marie',
organizationName: 'Compagnie Théâtrale', organizationName: 'Compagnie Théâtrale',
activationUrl: 'https://espace-paie.odentas.fr/activate?token=abc' activationUrl: 'https://paie.odentas.fr/activate?token=abc'
}); });
// Modification dhabilitation // 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 { Label } from "@/components/ui/label";
import { KeyRound, Lock, Loader2, Check, X, Eye, EyeOff } from "lucide-react"; import { KeyRound, Lock, Loader2, Check, X, Eye, EyeOff } from "lucide-react";
import { MfaSetupComponent } from "@/components/auth/MfaSetupComponent"; import { MfaSetupComponent } from "@/components/auth/MfaSetupComponent";
import { usePageTitle } from "@/hooks/usePageTitle";
/** /**
* Critères de validation du mot de passe * Critères de validation du mot de passe
@ -31,6 +32,8 @@ const isPasswordValid = (validation: ReturnType<typeof validatePassword>) => {
* - 2FA désactivé temporairement * - 2FA désactivé temporairement
*/ */
export default function CompteSecuritePage() { export default function CompteSecuritePage() {
usePageTitle("Sécurité");
const [hasPassword, setHasPassword] = useState<boolean | null>(null); const [hasPassword, setHasPassword] = useState<boolean | null>(null);
React.useEffect(() => { React.useEffect(() => {
@ -132,11 +135,11 @@ export default function CompteSecuritePage() {
{hasPassword === null ? ( {hasPassword === null ? (
<span className="inline-block text-xs text-slate-500">Vérification du statut</span> <span className="inline-block text-xs text-slate-500">Vérification du statut</span>
) : hasPassword ? ( ) : 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 Mot de passe défini
</span> </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 Aucun mot de passe défini
</span> </span>
)} )}
@ -167,8 +170,8 @@ export default function CompteSecuritePage() {
{/* Critères de validation en temps réel */} {/* Critères de validation en temps réel */}
{pw1.length > 0 && ( {pw1.length > 0 && (
<div className="mt-2 p-3 bg-slate-50 dark:bg-slate-900 rounded-lg border"> <div className="mt-2 p-3 bg-slate-50 rounded-lg border">
<div className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"> <div className="text-sm font-medium text-slate-700 mb-2">
Critères requis : Critères requis :
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@ -178,7 +181,7 @@ export default function CompteSecuritePage() {
) : ( ) : (
<X className="h-4 w-4 text-red-600" /> <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 Au moins 12 caractères
</span> </span>
</div> </div>
@ -188,7 +191,7 @@ export default function CompteSecuritePage() {
) : ( ) : (
<X className="h-4 w-4 text-red-600" /> <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) Au moins une minuscule (a-z)
</span> </span>
</div> </div>
@ -198,7 +201,7 @@ export default function CompteSecuritePage() {
) : ( ) : (
<X className="h-4 w-4 text-red-600" /> <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) Au moins une majuscule (A-Z)
</span> </span>
</div> </div>
@ -208,7 +211,7 @@ export default function CompteSecuritePage() {
) : ( ) : (
<X className="h-4 w-4 text-red-600" /> <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) Au moins un chiffre (0-9)
</span> </span>
</div> </div>
@ -218,7 +221,7 @@ export default function CompteSecuritePage() {
) : ( ) : (
<X className="h-4 w-4 text-red-600" /> <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 (!@#$%^&*) Au moins un caractère spécial (!@#$%^&*)
</span> </span>
</div> </div>
@ -255,14 +258,14 @@ export default function CompteSecuritePage() {
{passwordMatch === true ? ( {passwordMatch === true ? (
<> <>
<Check className="h-4 w-4 text-green-600" /> <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 Les mots de passe correspondent
</span> </span>
</> </>
) : ( ) : (
<> <>
<X className="h-4 w-4 text-red-600" /> <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 Les mots de passe ne correspondent pas
</span> </span>
</> </>

View file

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

View file

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

View file

@ -75,13 +75,13 @@ function useContrats(
// --- Mapping état → couleur/texte // --- Mapping état → couleur/texte
const ETATS: Record<Contrat["etat"], { label: string; className: string }> = { 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" }, "pre-demande": { label: "Pré-demande", className: "bg-slate-100 text-slate-700" },
"Reçue": { label: "Reçue", className: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" }, "Reçue": { label: "Reçue", className: "bg-blue-100 text-blue-800" },
"envoye": { label: "Envoyé", 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" },
"signe": { label: "Contrat signé", className: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300" }, "signe": { label: "Contrat signé", className: "bg-emerald-100 text-emerald-800" },
"modification": { label: "Modifier la demande", className: "bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300" }, "modification": { label: "Modifier la demande", className: "bg-rose-100 text-rose-800" },
"traitee": { label: "Traitée", className: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300" }, "traitee": { label: "Traitée", className: "bg-emerald-100 text-emerald-800" },
"en_cours": { label: "En cours", className: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" }, "en_cours": { label: "En cours", className: "bg-blue-100 text-blue-800" },
}; };
function humanizeEtat(raw?: string){ function humanizeEtat(raw?: string){
@ -107,7 +107,7 @@ function safeEtat(etat?: string){
const label = etat ? etat.charAt(0).toUpperCase() + etat.slice(1) : "—"; const label = etat ? etat.charAt(0).toUpperCase() + etat.slice(1) : "—";
return { return {
label, 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 }; } as { label: string; className: string };
} }
@ -159,7 +159,7 @@ export default function ContratsClient({ clientInfo }: { clientInfo: ClientInfo
if (!clientInfo) { if (!clientInfo) {
return ( return (
<div className="space-y-5"> <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 className="text-slate-500">Impossible de récupérer les informations de votre organisation.</div>
</div> </div>
</div> </div>
@ -169,11 +169,11 @@ export default function ContratsClient({ clientInfo }: { clientInfo: ClientInfo
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* En-tête + Recherche */} {/* 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 className="flex flex-col sm:flex-row sm:items-center gap-3">
<h1 className="text-xl font-semibold">Contrats & Paies</h1> <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="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"/> <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"/> <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> </div>
@ -181,16 +181,16 @@ export default function ContratsClient({ clientInfo }: { clientInfo: ClientInfo
</div> </div>
{/* Régime tabs */} {/* 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"> <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 dark:bg-slate-900 shadow border dark:border-slate-700' : 'opacity-80'}`}>CDDU</button> <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 dark:bg-slate-900 shadow border dark:border-slate-700' : 'opacity-80'}`}>Régime général</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> </div>
{/* Onglets + action */} {/* Onglets + action */}
<div className="mt-4 flex items-center justify-between gap-3"> <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"> <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 dark:bg-slate-900 shadow border dark:border-slate-700' : 'opacity-80'}`}>En cours</button> <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 dark:bg-slate-900 shadow border dark:border-slate-700' : 'opacity-80'}`}>Terminés</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> </div>
<a <a
href={regime === 'CDDU' ? '/contrats/nouveau' : '/contrats-rg/nouveau'} href={regime === 'CDDU' ? '/contrats/nouveau' : '/contrats-rg/nouveau'}
@ -201,13 +201,13 @@ export default function ContratsClient({ clientInfo }: { clientInfo: ClientInfo
</div> </div>
{status === "termines" && ( {status === "termines" && (
<div className="mt-3 flex flex-col sm:flex-row gap-2 sm:items-center"> <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"> <div className="flex gap-2">
<select <select
id="period-select" id="period-select"
value={period} value={period}
onChange={(e)=>{ setPeriod(e.target.value); setPage(1); }} 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"> <optgroup label="Année">
<option value="Y">Toute l'année</option> <option value="Y">Toute l'année</option>
@ -240,7 +240,7 @@ export default function ContratsClient({ clientInfo }: { clientInfo: ClientInfo
<select <select
value={year} value={year}
onChange={(e)=>{ setYear(parseInt(e.target.value,10)); setPage(1); }} 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>)} {yearOptions.map(y => <option key={y} value={y}>{y}</option>)}
</select> </select>
@ -250,11 +250,11 @@ export default function ContratsClient({ clientInfo }: { clientInfo: ClientInfo
</section> </section>
{/* Tableau */} {/* 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"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <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>État</Th>
<Th>Référence</Th> <Th>Référence</Th>
<Th>Nom</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> <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)=> ( 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> <Td>
{(() => { const e = safeEtat(c.etat as any); return ( {(() => { 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> <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"> <div className="flex flex-col">
<a href={detailHref(c)} className="underline font-medium">{c.reference}</a> <a href={detailHref(c)} className="underline font-medium">{c.reference}</a>
{(c.is_multi_mois === true || (c.regime && c.regime.toUpperCase() === "CDDU_MULTI")) && ( {(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> </div>
</Td> </Td>
@ -304,10 +304,10 @@ export default function ContratsClient({ clientInfo }: { clientInfo: ClientInfo
</div> </div>
{/* Pagination */} {/* Pagination */}
<div className="p-3 flex items-center gap-3 border-t dark:border-slate-800"> <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 dark:border-slate-800 disabled:opacity-40"><ChevronLeft className="w-4 h-4"/></button> <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> <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 className="ml-auto text-sm text-slate-500">{isFetching ? 'Mise à jour…' : `${items.length} élément${items.length>1?'s':''}${hasMore ? ' (plus disponibles)' : ''}`}</div>
</div> </div>
</section> </section>

View file

@ -138,9 +138,9 @@ export default function EditFormulairePage() {
return ( return (
<div className="p-4"> <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-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> Contrat <strong>{data.numero || data.id}</strong>
</div> </div>
</div> </div>

View file

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

View file

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

View file

@ -20,6 +20,7 @@ function formatEUR(value?: string | number | null): string | undefined {
import Link from "next/link"; import Link from "next/link";
import Script from "next/script"; import Script from "next/script";
import { usePageTitle } from "@/hooks/usePageTitle";
// ---------- Hook récupération fiches de paie Supabase ---------- // ---------- Hook récupération fiches de paie Supabase ----------
// ...existing code... // ...existing code...
@ -45,6 +46,54 @@ type Payslip = {
}; };
function usePayslips(contractId: string) { 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[]>({ return useQuery<Payslip[]>({
queryKey: ["payslips", contractId], queryKey: ["payslips", contractId],
queryFn: async () => { queryFn: async () => {
@ -217,6 +266,88 @@ type ContratDetail = {
// ---------- Data hooks ---------- // ---------- Data hooks ----------
function useContratDetail(id: string) { 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>({ return useQuery<ContratDetail>({
queryKey: ["contrat", id], queryKey: ["contrat", id],
queryFn: async () => { queryFn: async () => {
@ -303,9 +434,9 @@ function useToggleVirement(id: string) {
function Section({ title, icon: Icon, children }: { title: string; icon?: React.ElementType; children: React.ReactNode }) { function Section({ title, icon: Icon, children }: { title: string; icon?: React.ElementType; children: React.ReactNode }) {
return ( return (
<Card className="rounded-3xl overflow-hidden"> <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"> <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> <span className="text-lg font-semibold">{title}</span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -334,11 +465,11 @@ function Badge({
tone?: "default" | "ok" | "warn" | "error" | "info"; tone?: "default" | "ok" | "warn" | "error" | "info";
}) { }) {
const cls = const cls =
tone === "ok" ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200" : tone === "ok" ? "bg-emerald-100 text-emerald-800" :
tone === "warn" ? "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200" : tone === "warn" ? "bg-amber-100 text-amber-800" :
tone === "error" ? "bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-200" : tone === "error" ? "bg-rose-100 text-rose-800" :
tone === "info" ? "bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200" : tone === "info" ? "bg-sky-100 text-sky-800" :
"bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300"; "bg-slate-100 text-slate-700";
return <span className={`text-xs px-2 py-1 rounded-full ${cls}`}>{children}</span>; 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 // - En cours d'envoi: indigo
type Style = { bg: string; border: string; text: string; label: string }; type Style = { bg: string; border: string; text: string; label: string };
const styles: Record<string, Style> = { 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" }, "Reçue": { bg: "bg-sky-50", border: "border-sky-200", text: "text-sky-800", 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" }, "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 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" }, "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 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" }, "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 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" }, "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 // Normalisations alternatives depuis l'API
@ -383,7 +514,7 @@ function stateBadgeDemande(s: EtatDemande | string) {
styles[normalized] || styles[normalized] ||
styles[alt] || styles[alt] ||
// fallback générique // 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 ( 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}`}> <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 ---------- // ---------- Composant d'erreur d'accès ----------
function AccessDeniedError({ contractId }: { contractId: string }) { function AccessDeniedError({ contractId }: { contractId: string }) {
return ( 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="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"> <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 dark:text-rose-400" /> <AlertTriangle className="w-8 h-8 text-rose-600" />
</div> </div>
<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é Accès non autorisé
</h2> </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. 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> </p>
</div> </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} ID: {contractId}
</div> </div>
<Link <Link
href="/contrats" 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" /> <ArrowLeft className="w-4 h-4" />
Retour aux contrats Retour aux contrats
@ -438,6 +569,13 @@ export default function ContratPage() {
const router = useRouter(); const router = useRouter();
const { data, isLoading, isError, error } = useContratDetail(id); 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 payslipsQuery = usePayslips(id);
const [signedPayslipUrls, setSignedPayslipUrls] = useState<Record<string, string>>({}); const [signedPayslipUrls, setSignedPayslipUrls] = useState<Record<string, string>>({});
@ -720,7 +858,7 @@ export default function ContratPage() {
if (isLoading) { if (isLoading) {
return ( 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" /> <Loader2 className="w-4 h-4 inline animate-spin mr-2" />
Chargement du contrat Chargement du contrat
</div> </div>
@ -737,11 +875,11 @@ export default function ContratPage() {
if (errorMessage === "not_found") { if (errorMessage === "not_found") {
return ( 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-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="text-sm text-slate-500">Le contrat demandé n'existe pas ou a é supprimé.</div>
<div className="mt-4"> <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 <ArrowLeft className="w-4 h-4" /> Retour aux contrats
</Link> </Link>
</div> </div>
@ -751,11 +889,11 @@ export default function ContratPage() {
// Erreur générique // Erreur générique
return ( 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-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="text-sm text-slate-500">{errorMessage || "Erreur inconnue"}</div>
<div className="mt-4"> <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 <ArrowLeft className="w-4 h-4" /> Retour aux contrats
</Link> </Link>
</div> </div>
@ -970,29 +1108,29 @@ return (
router.push(`/contrats/nouveau?dupe=${encodeURIComponent(b64)}`); 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" title="Dupliquer ce contrat"
> >
<Copy className="w-4 h-4" /> <Copy className="w-4 h-4" />
Dupliquer Dupliquer
</button> </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 <Pencil className="w-4 h-4" /> Modifier
</Link> </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 <Check className="w-4 h-4" /> Valider
</button> </button>
</div> </div>
</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"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-center">
{/* Titre du contrat - 1 colonne */} {/* Titre du contrat - 1 colonne */}
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="text-lg font-semibold">{title}</div> <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 className="text-sm text-slate-500">CDDU</div>
</div> </div>
</div> </div>
@ -1001,7 +1139,7 @@ return (
<div className="lg:col-span-3"> <div className="lg:col-span-3">
<div className="flex items-center justify-between relative"> <div className="flex items-center justify-between relative">
{/* Ligne de progression */} {/* 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 steps = getTimelineSteps(data.etat_demande, data.contrat_signe_salarie, payslipsQuery.data);
const completedSteps = steps.filter(s => s.status === "completed").length; const completedSteps = steps.filter(s => s.status === "completed").length;
@ -1018,26 +1156,26 @@ return (
{/* Étapes */} {/* Étapes */}
<div className="flex items-center justify-between w-full relative z-10"> <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) => ( {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 ${ <div className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
step.status === "completed" step.status === "completed"
? "bg-green-500" ? "bg-green-500"
: step.status === "current" : step.status === "current"
? "bg-blue-500" ? "bg-blue-500"
: "bg-slate-300 dark:bg-slate-600" : "bg-slate-300"
}`}> }`}>
{step.status === "completed" ? ( {step.status === "completed" ? (
<Check className="w-4 h-4 text-white" /> <Check className="w-4 h-4 text-white" />
) : step.status === "current" ? ( ) : step.status === "current" ? (
<Clock className="w-4 h-4 text-white" /> <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>
<div className={`text-xs font-medium text-center ${ <div className={`text-xs font-medium text-center ${
step.status === "upcoming" step.status === "upcoming"
? "text-slate-500 dark:text-slate-400" ? "text-slate-500"
: "text-slate-700 dark:text-slate-300" : "text-slate-700"
}`}> }`}>
{step.label} {step.label}
</div> </div>
@ -1208,11 +1346,11 @@ return (
label="DPAE" label="DPAE"
value={ value={
data.dpae === "OK" ? ( 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 <CheckCircle className="w-3 h-3" /> Effectuée
</span> </span>
) : data.dpae === "À traiter" ? ( ) : data.dpae === "À traiter" ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 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 <Clock className="w-3 h-3" /> En cours
</span> </span>
) : ( ) : (
@ -1228,7 +1366,7 @@ return (
<div className="text-slate-400">Chargement des fiches de paie</div> <div className="text-slate-400">Chargement des fiches de paie</div>
) : payslipsQuery.data && payslipsQuery.data.length > 0 ? ( ) : payslipsQuery.data && payslipsQuery.data.length > 0 ? (
payslipsQuery.data.map((slip) => ( 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 */} {/* Bouton de paiement en position absolue dans le coin bas droit */}
{slip.processed && ( {slip.processed && (
<Button <Button
@ -1242,7 +1380,7 @@ return (
)} )}
<div className="mb-3"> <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)} Période : {formatDateFR(slip.period_start)} {formatDateFR(slip.period_end)}
</div> </div>
</div> </div>
@ -1278,14 +1416,14 @@ return (
{/* Modale d'erreur DocuSeal */} {/* Modale d'erreur DocuSeal */}
{showErrorModal && ( {showErrorModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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="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"> <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 dark:text-red-400" /> <AlertTriangle className="w-5 h-5 text-red-600" />
</div> </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>
<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 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> <p>La signature électronique n'est pas encore prête pour ce contrat. Nos équipes travaillent activement sur la préparation des documents.</p>
</div> </div>

View file

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

View file

@ -4,8 +4,24 @@
import React, { useCallback, useMemo, useRef, useState } from "react"; import React, { useCallback, useMemo, useRef, useState } from "react";
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import AccessDeniedCard from "@/components/AccessDeniedCard"; import AccessDeniedCard from "@/components/AccessDeniedCard";
import { usePageTitle } from "@/hooks/usePageTitle";
// --- Utils: debounce --------------------------------------------------------- // --- 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) { function useDebouncedValue<T>(value: T, delay = 300) {
const [debounced, setDebounced] = React.useState(value); const [debounced, setDebounced] = React.useState(value);
React.useEffect(() => { React.useEffect(() => {
@ -31,6 +47,8 @@ function useClientInfo() {
React.useEffect(() => { React.useEffect(() => {
if (loaded) return; if (loaded) return;
console.log('🔍 [CLIENT INFO DEBUG] Loading client info...');
(async () => { (async () => {
try { try {
const res = await fetch("/api/me", { const res = await fetch("/api/me", {
@ -38,16 +56,26 @@ function useClientInfo() {
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
credentials: "include" credentials: "include"
}); });
console.log('🔍 [CLIENT INFO DEBUG] /api/me response status:', res.status);
if (res.ok) { if (res.ok) {
const me = await res.json(); const me = await res.json();
setClientInfo({ console.log('🔍 [CLIENT INFO DEBUG] /api/me response data:', me);
const info = {
id: me.active_org_id || null, id: me.active_org_id || null,
name: me.active_org_name || "Organisation", name: me.active_org_name || "Organisation",
api_name: me.active_org_api_name 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) { } catch (e) {
console.warn('Could not load client info:', e); console.warn('🔍 [CLIENT INFO DEBUG] Could not load client info:', e);
setClientInfo(null); setClientInfo(null);
} }
setLoaded(true); setLoaded(true);
@ -58,7 +86,7 @@ function useClientInfo() {
} }
// Recherche de productions (alignée avec useSearchSpectacles) // 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 []; if (!q || q.trim().length < 2 || !clientInfo) return [];
try { try {
@ -69,11 +97,7 @@ async function searchProductions(q: string, clientInfo: ClientInfo): Promise<str
const result = await api<{ items: SpectacleOption[] }>(`/spectacles?${params.toString()}`, {}, clientInfo); const result = await api<{ items: SpectacleOption[] }>(`/spectacles?${params.toString()}`, {}, clientInfo);
return (result.items || []).map((s) => { return (result.items || []).filter(s => s.nom && s.nom.length > 0);
const nom = s.nom || "";
const numero = s.numero_objet;
return numero ? `${nom}${numero}` : nom;
}).filter(name => name.length > 0);
} catch (e) { } catch (e) {
console.warn('Search productions failed:', e); console.warn('Search productions failed:', e);
return []; 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 // Recherche de salariés (alignée avec useSearchSalaries) — renvoie des objets complets
async function searchSalaries(q: string, clientInfo: ClientInfo): Promise<SalarieOption[]> { 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 { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("page", "1"); params.set("page", "1");
params.set("limit", "10"); params.set("limit", "10");
params.set("q", q.trim()); 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) { } catch (e) {
console.warn('Search salaries failed:', e); console.warn('🔍 [SEARCH DEBUG] Search salaries failed:', e);
return []; return [];
} }
} }
@ -140,6 +179,7 @@ export type SalaryType = "BRUT" | "NET_AVT_PAS" | "CTE" | "MINIMA";
export type ContractRow = { export type ContractRow = {
id: string; id: string;
salarie: string; salarie: string;
salarieMatricule?: string; // Stocker le matricule séparément
role: RoleType; role: RoleType;
profession: string; profession: string;
dateDebut: string; // YYYY-MM-DD dateDebut: string; // YYYY-MM-DD
@ -359,9 +399,9 @@ function ComboBox({
inputProps, inputProps,
error, error,
}: { }: {
value: string; value: string | SpectacleOption | null;
onChange: (v: string) => void; onChange: (v: string | SpectacleOption | null) => void;
onSelect: (v: string) => void; onSelect: (v: string | SpectacleOption | null) => void;
placeholder?: string; placeholder?: string;
searchType: 'productions' | 'salaries' | 'professionsArtiste' | 'professionsTechnicien'; searchType: 'productions' | 'salaries' | 'professionsArtiste' | 'professionsTechnicien';
className?: string; className?: string;
@ -440,23 +480,56 @@ function ComboBox({
}, [open, updatePopupPos]); }, [open, updatePopupPos]);
const commit = (v: any) => { 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') { 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') { } else if (typeof v === 'string') {
console.log('🔍 [COMBOBOX DEBUG] String value:', v);
onSelect(v); onSelect(v);
} else { } else {
console.log('🔍 [COMBOBOX DEBUG] Other value (converted to string):', v);
onSelect(String(v ?? '')); onSelect(String(v ?? ''));
} }
setOpen(false); 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 ( return (
<div ref={anchorRef} className={`relative ${className}`}> <div ref={anchorRef} className={`relative ${className}`}>
<input <input
value={value} value={inputValue}
onChange={(e) => { onChange={(e) => {
onChange(e.target.value); const newValue = e.target.value;
setQuery(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); setOpen(true);
}} }}
onFocus={() => setOpen(true)} onFocus={() => setOpen(true)}
@ -510,10 +583,10 @@ function ComboBox({
</li> </li>
); );
} }
// default (productions/professions) // productions et professions
return ( return (
<li <li
key={typeof it === 'string' ? it : 'opt-' + i} key={typeof it === 'string' ? it : (it.id || it.nom || 'opt-' + i)}
role="option" role="option"
aria-selected={isActive} aria-selected={isActive}
className={`px-3 py-2 cursor-pointer transition border-l-4 ${ className={`px-3 py-2 cursor-pointer transition border-l-4 ${
@ -525,6 +598,17 @@ function ComboBox({
onMouseDown={(e) => { e.preventDefault(); commit(it); }} 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 text = typeof it === 'string' ? it : String(it);
const [main, extra] = text.split(' — '); const [main, extra] = text.split(' — ');
return ( return (
@ -549,49 +633,26 @@ function ComboBox({
} }
export default function Page() { export default function Page() {
usePageTitle("Saisie en tableau - Nouveaux contrats");
// Staff-only guard for this page // Staff-only guard for this page
const [authChecked, setAuthChecked] = React.useState(false); const [authChecked, setAuthChecked] = React.useState(false);
const [isStaff, setIsStaff] = React.useState<boolean>(false); const [isStaff, setIsStaff] = React.useState<boolean>(false);
React.useEffect(() => {
let mounted = true; // Tous les autres hooks DOIVENT être déclarés avant les conditions de retour
(async () => { const [production, setProduction] = useState<SpectacleOption | null>(null);
try { const [rows, setRows] = useState<ContractRow[]>([emptyRow()]);
const res = await fetch('/api/me', { cache: 'no-store', credentials: 'include' }); const pasteTargetRef = useRef<HTMLTextAreaElement | null>(null);
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) { // State pour l'organisation sélectionnée (staff uniquement)
return <div className="p-6 text-sm text-slate-500">Chargement</div>; const [selectedOrg, setSelectedOrg] = useState<{id: string; name: string} | null>(null);
} const [availableOrgs, setAvailableOrgs] = useState<{id: string; name: string; structure_api: string}[]>([]);
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>
);
}
// Focus helpers for row/column navigation // Focus helpers for row/column navigation
const focusCell = useCallback((rowId: string, field: keyof ContractRow) => { const focusCell = useCallback((rowId: string, field: keyof ContractRow) => {
const el = document.querySelector<HTMLElement>(`[data-row="${rowId}"][data-field="${field}"]`); const el = document.querySelector<HTMLElement>(`[data-row="${rowId}"][data-field="${field}"]`);
el?.focus(); el?.focus();
}, []); }, []);
const [production, setProduction] = useState("");
const [rows, setRows] = useState<ContractRow[]>([emptyRow()]);
const pasteTargetRef = useRef<HTMLTextAreaElement | null>(null);
// Tooltip custom pour le bouton Valider // Tooltip custom pour le bouton Valider
const validateWrapRef = useRef<HTMLDivElement | null>(null); 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 }); 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) // Tooltip d'aide pour "Annexe" (icône ou select role)
const annexeIconRef = useRef<HTMLSpanElement | null>(null); const annexeIconRef = useRef<HTMLSpanElement | null>(null);
const annexeAnchorEl = useRef<HTMLElement | 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 }); setAnnexeTipPos({ top: r.top + window.scrollY, left: r.left + window.scrollX + r.width / 2 });
setAnnexeTipOpen(true); 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(() => { React.useEffect(() => {
if (!annexeTipOpen) return; let mounted = true;
const onScroll = () => computeAnnexeTipPos(); (async () => {
const onResize = () => computeAnnexeTipPos(); 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('scroll', onScroll, true);
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
return () => { return () => {
window.removeEventListener('scroll', onScroll, true); window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize); window.removeEventListener('resize', onResize);
}; };
}, [annexeTipOpen, computeAnnexeTipPos]); }, [validateTipOpen, computeValidateTipPos]);
// Hooks pour CSV
const csvInputRef = useRef<HTMLInputElement | null>(null); const csvInputRef = useRef<HTMLInputElement | null>(null);
const openCSVDialog = useCallback(() => { const openCSVDialog = useCallback(() => {
@ -682,6 +787,22 @@ export default function Page() {
reader.readAsText(file); 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 downloadCSVTemplate = useCallback(() => {
const headers = Array.from(PASTE_ORDER).join(','); const headers = Array.from(PASTE_ORDER).join(',');
const blob = new Blob([headers + '\n'], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob([headers + '\n'], { type: 'text/csv;charset=utf-8;' });
@ -735,7 +856,6 @@ export default function Page() {
setPanelPos(null); setPanelPos(null);
}, []); }, []);
// Maintenir la position du panneau et de la flèche au scroll/resize // Maintenir la position du panneau et de la flèche au scroll/resize
React.useEffect(() => { React.useEffect(() => {
if (!noteEditorOpen || !noteAnchor) return; 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(() => { React.useEffect(() => {
const onKey = (e: KeyboardEvent) => { 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 tag = (e.target as HTMLElement)?.tagName?.toLowerCase();
const isTyping = tag === 'input' || tag === 'select' || tag === 'textarea'; 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.ctrlKey && e.shiftKey) || e.metaKey) return;
if (e.key.toLowerCase() === 'd') { if (e.key.toLowerCase() === 'd') {
@ -850,13 +968,123 @@ export default function Page() {
return () => window.removeEventListener('keydown', onKey, { capture: true }); return () => window.removeEventListener('keydown', onKey, { capture: true });
}, [activeRowId, duplicateRowById, addEmptyRowAfterId, deleteRowById, moveFocusVertical]); }, [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 anyErrors = useMemo(() => rows.some(r => Object.keys(validateRow(r)).length > 0), [rows]);
const hasAnyData = useMemo(() => rows.some(r => ( const hasAnyData = useMemo(() => rows.some(r => (
!!r.salarie || !!r.profession || !!r.dateDebut || !!r.dateFin || r.salaire !== "" || r.nbreCachetsRepresentation !== "" || r.nbreServiceRepet !== "" || r.heuresSiTechnicien !== "" || !!r.note !!r.salarie || !!r.profession || !!r.dateDebut || !!r.dateFin || r.salaire !== "" || r.nbreCachetsRepresentation !== "" || r.nbreServiceRepet !== "" || r.heuresSiTechnicien !== "" || !!r.note
)), [rows]); )), [rows]);
const canSubmit = hasAnyData && !anyErrors; 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 totals = useMemo(() => {
const totalSalaire = rows.reduce((acc, r) => acc + (Number(r.salaire) || 0), 0); 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); 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 }; return { totalSalaire, totalCachets, totalServices, totalHeures };
}, [rows]); }, [rows]);
// Tous les autres useCallback DOIVENT être ici AVANT les conditions de retour
const addRow = useCallback((preset?: Partial<ContractRow>) => { const addRow = useCallback((preset?: Partial<ContractRow>) => {
setRows((prev) => [...prev, emptyRow(preset)]); setRows((prev) => [...prev, emptyRow(preset)]);
}, []); }, []);
@ -970,6 +1199,35 @@ export default function Page() {
closeNote(); closeNote();
}, [noteEditorRowId, noteDraft, updateCell, 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 = ( const onKeyDownCell = (
e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>, e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
row: ContractRow, 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 ( return (
<div className="space-y-3"> <div className="space-y-3">
<header className={`flex items-center justify-between ${headerPad}`}> <header className={`flex items-center justify-between ${headerPad}`}>
@ -1013,11 +1260,34 @@ export default function Page() {
<button <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" 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" 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> </button>
</div> </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( {validateTipOpen && !canSubmit && validateTipPos && createPortal(
(() => { (() => {
const top = validateTipPos.top - 10; // au-dessus 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}`}> <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"> <div className="col-span-2">
<label className="text-[12px] font-medium">Production (commune à toutes les lignes)</label> <label className="text-[12px] font-medium">Production (commune à toutes les lignes)</label>
<ComboBox <ComboBox
value={production} value={production}
onChange={setProduction} onChange={(v) => {
onSelect={setProduction} 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…" placeholder="Rechercher ou saisir une production…"
searchType="productions" searchType="productions"
className="mt-1" className="mt-1"
@ -1147,7 +1460,7 @@ export default function Page() {
<Td> <Td>
<ComboBox <ComboBox
value={row.salarie} value={row.salarie.includes(' | ') ? row.salarie.split(' | ')[1] : row.salarie}
onChange={(v) => updateCell(row.id, "salarie", v)} onChange={(v) => updateCell(row.id, "salarie", v)}
onSelect={(v) => updateCell(row.id, "salarie", v)} onSelect={(v) => updateCell(row.id, "salarie", v)}
placeholder="Rechercher un salarié…" placeholder="Rechercher un salarié…"
@ -1179,9 +1492,10 @@ export default function Page() {
{row.role === 'ARTISTE' ? ( {row.role === 'ARTISTE' ? (
<ComboBox <ComboBox
value={row.profession} value={row.profession}
onChange={(v) => updateCell(row.id, 'profession', v)} onChange={(v) => updateCell(row.id, 'profession', typeof v === 'string' ? v : String(v || ''))}
onSelect={(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); updateCell(row.id, 'profession', label);
}} }}
placeholder="Rechercher une profession (Artiste)…" placeholder="Rechercher une profession (Artiste)…"
@ -1192,9 +1506,10 @@ export default function Page() {
) : ( ) : (
<ComboBox <ComboBox
value={row.profession} value={row.profession}
onChange={(v) => updateCell(row.id, 'profession', v)} onChange={(v) => updateCell(row.id, 'profession', typeof v === 'string' ? v : String(v || ''))}
onSelect={(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); updateCell(row.id, 'profession', label);
}} }}
placeholder="Rechercher une profession (Technicien)…" 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 { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/fetcher"; import { api } from "@/lib/fetcher";
import { Calendar, Loader2 } from "lucide-react"; import { Calendar, Loader2 } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
/* ========================= /* =========================
Types attendus du backend 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 }) { function Section({ title, children, actions }: { title: string; children: React.ReactNode; actions?: React.ReactNode }) {
return ( return (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800"> <section className="rounded-2xl border bg-white">
<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="px-4 py-3 border-b bg-slate-50/60 flex items-center justify-between">
<div className="font-medium text-slate-700 dark:text-slate-200">{title}</div> <div className="font-medium text-slate-700">{title}</div>
{actions ? <div className="shrink-0">{actions}</div> : null} {actions ? <div className="shrink-0">{actions}</div> : null}
</div> </div>
<div className="p-4">{children}</div> <div className="p-4">{children}</div>
@ -164,6 +165,8 @@ function Section({ title, children, actions }: { title: string; children: React.
Page Page
===== */ ===== */
export default function CotisationsMensuellesPage() { export default function CotisationsMensuellesPage() {
usePageTitle("Cotisations mensuelles");
const now = new Date(); const now = new Date();
const handlePeriodChange = (value: Filters["period"]) => { const handlePeriodChange = (value: Filters["period"]) => {
@ -304,9 +307,9 @@ export default function CotisationsMensuellesPage() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Bandeau titre + aide */} {/* 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> <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. 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. Par exemple, pour les salaires de septembre, les télépaiements ont lieu entre le 15 et le 30 octobre.
</p> </p>
@ -318,7 +321,7 @@ export default function CotisationsMensuellesPage() {
<div> <div>
<label className="text-xs text-slate-500 block mb-1">Filtrer par année</label> <label className="text-xs text-slate-500 block mb-1">Filtrer par année</label>
<select <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} value={filters.year}
onChange={(e) => { onChange={(e) => {
const y = parseInt(e.target.value, 10); const y = parseInt(e.target.value, 10);
@ -347,7 +350,7 @@ export default function CotisationsMensuellesPage() {
<div> <div>
<label className="text-xs text-slate-500 block mb-1">Filtrer par période</label> <label className="text-xs text-slate-500 block mb-1">Filtrer par période</label>
<select <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} value={filters.period}
onChange={(e) => handlePeriodChange(e.target.value as Filters["period"])} onChange={(e) => handlePeriodChange(e.target.value as Filters["period"])}
> >
@ -384,7 +387,7 @@ export default function CotisationsMensuellesPage() {
<div className="flex items-end"> <div className="flex items-end">
<button <button
onClick={handleReset} 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" type="button"
> >
Réinitialiser Réinitialiser
@ -412,7 +415,7 @@ export default function CotisationsMensuellesPage() {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <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-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">Total</th>
<th className="text-right font-medium px-3 py-2">URSSAF</th> <th className="text-right font-medium px-3 py-2">URSSAF</th>
@ -427,7 +430,7 @@ export default function CotisationsMensuellesPage() {
<tbody> <tbody>
{/* Ligne Total */} {/* Ligne Total */}
{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"> <td className="px-3 py-2 flex items-center gap-2">
<StatusDot s={total.status} /> <StatusDot s={total.status} />
Total Total
@ -449,7 +452,7 @@ export default function CotisationsMensuellesPage() {
) : ( ) : (
<> <>
{items.map((row) => ( {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"> <td className="px-3 py-2">
<div className="flex items-center gap-2 group relative"> <div className="flex items-center gap-2 group relative">
<StatusDot s={row.status} /> <StatusDot s={row.status} />
@ -462,11 +465,11 @@ export default function CotisationsMensuellesPage() {
{/* Tooltip custom */} {/* Tooltip custom */}
<div <div
role="tooltip" 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%)' }} style={{ top: '50%', transform: 'translateY(-50%)' }}
> >
Fenêtre de paiement : {paymentWindowLabel(row.annee, row.mois)} 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>
</div> </div>
</td> </td>

View file

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

View file

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

View file

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

View file

@ -4,10 +4,14 @@ import { MaintenanceButton } from "@/components/MaintenanceButton";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
// import { getAccessToken } from "@/lib/auth"; // Supprimé, on utilise directement Supabase // 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"; import { createSbServer } from "@/lib/supabaseServer";
// app/layout.tsx // app/layout.tsx
import "@/styles/cmdk.css"; 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 = { type ClientInfo = {
id: string; id: string;
@ -23,6 +27,59 @@ type ClientInfo = {
export default async function AppLayout({ children }: { children: ReactNode }) { export default async function AppLayout({ children }: { children: ReactNode }) {
const c = cookies(); 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 // ⚠️ DEV ONLY : bypass auth si AUTH_BYPASS=1
if (process.env.NODE_ENV === "development" && process.env.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 { api } from "@/lib/fetcher";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { usePageTitle } from "@/hooks/usePageTitle";
type ClientInfo = { type ClientInfo = {
id: string; id: string;
@ -22,6 +23,9 @@ type ClientInfo = {
export default function Dashboard() { export default function Dashboard() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
// Définir le titre de la page
usePageTitle("Tableau de bord");
const { data, isLoading, isError } = useContrats({ const { data, isLoading, isError } = useContrats({
// Pas de restriction de régime - récupère tous les contrats // Pas de restriction de régime - récupère tous les contrats
status: "en_cours", status: "en_cours",
@ -84,14 +88,14 @@ export default function Dashboard() {
return ( return (
<div className="space-y-6"> <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> <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>
<section className="grid grid-cols-1 md:grid-cols-2 gap-6"> <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> <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} {countLabel}
</p> </p>
@ -102,21 +106,21 @@ export default function Dashboard() {
<Link <Link
key={c.id} key={c.id}
href={c.regime === 'RG' ? `/contrats-rg/${c.id}` : `/contrats/${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> <h3 className="text-lg font-semibold">{c.salarie_nom}</h3>
<div className="flex flex-wrap items-center gap-2 mt-1"> <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} {c.profession}
</span> </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} Réf. {c.reference}
</span> </span>
{c.regime && ( {c.regime && (
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${ <span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
c.regime === 'RG' c.regime === 'RG'
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200' ? 'bg-emerald-100 text-emerald-800'
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' : 'bg-purple-100 text-purple-800'
}`}> }`}>
{c.regime === 'RG' ? 'Régime Général' : 'CDDU'} {c.regime === 'RG' ? 'Régime Général' : 'CDDU'}
</span> </span>
@ -131,7 +135,7 @@ export default function Dashboard() {
<span className="flex items-center text-sm"> <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> <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' ? ( {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)}</> <>Fin : {formatFR(c.date_fin)}</>
)} )}
@ -164,12 +168,12 @@ export default function Dashboard() {
</Button> </Button>
</div> </div>
</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> <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 */} {/* 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"> <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"/> <path d="M10 2L13 8h6l-5 4 2 6-6-4-6 4 2-6-5-4h6l3-6z"/>
</svg> </svg>
@ -178,22 +182,22 @@ export default function Dashboard() {
{/* Contenu principal */} {/* Contenu principal */}
<div className="relative"> <div className="relative">
<div className="flex items-center gap-3 mb-3"> <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"> <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 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-5 5v-5zM4 3h11v6H4V3zM4 13h7v7H4v-7z" />
</svg> </svg>
</div> </div>
<div> <div>
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Bientôt disponible</h3> <h3 className="font-semibold text-slate-900">Bientôt disponible</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">Fonctionnalité en développement</p> <p className="text-sm text-slate-600">Fonctionnalité en développement</p>
</div> </div>
</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. 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> </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> <div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse"></div>
<span>Cette fonctionnalité sera lancée prochainement</span> <span>Cette fonctionnalité sera lancée prochainement</span>
</div> </div>

View file

@ -4,6 +4,7 @@ import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { ArrowLeft, Loader2, ChevronLeft, ChevronRight } from "lucide-react"; import { ArrowLeft, Loader2, ChevronLeft, ChevronRight } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
// Types // Types
type SalarieDetail = { type SalarieDetail = {
@ -64,8 +65,8 @@ function Field({ label, value }: { label: string; value: string }) {
// Composant Section // Composant Section
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800"> <section className="rounded-2xl border bg-white">
<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"> <div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
{title} {title}
</div> </div>
<div className="p-4">{children}</div> <div className="p-4">{children}</div>
@ -93,6 +94,11 @@ export default function SalariePage() {
const router = useRouter(); const router = useRouter();
const [salarie, setSalarie] = useState<SalarieDetail | null>(null); 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 [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -125,6 +131,48 @@ export default function SalariePage() {
setLoading(true); setLoading(true);
setError(null); 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}`); console.log(`🔍 Fetching salarie: ${matricule}`);
const response = await fetch(`/api/salaries/${matricule}`, { const response = await fetch(`/api/salaries/${matricule}`, {
@ -164,6 +212,66 @@ export default function SalariePage() {
setContratsLoading(true); setContratsLoading(true);
setContratsError(null); 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}`); console.log(`🔍 Fetching contrats for ${matricule}, year ${year}, page ${page}`);
const response = await fetch(`/api/salaries/${matricule}/contrats?year=${year}&page=${page}&limit=${limit}`, { 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 <ArrowLeft className="w-4 h-4" /> Retour
</Link> </Link>
</div> </div>
<div className="rounded-2xl border bg-rose-50 dark:bg-rose-900/20 dark:border-rose-800 p-4"> <div className="rounded-2xl border bg-rose-50 p-4">
<h3 className="font-medium text-rose-800 dark:text-rose-200 mb-2"> <h3 className="font-medium text-rose-800 mb-2">
Erreur lors du chargement Erreur lors du chargement
</h3> </h3>
<p className="text-rose-700 dark:text-rose-300 text-sm"> <p className="text-rose-700 text-sm">
{error} {error}
</p> </p>
<button <button
onClick={() => window.location.reload()} 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 Réessayer
</button> </button>
@ -273,7 +381,7 @@ export default function SalariePage() {
</div> </div>
{/* Titre */} {/* 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="flex flex-wrap items-center gap-3">
<div className="text-lg font-semibold"> <div className="text-lg font-semibold">
{formatValue(salarie.nom_usage).toUpperCase()} {formatValue(salarie.prenom)} {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="BIC" value={formatValue(salarie.bic)} />
<Field label="Justificatifs" value={formatValue(salarie.justificatifs)} /> <Field label="Justificatifs" value={formatValue(salarie.justificatifs)} />
<div className="pt-3 border-t dark:border-slate-800"> <div className="pt-3 border-t">
<Field <Field
label="Espace Transat" label="Espace Transat"
value={salarie.transat_connecte !== undefined value={salarie.transat_connecte !== undefined
@ -354,7 +462,7 @@ export default function SalariePage() {
setYear(parseInt(e.target.value, 10)); setYear(parseInt(e.target.value, 10));
setPage(1); 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) => ( {yearOptions.map((y) => (
<option key={y} value={y}> <option key={y} value={y}>
@ -368,7 +476,7 @@ export default function SalariePage() {
<button <button
onClick={() => setPage((p) => Math.max(1, p - 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1 || contratsLoading} 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" title="Page précédente"
> >
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
@ -377,7 +485,7 @@ export default function SalariePage() {
<button <button
onClick={() => setPage((p) => p + 1)} onClick={() => setPage((p) => p + 1)}
disabled={!hasMore || contratsLoading} 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" title="Page suivante"
> >
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
@ -391,17 +499,17 @@ export default function SalariePage() {
{/* Grille des contrats */} {/* Grille des contrats */}
<div className="space-y-3"> <div className="space-y-3">
{contratsError ? ( {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> <p>Erreur lors du chargement des contrats</p>
<button <button
onClick={fetchContrats} 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 Réessayer
</button> </button>
</div> </div>
) : contrats.length === 0 && !contratsLoading ? ( ) : 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}. Aucun contrat pour {year}.
</div> </div>
) : ( ) : (
@ -409,30 +517,30 @@ export default function SalariePage() {
<Link <Link
key={c.id} key={c.id}
href={hrefContrat(c)} 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> <h3 className="text-lg font-semibold">{c.reference}</h3>
<div className="flex flex-wrap items-center gap-2 mt-1"> <div className="flex flex-wrap items-center gap-2 mt-1">
{c.profession && ( {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} {c.profession}
</span> </span>
)} )}
{c.regime && ( {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} {c.regime === 'CDDU_MULTI' ? 'Multi-mois' : c.regime === 'CDDU_MONO' ? 'Mono-mois' : c.regime}
</span> </span>
)} )}
</div> </div>
<div className="flex flex-col sm:flex-row gap-1 sm:gap-4 mt-2"> <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"> <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"/> <rect x="3" y="4" width="18" height="16" rx="2"/>
<path d="M7 2v2M17 2v2M3 10h18"/> <path d="M7 2v2M17 2v2M3 10h18"/>
</svg> </svg>
Début : {formatDateFR(c.date_debut)} Début : {formatDateFR(c.date_debut)}
</span> </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"> <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"/> <rect x="3" y="4" width="18" height="16" rx="2"/>
<path d="M7 2v2M17 2v2M3 10h18"/> <path d="M7 2v2M17 2v2M3 10h18"/>
@ -455,7 +563,7 @@ export default function SalariePage() {
/> />
{/* Modal */} {/* 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> <div className="text-base font-medium mb-1">Créer un nouveau contrat</div>
<p className="text-sm text-slate-500 mb-4"> <p className="text-sm text-slate-500 mb-4">
Pour quel type de contrat souhaitez-vous procéder ? Pour quel type de contrat souhaitez-vous procéder ?
@ -468,7 +576,7 @@ export default function SalariePage() {
setNewContratOpen(false); setNewContratOpen(false);
router.push(`/contrats/nouveau?salarie=${encodeURIComponent(matricule)}`); 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 CDDU
<div className="text-xs text-slate-500">Contrat à durée déterminée d'usage</div> <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 // Non fonctionnel pour l'instant
setNewContratOpen(false); 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" title="Bientôt disponible"
disabled disabled
> >
@ -493,7 +601,7 @@ export default function SalariePage() {
<button <button
type="button" type="button"
onClick={() => setNewContratOpen(false)} 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 Annuler
</button> </button>

View file

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

View file

@ -7,6 +7,7 @@ import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { useMemo, useState, useEffect } from "react"; import { useMemo, useState, useEffect } from "react";
import { api } from "@/lib/fetcher"; import { api } from "@/lib/fetcher";
import { Loader2, ChevronLeft, ChevronRight, Plus } from "lucide-react"; import { Loader2, ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
/* ===== Types ===== */ /* ===== Types ===== */
type SalarieRow = { type SalarieRow = {
@ -40,8 +41,8 @@ type ClientInfo = {
/* ===== Helpers ===== */ /* ===== Helpers ===== */
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800"> <section className="rounded-2xl border bg-white">
<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"> <div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
{title} {title}
</div> </div>
<div className="p-4">{children}</div> <div className="p-4">{children}</div>
@ -57,6 +58,124 @@ function lastContractHref(c?: SalarieRow["dernier_contrat"]) {
/* ===== Data hook ===== */ /* ===== Data hook ===== */
function useSalaries(page: number, limit: number, search: string, org?: string | null) { 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 // Récupération dynamique des infos client via /api/me
const { data: clientInfo } = useQuery({ const { data: clientInfo } = useQuery({
queryKey: ["client-info"], queryKey: ["client-info"],
@ -105,6 +224,8 @@ function useSalaries(page: number, limit: number, search: string, org?: string |
/* ===== Page ===== */ /* ===== Page ===== */
export default function SalariesPage() { export default function SalariesPage() {
usePageTitle("Salariés");
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -178,7 +299,7 @@ export default function SalariesPage() {
}); });
}} }}
disabled={page === 1 || isFetching} 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" title="Page précédente"
> >
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
@ -197,7 +318,7 @@ export default function SalariesPage() {
}); });
}} }}
disabled={!hasMore || isFetching} 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" title="Page suivante"
> >
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
@ -210,7 +331,7 @@ export default function SalariesPage() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Barre de titre + actions */} {/* 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="flex flex-col md:flex-row md:items-center gap-3">
<div className="text-lg font-semibold">Vos salariés</div> <div className="text-lg font-semibold">Vos salariés</div>
<div className="md:ml-auto w-full md:w-auto"> <div className="md:ml-auto w-full md:w-auto">
@ -234,7 +355,7 @@ export default function SalariesPage() {
router.replace(`/salaries?${sp.toString()}`); 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> </div>
{/* Organization filter for staff */} {/* Organization filter for staff */}
@ -242,7 +363,7 @@ export default function SalariesPage() {
<select <select
value={selectedOrg || ""} value={selectedOrg || ""}
onChange={(e) => { setSelectedOrg(e.target.value || null); setPage(1); }} 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> <option value="">Toutes les structures</option>
{orgs.map(o => <option key={o.id} value={o.id}>{o.name}</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"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <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">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 hidden md:table-cell">Structure</th>
<th className="text-left font-medium px-3 py-2">Matricule</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) => { rows.map((r: SalarieRow) => {
const contratHref = lastContractHref(r.dernier_contrat); const contratHref = lastContractHref(r.dernier_contrat);
return ( 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"> <td className="px-3 py-2">
<Link href={`/salaries/${r.matricule}`} className="underline font-medium"> <Link href={`/salaries/${r.matricule}`} className="underline font-medium">
{r.nom} {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">{r.matricule}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<span className={`px-2 py-1 rounded-full text-xs whitespace-nowrap ${ <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" r.transat_connecte ? "bg-emerald-100 text-emerald-800"
: "bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-200" : "bg-rose-100 text-rose-800"
}`}> }`}>
{r.transat_connecte ? "Connecté" : "Non connecté"} {r.transat_connecte ? "Connecté" : "Non connecté"}
</span> </span>
@ -324,7 +445,7 @@ export default function SalariesPage() {
setSelectedNom(r.nom || r.matricule); setSelectedNom(r.nom || r.matricule);
setNewContratOpen(true); 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é" aria-label="Créer un contrat pour ce salarié"
title="Créer un contrat" title="Créer un contrat"
> >
@ -355,7 +476,7 @@ export default function SalariesPage() {
}); });
}} }}
disabled={page === 1 || isFetching} 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" /> <ChevronLeft className="w-4 h-4" />
</button> </button>
@ -373,7 +494,7 @@ export default function SalariesPage() {
}); });
}} }}
disabled={!hasMore || isFetching} 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" /> <ChevronRight className="w-4 h-4" />
</button> </button>
@ -387,10 +508,10 @@ export default function SalariesPage() {
/> />
{/* Modal */} {/* 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> <div className="text-base font-medium mb-1">Créer un nouveau contrat</div>
{selectedNom && ( {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> Pour: <span className="font-medium">{selectedNom}</span>
</div> </div>
)} )}
@ -407,7 +528,7 @@ export default function SalariesPage() {
router.push(`/contrats/nouveau?salarie=${encodeURIComponent(selectedMatricule)}`); 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 CDDU
<div className="text-xs text-slate-500">Contrat à durée déterminée d'usage</div> <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 // Non fonctionnel pour l'instant
setNewContratOpen(false); 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" title="Bientôt disponible"
disabled disabled
> >
@ -432,7 +553,7 @@ export default function SalariesPage() {
<button <button
type="button" type="button"
onClick={() => setNewContratOpen(false)} 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 Annuler
</button> </button>

View file

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

View file

@ -68,8 +68,8 @@ type ClientData = {
function Line({ label, value }: { label: string; value?: string | number | null }) { function Line({ label, value }: { label: string; value?: string | number | null }) {
return ( return (
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 dark:border-slate-800 py-2"> <div className="grid grid-cols-3 gap-2 border-b last:border-b-0 py-2">
<div className="text-slate-500 dark:text-slate-400">{label}</div> <div className="text-slate-500">{label}</div>
<div className="col-span-2">{value ?? "—"}</div> <div className="col-span-2">{value ?? "—"}</div>
</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 }) { function LogoLine({ label, value }: { label: string; value?: string | null }) {
return ( return (
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 dark:border-slate-800 py-2"> <div className="grid grid-cols-3 gap-2 border-b last:border-b-0 py-2">
<div className="text-slate-500 dark:text-slate-400">{label}</div> <div className="text-slate-500">{label}</div>
<div className="col-span-2"> <div className="col-span-2">
{value ? ( {value ? (
<img <img
@ -108,14 +108,14 @@ function EditableLine({
options?: { value: string; label: string }[]; options?: { value: string; label: string }[];
}) { }) {
return ( return (
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 dark:border-slate-800 py-2"> <div className="grid grid-cols-3 gap-2 border-b last:border-b-0 py-2">
<div className="text-slate-500 dark:text-slate-400">{label}</div> <div className="text-slate-500">{label}</div>
<div className="col-span-2"> <div className="col-span-2">
{type === "select" && options ? ( {type === "select" && options ? (
<select <select
value={value || ""} value={value || ""}
onChange={(e) => onChange(e.target.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> <option value=""></option>
{options.map((opt) => ( {options.map((opt) => (
@ -129,7 +129,7 @@ function EditableLine({
type={type} type={type}
value={value || ""} value={value || ""}
onChange={(e) => onChange(e.target.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> </div>
@ -194,8 +194,8 @@ function ImageUpload({
}; };
return ( return (
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 dark:border-slate-800 py-2"> <div className="grid grid-cols-3 gap-2 border-b last:border-b-0 py-2">
<div className="text-slate-500 dark:text-slate-400">{label}</div> <div className="text-slate-500">{label}</div>
<div className="col-span-2 space-y-2"> <div className="col-span-2 space-y-2">
{preview && ( {preview && (
<div className="relative inline-block"> <div className="relative inline-block">
@ -217,7 +217,7 @@ function ImageUpload({
type="file" type="file"
accept="image/*" accept="image/*"
onChange={handleFileChange} 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"> <div className="text-xs text-slate-500">
Formats acceptés: JPG, PNG, GIF (max 5MB) Formats acceptés: JPG, PNG, GIF (max 5MB)
@ -412,7 +412,7 @@ export default function ClientDetailPage() {
<> <>
<button <button
onClick={handleCancel} 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} disabled={updateMutation.isPending}
> >
Annuler 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="grid grid-cols-1 lg:grid-cols-2 gap-4 items-start">
<div className="space-y-4"> <div className="space-y-4">
{/* Informations principales + Votre structure */} {/* Informations principales + Votre structure */}
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800"> <section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b dark:border-slate-800"> <div className="px-4 py-3 border-b">
<h2 className="font-medium">Informations principales</h2> <h2 className="font-medium">Informations principales</h2>
</div> </div>
<div className="p-4 text-sm"> <div className="p-4 text-sm">
@ -597,8 +597,8 @@ export default function ClientDetailPage() {
</section> </section>
{/* Abonnement */} {/* Abonnement */}
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800"> <section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b dark:border-slate-800"> <div className="px-4 py-3 border-b">
<h2 className="font-medium">Abonnement</h2> <h2 className="font-medium">Abonnement</h2>
</div> </div>
<div className="p-4 text-sm"> <div className="p-4 text-sm">
@ -649,8 +649,8 @@ export default function ClientDetailPage() {
<div className="space-y-4"> <div className="space-y-4">
{/* Informations de contact */} {/* Informations de contact */}
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800"> <section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b dark:border-slate-800"> <div className="px-4 py-3 border-b">
<h2 className="font-medium">Informations de contact</h2> <h2 className="font-medium">Informations de contact</h2>
</div> </div>
<div className="p-4 text-sm"> <div className="p-4 text-sm">
@ -731,8 +731,8 @@ export default function ClientDetailPage() {
</section> </section>
{/* Caisses & organismes */} {/* Caisses & organismes */}
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800"> <section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b dark:border-slate-800"> <div className="px-4 py-3 border-b">
<h2 className="font-medium">Caisses & organismes</h2> <h2 className="font-medium">Caisses & organismes</h2>
</div> </div>
<div className="p-4 text-sm"> <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> <Link href="/staff/utilisateurs" className="text-sm underline"> Retour</Link>
</div> </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"> <form action="/api/staff/organizations/create" method="post" className="space-y-4">
<div> <div>
<label className="block text-sm font-medium mb-1">Nom de la structure *</label> <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>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1">Identifiant Structure API</label> <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>
<div> <div>
<label className="block text-sm font-medium mb-1">SIRET (optionnel)</label> <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> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Email de contact (optionnel)</label> <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>
<div> <div>
<label className="block text-sm font-medium mb-1">Notes (interne)</label> <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> </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> <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> </form>

View file

@ -65,7 +65,7 @@ export default async function StaffClientsPage({ searchParams }: { searchParams?
name="q" name="q"
defaultValue={q} defaultValue={q}
placeholder="Rechercher…" 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> </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"> <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> </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"> <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> <tr>
<th className="text-left px-4 py-2 font-medium">Nom</th> <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> <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> <tbody>
{orgs?.length ? ( {orgs?.length ? (
orgs.map((o) => ( 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.name}</td>
<td className="px-4 py-2">{o.structure_api || "—"}</td> <td className="px-4 py-2">{o.structure_api || "—"}</td>
<td className="px-4 py-2">{formatDate(o.created_at)}</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 const { data: contracts, error } = await sb
.from("cddu_contracts") .from("cddu_contracts")
.select( .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") .eq("type_de_contrat", "CDD d'usage")
.order("start_date", { ascending: false }) .order("start_date", { ascending: false })
@ -73,7 +73,7 @@ export default async function StaffContractsPage() {
<h1 className="text-lg font-semibold">Contrats (Staff)</h1> <h1 className="text-lg font-semibold">Contrats (Staff)</h1>
</div> </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) && ( {(!contracts || contracts.length === 0) && (
<div className="mb-4 p-3 rounded bg-yellow-50 text-sm text-slate-800 border"> <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> <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 }) { function Badge({ tone = "default", children }: { tone?: "ok" | "warn" | "error" | "default"; children: React.ReactNode }) {
const cls = const cls =
tone === "ok" 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" : 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" : tone === "error"
? "bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-200" ? "bg-rose-100 text-rose-800"
: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300"; : "bg-slate-100 text-slate-700";
return <span className={`text-xs px-2 py-1 rounded-full ${cls}`}>{children}</span>; return <span className={`text-xs px-2 py-1 rounded-full ${cls}`}>{children}</span>;
} }
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800"> <section className="rounded-2xl border bg-white">
<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"> <div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
{title} {title}
</div> </div>
<div className="p-4">{children}</div> <div className="p-4">{children}</div>
@ -397,7 +397,7 @@ export default function StaffFacturationDetailPage() {
Retour aux factures Retour aux factures
</Link> </Link>
<div className="text-slate-400">/</div> <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)}`} {invoice.numero || `Facture ${invoice.id.slice(0, 8)}`}
</h1> </h1>
</div> </div>
@ -488,7 +488,7 @@ export default function StaffFacturationDetailPage() {
type="text" type="text"
value={editForm.numero} value={editForm.numero}
onChange={(e) => setEditForm(prev => ({ ...prev, numero: e.target.value }))} 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" placeholder="Numéro de facture"
/> />
) : ( ) : (
@ -508,7 +508,7 @@ export default function StaffFacturationDetailPage() {
type="text" type="text"
value={editForm.periode} value={editForm.periode}
onChange={(e) => setEditForm(prev => ({ ...prev, periode: e.target.value }))} 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" placeholder="ex: Août 2025"
/> />
) : ( ) : (
@ -523,7 +523,7 @@ export default function StaffFacturationDetailPage() {
type="date" type="date"
value={editForm.date} value={editForm.date}
onChange={(e) => setEditForm(prev => ({ ...prev, date: e.target.value }))} 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> <div>{fmtDateFR(invoice.date || undefined)}</div>
@ -536,7 +536,7 @@ export default function StaffFacturationDetailPage() {
<select <select
value={editForm.payment_method} value={editForm.payment_method}
onChange={(e) => setEditForm(prev => ({ ...prev, payment_method: e.target.value }))} 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="sepa">Prélèvement SEPA</option>
<option value="cb">CB</option> <option value="cb">CB</option>
@ -559,7 +559,7 @@ export default function StaffFacturationDetailPage() {
type="date" type="date"
value={editForm.due_date} value={editForm.due_date}
onChange={(e) => setEditForm(prev => ({ ...prev, due_date: e.target.value }))} 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> <div>{fmtDateFR(invoice.due_date || undefined)}</div>
@ -573,7 +573,7 @@ export default function StaffFacturationDetailPage() {
type="date" type="date"
value={editForm.sepa_day} value={editForm.sepa_day}
onChange={(e) => setEditForm(prev => ({ ...prev, sepa_day: e.target.value }))} 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> <div>{fmtDateFR(invoice.sepa_day || undefined)}</div>
@ -586,7 +586,7 @@ export default function StaffFacturationDetailPage() {
<select <select
value={editForm.invoice_type} value={editForm.invoice_type}
onChange={(e) => setEditForm(prev => ({ ...prev, invoice_type: e.target.value }))} 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_mensuelle">Paie - mensuelle</option>
<option value="paie_ouverture">Paie - ouverture</option> <option value="paie_ouverture">Paie - ouverture</option>
@ -614,7 +614,7 @@ export default function StaffFacturationDetailPage() {
type="text" type="text"
value={editForm.site_name} value={editForm.site_name}
onChange={(e) => setEditForm(prev => ({ ...prev, site_name: e.target.value }))} 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" placeholder="Nom du site web"
/> />
) : ( ) : (
@ -631,7 +631,7 @@ export default function StaffFacturationDetailPage() {
<select <select
value={editForm.statut} value={editForm.statut}
onChange={(e) => setEditForm(prev => ({ ...prev, statut: e.target.value }))} 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="emise">Émise</option>
<option value="en_cours">En cours</option> <option value="en_cours">En cours</option>
@ -660,7 +660,7 @@ export default function StaffFacturationDetailPage() {
<select <select
value={editForm.notified ? "true" : "false"} value={editForm.notified ? "true" : "false"}
onChange={(e) => setEditForm(prev => ({ ...prev, notified: e.target.value === "true" }))} 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="false">Non</option>
<option value="true">Oui</option> <option value="true">Oui</option>
@ -683,7 +683,7 @@ export default function StaffFacturationDetailPage() {
step="0.01" step="0.01"
value={editForm.montant_ht} value={editForm.montant_ht}
onChange={(e) => setEditForm(prev => ({ ...prev, montant_ht: e.target.value }))} 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> <div className="text-lg font-semibold">{fmtEUR.format(invoice.montant_ht || 0)}</div>
@ -698,7 +698,7 @@ export default function StaffFacturationDetailPage() {
step="0.01" step="0.01"
value={editForm.montant_ttc} value={editForm.montant_ttc}
onChange={(e) => setEditForm(prev => ({ ...prev, montant_ttc: e.target.value }))} 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> <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" type="date"
value={editForm.payment_date} value={editForm.payment_date}
onChange={(e) => setEditForm(prev => ({ ...prev, payment_date: e.target.value }))} 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> <div>{fmtDateFR(invoice.payment_date || undefined)}</div>
@ -790,11 +790,11 @@ export default function StaffFacturationDetailPage() {
<textarea <textarea
value={editForm.notes} value={editForm.notes}
onChange={(e) => setEditForm(prev => ({ ...prev, notes: e.target.value }))} 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..." 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."} {invoice.notes || "Aucune note."}
</div> </div>
)} )}
@ -835,32 +835,32 @@ export default function StaffFacturationDetailPage() {
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <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"> <p className="mb-3">
Êtes-vous sûr de vouloir supprimer cette facture ? Êtes-vous sûr de vouloir supprimer cette facture ?
</p> </p>
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-3 space-y-2"> <div className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="font-medium text-slate-900 dark:text-slate-100"> <div className="font-medium text-slate-900">
Facture : {invoice?.numero || `#${invoice?.id.slice(0, 8)}`} Facture : {invoice?.numero || `#${invoice?.id.slice(0, 8)}`}
</div> </div>
<div className="text-slate-600 dark:text-slate-400"> <div className="text-slate-600">
Client : {invoice?.organization_name || "—"} Client : {invoice?.organization_name || "—"}
</div> </div>
<div className="text-slate-600 dark:text-slate-400"> <div className="text-slate-600">
Montant : {fmtEUR.format(invoice?.montant_ttc || 0)} Montant : {fmtEUR.format(invoice?.montant_ttc || 0)}
</div> </div>
</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="mt-4 p-3 bg-rose-50 border border-rose-200 rounded-lg">
<div className="text-rose-800 dark:text-rose-200 text-sm font-medium mb-2"> <div className="text-rose-800 text-sm font-medium mb-2">
Cette action supprimera définitivement : Cette action supprimera définitivement :
</div> </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>La facture de la base de données</li>
<li>Le fichier PDF associé (si présent)</li> <li>Le fichier PDF associé (si présent)</li>
</ul> </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. Cette action est irréversible.
</div> </div>
</div> </div>
@ -907,33 +907,33 @@ export default function StaffFacturationDetailPage() {
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <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"> <p className="mb-3">
Êtes-vous sûr de vouloir lancer cette facture ? Êtes-vous sûr de vouloir lancer cette facture ?
</p> </p>
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-3 space-y-2"> <div className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="font-medium text-slate-900 dark:text-slate-100"> <div className="font-medium text-slate-900">
Facture : {invoice?.numero || `#${invoice?.id.slice(0, 8)}`} Facture : {invoice?.numero || `#${invoice?.id.slice(0, 8)}`}
</div> </div>
<div className="text-slate-600 dark:text-slate-400"> <div className="text-slate-600">
Client : {invoice?.organization_name || "—"} Client : {invoice?.organization_name || "—"}
</div> </div>
<div className="text-slate-600 dark:text-slate-400"> <div className="text-slate-600">
Montant : {fmtEUR.format(invoice?.montant_ttc || 0)} Montant : {fmtEUR.format(invoice?.montant_ttc || 0)}
</div> </div>
{invoice?.sepa_day && ( {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)} Prélèvement SEPA : {fmtDateFR(invoice.sepa_day)}
</div> </div>
)} )}
</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="mt-4 p-3 bg-emerald-50 border border-emerald-200 rounded-lg">
<div className="text-emerald-800 dark:text-emerald-200 text-sm font-medium mb-2"> <div className="text-emerald-800 text-sm font-medium mb-2">
Cette action va : Cette action va :
</div> </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>Créer le prélèvement dans GoCardless</li>
<li>Envoyer une notification par email au client</li> <li>Envoyer une notification par email au client</li>
<li>Marquer la facture comme notifiée</li> <li>Marquer la facture comme notifiée</li>
@ -982,25 +982,25 @@ export default function StaffFacturationDetailPage() {
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <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"> <p className="mb-3">
Êtes-vous sûr de vouloir envoyer une notification pour cette facture ? Êtes-vous sûr de vouloir envoyer une notification pour cette facture ?
</p> </p>
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-3 space-y-2"> <div className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="font-medium text-slate-900 dark:text-slate-100"> <div className="font-medium text-slate-900">
Client : {invoice.organization_name} Client : {invoice.organization_name}
</div> </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' })} Montant : {invoice.montant_ttc?.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}
</div> </div>
</div> </div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3 mt-3"> <div className="bg-blue-50 rounded-lg p-3 mt-3">
<div className="text-blue-800 dark:text-blue-200 text-sm font-medium mb-2"> <div className="text-blue-800 text-sm font-medium mb-2">
📧 Cette action va : 📧 Cette action va :
</div> </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>Envoyer une notification par email au client</li>
<li>Marquer la facture comme notifiée</li> <li>Marquer la facture comme notifiée</li>
<li>Ne PAS créer de prélèvement GoCardless</li> <li>Ne PAS créer de prélèvement GoCardless</li>
@ -1049,25 +1049,25 @@ export default function StaffFacturationDetailPage() {
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <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"> <p className="mb-3">
Êtes-vous sûr de vouloir créer un paiement GoCardless pour cette facture ? Êtes-vous sûr de vouloir créer un paiement GoCardless pour cette facture ?
</p> </p>
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-3 space-y-2"> <div className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="font-medium text-slate-900 dark:text-slate-100"> <div className="font-medium text-slate-900">
Client : {invoice.organization_name} Client : {invoice.organization_name}
</div> </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' })} Montant : {invoice.montant_ttc?.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}
</div> </div>
</div> </div>
<div className="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-3 mt-3"> <div className="bg-orange-50 rounded-lg p-3 mt-3">
<div className="text-orange-800 dark:text-orange-200 text-sm font-medium mb-2"> <div className="text-orange-800 text-sm font-medium mb-2">
💳 Cette action va : 💳 Cette action va :
</div> </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>Créer le prélèvement dans GoCardless</li>
<li>Marquer la facture comme émise</li> <li>Marquer la facture comme émise</li>
<li>Ne PAS envoyer de notification email</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 }) { function Section({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800"> <section className="rounded-2xl border bg-white">
<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"> <div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
{title} {title}
</div> </div>
<div className="p-4">{children}</div> <div className="p-4">{children}</div>
@ -154,7 +154,7 @@ export default function CreateInvoicePage() {
Retour aux factures Retour aux factures
</Link> </Link>
<div className="text-slate-400">/</div> <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 Créer une nouvelle facture
</h1> </h1>
</div> </div>
@ -166,13 +166,13 @@ export default function CreateInvoicePage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4"> <div className="space-y-4">
<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">
Organisation cliente * Organisation cliente *
</label> </label>
<select <select
value={form.org_id} value={form.org_id}
onChange={(e) => updateForm("org_id", e.target.value)} 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 required
> >
<option value="">Sélectionner une organisation</option> <option value="">Sélectionner une organisation</option>
@ -185,47 +185,47 @@ export default function CreateInvoicePage() {
</div> </div>
<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 Numéro de facture
</label> </label>
<input <input
type="text" type="text"
value={form.numero} value={form.numero}
onChange={(e) => updateForm("numero", e.target.value)} 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" placeholder="ex: FAC-2025-001"
/> />
</div> </div>
<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 Période concernée
</label> </label>
<input <input
type="text" type="text"
value={form.periode} value={form.periode}
onChange={(e) => updateForm("periode", e.target.value)} 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" placeholder="ex: Janvier 2025"
/> />
</div> </div>
<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 Date d'émission
</label> </label>
<input <input
type="date" type="date"
value={form.date} value={form.date}
onChange={(e) => updateForm("date", e.target.value)} 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> </div>
<div className="space-y-4"> <div className="space-y-4">
<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 HT () Montant HT ()
</label> </label>
<input <input
@ -233,13 +233,13 @@ export default function CreateInvoicePage() {
step="0.01" step="0.01"
value={form.montant_ht} value={form.montant_ht}
onChange={(e) => updateForm("montant_ht", e.target.value)} 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" placeholder="0.00"
/> />
</div> </div>
<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 () * Montant TTC () *
</label> </label>
<input <input
@ -247,20 +247,20 @@ export default function CreateInvoicePage() {
step="0.01" step="0.01"
value={form.montant_ttc} value={form.montant_ttc}
onChange={(e) => updateForm("montant_ttc", e.target.value)} 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" placeholder="0.00"
required required
/> />
</div> </div>
<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 Statut
</label> </label>
<select <select
value={form.statut} value={form.statut}
onChange={(e) => updateForm("statut", e.target.value)} 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="emise">Émise</option>
<option value="en_cours">En cours</option> <option value="en_cours">En cours</option>
@ -277,19 +277,19 @@ export default function CreateInvoicePage() {
<Section title="Notes et PDF"> <Section title="Notes et PDF">
<div className="space-y-4"> <div className="space-y-4">
<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">
Notes sur la facture Notes sur la facture
</label> </label>
<textarea <textarea
value={form.notes} value={form.notes}
onChange={(e) => updateForm("notes", e.target.value)} 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..." placeholder="Notes additionnelles..."
/> />
</div> </div>
<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 PDF de la facture
</label> </label>
<div className="space-y-2"> <div className="space-y-2">
@ -318,7 +318,7 @@ export default function CreateInvoicePage() {
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={isUploadingPdf || !form.org_id || !form.numero} 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" /> <Upload className="w-4 h-4" />
{isUploadingPdf ? "Upload en cours..." : "Choisir un fichier PDF"} {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 { 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { usePageTitle } from "@/hooks/usePageTitle";
// ---------------- Types ---------------- // ---------------- Types ----------------
type Invoice = { type Invoice = {
@ -42,19 +43,19 @@ function fmtDateFR(iso?: string) {
function Badge({ tone = "default", children }: { tone?: "ok" | "warn" | "error" | "default"; children: React.ReactNode }) { function Badge({ tone = "default", children }: { tone?: "ok" | "warn" | "error" | "default"; children: React.ReactNode }) {
const cls = const cls =
tone === "ok" 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" : 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" : tone === "error"
? "bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-200" ? "bg-rose-100 text-rose-800"
: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300"; : "bg-slate-100 text-slate-700";
return <span className={`text-xs px-2 py-1 rounded-full ${cls}`}>{children}</span>; return <span className={`text-xs px-2 py-1 rounded-full ${cls}`}>{children}</span>;
} }
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<section className="rounded-2xl border bg-white dark:bg-slate-900 dark:border-slate-800"> <section className="rounded-2xl border bg-white">
<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"> <div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
{title} {title}
</div> </div>
<div className="p-4">{children}</div> <div className="p-4">{children}</div>
@ -73,6 +74,8 @@ function useStaffBilling(page: number, limit: number) {
// -------------- Page -------------- // -------------- Page --------------
export default function StaffFacturationPage() { export default function StaffFacturationPage() {
usePageTitle("Facturation (Staff)");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>(""); const [statusFilter, setStatusFilter] = useState<string>("");
const [clientFilter, setClientFilter] = useState<string>(""); const [clientFilter, setClientFilter] = useState<string>("");
@ -428,8 +431,8 @@ export default function StaffFacturationPage() {
{/* En-tête avec bouton de création */} {/* En-tête avec bouton de création */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Facturation</h1> <h1 className="text-2xl font-bold text-slate-900">Facturation</h1>
<p className="text-slate-600 dark:text-slate-400">Gestion des factures de tous les clients</p> <p className="text-slate-600">Gestion des factures de tous les clients</p>
</div> </div>
<Link <Link
href="/staff/facturation/create" href="/staff/facturation/create"
@ -441,14 +444,14 @@ export default function StaffFacturationPage() {
</div> </div>
{/* Filtres */} {/* Filtres */}
<div className="bg-white dark:bg-slate-900 rounded-xl border dark:border-slate-800"> <div className="bg-white rounded-xl border">
<div className="px-4 py-3 border-b dark:border-slate-800 flex items-center justify-between"> <div className="px-4 py-3 border-b flex items-center justify-between">
<h3 className="font-medium text-slate-700 dark:text-slate-200">Filtres</h3> <h3 className="font-medium text-slate-700">Filtres</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{hasActiveFilters && ( {hasActiveFilters && (
<button <button
onClick={clearFilters} 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" /> <X className="w-3 h-3" />
Effacer Effacer
@ -456,7 +459,7 @@ export default function StaffFacturationPage() {
)} )}
<button <button
onClick={() => setShowFilters(!showFilters)} 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" /> <Filter className="w-4 h-4" />
{showFilters ? "Masquer" : "Afficher"} les filtres {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"> <div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{/* Filtre par statut */} {/* Filtre par statut */}
<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 Statut
</label> </label>
<select <select
value={statusFilter} value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)} 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="">Tous les statuts</option>
<option value="emise">Émise</option> <option value="emise">Émise</option>
@ -488,13 +491,13 @@ export default function StaffFacturationPage() {
{/* Filtre par client */} {/* Filtre par client */}
<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">
Client Client
</label> </label>
<select <select
value={clientFilter} value={clientFilter}
onChange={(e) => setClientFilter(e.target.value)} 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> <option value="">Tous les clients</option>
{uniqueClients.map(client => ( {uniqueClients.map(client => (
@ -505,13 +508,13 @@ export default function StaffFacturationPage() {
{/* Filtre par période */} {/* Filtre par période */}
<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 Période
</label> </label>
<select <select
value={periodFilter} value={periodFilter}
onChange={(e) => setPeriodFilter(e.target.value)} 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> <option value="">Toutes les périodes</option>
{uniquePeriods.map(period => ( {uniquePeriods.map(period => (
@ -522,27 +525,27 @@ export default function StaffFacturationPage() {
{/* Filtre date de début */} {/* Filtre date de début */}
<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 de début Date de début
</label> </label>
<input <input
type="date" type="date"
value={dateFromFilter} value={dateFromFilter}
onChange={(e) => setDateFromFilter(e.target.value)} 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> </div>
{/* Filtre date de fin */} {/* Filtre date de fin */}
<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 de fin Date de fin
</label> </label>
<input <input
type="date" type="date"
value={dateToFilter} value={dateToFilter}
onChange={(e) => setDateToFilter(e.target.value)} 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>
</div> </div>
@ -550,9 +553,9 @@ export default function StaffFacturationPage() {
{/* Indicateur de filtres actifs */} {/* Indicateur de filtres actifs */}
{hasActiveFilters && ( {hasActiveFilters && (
<div className="mt-4 flex flex-wrap items-center gap-2"> <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 && ( {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} Statut: {statusFilter}
<button onClick={() => setStatusFilter("")} className="hover:text-blue-600"> <button onClick={() => setStatusFilter("")} className="hover:text-blue-600">
<X className="w-3 h-3" /> <X className="w-3 h-3" />
@ -560,7 +563,7 @@ export default function StaffFacturationPage() {
</span> </span>
)} )}
{clientFilter && ( {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} Client: {clientFilter}
<button onClick={() => setClientFilter("")} className="hover:text-green-600"> <button onClick={() => setClientFilter("")} className="hover:text-green-600">
<X className="w-3 h-3" /> <X className="w-3 h-3" />
@ -568,7 +571,7 @@ export default function StaffFacturationPage() {
</span> </span>
)} )}
{periodFilter && ( {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} Période: {periodFilter}
<button onClick={() => setPeriodFilter("")} className="hover:text-purple-600"> <button onClick={() => setPeriodFilter("")} className="hover:text-purple-600">
<X className="w-3 h-3" /> <X className="w-3 h-3" />
@ -576,7 +579,7 @@ export default function StaffFacturationPage() {
</span> </span>
)} )}
{dateFromFilter && ( {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")} Depuis: {new Date(dateFromFilter).toLocaleDateString("fr-FR")}
<button onClick={() => setDateFromFilter("")} className="hover:text-orange-600"> <button onClick={() => setDateFromFilter("")} className="hover:text-orange-600">
<X className="w-3 h-3" /> <X className="w-3 h-3" />
@ -584,7 +587,7 @@ export default function StaffFacturationPage() {
</span> </span>
)} )}
{dateToFilter && ( {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")} Jusqu'au: {new Date(dateToFilter).toLocaleDateString("fr-FR")}
<button onClick={() => setDateToFilter("")} className="hover:text-cyan-600"> <button onClick={() => setDateToFilter("")} className="hover:text-cyan-600">
<X className="w-3 h-3" /> <X className="w-3 h-3" />
@ -600,21 +603,21 @@ export default function StaffFacturationPage() {
{/* Statistiques rapides */} {/* Statistiques rapides */}
{data && ( {data && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <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="bg-white rounded-xl border p-4">
<div className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.total}</div> <div className="text-2xl font-bold text-slate-900">{stats.total}</div>
<div className="text-sm text-slate-600 dark:text-slate-400">Total factures</div> <div className="text-sm text-slate-600">Total factures</div>
</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-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>
<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-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>
<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-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>
</div> </div>
)} )}
@ -640,9 +643,9 @@ export default function StaffFacturationPage() {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{/* Bouton d'action en masse */} {/* Bouton d'action en masse */}
{selectedInvoices.size > 0 && ( {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="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" : ""} {selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -688,8 +691,8 @@ export default function StaffFacturationPage() {
)} )}
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-left bg-slate-50 dark:bg-slate-800/40"> <thead className="text-left bg-slate-50">
<tr className="border-b dark:border-slate-800"> <tr className="border-b">
<th className="px-3 py-2 w-12"> <th className="px-3 py-2 w-12">
<input <input
type="checkbox" type="checkbox"
@ -702,7 +705,7 @@ export default function StaffFacturationPage() {
<th className="px-3 py-2 w-32"> <th className="px-3 py-2 w-32">
<button <button
onClick={() => handleSort("numero")} 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 Numéro
{sortField === "numero" && ( {sortField === "numero" && (
@ -715,7 +718,7 @@ export default function StaffFacturationPage() {
<th className="px-3 py-2"> <th className="px-3 py-2">
<button <button
onClick={() => handleSort("client")} 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 Client
{sortField === "client" && ( {sortField === "client" && (
@ -729,7 +732,7 @@ export default function StaffFacturationPage() {
<th className="px-3 py-2"> <th className="px-3 py-2">
<button <button
onClick={() => handleSort("date")} 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 Date
{sortField === "date" && ( {sortField === "date" && (
@ -746,15 +749,15 @@ export default function StaffFacturationPage() {
</tr> </tr>
{/* Sous-header avec les totaux */} {/* Sous-header avec les totaux */}
{filteredAndSortedItems.length > 0 && ( {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 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" : ""}) Total affiché ({filteredAndSortedItems.length} facture{filteredAndSortedItems.length > 1 ? "s" : ""})
</td> </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)} {fmtEUR.format(filteredTotals.totalHT)}
</td> </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)} {fmtEUR.format(filteredTotals.totalTTC)}
</td> </td>
<td colSpan={2} className="px-3 py-2"></td> <td colSpan={2} className="px-3 py-2"></td>
@ -770,7 +773,7 @@ export default function StaffFacturationPage() {
</tr> </tr>
)} )}
{filteredAndSortedItems.map((f: Invoice) => ( {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"> <td className="px-3 py-2">
<input <input
type="checkbox" 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"} Page {data.factures.page} {items.length} facture{items.length > 1 ? "s" : ""} total{items.length > 1 ? "es" : "e"}
</div> </div>
{hasActiveFilters && ( {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 {filteredAndSortedItems.length} facture{filteredAndSortedItems.length > 1 ? "s" : ""} après filtrage
</div> </div>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<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={page === 1} disabled={page === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
> >
Précédent Précédent
</button> </button>
<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} disabled={!hasMore}
onClick={() => setPage((p) => p + 1)} onClick={() => setPage((p) => p + 1)}
> >
@ -870,22 +873,22 @@ export default function StaffFacturationPage() {
{/* Modal pour modification en masse de la date SEPA */} {/* Modal pour modification en masse de la date SEPA */}
{showSepaModal && ( {showSepaModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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> <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" : ""}. 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> </p>
<div className="mb-6"> <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 Nouvelle date de prélèvement
</label> </label>
<input <input
type="date" type="date"
value={newSepaDate} value={newSepaDate}
onChange={(e) => setNewSepaDate(e.target.value)} 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]} min={new Date().toISOString().split('T')[0]}
/> />
</div> </div>
@ -896,7 +899,7 @@ export default function StaffFacturationPage() {
setShowSepaModal(false); setShowSepaModal(false);
setNewSepaDate(""); 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} disabled={updateSepaDatesMutation.isPending}
> >
Annuler Annuler
@ -917,22 +920,22 @@ export default function StaffFacturationPage() {
{/* Modal pour modification en masse de la date de facture */} {/* Modal pour modification en masse de la date de facture */}
{showInvoiceDateModal && ( {showInvoiceDateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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> <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" : ""}. Cette action va modifier la date de facture pour {selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""}.
</p> </p>
<div className="mb-6"> <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 Nouvelle date de facture
</label> </label>
<input <input
type="date" type="date"
value={newInvoiceDate} value={newInvoiceDate}
onChange={(e) => setNewInvoiceDate(e.target.value)} 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> </div>
@ -942,7 +945,7 @@ export default function StaffFacturationPage() {
setShowInvoiceDateModal(false); setShowInvoiceDateModal(false);
setNewInvoiceDate(""); 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} disabled={updateInvoiceDatesMutation.isPending}
> >
Annuler Annuler
@ -963,22 +966,22 @@ export default function StaffFacturationPage() {
{/* Modal pour modification en masse de la date d'échéance */} {/* Modal pour modification en masse de la date d'échéance */}
{showDueDateModal && ( {showDueDateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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> <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" : ""}. 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> </p>
<div className="mb-6"> <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 Nouvelle date d'échéance
</label> </label>
<input <input
type="date" type="date"
value={newDueDate} value={newDueDate}
onChange={(e) => setNewDueDate(e.target.value)} 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> </div>
@ -988,7 +991,7 @@ export default function StaffFacturationPage() {
setShowDueDateModal(false); setShowDueDateModal(false);
setNewDueDate(""); 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} disabled={updateDueDatesMutation.isPending}
> >
Annuler Annuler
@ -1017,16 +1020,16 @@ export default function StaffFacturationPage() {
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <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"> <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" : ""} ? Ê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> </p>
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-3 space-y-2"> <div className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="font-medium text-slate-900 dark:text-slate-100"> <div className="font-medium text-slate-900">
{selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""} {selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""}
</div> </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 selectedArray = items.filter(invoice => selectedInvoices.has(invoice.id));
const totalTTC = selectedArray.reduce((sum, invoice) => sum + (invoice.montant_ttc || 0), 0); const totalTTC = selectedArray.reduce((sum, invoice) => sum + (invoice.montant_ttc || 0), 0);
@ -1035,11 +1038,11 @@ export default function StaffFacturationPage() {
</div> </div>
</div> </div>
<div className="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-3 mt-3"> <div className="bg-orange-50 rounded-lg p-3 mt-3">
<div className="text-orange-800 dark:text-orange-200 text-sm font-medium mb-2"> <div className="text-orange-800 text-sm font-medium mb-2">
💳 Cette action va : 💳 Cette action va :
</div> </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>Créer les prélèvements dans GoCardless</li>
<li>Marquer les factures comme émises</li> <li>Marquer les factures comme émises</li>
<li>Ignorer les factures sans mandat SEPA</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> <h1 className="text-lg font-semibold">Salariés (Staff)</h1>
</div> </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) && ( {(!salaries || salaries.length === 0) && (
<div className="mb-4 p-3 rounded bg-yellow-50 text-sm text-slate-800 border"> <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> <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" /> <StaffTicketActions ticketId={ticket.id} status={ticket.status} mode="status" />
</div> </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"> <div className="space-y-3">
{(messages || []).map((m) => ( {(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"> <div className="text-[11px] text-slate-500 flex items-center gap-2">
<span>{formatDate(m.created_at)}</span> <span>{formatDate(m.created_at)}</span>
<span></span> <span></span>

View file

@ -2,6 +2,11 @@ export const dynamic = "force-dynamic";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { createSbServer } from "@/lib/supabaseServer"; import { createSbServer } from "@/lib/supabaseServer";
import NewStaffTicketForm from "@/components/staff/NewStaffTicketForm"; 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() { export default async function StaffNewTicketPage() {
const sb = createSbServer(); const sb = createSbServer();

View file

@ -2,6 +2,11 @@ export const dynamic = "force-dynamic";
import Link from "next/link"; import Link from "next/link";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { createSbServer } from "@/lib/supabaseServer"; import { createSbServer } from "@/lib/supabaseServer";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Tickets support | Espace Paie Odentas",
};
function formatDate(d?: string | null) { function formatDate(d?: string | null) {
if (!d) return "—"; 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> <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>
<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"> <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> <tr>
<th className="text-left px-4 py-2 font-medium">Sujet</th> <th className="text-left px-4 py-2 font-medium">Sujet</th>
<th className="text-left px-4 py-2 font-medium">Statut</th> <th className="text-left px-4 py-2 font-medium">Statut</th>
@ -70,7 +75,7 @@ export default async function StaffTicketsPage() {
<tbody> <tbody>
{(items || []).length ? ( {(items || []).length ? (
items!.map((t) => ( 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.subject}</td>
<td className="px-4 py-2">{t.status}</td> <td className="px-4 py-2">{t.status}</td>
<td className="px-4 py-2">{t.priority}</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 Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
import InviteForm from "@/components/staff/InviteForm"; import InviteForm from "@/components/staff/InviteForm";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Nouvel utilisateur | Espace Paie Odentas",
};
async function getContext() { async function getContext() {
const sb = createSbServer(); const sb = createSbServer();

View file

@ -76,9 +76,9 @@ export default async function StaffUsersListPage() {
</Link> </Link>
</div> </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> <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> <li>
<span className="font-medium">Super Admin</span> accès total : gestion des utilisateurs (création, modification de niveau, révocation), <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. 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> </ul>
</section> </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"> <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> <tr>
<th className="text-left px-4 py-3">Prénom</th> <th className="text-left px-4 py-3">Prénom</th>
<th className="text-left px-4 py-3">Email</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 status = m.revoked ? "Révoqué" : "Actif";
const disabled = !!m.revoked; const disabled = !!m.revoked;
return ( 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 whitespace-nowrap">{m.first_name || "—"}</td>
<td className="px-4 py-3">{m.email}</td> <td className="px-4 py-3">{m.email}</td>
<td className="px-4 py-3 uppercase tracking-wide text-xs">{m.role || "—"}</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" name="role"
defaultValue={m.role || "ADMIN"} defaultValue={m.role || "ADMIN"}
disabled={disabled} 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="SUPER_ADMIN">Super Admin</option>
<option value="ADMIN">Admin</option> <option value="ADMIN">Admin</option>

View file

@ -3,6 +3,11 @@ import Link from "next/link";
import ConfirmableForm from "@/components/ConfirmableForm"; import ConfirmableForm from "@/components/ConfirmableForm";
import { createSbServer } from "@/lib/supabaseServer"; import { createSbServer } from "@/lib/supabaseServer";
import { createClient } from "@supabase/supabase-js"; 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"; export const dynamic = "force-dynamic";
@ -147,9 +152,9 @@ export default async function StaffUsersListPage() {
</Link> </Link>
</div> </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> <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> <li>
<span className="font-medium">Super Admin</span> accès total : gestion des utilisateurs (création, modification de niveau, révocation), <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. 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> </ul>
</section> </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"> <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> <tr>
<th className="text-left px-4 py-3">Prénom</th> <th className="text-left px-4 py-3">Prénom</th>
<th className="text-left px-4 py-3">Email</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 status = m.revoked ? "Révoqué" : "Actif";
const disabled = !!m.revoked; const disabled = !!m.revoked;
return ( 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 whitespace-nowrap">{m.first_name || "—"}</td>
<td className="px-4 py-3">{m.email}</td> <td className="px-4 py-3">{m.email}</td>
<td className="px-4 py-3">{m.organization_name}</td> <td className="px-4 py-3">{m.organization_name}</td>
@ -227,7 +232,7 @@ export default async function StaffUsersListPage() {
name="role" name="role"
defaultValue={m.role || "ADMIN"} defaultValue={m.role || "ADMIN"}
disabled={disabled} 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="SUPER_ADMIN">Super Admin</option>
<option value="ADMIN">Admin</option> <option value="ADMIN">Admin</option>

View file

@ -2,6 +2,11 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import NextDynamic from "next/dynamic"; import NextDynamic from "next/dynamic";
import { createSbServer } from "@/lib/supabaseServer"; 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"; 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> <h1 className="text-lg font-semibold">Virements de Salaires (Staff)</h1>
</div> </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) && ( {(!salaryTransfers || salaryTransfers.length === 0) && (
<div className="mb-4 p-3 rounded bg-yellow-50 text-sm text-slate-800 border"> <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> <div><strong>Debug:</strong> Aucun virement de salaire trouvé côté serveur (initialData vide).</div>

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { usePageTitle } from "@/hooks/usePageTitle";
type Ticket = { type Ticket = {
id: string; id: string;
@ -21,9 +22,17 @@ type Message = {
export default function TicketDetailPage({ params }: { params: { id: string } }) { export default function TicketDetailPage({ params }: { params: { id: string } }) {
const { id } = params; const { id } = params;
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [ticket, setTicket] = useState<Ticket | 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 [messages, setMessages] = useState<Message[]>([]);
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const [posting, setPosting] = useState(false); const [posting, setPosting] = useState(false);
@ -89,13 +98,13 @@ export default function TicketDetailPage({ params }: { params: { id: string } })
) : ticket ? ( ) : ticket ? (
<> <>
<h1 className="text-lg font-semibold">{ticket.subject}</h1> <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="rounded-2xl border bg-white p-4 space-y-4">
<div className="text-sm text-slate-600 dark:text-slate-300"> <div className="text-sm text-slate-600">
Statut: <strong>{ticket.status}</strong> Priorité: <strong>{ticket.priority}</strong> Statut: <strong>{ticket.status}</strong> Priorité: <strong>{ticket.priority}</strong>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{messages.map((m) => ( {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"> <div className="text-[11px] text-slate-500 flex items-center gap-2">
<span>{new Date(m.created_at).toLocaleString('fr-FR')}</span> <span>{new Date(m.created_at).toLocaleString('fr-FR')}</span>
<span></span> <span></span>
@ -106,7 +115,7 @@ export default function TicketDetailPage({ params }: { params: { id: string } })
))} ))}
</div> </div>
<form onSubmit={onSubmit} className="space-y-2"> <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"> <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> <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> </div>

View file

@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { MessageCircle } from "lucide-react"; import { MessageCircle } from "lucide-react";
import TicketConversation from "@/components/TicketConversation"; import TicketConversation from "@/components/TicketConversation";
import { usePageTitle } from "@/hooks/usePageTitle";
type Ticket = { type Ticket = {
id: string; id: string;
@ -27,15 +28,15 @@ const STATUS_LABEL: Record<string, string> = {
function getStatusClass(status: string) { function getStatusClass(status: string) {
switch (status?.toLowerCase()) { switch (status?.toLowerCase()) {
case "open": 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": 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": 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": 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: 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) { function getPriorityClass(priority: string) {
switch (priority?.toLowerCase()) { switch (priority?.toLowerCase()) {
case "urgent": 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": 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": 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": 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: 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() { export default function SupportPage() {
usePageTitle("Support");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [items, setItems] = useState<Ticket[]>([]); const [items, setItems] = useState<Ticket[]>([]);
@ -244,12 +247,12 @@ export default function SupportPage() {
<main className="p-6 max-w-7xl mx-auto"> <main className="p-6 max-w-7xl mx-auto">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold">Support</h1> <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> </div>
{/* Mode conversation */} {/* Mode conversation */}
{viewMode === "conversation" && selectedTicket ? ( {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 <TicketConversation
ticket={selectedTicket} ticket={selectedTicket}
onClose={closeConversation} onClose={closeConversation}
@ -260,10 +263,10 @@ export default function SupportPage() {
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Colonne de gauche: formulaire de création - 2 colonnes */} {/* Colonne de gauche: formulaire de création - 2 colonnes */}
<div className="lg:col-span-2"> <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> <h2 className="text-lg font-semibold mb-4">Ouvrir un ticket</h2>
{error && ( {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} {error}
</div> </div>
)} )}
@ -271,7 +274,7 @@ export default function SupportPage() {
{/* Catégorie */} {/* Catégorie */}
<div> <div>
<label className="block text-sm font-medium mb-1">Catégorie</label> <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="general">Général</option>
<option value="contrat">Contrat</option> <option value="contrat">Contrat</option>
<option value="salarie">Salarié·e</option> <option value="salarie">Salarié·e</option>
@ -287,7 +290,7 @@ export default function SupportPage() {
<input <input
value={contractInput} value={contractInput}
onChange={(e) => setContractInput(e.target.value)} 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..." placeholder="Nom du contrat ou référence..."
/> />
</div> </div>
@ -299,24 +302,24 @@ export default function SupportPage() {
<input <input
value={salarieInput} value={salarieInput}
onChange={(e) => setSalarieInput(e.target.value)} 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..." placeholder="Nom du/de la salarié·e ou matricule..."
/> />
</div> </div>
)} )}
<div> <div>
<label className="block text-sm font-medium mb-1">Sujet</label> <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>
<div> <div>
<label className="block text-sm font-medium mb-1">Message</label> <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> </div>
{/* Pièces jointes */} {/* Pièces jointes */}
<div> <div>
<label className="block text-sm font-medium mb-1">Pièces jointes (20 Mo max/fichier)</label> <label className="block text-sm font-medium mb-1">Pièces jointes (20 Mo max/fichier)</label>
<div className="flex items-center gap-3"> <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> <span className="text-sm">Choisir des fichiers</span>
<input type="file" multiple className="hidden" onChange={(e) => onFilesSelected(e.target.files)} /> <input type="file" multiple className="hidden" onChange={(e) => onFilesSelected(e.target.files)} />
</label> </label>
@ -339,7 +342,7 @@ export default function SupportPage() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<label className="text-sm">Priorité</label> <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="low">Basse</option>
<option value="normal">Normale</option> <option value="normal">Normale</option>
<option value="high">Haute</option> <option value="high">Haute</option>
@ -354,8 +357,8 @@ export default function SupportPage() {
</div> </div>
{/* Colonne droite: liste des tickets + pagination - 2 colonnes */} {/* 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="lg:col-span-2 rounded-2xl border bg-white overflow-hidden">
<div className="px-4 py-3 border-b dark:border-slate-800"> <div className="px-4 py-3 border-b">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">Vos tickets</h2> <h2 className="text-lg font-semibold">Vos tickets</h2>
{hasTickets ? ( {hasTickets ? (
@ -367,10 +370,10 @@ export default function SupportPage() {
{/* Première ligne : Recherche + Filtres principaux */} {/* Première ligne : Recherche + Filtres principaux */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-3 text-sm"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-3 text-sm">
<div className="lg:col-span-2"> <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>
<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="all">Tous statuts</option>
<option value="open">Ouvert</option> <option value="open">Ouvert</option>
<option value="waiting_client">En attente client</option> <option value="waiting_client">En attente client</option>
@ -379,7 +382,7 @@ export default function SupportPage() {
</select> </select>
</div> </div>
<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="all">Toutes priorités</option>
<option value="urgent">Urgente</option> <option value="urgent">Urgente</option>
<option value="high">Haute</option> <option value="high">Haute</option>
@ -391,12 +394,12 @@ export default function SupportPage() {
{/* Deuxième ligne : Options avancées + Tri */} {/* Deuxième ligne : Options avancées + Tri */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-3 text-sm"> <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)} /> <input type="checkbox" checked={unreadOnly} onChange={(e) => setUnreadOnly(e.target.checked)} />
<span>Non lus uniquement</span> <span>Non lus uniquement</span>
</label> </label>
<div className="lg:col-span-2"> <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="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="created_at_desc">Tri: Création (récent ancien)</option>
<option value="priority_desc">Tri: Priorité (haut bas)</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é */} {/* Affichage des tickets en cards - Disposition en 1 colonne pour plus de lisibilité */}
<div className="space-y-4"> <div className="space-y-4">
{displayed.map((t) => ( {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"> <div className="flex flex-col gap-3">
{/* En-tête avec sujet et badges statut/priorité */} {/* 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"> <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> </div>
{/* Métadonnées du ticket */} {/* 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"> <div className="flex items-center gap-4">
{typeof t.message_count === 'number' && ( {typeof t.message_count === 'number' && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
@ -478,11 +481,11 @@ export default function SupportPage() {
{/* Pagination */} {/* Pagination */}
{totalPages > 1 ? ( {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 <button
onClick={() => setPage((p) => Math.max(1, p - 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 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 Précédent
</button> </button>
@ -494,10 +497,10 @@ export default function SupportPage() {
<button <button
key={n} key={n}
onClick={() => setPage(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 active
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900' ? 'bg-slate-900 text-white'
: 'hover:bg-slate-50 dark:hover:bg-slate-800' : 'hover:bg-slate-50'
}`} }`}
> >
{n} {n}
@ -508,7 +511,7 @@ export default function SupportPage() {
<button <button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages} 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 Suivant
</button> </button>

View file

@ -2,8 +2,9 @@
"use client"; "use client";
import React, { useMemo, useState, useEffect } from "react"; import React, { useMemo, useState, useEffect } from "react";
import { useSearchParams, usePathname, useRouter } from "next/navigation"; 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 { Loader2, Search, Download, ExternalLink, Info, Copy, Check } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
// --- Types --- // --- Types ---
type PeriodKey = type PeriodKey =
@ -51,8 +52,10 @@ type OrgSummary = {
type ClientVirementItem = { type ClientVirementItem = {
id: string; id: string;
kind: 'CDDU_MONO' | 'CDDU_MULTI' | 'RG'; 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?: string | null;
salarie_matricule?: string | null;
reference?: string | null; reference?: string | null;
profession?: string | null; profession?: string | null;
date_debut?: string | null; date_debut?: string | null;
@ -91,6 +94,46 @@ function formatFR(iso: string) {
return `${dd}/${mm}/${yyyy}`; 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) { function formatCurrency(amount?: number) {
if (amount == null) return "—"; if (amount == null) return "—";
return new Intl.NumberFormat('fr-FR', { return new Intl.NumberFormat('fr-FR', {
@ -213,6 +256,8 @@ function useVirements(filters: Filters, selectedOrgId?: string) {
} }
export default function VirementsPage() { export default function VirementsPage() {
usePageTitle("Virements salaires");
const now = new Date(); const now = new Date();
const [filters, setFilters] = useState<Filters>({ const [filters, setFilters] = useState<Filters>({
year: now.getFullYear(), year: now.getFullYear(),
@ -226,6 +271,7 @@ export default function VirementsPage() {
// Récupération des informations utilisateur et des organisations (pour le staff) // Récupération des informations utilisateur et des organisations (pour le staff)
const { data: userInfo } = useUserInfo(); const { data: userInfo } = useUserInfo();
const { data: organizations } = useOrganizations(); const { data: organizations } = useOrganizations();
const queryClient = useQueryClient();
const years = useMemo(() => { const years = useMemo(() => {
const base = now.getFullYear(); const base = now.getFullYear();
@ -360,6 +406,25 @@ export default function VirementsPage() {
const clientUnpaid = clientFilter(clientUnpaidAll); const clientUnpaid = clientFilter(clientUnpaidAll);
const clientRecent = clientFilter(clientRecentAll); 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 // Filtrage local pour la recherche ET la période
const filteredItems = useMemo((): VirementItem[] => { const filteredItems = useMemo((): VirementItem[] => {
let result: VirementItem[] = items; let result: VirementItem[] = items;
@ -417,17 +482,17 @@ export default function VirementsPage() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* En-tête + Recherche */} {/* 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 className="flex flex-col sm:flex-row sm:items-center gap-3">
<div> <div>
<h1 className="text-xl font-semibold">Virements de salaires</h1> <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. Suivez vos appels à virement, l'état des virements reçus et les salaires payés.
</p> </p>
</div> </div>
{isOdentas && ( {isOdentas && (
<div className="sm:ml-auto flex items-center gap-2 w-full sm:w-auto"> <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" /> <Search className="w-4 h-4" />
<input <input
value={searchQuery} value={searchQuery}
@ -445,7 +510,7 @@ export default function VirementsPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-sm font-medium">Année :</label> <label className="text-sm font-medium">Année :</label>
<select <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} value={filters.year}
onChange={(e) => setFilters(f => ({ ...f, year: parseInt(e.target.value, 10) }))} 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"> <div className="flex items-center gap-2">
<label className="text-sm font-medium">Organisation :</label> <label className="text-sm font-medium">Organisation :</label>
<select <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} value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)} onChange={(e) => setSelectedOrgId(e.target.value)}
> >
@ -476,7 +541,7 @@ export default function VirementsPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-sm font-medium">Période :</label> <label className="text-sm font-medium">Période :</label>
<select <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} value={filters.period}
onChange={(e) => setFilters(f => ({ ...f, period: e.target.value as PeriodKey }))} onChange={(e) => setFilters(f => ({ ...f, period: e.target.value as PeriodKey }))}
> >
@ -502,11 +567,11 @@ export default function VirementsPage() {
</div> </div>
)} )}
<div className="sm:ml-auto min-w-[280px] max-w-sm flex-1"> <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 className="flex items-start justify-between">
<div> <div>
<div className="text-sm font-semibold">Gestion des virements</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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative group inline-block"> <div className="relative group inline-block">
@ -514,22 +579,22 @@ export default function VirementsPage() {
type="button" type="button"
disabled disabled
aria-disabled="true" 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 Modifier
</button> </button>
<div <div
role="tooltip" 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. 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> </div>
</div> </div>
<div className="mt-3 flex items-center justify-between gap-2"> <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="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: isOdentas ? '#10b981' : '#64748b', opacity: loadingOrg ? 0.6 : 1 }} />
<span className="font-medium">{gestionLabel}</span> <span className="font-medium">{gestionLabel}</span>
</div> </div>
@ -537,7 +602,7 @@ export default function VirementsPage() {
type="button" type="button"
onClick={() => !loadingOrg && setAboutOpen(true)} onClick={() => !loadingOrg && setAboutOpen(true)}
disabled={loadingOrg} 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" aria-label="En savoir plus sur les virements"
> >
<Info className="w-3.5 h-3.5" /> <Info className="w-3.5 h-3.5" />
@ -551,11 +616,11 @@ export default function VirementsPage() {
{/* Tableau principal */} {/* Tableau principal */}
{isOdentas ? ( {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"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <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>Période</Th>
<Th>N° d'appel</Th> <Th>N° d'appel</Th>
<Th>Date d'appel</Th> <Th>Date d'appel</Th>
@ -587,8 +652,8 @@ export default function VirementsPage() {
</tr> </tr>
) : ( ) : (
filteredItems.map((row: VirementItem) => ( 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"> <tr key={row.id} className="border-b last:border-b-0 hover:bg-slate-50/50">
<Td>{row.periode_label || row.periode || "—"}</Td> <Td>{row.periode_label || formatPeriode(row.periode) || "—"}</Td>
<Td> <Td>
<span className="font-medium"> <span className="font-medium">
{row.callsheet || row.num_appel || "—"} {row.callsheet || row.num_appel || "—"}
@ -610,7 +675,7 @@ export default function VirementsPage() {
href={row.pdf_url} href={row.pdf_url}
target="_blank" target="_blank"
rel="noreferrer" 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" /> <Download className="w-3 h-3" />
PDF PDF
@ -629,7 +694,7 @@ export default function VirementsPage() {
{/* Footer avec indicateur de chargement */} {/* Footer avec indicateur de chargement */}
{filteredItems.length > 0 && ( {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"> <div className="text-sm text-slate-500">
{isFetching ? ( {isFetching ? (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
@ -644,11 +709,11 @@ export default function VirementsPage() {
)} )}
</section> </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"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <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>Salarié·e</Th>
<Th>Contrat</Th> <Th>Contrat</Th>
<Th>Profession</Th> <Th>Profession</Th>
@ -670,35 +735,88 @@ export default function VirementsPage() {
) : ( ) : (
<> <>
{clientUnpaid.map((it) => ( {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"> <tr key={`unpaid-${it.source}-${it.id}`} className="border-b last:border-b-0 hover:bg-slate-50/50">
<Td>{it.salarie || '—'}</Td> <Td>
<Td><span className="font-medium">{it.reference || '—'}</span></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>{it.profession || '—'}</Td>
<Td>{formatFR(it.date_debut || '')}</Td> <Td>{formatFR(it.date_debut || '')}</Td>
<Td>{formatFR(it.date_fin || '')}</Td> <Td>{maskEndDate(it.date_fin || '')}</Td>
<Td>{it.periode || '—'}</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-right font-medium">{it.net_a_payer != null ? formatCurrency(it.net_a_payer) : '—'}</Td>
<Td className="text-center"> <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> </Td>
</tr> </tr>
))} ))}
{clientRecent.length > 0 && ( {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> <td colSpan={8} className="px-3 py-2 text-xs text-slate-500">Récemment virés ( 30 jours)</td>
</tr> </tr>
)} )}
{clientRecent.map((it) => ( {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"> <tr key={`recent-${it.source}-${it.id}`} className="border-b last:border-b-0 hover:bg-slate-50/50">
<Td>{it.salarie || '—'}</Td> <Td>
<Td><span className="font-medium">{it.reference || '—'}</span></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>{it.profession || '—'}</Td>
<Td>{formatFR(it.date_debut || '')}</Td> <Td>{formatFR(it.date_debut || '')}</Td>
<Td>{formatFR(it.date_fin || '')}</Td> <Td>{maskEndDate(it.date_fin || '')}</Td>
<Td>{it.periode || '—'}</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-right font-medium">{it.net_a_payer != null ? formatCurrency(it.net_a_payer) : '—'}</Td>
<Td className="text-center"> <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> </Td>
</tr> </tr>
))} ))}
@ -716,12 +834,12 @@ export default function VirementsPage() {
onClick={() => setAboutOpen(false)} onClick={() => setAboutOpen(false)}
/> />
<div className="absolute inset-0 flex items-center justify-center p-4"> <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 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 dark:border-slate-800 flex items-start justify-between"> <div className="p-5 border-b flex items-start justify-between">
<div> <div>
<h2 id="about-virements-title" className="text-base font-semibold">Gestion des virements de salaires</h2> <h2 id="about-virements-title" className="text-base font-semibold">Gestion des virements de salaires</h2>
</div> </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>
<div className="p-5 space-y-4 text-sm"> <div className="p-5 space-y-4 text-sm">
<p> <p>
@ -730,10 +848,10 @@ export default function VirementsPage() {
<p> <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 »). 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> </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="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 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>
<div className="text-[11px] text-slate-500">Bénéficiaire</div> <div className="text-[11px] text-slate-500">Bénéficiaire</div>
<div className="font-mono text-xs break-all">ODENTAS MEDIA SAS</div> <div className="font-mono text-xs break-all">ODENTAS MEDIA SAS</div>
@ -741,13 +859,13 @@ export default function VirementsPage() {
<button <button
type="button" type="button"
onClick={() => { navigator.clipboard?.writeText('ODENTAS MEDIA SAS').then(()=>{ setCopiedField('benef'); setTimeout(()=>setCopiedField(null), 1400); }); }} 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" 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 {copiedField === 'benef' ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} Copier
</button> </button>
</div> </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>
<div className="text-[11px] text-slate-500">IBAN</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> <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 <button
type="button" type="button"
onClick={() => { navigator.clipboard?.writeText("FR76 1695 8000 0141 0850 9729 813" as string).then(()=>{ setCopiedField('iban'); setTimeout(()=>setCopiedField(null), 1400); }); }} 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" aria-label="Copier lIBAN"
> >
{copiedField === 'iban' ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} Copier {copiedField === 'iban' ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} Copier
</button> </button>
)} )}
</div> </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>
<div className="text-[11px] text-slate-500">BIC</div> <div className="text-[11px] text-slate-500">BIC</div>
<div className="font-mono text-xs break-all">QNTOFRP1XXX</div> <div className="font-mono text-xs break-all">QNTOFRP1XXX</div>
@ -772,7 +890,7 @@ export default function VirementsPage() {
<button <button
type="button" type="button"
onClick={() => { navigator.clipboard?.writeText("QNTOFRP1XXX" as string).then(()=>{ setCopiedField('bic'); setTimeout(()=>setCopiedField(null), 1400); }); }} 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" aria-label="Copier le BIC"
> >
{copiedField === 'bic' ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} Copier {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>
</div> </div>
<div className="p-4 border-t dark:border-slate-800 flex justify-end"> <div className="p-4 border-t 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> <button onClick={() => setAboutOpen(false)} className="px-3 py-2 rounded-md border hover:bg-slate-50 text-sm">Fermer</button>
</div> </div>
</div> </div>
</div> </div>
@ -816,7 +934,7 @@ function StatusBadge({ status, date }: { status?: boolean | string; date?: strin
if (val === true) { if (val === true) {
return ( return (
<div className="flex flex-col items-center gap-1"> <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 Oui
</span> </span>
{date && ( {date && (
@ -828,7 +946,7 @@ function StatusBadge({ status, date }: { status?: boolean | string; date?: strin
); );
} else if (val === false) { } else if (val === false) {
return ( 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 Non
</span> </span>
); );

View file

@ -6,6 +6,7 @@ import Link from "next/link";
import ConfirmableForm from "@/components/ConfirmableForm"; import ConfirmableForm from "@/components/ConfirmableForm";
import { api } from "@/lib/fetcher"; import { api } from "@/lib/fetcher";
import AccessDeniedCard from "@/components/AccessDeniedCard"; import AccessDeniedCard from "@/components/AccessDeniedCard";
import { usePageTitle } from "@/hooks/usePageTitle";
// Types // Types
type Member = { type Member = {
@ -133,6 +134,8 @@ function useOrganizationMembers(clientInfo: ClientInfo) {
} }
export default function StaffUsersListPage() { export default function StaffUsersListPage() {
usePageTitle("Vos accès");
// Récupération des infos client // Récupération des infos client
const { data: clientInfo = null, isLoading: isLoadingClient, error: clientError } = useClientInfo(); const { data: clientInfo = null, isLoading: isLoadingClient, error: clientError } = useClientInfo();
@ -213,9 +216,9 @@ export default function StaffUsersListPage() {
</Link> </Link>
</div> </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> <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> <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), <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. 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> </ul>
</section> </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"> <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> <tr>
<th className="text-left px-4 py-3">Prénom</th> <th className="text-left px-4 py-3">Prénom</th>
<th className="text-left px-4 py-3">Email</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()); (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 }); // console.debug("ROW SELF CHECK", { currentUserId, rowUserId: m.user_id, currentUserEmail, rowEmail: m.email, isSelf });
return ( 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 whitespace-nowrap">{m.first_name || "—"}</td>
<td className="px-4 py-3">{m.email}</td> <td className="px-4 py-3">{m.email}</td>
<td className="px-4 py-3 uppercase tracking-wide text-xs">{m.role || "—"}</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" ? ( m.role === "SUPER_ADMIN" ? (
// Cas SUPER_ADMIN : on garde la card actuelle // 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="flex items-start gap-3">
<div className="space-y-1"> <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>. Contactez l'équipe Odentas pour modifier le <span className="font-medium uppercase tracking-wide text-xs">SUPER ADMIN</span>.
</p> </p>
</div> </div>
@ -294,9 +297,9 @@ export default function StaffUsersListPage() {
</div> </div>
) : isSelf ? ( ) : isSelf ? (
// Cas utilisateur connecté (non SUPER_ADMIN) : petite card informative // 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"> <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. 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> </p>
</div> </div>
@ -404,7 +407,7 @@ function RoleUpdateForm({
name="role" name="role"
defaultValue={member.role || "ADMIN"} defaultValue={member.role || "ADMIN"}
disabled={disabled || isSubmitting} 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="ADMIN">Admin</option>
<option value="AGENT">Agent</option> <option value="AGENT">Agent</option>

View file

@ -2,31 +2,24 @@
import React from 'react' import React from 'react'
import { useQuery } from '@tanstack/react-query' 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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' 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 { Textarea } from '@/components/ui/textarea'
// ----------------------------- import { usePageTitle } from '@/hooks/usePageTitle'
import { Tooltip } from '@/components/ui/tooltip'
type DocumentItem = { type DocumentItem = {
id: string id: string
title: string title: string
url?: string // lien S3 signé ou route API url?: string
updatedAt?: string // ISO updatedAt?: string
sizeBytes?: number sizeBytes?: number
meta?: Record<string, any> meta?: Record<string, any>
period_label?: string | null
} }
type ClientInfo = {
id: string
name: string
api_name?: string
} | null
// -----------------------------
// Utils
// -----------------------------
function formatBytes(bytes?: number) { function formatBytes(bytes?: number) {
if (!bytes && bytes !== 0) return '' if (!bytes && bytes !== 0) return ''
const sizes = ['o', 'Ko', 'Mo', 'Go', 'To'] 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]}` return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`
} }
// ----------------------------- function formatDateLast(dateStr?: string) {
// UI Atomes if (!dateStr) return ''
// ----------------------------- const date = new Date(dateStr)
function DownloadButton({ href, filename }: { href?: string, filename?: string }) { return date.toLocaleDateString('fr-FR', {
return ( day: '2-digit',
<Button variant="secondary" size="sm" disabled={!href}> month: '2-digit',
<a href={href} download={filename} target="_blank" rel="noreferrer" className="flex items-center gap-1"> year: 'numeric'
<Download className="h-4 w-4" /><span>Télécharger</span> })
</a>
</Button>
)
} }
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() { function UploadPanel() {
const [description, setDescription] = React.useState('')
const handleSubmit = () => {
alert('Fonctionnalité de transmission de document en cours de développement')
}
return ( return (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader>
<CardTitle className="text-base flex items-center gap-2"><Upload className="h-4 w-4"/> Transmettre un document</CardTitle> <CardTitle className="flex items-center gap-2">
<CardDescription>Formats acceptés : pdf, docx, xlsx, jpg, png</CardDescription> <Upload className="h-4 w-4" />
Transmettre un document
</CardTitle>
<CardDescription>
Envoyez-nous directement vos documents
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-4">
<Input placeholder="Nom du fichier (optionnel)" />
<Input type="file" /> <Input type="file" />
<Textarea placeholder="Commentaire (optionnel)" /> <Textarea
<div className="flex justify-end"> placeholder="Description du document (optionnel)"
<Button> value={description}
<Upload className="mr-2 h-4 w-4"/> Envoyer onChange={(e) => setDescription(e.target.value)}
</Button> />
</div> <Button onClick={handleSubmit} className="w-full">
Envoyer
</Button>
</CardContent> </CardContent>
</Card> </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() { function SectionGeneraux() {
const { data, isLoading, error } = useDocumentsGeneraux(); const { data: documentsGeneraux, isLoading, error } = useQuery<DocumentItem[]>({
if (isLoading) return <SkeletonGrid />; queryKey: ['documents', 'generaux'],
if (error) return <div className="rounded-2xl border p-6 text-center text-rose-600 text-sm">{error.message}</div>; queryFn: async () => {
return <DocumentList items={data || []} icon={<Folder className="h-4 w-4"/>} />; 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() { function SectionCaisses() {
const { data, isLoading } = useDocumentsCaisses(); const { data: documentsOrganismes, isLoading, error } = useQuery<DocumentItem[]>({
if (isLoading) return <SkeletonGrid />; queryKey: ['documents', 'caisses'],
return <DocumentGrid items={data || []} icon={<Building2 className="h-4 w-4"/>} />; 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() { 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 { data: documentsCompta, isLoading, error } = useQuery<DocumentItem[]>({
const [open, setOpen] = React.useState<string | null>(null) queryKey: ['documents', 'comptables'],
const [selectedYear, setSelectedYear] = React.useState<string>('Autres') queryFn: async () => {
console.log('📄 Fetching comptables with category=docs_comptables')
const { byYear, years } = React.useMemo(() => buildYearIndex(data || []), [data]) const res = await fetch('/api/documents?category=docs_comptables')
const data = await res.json()
// Ensure selectedYear stays valid when years changes console.log('📄 Documents Comptables - Raw Response:', data)
React.useEffect(() => { console.log('📄 Documents Comptables - Is Array?', Array.isArray(data))
if (!years?.length) {
setSelectedYear('Autres') if (data && typeof data === 'object' && !Array.isArray(data)) {
return 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
}
}
const result = Array.isArray(data) ? data : []
console.log('📄 Final result:', result)
return result
} }
if (!years.includes(selectedYear)) { })
setSelectedYear(years[0])
// 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')
} }
}, [years, selectedYear]) }
if (isLoading) return <SkeletonGrid /> if (isLoading) {
if (isError) return <div className="rounded-2xl border p-6 text-center text-rose-600 text-sm">{error?.message}</div> return <p className="text-center text-muted-foreground py-8">Chargement...</p>
if (!data?.length) return <EmptyState /> }
const currentIndexRaw = years.indexOf(selectedYear) if (error) {
const currentIndex = currentIndexRaw === -1 ? 0 : currentIndexRaw return <p className="text-center text-red-500 py-8">Erreur: {String(error)}</p>
const hasPrev = currentIndex < years.length - 1 }
const hasNext = currentIndex > 0
const effectiveYear = years[currentIndex] || 'Autres' if (!documentsCompta || documentsCompta.length === 0) {
const periodsForYear = byYear[effectiveYear] || [] return (
<p className="text-center text-muted-foreground py-8">
Aucun document comptable disponible
</p>
)
}
return ( return (
<div className="flex flex-col space-y-4"> <div className="space-y-3">
{/* Pagination par année */} {Array.from(documentsByPeriod.entries()).map(([period, docs]) => {
<div className="flex items-center justify-between"> const isExpanded = expandedPeriods.has(period)
<div className="flex items-center gap-2">
<button return (
type="button" <div key={period} className="border rounded-lg overflow-hidden">
disabled={!hasPrev} {/* Header de la période - cliquable */}
onClick={() => hasPrev && setSelectedYear(years[currentIndex + 1])} <button
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'}`} onClick={() => togglePeriod(period)}
> className="w-full flex items-center justify-between p-4 bg-muted/30 hover:bg-muted/50 transition-colors"
Année précédente >
</button> <div className="flex items-center gap-3">
</div> {isExpanded ? (
<div className="text-sm font-medium">{effectiveYear}</div> <ChevronDown className="h-5 w-5 text-muted-foreground" />
<div className="flex items-center gap-2"> ) : (
<button <ChevronRight className="h-5 w-5 text-muted-foreground" />
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>
{/* Cards par période pour l'année sélectionnée */}
{periodsForYear.length === 0 ? (
<EmptyState />
) : (
<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>
</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" />} />
))}
</div>
</CardContent>
)} )}
</Card> <div className="text-left">
) <h3 className="font-semibold text-base">{period}</h3>
})} <p className="text-sm text-muted-foreground">
</div> {docs.length} document{docs.length > 1 ? 's' : ''}
)} </p>
</div>
</div>
</button>
{/* Sélecteur direct d'année (optionnel) */} {/* Liste des documents - affichée si expanded */}
<div className="flex flex-wrap items-center gap-2 pt-2"> {isExpanded && (
{years.map((y) => ( <div className="p-2 space-y-2 bg-background">
<button {docs.map((item: DocumentItem) => (
key={y} <div
type="button" key={item.id}
onClick={() => setSelectedYear(y)} className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/30 transition-colors"
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'}`} >
> <div className="flex-1 min-w-0">
{y} <h4 className="font-medium truncate">{item.title}</h4>
</button> <p className="text-sm text-muted-foreground">
))} {formatDateLast(item.updatedAt)} {formatBytes(item.sizeBytes)}
</div> </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>
)}
</div>
)
})}
</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() { export default function VosDocumentsPage() {
usePageTitle("Vos documents");
const [activeTab, setActiveTab] = React.useState('generaux');
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">Vos documents</h2> <h2 className="text-2xl font-semibold tracking-tight">Vos documents</h2>
{/* Optionnel : panneau dupload dans un slide-over plus tard */}
</header> </header>
<Tabs defaultValue="generaux" className="w-full"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<TabsList className="grid grid-cols-3 w-full md:w-auto"> {/* Colonne gauche : Documents disponibles */}
<TabsTrigger value="generaux">Documents généraux</TabsTrigger> <div className="lg:col-span-2">
<TabsTrigger value="caisses">Caisses & organismes</TabsTrigger> <Card>
<TabsTrigger value="comptables">Documents comptables</TabsTrigger> <CardHeader>
</TabsList> <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>
<TabsContent value="generaux" className="mt-4"> {/* Colonne droite : Onglets + Transmettre un document */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="lg:col-span-1 space-y-4">
<div className="lg:col-span-2"> <Card>
<SectionGeneraux /> <CardHeader>
</div> <CardTitle className="text-base">Catégories</CardTitle>
<div className="lg:col-span-1 space-y-4"> <CardDescription>Sélectionnez une catégorie</CardDescription>
<UploadPanel /> </CardHeader>
<Card> <CardContent className="space-y-2">
<CardHeader> <Button
<CardTitle className="text-base">Besoin dun document particulier ?</CardTitle> variant={activeTab === 'generaux' ? 'default' : 'outline'}
<CardDescription>Nhésitez pas à nous contacter si vous avez besoin dune attestation spécifique.</CardDescription> className="w-full justify-start"
</CardHeader> onClick={() => setActiveTab('generaux')}
</Card> >
</div> <FileText className="h-4 w-4 mr-2" />
</div> Documents généraux
</TabsContent> </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>
<TabsContent value="caisses" className="mt-4"> <UploadPanel />
<SectionCaisses />
</TabsContent>
<TabsContent value="comptables" className="mt-4"> <Card>
<SectionComptables /> <CardHeader>
</TabsContent> <CardTitle className="text-base">Besoin d'aide ?</CardTitle>
</Tabs> <CardDescription>
N'hésitez pas à nous contacter si vous avez besoin d'une attestation spécifique.
</CardDescription>
</CardHeader>
</Card>
</div>
</div>
</div> </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 { Suspense } from "react";
import ActivateContent from "./ActivateContent"; import ActivateContent from "./ActivateContent";
import { usePageTitle } from "@/hooks/usePageTitle";
export default function ActivatePage() { export default function ActivatePage() {
usePageTitle("Activation de compte");
return ( return (
<Suspense fallback={ <Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-gray-50"> <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, newRole: role,
updatedBy, updatedBy,
updateDate: new Date().toLocaleString('fr-FR'), 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 } } { 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(); const { data: users, error: listErr } = await (srv.auth.admin as any).listUsers();
if (listErr) return new NextResponse(listErr.message, { status: 500 }); if (listErr) return new NextResponse(listErr.message, { status: 500 });
const user = (users?.users || []).find( const user = (users?.users || []).find(
@ -32,15 +32,33 @@ export async function POST(req: Request) {
} }
if (!isStaff) { 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 }); 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 (rpcErr) return new NextResponse(rpcErr.message, { status: 500 });
if (!ok) return new NextResponse("revoked", { status: 403 }); 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 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({ const { error } = await supabase.auth.signInWithOtp({
email, email,
options: { shouldCreateUser: false, emailRedirectTo: `${origin}/auth/callback` }, 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 { NextRequest, NextResponse } from 'next/server';
import { createSbServer } from '@/lib/supabaseServer'; import { createClient, SupabaseClient, PostgrestError } from '@supabase/supabase-js';
import { createClient } from '@supabase/supabase-js';
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { sendContractNotifications } from '@/lib/emailService'; import { sendContractNotifications } from '@/lib/emailService';
import { resolveActiveOrg } from '@/lib/resolveActiveOrg'; import { resolveActiveOrg } from '@/lib/resolveActiveOrg';
async function assertStaff(sb: ReturnType<typeof createSbServer>, userId: string) { type QueryResult<T> = { data: T | null; error: PostgrestError | null };
const { data: me } = await sb
.from("staff_users") type EmployeeRow = {
.select("is_staff") id: string;
.eq("user_id", userId) code_salarie: string;
.maybeSingle(); nom: string;
return !!me?.is_staff; 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) { export async function POST(request: NextRequest) {
const supabase = createRouteHandlerClient({ cookies }); const supabase = createRouteHandlerClient({ cookies });
@ -23,64 +48,208 @@ export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
console.log("Body reçu pour création CDDU:", body); console.log("Body reçu pour création CDDU:", body);
// Générer un identifiant unique pour le contrat // Générer un identifiant unique pour le contrat
const contractId = uuidv4(); 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é // Détecter si l'utilisateur est staff et préparer un client service_role si disponible
console.log("Recherche employé avec matricule:", body.salarie_matricule); let isStaff = false;
console.log('🔍 [DEBUG] Début détection staff pour user:', user.id);
const { data: employee, error: empError } = await supabase try {
.from('salaries') const { data: staffRow } = await supabase
.select('id, code_salarie, nom, prenom, adresse_mail') .from('staff_users')
.eq('code_salarie', body.salarie_matricule) .select('is_staff')
.single(); .eq('user_id', user.id)
.maybeSingle();
console.log("Résultat recherche employé:", { employee, empError }); console.log('🔍 [DEBUG] Résultat query staff_users:', staffRow);
isStaff = !!staffRow?.is_staff;
if (empError || !employee) { console.log('🔍 [DEBUG] isStaff depuis DB:', isStaff);
// Essayons de voir quels employés existent } catch (err) {
const { data: allEmployees } = await supabase console.log('🔍 [DEBUG] Erreur query staff_users, fallback metadata:', err);
.from('salaries') const userMeta = user.user_metadata || {};
.select('code_salarie, nom, prenom') const appMeta = user.app_metadata || {};
.limit(5); console.log('🔍 [DEBUG] user_metadata:', userMeta);
console.log("Exemple d'employés dans la base:", allEmployees); console.log('🔍 [DEBUG] app_metadata:', appMeta);
isStaff = Boolean(
return NextResponse.json({ error: 'Employé non trouvé' }, { status: 404 }); 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 // Récupérer les informations de l'organisation en premier
let orgId = body.org_id; let orgId = typeof body.org_id === 'string' && body.org_id.trim().length > 0 ? body.org_id.trim() : null;
let orgName = 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) { if (!orgId) {
console.log('🔍 [DEBUG] Pas d\'orgId, utilisation de resolveActiveOrg...');
// Utiliser resolveActiveOrg pour obtenir l'organisation active de l'utilisateur // Utiliser resolveActiveOrg pour obtenir l'organisation active de l'utilisateur
orgId = await resolveActiveOrg(supabase); orgId = await resolveActiveOrg(supabase);
console.log("Organisation résolue via resolveActiveOrg:", orgId); 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) { if (!orgId) {
console.error("Aucune organisation trouvée pour l'utilisateur:", user.id); console.error("❌ [DEBUG] Aucune organisation trouvée pour l'utilisateur:", user.id);
return NextResponse.json({ error: 'Organisation non trouvée' }, { status: 400 }); return NextResponse.json({ error: 'Organisation non trouvée' }, { status: 400 });
} }
// Récupérer le nom de l'organisation avec les détails de notification console.log('✅ [DEBUG] Organisation finale:', orgId);
const { data: organization, error: orgError } = await supabase
.from('organizations') const salarieMatricule = (body.salarie_matricule ?? '').toString().trim();
.select(` console.log('🔍 [DEBUG] Body original salarie_matricule:', body.salarie_matricule);
name, console.log('🔍 [DEBUG] Matricule après trim:', salarieMatricule);
organization_details!inner( console.log('🔍 [DEBUG] Type de matricule:', typeof salarieMatricule);
email_notifs, console.log('🔍 [DEBUG] Longueur matricule:', salarieMatricule.length);
email_notifs_cc, console.log('🔍 [DEBUG] Staff user:', isStaff);
prenom_contact, console.log('🔍 [DEBUG] OrgId à utiliser:', orgId);
code_employeur console.log('🔍 [DEBUG] Nombre de clients Supabase:', clients.length);
) console.log('🔍 [DEBUG] Service role disponible:', !!serviceSupabase);
`)
.eq('id', orgId) if (!salarieMatricule) {
.single(); 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, employer_id')
.eq('code_salarie', salarieMatricule)
.eq('employer_id', orgId)
.maybeSingle();
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) {
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, 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(`🔍 [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 });
}
const { data: organization, error: orgError } = await runOnClients<OrganizationRow>(async (client) => {
const { data, error } = await client
.from('organizations')
.select(`
name,
organization_details!inner(
email_notifs,
email_notifs_cc,
prenom_contact,
code_employeur
)
`)
.eq('id', orgId)
.maybeSingle();
return { data: (data as OrganizationRow | null), error };
});
if (orgError || !organization) { if (orgError || !organization) {
console.error('Erreur récupération organisation:', orgError); console.error('Erreur récupération organisation:', orgError);
@ -89,34 +258,42 @@ export async function POST(request: NextRequest) {
orgName = organization.name; orgName = organization.name;
// Récupérer les informations de la production // Récupérer les informations de la production
let production: any = null; let production: ProductionRow | null = null;
let prodError: any = null; let prodError: PostgrestError | null = null;
if (body.production_id) { if (body.production_id) {
// Si un ID de production est fourni, récupérer par ID (production existante) // Si un ID de production est fourni, récupérer par ID (production existante)
console.log("Recherche production par ID:", body.production_id); console.log('Recherche production par ID:', body.production_id);
const result = await supabase const result = await runOnClients<ProductionRow>(async (client) => {
.from('productions') const { data, error } = await client
.select('id, name, reference') .from('productions')
.eq('id', body.production_id) .select('id, name, reference, org_id')
.single(); .eq('id', body.production_id)
.eq('org_id', orgId)
.maybeSingle();
return { data: (data as ProductionRow | null), error };
});
production = result.data; production = result.data;
prodError = result.error; prodError = result.error;
console.log("Résultat recherche production par ID:", { production, prodError }); console.log('Résultat recherche production par ID:', { production, prodError });
} else { } else if (body.spectacle) {
// Sinon, rechercher par nom (pour créer une nouvelle production) // Sinon, rechercher par nom (pour créer une nouvelle production)
console.log("Recherche production par nom:", body.spectacle); console.log('Recherche production par nom:', body.spectacle);
const result = await supabase const result = await runOnClients<ProductionRow>(async (client) => {
.from('productions') const { data, error } = await client
.select('id, name, reference') .from('productions')
.eq('name', body.spectacle) .select('id, name, reference, org_id')
.single(); .eq('org_id', orgId)
.eq('name', body.spectacle)
.maybeSingle();
return { data: (data as ProductionRow | null), error };
});
production = result.data; production = result.data;
prodError = result.error; 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 // 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 }); 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 && (!production || prodError)) {
if (!body.production_id && (prodError || !production)) { if (!body.spectacle) {
console.log("Production non trouvée, création automatique..."); 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 // Générer une référence unique pour la nouvelle production
const generateReference = () => { const generateReference = () => {
@ -144,46 +324,42 @@ export async function POST(request: NextRequest) {
reference: body.numero_objet || generateReference(), reference: body.numero_objet || generateReference(),
declaration_date: new Date().toISOString().split('T')[0], // YYYY-MM-DD declaration_date: new Date().toISOString().split('T')[0], // YYYY-MM-DD
sent_date: null, sent_date: null,
prod_type: "Spectacle vivant", // Valeur par défaut prod_type: 'Spectacle vivant', // Valeur par défaut
director: null, director: null,
created_at: new Date().toISOString() 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 writeClient: SupabaseClient = serviceSupabase ?? supabase;
const { data: newProduction, error: createError } = await supabase
let creationResult = await writeClient
.from('productions') .from('productions')
.insert(newProductionData) .insert(newProductionData)
.select('id, name, reference') .select('id, name, reference, org_id')
.single(); .single();
if (createError) { if (creationResult.error && serviceSupabase && writeClient !== serviceSupabase) {
console.error('Erreur création nouvelle production:', createError); console.error('Erreur création nouvelle production:', creationResult.error);
creationResult = await serviceSupabase
// 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
.from('productions') .from('productions')
.insert(newProductionData) .insert(newProductionData)
.select('id, name, reference') .select('id, name, reference, org_id')
.single(); .single();
if (serviceCreateError) {
console.error('Erreur création production avec service_role:', serviceCreateError);
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);
} }
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 = 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 // 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; 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 // Envoyer les notifications par email après la création réussie du contrat
try { try {
await sendContractNotifications(contractData, organization); 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) { } catch (emailError) {
console.error('Erreur envoi notifications email:', emailError); console.error('Erreur envoi notifications email:', emailError);
// Ne pas faire échouer la création du contrat si l'envoi d'email échoue // 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 { function generateContractReference(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const letters = 'ABCDEFGHIKLMNPQRSTUVWXYZ'; // sans O
let result = ''; const digits = '123456789'; // sans 0
for (let i = 0; i < 8; i++) { const pool = letters + digits;
result += chars.charAt(Math.floor(Math.random() * chars.length)); 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 // app/api/contrats/[id]/generate-pdf/route.ts
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer"; import { createSbServer } from "@/lib/supabaseServer";
import { createClient } from "@supabase/supabase-js";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { PROFESSIONS_ARTISTE, ProfessionOption } from "@/components/constants/ProfessionsArtiste"; import { PROFESSIONS_ARTISTE, ProfessionOption } from "@/components/constants/ProfessionsArtiste";
import { promises as fs } from "fs"; 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[]> { async function getFeminisations(): Promise<ProfessionFeminisation[]> {
try { try {
const filePath = path.join(process.cwd(), 'public/data/professions-feminisations.json'); const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const fileContent = await fs.readFile(filePath, 'utf8'); const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
return JSON.parse(fileContent);
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) { } catch (error) {
console.warn("Erreur lors du chargement des féminisations:", error); console.warn("Erreur lors du chargement des féminisations:", error);
return []; return [];
@ -416,7 +434,7 @@ export async function POST(
console.log("Upload du fichier dans S3 sous la clé:", s3Key); console.log("Upload du fichier dans S3 sous la clé:", s3Key);
const uploadCommand = new PutObjectCommand({ const uploadCommand = new PutObjectCommand({
Bucket: "odentas-docs", Bucket: (process.env.AWS_S3_BUCKET || "odentas-docs").trim(),
Key: s3Key, Key: s3Key,
Body: pdfBuffer, Body: pdfBuffer,
ContentType: "application/pdf", ContentType: "application/pdf",
@ -426,7 +444,8 @@ export async function POST(
console.log("Fichier PDF uploadé sur S3 avec succès."); console.log("Fichier PDF uploadé sur S3 avec succès.");
// URL S3 pour accéder au fichier // 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 // Mettre à jour le contrat avec l'URL du PDF
console.log("Mise à jour du contrat avec:", { 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 // Récupération du contrat et de l'URL du PDF
const { data: contract, error: contractError } = await sb const { data: contract, error: contractError } = await sb
.from("cddu_contracts") .from("cddu_contracts")
.select("contract_pdf_filename") .select("contract_pdf_filename, contract_number, employee_name")
.eq("id", params.id) .eq("id", params.id)
.single(); .single();
if (contractError || !contract?.contract_pdf_filename) { if (contractError) {
return NextResponse.json( 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 } { status: 404 }
); );
} }
@ -58,7 +75,7 @@ export async function GET(
// Génération de l'URL pré-signée (valide 1 heure) // Génération de l'URL pré-signée (valide 1 heure)
const command = new GetObjectCommand({ const command = new GetObjectCommand({
Bucket: "odentas-docs", Bucket: (process.env.AWS_S3_BUCKET || "odentas-docs").trim(),
Key: `unsigned-contracts/${contract.contract_pdf_filename}`, Key: `unsigned-contracts/${contract.contract_pdf_filename}`,
}); });
@ -67,7 +84,8 @@ export async function GET(
}); });
return NextResponse.json({ return NextResponse.json({
signedUrl: signedUrl, pdfUrl: signedUrl,
signedUrl: signedUrl, // Garde la compatibilité
filename: contract.contract_pdf_filename filename: contract.contract_pdf_filename
}); });

View file

@ -352,18 +352,55 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
// Récupérer les données d'organisation avec tous les détails // Récupérer les données d'organisation avec tous les détails
let organizationData; let organizationData;
if (org.id === null) { if (org.id === null) {
// Pour staff, utiliser l'organisation du contrat // Pour staff, récupérer l'organisation du contrat depuis la DB
organizationData = { organization_details: {} }; 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 { } else {
const { data: orgDetails } = await supabase const { data: orgDetails, error: orgError } = await supabase
.from("organizations") .from("organizations")
.select("*, organization_details(*)") .select("*, organization_details(*)")
.eq("id", org.id) .eq("id", org.id)
.single(); .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; organizationData = orgDetails;
} }
await sendContractUpdateNotifications(contractData, organizationData); 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) { } catch (emailError) {
console.error("⚠️ EMAIL NOTIFICATION ERROR (non-blocking):", { contractId, error: emailError, requestId }); console.error("⚠️ EMAIL NOTIFICATION ERROR (non-blocking):", { contractId, error: emailError, requestId });
@ -488,18 +525,56 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
// Récupérer les données d'organisation avec tous les détails // Récupérer les données d'organisation avec tous les détails
let organizationData; let organizationData;
if (org.id === null) { if (org.id === null) {
// Pour staff, utiliser l'organisation du contrat // Pour staff, récupérer l'organisation du contrat depuis la DB
organizationData = { organization_details: {} }; 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 {
// Pas d'organisation liée, skip les notifications
console.log("⚠️ No organization linked to contract, skipping email notifications");
organizationData = null;
}
} else { } else {
const { data: orgDetails } = await supabase const { data: orgDetails, error: orgError } = await supabase
.from("organizations") .from("organizations")
.select("*, organization_details(*)") .select("*, organization_details(*)")
.eq("id", org.id) .eq("id", org.id)
.single(); .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; organizationData = orgDetails;
} }
await sendContractUpdateNotifications(contractData, organizationData); 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) { } catch (emailError) {
console.error("⚠️ EMAIL NOTIFICATION ERROR (non-blocking):", { contractId, error: emailError, requestId }); 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 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(); const { data } = await admin.from("cddu_contracts").select("*").eq("id", contractId).single();
contractData = data; 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 { } else {
// Client avec accès limité à son organisation // Client avec accès limité à son organisation
const { data } = await supabase.from("cddu_contracts").select("*").eq("id", contractId).eq("org_id", org.id).single(); 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 // 4. Envoyer les notifications email d'annulation
try { try {
await sendContractCancellationNotifications(contractData, organizationData); if (organizationData) {
await sendContractCancellationNotifications(contractData, organizationData);
} else {
console.log("⚠️ No organization data available, skipping cancellation email notifications");
}
} catch (emailError) { } catch (emailError) {
console.error("⚠️ EMAIL NOTIFICATION ERROR (non-blocking):", { contractId, error: emailError, requestId }); 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 // app/api/contrats/route.ts
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers"; import { cookies, headers } from "next/headers";
import { resolveActiveOrg } from "@/lib/resolveActiveOrg"; import { resolveActiveOrg } from "@/lib/resolveActiveOrg";
import { createClient } from '@supabase/supabase-js'; 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 // Force dynamic rendering and disable revalidation cache for this proxy
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -24,6 +26,54 @@ function buildUpstreamUrl(req: Request) {
} }
export async function GET(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 { try {
const url = new URL(req.url); const url = new URL(req.url);
const regime = url.searchParams.get("regime"); 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 // app/api/documents/route.ts
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { cookies, headers } from "next/headers"; import { cookies } from "next/headers";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; 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) { function json(status: number, body: any) {
return NextResponse.json(body, { status }); return NextResponse.json(body, { status });
} }
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
if (!API_BASE) return json(500, { error: "missing_env_NEXT_PUBLIC_API_BASE" });
const c = cookies(); const c = cookies();
const h = headers(); const sb = createRouteHandlerClient({ cookies });
// 1) Sources possibles pour l'orga // 1) Récupérer la catégorie depuis les query params
// a) cookies (prioritaires côté app) const { searchParams } = new URL(req.url);
let orgKey = c.get("active_org_key")?.value || ""; const category = searchParams.get("category");
let orgName = c.get("active_org_name")?.value || "";
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 || ""; let orgId = c.get("active_org_id")?.value || "";
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,
});
// b) headers (si le client envoie manuellement) // 3) Si pas d'orgId dans les cookies, vérifier si c'est un client authentifié
if (!orgKey) orgKey = h.get("x-company-key") || orgKey; if (!orgId) {
if (!orgName) orgName = h.get("x-company-name") || orgName; const { data: { user }, error: userError } = await sb.auth.getUser();
// On accepte aussi une version base64 (au cas où)
if (!orgName) { console.log('📄 Documents API - User:', user?.id, 'Error:', userError);
const b64 = h.get("x-company-name-b64");
if (b64) { if (!user) {
try { orgName = Buffer.from(b64, "base64").toString("utf8"); } catch {} return json(401, { error: "unauthorized", details: "No user found" });
}
// 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);
} }
} }
// c) fallback: si on a l'ID mais pas le nom/clé, on va chercher dans Supabase if (!orgId) {
if (!orgKey && !orgName && orgId) { return json(400, { error: "no_organization_found" });
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. // 4) Récupérer les documents depuis Supabase avec RLS
// For staff users (null-org semantics) we want to allow calls without an active org. console.log('📄 Documents API - Fetching from Supabase with org_id:', orgId, 'category:', category);
// 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 { data: documents, error } = await sb
const lambdaURL = new URL(API_BASE.replace(/\/$/, "") + "/documents"); .from("documents")
const src = new URL(req.url); .select("*")
src.searchParams.forEach((v, k) => lambdaURL.searchParams.set(k, v)); .eq("org_id", orgId)
.eq("category", category)
.order("date_added", { ascending: false });
// 3) Préparer les headers pour la Lambda if (error) {
const outHeaders: Record<string, string> = { accept: "application/json" }; console.error('📄 Documents API - Supabase Error:', error);
if (orgKey) { return json(500, { error: "supabase_error", details: error.message });
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(), { console.log('📄 Documents API - Found documents:', documents?.length || 0);
method: "GET",
headers: outHeaders,
cache: "no-store",
});
if (!res.ok) { // 5) Transformer les documents au format attendé par le frontend
const text = await res.text(); const formattedDocuments = (documents || []).map(doc => ({
return json(502, { error: "lambda_error", status: res.status, body: text }); 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,
}
}));
const data = await res.json(); console.log('📄 Documents API - Returning formatted documents:', formattedDocuments.length);
return json(200, data);
} catch (e: any) { return json(200, formattedDocuments);
return json(500, { error: "internal_error", message: e?.message || String(e) });
} 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 { DynamoDBClient, PutItemCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb';
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { sendUniversalEmailV2, renderUniversalEmailV2, EmailDataV2 } from '@/lib/emailTemplateService'; import { sendUniversalEmailV2, renderUniversalEmailV2, EmailDataV2 } from '@/lib/emailTemplateService';
import { ENV } from '@/lib/cleanEnv';
import axios from 'axios'; import axios from 'axios';
// Configuration AWS // Configuration AWS
const region = process.env.AWS_REGION || 'eu-west-3'; const region = ENV.AWS_REGION;
const dynamoDBClient = new DynamoDBClient({ const dynamoDBClient = new DynamoDBClient({
region, region,
credentials: { credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!, accessKeyId: ENV.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY! secretAccessKey: ENV.AWS_SECRET_ACCESS_KEY
} }
}); });
// Vérification des variables d'environnement au démarrage // Vérification des variables d'environnement au démarrage
console.log('🔧 Configuration DynamoDB:', { console.log('🔧 Configuration DynamoDB:', {
region, region,
hasAccessKey: !!process.env.AWS_ACCESS_KEY_ID, hasAccessKey: !!ENV.AWS_ACCESS_KEY_ID,
hasSecretKey: !!process.env.AWS_SECRET_ACCESS_KEY, hasSecretKey: !!ENV.AWS_SECRET_ACCESS_KEY,
accessKeyLength: process.env.AWS_ACCESS_KEY_ID?.length, accessKeyLength: ENV.AWS_ACCESS_KEY_ID?.length,
secretKeyLength: process.env.AWS_SECRET_ACCESS_KEY?.length secretKeyLength: ENV.AWS_SECRET_ACCESS_KEY?.length
}); });
const s3Client = new S3Client({ region }); const s3Client = new S3Client({ region });
// Envoi d'e-mails géré via le service de templates universels // 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 // 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({ const getObjectCommand = new GetObjectCommand({
Bucket: 'odentas-docs', // Bucket utilisé pour les PDFs de contrats Bucket: ENV.AWS_S3_BUCKET,
Key: pdfS3Key Key: pdfS3Key,
}); });
const s3Object = await s3Client.send(getObjectCommand); const s3Object = await s3Client.send(getObjectCommand);
@ -121,14 +122,24 @@ export async function POST(request: NextRequest) {
// Extraire le nom de fichier depuis la clé S3 (exemple: "unsigned-contracts/contrat_cddu_6ET5Z3XW_DEMO002.pdf") // Extraire le nom de fichier depuis la clé S3 (exemple: "unsigned-contracts/contrat_cddu_6ET5Z3XW_DEMO002.pdf")
const docusealFileName = pdfS3Key.split('/').pop() || `contrat_${submissionId}.pdf`; const docusealFileName = pdfS3Key.split('/').pop() || `contrat_${submissionId}.pdf`;
console.log('Nom de fichier extrait pour DocuSeal:', docusealFileName); 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 // É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}`, name: `CDDU - ${reference}`,
documents: [{ name: docusealFileName, file: pdfBase64 }] documents: [{ name: docusealFileName, file: pdfBase64 }]
}, { }, {
headers: { headers: {
'X-Auth-Token': process.env.DOCUSEAL_API_TOKEN!, 'X-Auth-Token': ENV.DOCUSEAL_TOKEN,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}); });
@ -137,7 +148,7 @@ export async function POST(request: NextRequest) {
console.log('Template DocuSeal créé:', templateResponse.data); console.log('Template DocuSeal créé:', templateResponse.data);
// Étape 4 : Création de la soumission DocuSeal // É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, template_id: templateId,
send_email: false, send_email: false,
submitters: [ submitters: [
@ -146,7 +157,7 @@ export async function POST(request: NextRequest) {
] ]
}, { }, {
headers: { headers: {
'X-Auth-Token': process.env.DOCUSEAL_API_TOKEN!, 'X-Auth-Token': ENV.DOCUSEAL_TOKEN,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}); });
@ -368,7 +379,7 @@ async function updateContractWithDocusealId(submissionId: string, docusealSubID:
// Fonction pour uploader l'email HTML sur S3 // Fonction pour uploader l'email HTML sur S3
async function uploadEmailToS3(emailHtml: string, key: string): Promise<string> { async function uploadEmailToS3(emailHtml: string, key: string): Promise<string> {
const command = new PutObjectCommand({ const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME_EMAILS!, Bucket: ENV.S3_BUCKET_NAME_EMAILS,
Key: `${key}.html`, Key: `${key}.html`,
Body: emailHtml, Body: emailHtml,
ContentType: 'text/html', ContentType: 'text/html',
@ -377,7 +388,7 @@ async function uploadEmailToS3(emailHtml: string, key: string): Promise<string>
try { try {
await s3Client.send(command); 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) { } catch (error) {
console.error('Erreur lors de l\'upload de l\'email sur S3:', 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.'); 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 // 3) Presign S3 URLs for PDFs
let signer: any = null; 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 region = process.env.AWS_REGION || 'eu-west-3';
const expireSeconds = Math.max(60, Math.min(60 * 60, Number(process.env.INVOICE_URL_EXPIRES ?? 900))); 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 { NextResponse } from 'next/server';
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies, headers } 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() { 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 { try {
const sb = createRouteHandlerClient({ cookies }); const sb = createRouteHandlerClient({ cookies });
const { data: { user } } = await sb.auth.getUser(); const { data: { user } } = await sb.auth.getUser();

View file

@ -2,8 +2,10 @@
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers"; import { cookies, headers } from "next/headers";
import { resolveActiveOrg } from "@/lib/resolveActiveOrg"; 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 * API simplifiée /api/me
@ -13,6 +15,25 @@ import { resolveActiveOrg } from "@/lib/resolveActiveOrg";
export async function GET() { export async function GET() {
console.log("📊 [API ME] Début de la requête /api/me"); 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 { try {
// Vérifier les cookies reçus // Vérifier les cookies reçus
const cookiesStore = cookies(); const cookiesStore = cookies();

View file

@ -1,8 +1,22 @@
// app/api/organizations/route.ts // app/api/organizations/route.ts
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; 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() { 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 { try {
const supabase = createRouteHandlerClient({ cookies }); const supabase = createRouteHandlerClient({ cookies });

View file

@ -1,9 +1,11 @@
// app/api/professions-feminisations/route.ts // app/api/professions-feminisations/route.ts
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { promises as fs } from "fs"; import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import path from "path"; import { createClient } from "@supabase/supabase-js";
import { cookies } from "next/headers";
type ProfessionFeminisation = { type ProfessionFeminisation = {
id?: string;
profession_code: string; profession_code: string;
profession_label: string; profession_label: string;
profession_feminine: string; profession_feminine: string;
@ -11,25 +13,17 @@ type ProfessionFeminisation = {
updated_at?: string; 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() {
async function loadFeminisations(): Promise<ProfessionFeminisation[]> { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
try { const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const fileContent = await fs.readFile(FEMINISATIONS_FILE, 'utf8');
return JSON.parse(fileContent); return createClient(supabaseUrl, supabaseServiceKey, {
} catch (error) { auth: {
console.warn("Erreur lors du chargement des féminisations:", error); autoRefreshToken: false,
return []; persistSession: false
} }
} });
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;
}
} }
// GET - Récupérer toutes les féminisations ou une spécifique // 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 url = new URL(request.url);
const professionCode = url.searchParams.get('code'); 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) { 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); 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); return NextResponse.json(feminisations);
} catch (error) { } catch (error) {
console.error("Erreur GET féminisations:", error); console.error("Erreur GET féminisations:", error);
@ -65,29 +81,26 @@ export async function POST(request: NextRequest) {
); );
} }
const feminisations = await loadFeminisations(); // Utiliser le client avec service role pour écrire
const existingIndex = feminisations.findIndex(f => f.profession_code === profession_code); const supabase = getServiceRoleClient();
const now = new Date().toISOString(); const { data: feminisation, error } = await supabase
const feminisation: ProfessionFeminisation = { .from('professions_feminisations')
profession_code, .upsert({
profession_label, profession_code,
profession_feminine, profession_label,
updated_at: now profession_feminine: profession_feminine.trim()
}; }, {
onConflict: 'profession_code'
})
.select('*')
.single();
if (existingIndex >= 0) { if (error) {
// Mise à jour console.error("Erreur lors de la sauvegarde de la féminisation:", error);
feminisation.created_at = feminisations[existingIndex].created_at || now; return NextResponse.json({ error: "Erreur lors de la sauvegarde de la féminisation" }, { status: 500 });
feminisations[existingIndex] = feminisation;
} else {
// Création
feminisation.created_at = now;
feminisations.push(feminisation);
} }
await saveFeminisations(feminisations);
return NextResponse.json(feminisation); return NextResponse.json(feminisation);
} catch (error) { } catch (error) {
console.error("Erreur POST féminisation:", 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 }); return NextResponse.json({ error: "Le code de profession est requis" }, { status: 400 });
} }
const feminisations = await loadFeminisations(); // Utiliser le client avec service role pour supprimer
const filteredFeminisations = feminisations.filter(f => f.profession_code !== professionCode); const supabase = getServiceRoleClient();
if (filteredFeminisations.length === feminisations.length) { const { error } = await supabase
return NextResponse.json({ error: "Féminisation non trouvée" }, { status: 404 }); .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 }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error("Erreur DELETE féminisation:", 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 { try {
const data = await request.json(); const body = await request.json();
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);
// Pour l'instant, nous redirigeons vers l'endpoint CDDU existant try {
// avec des adaptations pour le régime général 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 = { 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 // Marquer explicitement que c'est un contrat RG
regime: "RG", regime: "RG",
// Les champs production ne sont pas utilisés en RG // Les champs production ne sont pas utilisés en RG
@ -26,22 +81,23 @@ export async function POST(request: Request) {
jours_travail: null, jours_travail: null,
}; };
// Utiliser l'endpoint CDDU existant pour l'instant // Pour contourner le problème de l'appel HTTP externe qui perd la session,
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3001'}/api/cddu-contracts`, { // 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Cookie': request.headers.get('Cookie') || '',
}, },
body: JSON.stringify(adaptedData), body: JSON.stringify(adaptedData),
}); });
if (!response.ok) { // Import de la fonction POST de l'API CDDU pour l'appeler directement
const error = await response.text(); const { POST: cdduPost } = await import('../cddu-contracts/route');
return NextResponse.json({ error: `Erreur lors de la création du contrat RG: ${error}` }, { status: response.status }); const result = await cdduPost(cdduRequest);
}
const result = await response.json(); return result;
return NextResponse.json(result);
} catch (error) { } catch (error) {
console.error('Erreur API RG contracts:', 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) { export async function POST(request: NextRequest) {
try { 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 }); 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 { try {
const sbAuth = createSbServer(); const sbAuth = createSbServer();
const { data: { user } } = await sbAuth.auth.getUser(); 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 }; 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) { if (user && orgDetails?.data) {
await sendUniversalEmailV2({ await sendUniversalEmailV2({
type: 'employee-created', 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) { } catch (emailError) {
console.error('📧 [API /salaries POST] Failed to send email notification:', emailError); console.error('📧 [API /salaries POST] Failed to send email notifications:', emailError);
// Ne pas bloquer la réponse en cas d'échec de l'e-mail // Ne pas bloquer la réponse en cas d'échec des e-mails
} }
return NextResponse.json({ ok: true, data }, { status: 201 }); return NextResponse.json({ ok: true, data }, { status: 201 });

View file

@ -1,139 +1,48 @@
// app/api/search/route.ts - Version debug // app/api/search/route.ts - Production-safe
import { NextRequest, NextResponse } from "next/server"; 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) { export async function GET(request: NextRequest) {
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(
{ error: "Query parameter 'q' is required and must be at least 2 characters long." },
{ status: 400 }
);
}
// 🎭 Vérification du mode démo en premier
const h = headers();
const isDemoMode = detectDemoModeFromHeaders(h);
if (isDemoMode) {
console.log("🎭 [API SEARCH] Mode démo détecté - recherche dans les données fictives");
const results = searchDemoData(q);
return NextResponse.json({
results: results.slice(0, limit),
total: results.length,
query: q,
demo: true
});
}
try { try {
const searchParams = request.nextUrl.searchParams; const supabase = createRouteHandlerClient({ cookies });
const q = searchParams.get("q")?.trim() ?? ""; const { data: { session }, error: sessionError } = await supabase.auth.getSession();
const limitParam = searchParams.get("limit");
const limit = limitParam ? Math.max(1, Math.min(50, parseInt(limitParam, 10))) : 20;
if (q.length < 2) {
return NextResponse.json(
{ error: "Query parameter 'q' is required and must be at least 2 characters long." },
{ status: 400 }
);
}
// 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!
);
console.log("Mode bypass - Recherche pour:", q);
let userOrgId: string | null = null;
try {
const res = NextResponse.next();
const supabase = createMiddlewareClient({ req: request, res });
const { data: { session } } = await supabase.auth.getSession();
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') || '',
},
});
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 });
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
if (sessionError || !session) { if (sessionError || !session) {
console.error("Auth error in search API:", sessionError); console.error("Auth error in search API:", sessionError);
@ -142,6 +51,8 @@ export async function GET(request: NextRequest) {
// Récupérer l'organisation de l'utilisateur // Récupérer l'organisation de l'utilisateur
let userOrgId: string | null = null; let userOrgId: string | null = null;
console.log("User ID:", session.user.id);
try { try {
const { data: member, error: mErr } = await supabase const { data: member, error: mErr } = await supabase
.from("organization_members") .from("organization_members")
@ -149,13 +60,18 @@ export async function GET(request: NextRequest) {
.eq("user_id", session.user.id) .eq("user_id", session.user.id)
.single(); .single();
console.log("Organization member query result:", { member, error: mErr });
if (!mErr && member?.org_id) { if (!mErr && member?.org_id) {
userOrgId = member.org_id; userOrgId = member.org_id;
console.log("Found user org_id:", userOrgId);
} else { } else {
// If no membership, check if the user is a staff user and allow global access for staff // If no membership, check if the user is a staff user and allow global access for staff
try { try {
const { data: s } = await supabase.from('staff_users').select('is_staff').eq('user_id', session.user.id).maybeSingle(); const { data: s } = await supabase.from('staff_users').select('is_staff').eq('user_id', session.user.id).maybeSingle();
const isStaff = !!s?.is_staff; const isStaff = !!s?.is_staff;
console.log("Staff check result:", { staff_data: s, isStaff });
if (!isStaff) { if (!isStaff) {
console.warn("Utilisateur sans organisation associée:", session.user.id); console.warn("Utilisateur sans organisation associée:", session.user.id);
return NextResponse.json({ error: "No organization found" }, { status: 403 }); return NextResponse.json({ error: "No organization found" }, { status: 403 });
@ -172,21 +88,74 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Organization lookup failed" }, { status: 500 }); return NextResponse.json({ error: "Organization lookup failed" }, { status: 500 });
} }
console.log("Recherche pour:", q, "- org_id:", userOrgId); console.log("Recherche pour:", q, "- org_id:", userOrgId);
// Debug: Vérifier qu'il y a bien des données pour cette org // Debug: Vérifier qu'il y a bien des données pour cette org
const { data: sampleData, error: sampleError } = await supabase const { data: sampleData, error: sampleError } = await supabase
.from("search_index") .from("search_index")
.select("id, entity_type, title, searchable") .select("id, entity_type, title, searchable, org_id")
.eq("org_id", userOrgId) .eq("org_id", userOrgId)
.limit(5); .limit(5);
console.log("Échantillon de données pour cette org:", sampleData?.length, "résultats"); 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) { if (sampleData && sampleData.length > 0) {
console.log("Premier échantillon:", { console.log("Premier échantillon:", {
id: sampleData[0].id,
org_id: sampleData[0].org_id,
title: sampleData[0].title, title: sampleData[0].title,
searchable: sampleData[0].searchable?.substring(0, 100) + "..." 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 // 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 // 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 // Approche 1: Recherche FTS simple
console.log("1. Tentative FTS simple:", q); console.log("1. Tentative FTS simple:", q);

View file

@ -46,43 +46,43 @@ export async function POST(req: NextRequest) {
} }
} catch {} } catch {}
// Récupération des données du contrat depuis Supabase // Récupération des données du contrat depuis Supabase (cddu_contracts)
const { data: contract, error: contractError } = await sb let query = sb
.from('contrats') .from('cddu_contracts')
.select(` .select(`
id, id,
reference, reference,
contract_number,
employee_name, employee_name,
employee_email,
employee_matricule, employee_matricule,
production_name,
role, role,
date_debut, start_date,
analytique, end_date,
docuseal_template_id, docuseal_template_id,
docuseal_submission_id, docuseal_submission_id,
signature_link, signature_link,
embed_src_employeur, org_id
organizations!inner (
id,
name,
structure_api
)
`) `)
.eq('id', contractId) .eq('id', contractId);
.single();
if (orgId) {
query = query.eq('org_id', orgId);
}
const { data: contract, error: contractError } = await query.single();
if (contractError || !contract) { if (contractError || !contract) {
console.error('Erreur récupération contrat:', contractError); console.error('Erreur récupération contrat:', contractError);
return NextResponse.json({ error: 'Contrat non trouvé' }, { status: 404 }); return NextResponse.json({ error: 'Contrat non trouvé' }, { status: 404 });
} }
// Vérifier que c'est bien un contrat en attente de signature salarié // Vérifier que c'est bien un contrat en attente de signature salarié
if (!contract.employee_email) { // Email: récupéré depuis la table salaries (colonne adresse_mail) à partir du matricule
return NextResponse.json({ error: 'Email du salarié manquant' }, { status: 400 });
}
// Récupération du slug du salarié via DocuSeal API // Récupération du slug du salarié via DocuSeal API
let employeeSlug: string | null = null; let employeeSlug: string | null = null;
let docusealEmail: string | null = null;
if (contract.docuseal_submission_id) { if (contract.docuseal_submission_id) {
try { try {
const docusealResponse = await fetch(`${process.env.NEXT_PUBLIC_API_BASE || ''}/api/docuseal/submissions/${contract.docuseal_submission_id}`); 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 submitters = docusealData?.submitters || [];
const employeeSubmitter = submitters.find((s: any) => s.role === 'Salarié'); const employeeSubmitter = submitters.find((s: any) => s.role === 'Salarié');
employeeSlug = employeeSubmitter?.slug || null; 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) { } catch (error) {
console.error('Erreur récupération DocuSeal:', error); console.error('Erreur récupération DocuSeal:', error);
@ -98,9 +102,10 @@ export async function POST(req: NextRequest) {
} }
// Construction du lien de signature // Construction du lien de signature
let signatureLink = contract.signature_link; let signatureLink = contract.signature_link as string | null;
if (!signatureLink && employeeSlug) { 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) { if (!signatureLink) {
@ -108,31 +113,75 @@ export async function POST(req: NextRequest) {
} }
// Formatage des données pour l'email // Formatage des données pour l'email
const formattedDate = formatDate(contract.date_debut); const formattedDate = formatDate((contract as any).start_date);
const structure = (contract as any).organizations?.name || 'Employeur'; // 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é'; 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é) // Envoi de l'email via le template universel (variant salarié)
const emailData: EmailDataV2 = { const emailData: EmailDataV2 = {
firstName: prenom_salarie, firstName: prenom_salarie,
organizationName: structure, organizationName: employerName,
employerCode: contract.employee_matricule || '', matricule: contract.employee_matricule || (contract as any).matricule || '',
profession: contract.role || 'Contrat',
startDate: formattedDate,
productionName: (contract as any).production_name || '',
documentType: contract.role || 'Contrat', documentType: contract.role || 'Contrat',
contractReference: contract.reference || String(contract.id), contractReference: contract.reference || String(contract.id),
status: 'En attente',
ctaUrl: signatureLink, ctaUrl: signatureLink,
}; };
const messageId = await sendUniversalEmailV2({ const messageId = await sendUniversalEmailV2({
type: 'signature-request-employee', type: 'signature-request-employee',
toEmail: contract.employee_email, toEmail,
subject: `[Rappel] Signez votre contrat ${structure}`, subject: `[Rappel] Signez votre contrat ${employerName}`,
data: emailData, data: emailData,
}); });
console.log('Email de relance envoyé:', { console.log('Email de relance envoyé:', {
contractId, contractId,
email: contract.employee_email, email: toEmail,
messageId 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 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_demande = url.searchParams.get("etat_de_la_demande");
const etat_de_la_paie = url.searchParams.get("etat_de_la_paie"); 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 employee_matricule = url.searchParams.get("employee_matricule");
const start_from = url.searchParams.get("start_from"); const start_from = url.searchParams.get("start_from");
const start_to = url.searchParams.get("start_to"); 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)); const offset = Math.max(0, parseInt(url.searchParams.get("offset") || "0", 10));
// Build base query // 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) { if (q) {
// simple ilike search on a few columns // simple ilike search on a few columns
@ -36,19 +37,66 @@ export async function GET(req: Request) {
if (type_de_contrat) query = query.eq("type_de_contrat", type_de_contrat); 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_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 (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_from) query = query.gte("start_date", start_from);
if (start_to) query = query.lte("start_date", start_to); if (start_to) query = query.lte("start_date", start_to);
// allow sort by start_date or end_date or created_at or employee_name // 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 allowedSorts = new Set(["start_date", "end_date", "created_at", "contract_number", "employee_name"]);
const sortCol = allowedSorts.has(sort) ? sort : "created_at"; const sortCol = allowedSorts.has(sort) ? sort : "created_at";
query = query.order(sortCol, { ascending: order === "asc" });
query = query.range(offset, offset + limit - 1); // Pour le tri par nom, on doit traiter différemment
if (sortCol === "employee_name") {
const { data, error, count } = await query; // D'abord récupérer les données sans tri
if (error) return NextResponse.json({ error: error.message }, { status: 500 }); query = query.range(offset, offset + limit - 1);
return NextResponse.json({ rows: data ?? [], count: count ?? (data ? data.length : 0) }); 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);
const { data, error, count } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ rows: data ?? [], count: count ?? (data ? data.length : 0) });
}
} catch (err: any) { } catch (err: any) {
console.error(err); console.error(err);
return NextResponse.json({ error: "Internal" }, { status: 500 }); 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) { if (invoice.pdf_s3_key) {
try { try {
const { S3Client, GetObjectCommand, getSignedUrl } = await getS3Presigner(); 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 region = process.env.AWS_REGION || 'eu-west-3';
const expireSeconds = Math.max(60, Math.min(60 * 60, Number(process.env.INVOICE_URL_EXPIRES ?? 900))); 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({ 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, 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 buffer = Buffer.from(await file.arrayBuffer());
const uploadCommand = new PutObjectCommand({ const uploadCommand = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET || 'odentas-docs', Bucket: (process.env.AWS_S3_BUCKET || 'odentas-docs').trim(),
Key: s3Key, Key: s3Key,
Body: buffer, Body: buffer,
ContentType: 'application/pdf', ContentType: 'application/pdf',
@ -158,7 +158,7 @@ export async function DELETE(req: Request, { params }: { params: { id: string }
}); });
const deleteCommand = new DeleteObjectCommand({ 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, 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({ 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, Key: invoice.pdf_s3_key,
}); });

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