Initial commit
This commit is contained in:
parent
5fb2a4c513
commit
f27de28bb4
212 changed files with 18207 additions and 2882 deletions
41
.env.local.bak
Normal file
41
.env.local.bak
Normal 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=y953DQXAK7r9oVmuOcDWZt+bV4YJcAdO96DJkURI
|
||||||
|
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
41
.env.local.bak2
Normal 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=y953DQXAK7r9oVmuOcDWZt+bV4YJcAdO96DJkURI
|
||||||
|
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
53
.gitignore
vendored
|
|
@ -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
3
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"npm.scriptRunner": "npm"
|
||||||
|
}
|
||||||
73
BULK_EMAIL_PROGRESS_MODAL.md
Normal file
73
BULK_EMAIL_PROGRESS_MODAL.md
Normal 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
78
DEBUG_EMAIL_LOGS.md
Normal 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
113
DEPLOYMENT.md
Normal 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
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 d’habilitation
|
// Modification d’habilitation
|
||||||
|
|
|
||||||
114
FIX_EMAIL_SES_ERROR.md
Normal file
114
FIX_EMAIL_SES_ERROR.md
Normal 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
73
FIX_OTP_EMAIL_LINKS.md
Normal 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
114
GUIDE_ERREURS_EMAILS.md
Normal 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
62
GUIDE_MODE_DEMO.md
Normal 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.
|
||||||
57
MAINTENANCE_PUBLIC_PAGES.md
Normal file
57
MAINTENANCE_PUBLIC_PAGES.md
Normal 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.
|
||||||
46
NOTES_CREATION_AUTOMATIQUES.md
Normal file
46
NOTES_CREATION_AUTOMATIQUES.md
Normal 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
84
OPTIMISATION_DEBIT_SES.md
Normal 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é ! 🎉
|
||||||
103
STAFF_MAINTENANCE_ACCESS_GUIDE.md
Normal file
103
STAFF_MAINTENANCE_ACCESS_GUIDE.md
Normal 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
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">Multi‑mois</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">Multi‑mois</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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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° d’objet {data.numero_objet}</span> : null}
|
{data.numero_objet ? <span className="text-slate-500"> — n° d’objet {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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 été supprimé.</div>
|
<div className="text-sm text-slate-500">Le contrat demandé n'existe pas ou a été 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>
|
||||||
|
|
|
||||||
|
|
@ -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 />;
|
||||||
}
|
}
|
||||||
|
|
@ -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)…"
|
||||||
|
|
|
||||||
|
|
@ -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 s’effectuent entre le 15 et le 30 du mois suivant les salaires concernés.
|
Les télépaiements s’effectuent 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>
|
||||||
|
|
|
||||||
|
|
@ -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 />;
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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° d’objet</th>
|
<th className="px-3 py-2">N° d’objet</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">
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 ci‑dessous. Nous avons besoin a minima de son
|
Pour enregistrer votre nouveau salarié, indiquez les informations ci‑dessous. Nous avons besoin a minima de son
|
||||||
<strong> nom</strong>, son <strong>prénom</strong> et son <strong>adresse e‑mail</strong>.
|
<strong> nom</strong>, son <strong>prénom</strong> et son <strong>adresse e‑mail</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 e‑mail lui sera envoyé pour déposer ses justificatifs et, si nécessaire, compléter son état‑civil.
|
Un e‑mail lui sera envoyé pour déposer ses justificatifs et, si nécessaire, compléter son état‑civil.
|
||||||
</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 s’il diffère du nom d’usage (automatiquement en majuscules).</p>
|
<p className="text-[11px] text-slate-500 mt-1">Remplir uniquement s’il diffère du nom d’usage (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 l’adresse"
|
aria-label="Effacer l’adresse"
|
||||||
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 d’adresses…</div>
|
<div className="p-3 text-xs text-slate-500">Recherche d’adresses…</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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 l’organisation</button>
|
<button type="submit" className="px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm hover:bg-emerald-700">Créer l’organisation</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
438
app/(app)/staff/email-logs/EmailLogsClient.tsx
Normal file
438
app/(app)/staff/email-logs/EmailLogsClient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
app/(app)/staff/email-logs/page.tsx
Normal file
102
app/(app)/staff/email-logs/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
126
app/(app)/staff/emails-groupes/page.tsx
Normal file
126
app/(app)/staff/emails-groupes/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 d’aoû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 d’aoû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>
|
||||||
|
|
|
||||||
|
|
@ -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 l’IBAN"
|
aria-label="Copier l’IBAN"
|
||||||
>
|
>
|
||||||
{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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 d’onglets
|
|
||||||
// -----------------------------
|
|
||||||
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 d’upload 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 d’un document particulier ?</CardTitle>
|
variant={activeTab === 'generaux' ? 'default' : 'outline'}
|
||||||
<CardDescription>N’hésitez pas à nous contacter si vous avez besoin d’une 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 d’inté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
|
|
||||||
// =============================================================
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 c’est 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 qu’il 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 l’OTP
|
// 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` },
|
||||||
|
|
|
||||||
132
app/api/auto-declaration/generate-token/route.ts
Normal file
132
app/api/auto-declaration/generate-token/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
199
app/api/auto-declaration/route.ts
Normal file
199
app/api/auto-declaration/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
app/api/auto-declaration/upload/route.ts
Normal file
139
app/api/auto-declaration/upload/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
@ -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:", {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
127
app/api/contrats/generate-batch-pdf/route.ts
Normal file
127
app/api/contrats/generate-batch-pdf/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
32
app/api/debug-documents/route.ts
Normal file
32
app/api/debug-documents/route.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
75
app/api/debug-env/route.ts
Normal file
75
app/api/debug-env/route.ts
Normal 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
61
app/api/debug-s3/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
106
app/api/debug/pdf-diagnosis/route.ts
Normal file
106
app/api/debug/pdf-diagnosis/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/api/debug/url-detection/route.ts
Normal file
28
app/api/debug/url-detection/route.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
221
app/api/dl-contrat-signe/route.ts
Normal file
221
app/api/dl-contrat-signe/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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.');
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
348
app/api/staff/bulk-email-stream/route.ts
Normal file
348
app/api/staff/bulk-email-stream/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
191
app/api/staff/bulk-email/route.ts
Normal file
191
app/api/staff/bulk-email/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/api/staff/contracts/bulk-update-dpae/route.ts
Normal file
45
app/api/staff/contracts/bulk-update-dpae/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/api/staff/contracts/bulk-update-salary/route.ts
Normal file
57
app/api/staff/contracts/bulk-update-salary/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
76
app/api/staff/email-logs/[id]/content/route.ts
Normal file
76
app/api/staff/email-logs/[id]/content/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/api/staff/email-logs/route.ts
Normal file
80
app/api/staff/email-logs/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue