From 314a98c1ad9115d556769bbbf8d05ee4bc6aa265 Mon Sep 17 00:00:00 2001 From: Imer ouijdane Date: Mon, 17 Aug 2026 15:37:03 +0200 Subject: [PATCH] Ajouter completion des profils nouveaux arrivants et corrections diverses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Détection des profils incomplets (société/campus/service/date d'entrée/validateur) avec alerte Dashboard et section dédiée dans Équipe - Formulaire d'édition des collaborateurs (date d'entrée, rôle, validateur N+1, société, campus, service, type de contrat) avec combobox de recherche pour le validateur - Correction du mapping poste (colonne description au lieu de fonction) et du filtre des validateurs (variantes de rôle: Validateur/Validatrice, Directeur/Directrice, President) - Fix du proxy Vite vers le backend Docker Co-Authored-By: Claude Sonnet 5 --- Backend/.env | 10 +- Backend/package-lock.json | 3 +- Backend/package.json | 4 +- Backend/server.js | 8282 +++++++++++++++++-------- docker-compose.yml | 61 +- package-lock.json | 150 +- package.json | 2 + src/components/ValidateurCombobox.tsx | 79 + src/pages/CompteRenduRH.tsx | 1239 ++-- src/pages/Dashboard.tsx | 1086 ++-- src/pages/ExportPaie.tsx | 835 ++- src/pages/GestionCompteurs.tsx | 1150 +++- src/pages/GestionRecuperation.tsx | 1151 ++-- src/pages/Historique.tsx | 47 +- src/pages/LoginDev.tsx | 4 +- src/pages/ModificationDemande.tsx | 324 +- src/pages/SaisieManuelle.tsx | 1839 ++++-- src/pages/Teams.tsx | 776 ++- src/pages/ValidationDemandes.tsx | 1093 +++- tsconfig.json | 6 +- vite.config.js | 24 +- 21 files changed, 12321 insertions(+), 5844 deletions(-) create mode 100644 src/components/ValidateurCombobox.tsx diff --git a/Backend/.env b/Backend/.env index 8ac3f59..2966896 100644 --- a/Backend/.env +++ b/Backend/.env @@ -1,10 +1,4 @@ -# Configuration Base de données MySQL -DB_SERVER=192.168.0.4 -DB_DATABASE=DemandeConge -DB_PASSWORD=-2b/)ru5/Bi8P[7_ -DB_ENCRYPT=true -DB_TRUST_SERVER_CERTIFICATE=true -PORT=3000 + COLLABORATEURS_URL=http://localhost:3000 RH_URL=http://localhost:3001 @@ -13,7 +7,7 @@ WEBHOOK_SECRET=secret-rh-2025 # Configuration Serveur -PORT=3001 +PORT=3000 NODE_ENV=development # Configuration Azure AD (Microsoft Graph) diff --git a/Backend/package-lock.json b/Backend/package-lock.json index ce2bb20..3674a8d 100644 --- a/Backend/package-lock.json +++ b/Backend/package-lock.json @@ -103,7 +103,8 @@ "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", + "follow- + s": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } diff --git a/Backend/package.json b/Backend/package.json index e4cc13e..ec372a7 100644 --- a/Backend/package.json +++ b/Backend/package.json @@ -14,11 +14,11 @@ "dotenv": "^17.2.3", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", - "mysql2": "^3.15.1", + "mssql": "^11.0.1", "node-fetch": "^2.7.0", "pdfkit": "^0.17.2" }, "devDependencies": { "nodemon": "^3.1.10" } -} +} \ No newline at end of file diff --git a/Backend/server.js b/Backend/server.js index 1f9032c..c40bdd5 100644 --- a/Backend/server.js +++ b/Backend/server.js @@ -1,8 +1,8 @@ -console.log('🚀 1. Démarrage du script...'); + const express = require('express'); const cors = require('cors'); -const mysql = require('mysql2/promise'); +const sql = require('mssql'); // ⭐ CHANGEMENT: mssql au lieu de mysql2 const jwt = require('jsonwebtoken'); const PDFDocument = require('pdfkit'); @@ -32,7 +32,6 @@ process.on('exit', (code) => { console.log('✅ 4. Handlers d\'erreurs installés'); -// ⭐ IMPORTS WEBHOOKS // ⭐ IMPORTS WEBHOOKS try { console.log('🔄 5. Chargement WebhookManager...'); @@ -46,7 +45,7 @@ try { const app = express(); console.log('✅ 8. Express initialisé'); - const PORT = process.env.PORT || 3001; + const PORT = process.env.PORT || 3000; console.log(`✅ 9. Port configuré: ${PORT}`); // ⭐ INITIALISER LE WEBHOOK MANAGER @@ -59,33 +58,74 @@ try { app.use(express.json()); console.log('✅ 12. Middlewares installés'); - // Configuration MySQL + // ⭐ CONFIGURATION SQL SERVER const dbConfig = { - host: process.env.DB_SERVER || '192.168.0.4', - user: process.env.DB_USER || 'wpuser', - password: process.env.DB_PASSWORD, - database: process.env.DB_DATABASE || 'DemandeConge', - waitForConnections: true, - connectionLimit: 10 + server: '192.168.0.3', + user: 'gta_app', + password: 'GTA2025!Secure', + database: 'GTA', + port: 1433, + options: { + encrypt: true, + trustServerCertificate: true, + enableArithAbort: true, + connectTimeout: 60000, + requestTimeout: 60000 + }, + pool: { + max: 10, + min: 0, + idleTimeoutMillis: 30000 + } }; - console.log('🔄 13. Test connexion MySQL...'); - const pool = mysql.createPool(dbConfig); - - // TEST DE CONNEXION IMMÉDIAT - pool.getConnection() - .then(conn => { - console.log('✅ 14. Connexion MySQL réussie'); - conn.release(); - }) - .catch(err => { - console.error('❌ 14. ERREUR CONNEXION MYSQL:', err.message); - console.error(' Host:', dbConfig.host); + console.log('🔄 13. Test connexion SQL Server...'); + let pool; + // ⭐ FONCTION D'INITIALISATION DE LA BASE + async function initializeDatabase() { + try { + pool = await sql.connect(dbConfig); + console.log('✅ 14. Connexion SQL Server réussie'); + console.log(' Server:', dbConfig.server); + console.log(' User:', dbConfig.user); + console.log(' Database:', dbConfig.database); + } catch (err) { + console.error('❌ 14. ERREUR CONNEXION SQL SERVER:', err.message); + console.error(' Server:', dbConfig.server); console.error(' User:', dbConfig.user); console.error(' Database:', dbConfig.database); - }); + throw err; + } + } - console.log('✅ 15. Pool MySQL créé'); + // Initialiser la connexion + initializeDatabase().catch(err => { + console.error('❌ Impossible de démarrer le serveur:', err); + process.exit(1); + }); + + console.log('✅ 15. Pool SQL Server configuré'); + + // ================================================ + // SNAPSHOT QUOTIDIEN — Appelle la Stored Procedure + // ================================================ + async function executerSnapshotQuotidien() { + try { + console.log('[SNAPSHOT] Démarrage du snapshot quotidien...'); + + const result = await pool.request() + .query('EXEC [dbo].[SP_SnapshotQuotidienCompteurs]'); + + const resume = result.recordset?.[0]; + if (resume) { + console.log(`[SNAPSHOT] ✅ Terminé — Date: ${resume.DateSnapshot}, Insérés: ${resume.NbInseres}`); + } else { + console.log('[SNAPSHOT] ✅ Terminé'); + } + } catch (error) { + console.error('[SNAPSHOT] ❌ Erreur:', error.message); + } + } // Configuration Microsoft O365 const msalConfig = { @@ -139,7 +179,347 @@ try { return null; } } + function agregerLignesParEmploye(demandes) { + const employesMap = new Map(); + demandes.forEach(row => { + const key = row.collaborateurId; + + if (!employesMap.has(key)) { + employesMap.set(key, { + employe: row.employe, + email: row.email, + service: row.service || 'Non assigné', + rtt: { nb: 0, dates: [], periodes: [] }, + cp: { nb: 0, dates: [], periodes: [] }, + aap: { nb: 0, dates: [], periodes: [] }, + am: { nb: 0, dates: [], periodes: [] }, + jposf: { nb: 0, dates: [], periodes: [] }, + recup: { nb: 0, dates: [], periodes: [] }, + autres: [] + }); + } + + const employe = employesMap.get(key); + const dateDebutType = row.dateDebutType ? new Date(row.dateDebutType) : new Date(row.DateDebut); + const dateFinType = row.dateFinType ? new Date(row.dateFinType) : new Date(row.DateFin); + + // Pour les demi-journées sans date propre au type, + // la date de fin = date de début (une seule journée) + const periodeJourneeRaw = row.PeriodeJournee || ''; + const isDemiJournee = periodeJourneeRaw === 'Matin' || periodeJourneeRaw === 'Après-midi'; + const dateFinEffective = (isDemiJournee && !row.dateFinType) ? dateDebutType : dateFinType; + + const formatDate = (date) => + String(date.getDate()).padStart(2, '0') + '/' + + String(date.getMonth() + 1).padStart(2, '0'); + + const dateDebutStr = formatDate(dateDebutType); + const dateFinStr = formatDate(dateFinEffective); + const periode = dateDebutStr === dateFinStr + ? dateDebutStr + : `${dateDebutStr}-${dateFinStr}`; + + const nombreJours = parseFloat(row.NombreJours); + const typeConge = row.typeConge.toLowerCase(); + + let periodeJournee = row.PeriodeJournee || 'Journée'; + if (periodeJournee === 'Journée entière') periodeJournee = 'Journée'; + + if (typeConge === 'jposf') { + employe.jposf.nb += nombreJours; + employe.jposf.dates.push(periode); + employe.jposf.periodes.push(periodeJournee); + } else if (typeConge.includes('récup') || typeConge.includes('recup')) { + employe.aap.nb += nombreJours; + employe.aap.dates.push(periode); + employe.aap.periodes.push(periodeJournee); + } else if (typeConge.includes('rtt')) { + employe.rtt.nb += nombreJours; + employe.rtt.dates.push(periode); + employe.rtt.periodes.push(periodeJournee); + } else if (typeConge.includes('cp') || typeConge.includes('congé payé') || typeConge.includes('conge paye')) { + employe.cp.nb += nombreJours; + employe.cp.dates.push(periode); + employe.cp.periodes.push(periodeJournee); + } else if (typeConge.includes('aap') || typeConge.includes('absence autorisée') || typeConge.includes('enfant malade')) { + employe.aap.nb += nombreJours; + employe.aap.dates.push(periode); + employe.aap.periodes.push(periodeJournee); + } else if (typeConge.includes('maladie') || typeConge.includes('arrêt') || typeConge.includes('arret')) { + employe.am.nb += nombreJours; + employe.am.dates.push(periode); + employe.am.periodes.push(periodeJournee); + } else { + const existingAutre = employe.autres.find(a => a.type === row.typeConge); + if (existingAutre) { + existingAutre.nb += nombreJours; + existingAutre.dates.push(periode); + existingAutre.periodes.push(periodeJournee); + } else { + employe.autres.push({ + type: row.typeConge, + nb: nombreJours, + dates: [periode], + periodes: [periodeJournee] + }); + } + } + }); + + return Array.from(employesMap.values()).map(emp => ({ + employe: emp.employe, + email: emp.email, + service: emp.service, + rtt: { nb: emp.rtt.nb, dates: emp.rtt.dates.join(' ; '), periodes: emp.rtt.periodes.join(' ; ') }, + cp: { nb: emp.cp.nb, dates: emp.cp.dates.join(' ; '), periodes: emp.cp.periodes.join(' ; ') }, + aap: { nb: emp.aap.nb, dates: emp.aap.dates.join(' ; '), periodes: emp.aap.periodes.join(' ; ') }, + am: { nb: emp.am.nb, dates: emp.am.dates.join(' ; '), periodes: emp.am.periodes.join(' ; ') }, + jposf: { nb: emp.jposf.nb, dates: emp.jposf.dates.join(' ; '), periodes: emp.jposf.periodes.join(' ; ') }, + recup: { nb: emp.recup.nb, dates: emp.recup.dates.join(' ; '), periodes: emp.recup.periodes.join(' ; ') }, + autres: emp.autres.map(a => ({ + type: a.type, + nb: a.nb, + dates: a.dates.join(' ; '), + periodes: a.periodes.join(' ; ') + })) + })); + } + + function grouperParMoisConge(lignes) { + // 1. Séparer les lignes par mois de congé + const parMois = {}; + + lignes.forEach(row => { + const dateConge = new Date(row.dateDebutType || row.DateDebut); + const moisKey = `${dateConge.getFullYear()}-${String(dateConge.getMonth() + 1).padStart(2, '0')}`; + + if (!parMois[moisKey]) { + parMois[moisKey] = []; + } + parMois[moisKey].push(row); + }); + + // 2. Dans chaque mois, agréger par employé + const result = {}; + Object.keys(parMois).sort().forEach(mois => { + result[mois] = agregerLignesParEmploye(parMois[mois]); + }); + + return result; + } + function estTeletravail(typeCongeNom) { + if (!typeCongeNom) return false; + const nom = typeCongeNom.toLowerCase(); + return nom.includes('télétravail') || nom.includes('teletravail') || nom.includes('remote'); + } + + /** + * Détermine si un type de congé est un arrêt maladie + */ + function estArretMaladie(typeCongeNom) { + if (!typeCongeNom) return false; + const nom = typeCongeNom.toLowerCase(); + return nom.includes('maladie') || nom.includes('arrêt') || nom.includes('arret') || nom.includes('am'); + } + + /** + * Génère le sujet et le corps HTML de l'email selon le contexte + * @param {string} collaborateurNom - Prénom Nom du collaborateur + * @param {string} datesPeriode - Ex: "du 02/03 au 05/03" ou "02/03 (Matin)" + * @param {number} totalJours - Nombre de jours + * @param {string[]} typesCongeNoms - Tableau des noms de types de congé + * @param {string|null} commentaire - Commentaire optionnel + * @param {string} periodeJournee - "Journée entière", "Matin", "Après-midi" + * @returns {{ subject: string, body: string }} + */ + function genererEmailSaisieManuelle( + collaborateurNom, + datesPeriode, + totalJours, + typesCongeNoms, + commentaire, + periodeJournee + ) { + const tousLesTeletravail = typesCongeNoms.length > 0 && typesCongeNoms.every(t => estTeletravail(t)); + const tousArretMaladie = typesCongeNoms.length > 0 && typesCongeNoms.every(t => estArretMaladie(t)); + const contientTeletravail = typesCongeNoms.some(t => estTeletravail(t)); + const contientMaladie = typesCongeNoms.some(t => estArretMaladie(t)); + + // ─── CAS 1 : Télétravail pur ───────────────────────────────────────────── + if (tousLesTeletravail) { + return { + subject: '🏠 Télétravail enregistré', + body: ` +
+
+

🏠 Télétravail enregistré

+
+
+

Bonjour ${collaborateurNom},

+

Votre journée de télétravail a été enregistrée par le service RH.

+ +
+ + + + + + + + + + ${periodeJournee && periodeJournee !== 'Journée entière' ? ` + + + + ` : ''} + ${commentaire ? ` + + + + ` : ''} +
📅 Période :${datesPeriode}
⏱️ Durée :${totalJours} jour${totalJours > 1 ? 's' : ''}
🕐 Horaire :${periodeJournee}
💬 Commentaire :${commentaire}
+
+ + +

+ Pour toute question, contactez le service RH. +

+
+
` + }; + } + + // ─── CAS 2 : Arrêt maladie pur ────────────────────────────────────────── + if (tousArretMaladie) { + return { + subject: '🩺 Arrêt maladie enregistré', + body: ` +
+
+

🩺 Arrêt maladie enregistré

+
+
+

Bonjour ${collaborateurNom},

+

Votre arrêt maladie a bien été pris en compte dans notre système.

+ +
+ + + + + + + + + + ${commentaire ? ` + + + + ` : ''} +
📅 Période :${datesPeriode}
⏱️ Durée :${totalJours} jour${totalJours > 1 ? 's' : ''}
💬 Commentaire :${commentaire}
+
+ + + +

+ Pour toute question, contactez le service RH. +

+
+
` + }; + } + + // ─── CAS 3 : Mix contenant du télétravail (ex: télétravail + CP) ──────── + if (contientTeletravail) { + const autresTypes = typesCongeNoms.filter(t => !estTeletravail(t)); + return { + subject: '📋 Absence mixte enregistrée', + body: ` +
+
+

📋 Absence mixte enregistrée

+
+
+

Bonjour ${collaborateurNom},

+

Votre absence a été enregistrée et validée automatiquement par le service RH.

+ +
+ + + + + + + + + + + + + + ${commentaire ? ` + + + + ` : ''} +
📅 Période :${datesPeriode}
📁 Types :${typesCongeNoms.join(', ')}
⏱️ Durée :${totalJours} jour${totalJours > 1 ? 's' : ''}
💬 Commentaire :${commentaire}
+
+

+ Pour toute question, contactez le service RH. +

+
+
` + }; + } + + // ─── CAS 4 : Congé classique (CP, RTT, AAP, Récupération, etc.) ───────── + return { + subject: '✅ Congé enregistré et validé', + body: ` +
+
+

✅ Congé validé automatiquement

+
+
+

Bonjour ${collaborateurNom},

+

Une demande de congé a été enregistrée et validée automatiquement par le service RH.

+ +
+ + + + + + + + + + + + + + ${periodeJournee && periodeJournee !== 'Journée entière' ? ` + + + + ` : ''} + ${commentaire ? ` + + + + ` : ''} +
📅 Période :${datesPeriode}
📁 Type :${typesCongeNoms.join(', ')}
⏱️ Durée :${totalJours} jour${totalJours > 1 ? 's' : ''}
🕐 Horaire :${periodeJournee}
💬 Commentaire :${commentaire}
+
+

+ Pour toute question, contactez le service RH. +

+
+
` + }; + } async function sendMailGraph(accessToken, fromEmail, toEmail, subject, bodyHtml) { try { console.log('📤 Envoi email via Graph API...'); @@ -198,14 +578,9 @@ try { // GESTION DES SERVER-SENT EVENTS (SSE) // ================================================ - // ================================================ - // GESTION DES SERVER-SENT EVENTS (SSE) - // ================================================ - const sseClients = new Set(); // 🔌 ROUTE SSE POUR LE CALENDRIER - // ROUTE SSE POUR LE CALENDRIER RH app.get('/events', (req, res) => { const { token, userid } = req.query; @@ -272,7 +647,6 @@ try { }); }); - // 📢 FONCTION POUR NOTIFIER LES CLIENTS const notifyClients = (event, userId = null) => { console.log(`📢 Notification SSE: ${event.type}${userId ? ` pour ${userId}` : ''}`); @@ -320,30 +694,48 @@ try { console.log(` Type: ${data.type || data.typeConge}`); console.log(` Couleur: ${data.couleurHex}`); - notifyClients({ - type: 'demande-validated', - demandeId: data.demandeId, - statut: data.statut, - typeConge: data.typeConge || data.type, - type: data.type || data.typeConge, - couleurHex: data.couleurHex || '#d946ef', - date: data.date, - periode: data.periode, - collaborateurId: data.collaborateurId, - timestamp: new Date().toISOString() - }, data.collaborateurId); + try { + // Notification SSE locale (serveur RH) + notifyClients({ + type: 'demande-validated', + demandeId: demandeId, + statut: 'Validé', + typeConge: 'JPOSF', + couleurHex: '#d946ef', + collaborateurId: parseInt(collaborateurid), + timestamp: new Date().toISOString() + }, parseInt(collaborateurid)); - console.log(` ✅ SSE envoyé au collaborateur ${data.collaborateurId}`); - break; + // 🔧 WEBHOOK vers serveur COLLABORATEURS (pour calendrier) + await webhookManager.sendWebhook( + WEBHOOKS.COLLABORATEURS_URL, + EVENTS.DEMANDEVALIDATED, + { + demandeId: demandeId, + statut: 'Validé', + typeConge: 'JPOSF', + couleurHex: '#d946ef', + date: date, // ← ESSENTIEL + dateDebut: date, // ← ESSENTIEL + dateFin: date, // ← ESSENTIEL + periode: periodejournee, // ← ESSENTIEL + periodeJournee: periodejournee, + nombreJours: nombreJours, + collaborateurId: parseInt(collaborateurid) + } + ); + + console.log('✅ Webhook JPOSF envoyé au serveur collaborateurs'); + } catch (sseError) { + console.error('❌ Erreur SSE/Webhook:', sseError.message); + } - // 🆕 AJOUTER CE CAS POUR LES ANNULATIONS case EVENTS.DEMANDE_CANCELLED: console.log(`\n🔴 === WEBHOOK DEMANDE_CANCELLED REÇU ===`); console.log(` Demande: ${data.demandeId}`); console.log(` Annulée par: ${data.annulateurRole}`); console.log(` Commentaire: ${data.commentaire || 'Aucun'}`); - // Notifier les clients SSE notifyClients({ type: 'demande-cancelled', demandeId: data.demandeId, @@ -354,7 +746,6 @@ try { timestamp: new Date().toISOString() }); - // Notifier spécifiquement le collaborateur concerné notifyClients({ type: 'demande-updated', demandeId: data.demandeId, @@ -419,19 +810,19 @@ try { app.get('/users-dev', async (req, res) => { try { - // On récupère juste l'essentiel pour le sélecteur - const [users] = await pool.query(` + const result = await pool.request().query(` SELECT id, email, nom, prenom, role, service, Actif FROM CollaborateurAD WHERE Actif = 1 OR Actif IS NULL ORDER BY nom, prenom `); - res.json(users); + res.json(result.recordset); } catch (error) { console.error('❌ Erreur users-dev:', error); res.status(500).json({ error: error.message }); } }); + app.post('/login-dev', async (req, res) => { try { console.log('🔐 Login attempt started'); @@ -468,13 +859,16 @@ try { const userEmail = userInfo.mail || userInfo.userPrincipalName; - // ✅ MODIFICATION: Ajouter le filtre Actif - const [users] = await pool.query( - `SELECT * FROM CollaborateurAD - WHERE email = ? - AND (Actif = 1 OR Actif IS NULL)`, - [userEmail] - ); + // ⭐ REQUÊTE SQL SERVER + const result = await pool.request() + .input('email', sql.VarChar, userEmail) + .query(` + SELECT * FROM CollaborateurAD + WHERE email = @email + AND (Actif = 1 OR Actif IS NULL) + `); + + const users = result.recordset; if (users.length === 0) { console.warn('⚠️ User not found or inactive:', userEmail); @@ -512,7 +906,7 @@ try { } }); } catch (error) { - console.error('❌ Unexpected error in /api/auth/login:', error); + console.error('❌ Unexpected error in /login-dev:', error); res.status(500).json({ error: 'Erreur serveur inattendue', details: error.message @@ -524,87 +918,1550 @@ try { // ROUTES DES DEMANDES DE CONGÉS // ================================================ - app.get('/demandes', authenticateToken, async (req, res) => { + // ================================================ + // ROUTE POST /demandes - VERSION MODIFIÉE AVEC PRIORITÉ CP N-1 puis CP N + // Remplacer la route existante par celle-ci + // ================================================ + app.post('/demandes', authenticateToken, async (req, res) => { + const transaction = new sql.Transaction(pool); try { - const { statut, dateDebut, dateFin, service } = req.query; - let query = ` - SELECT dc.*, - CONCAT(ca.prenom, ' ', ca.nom) as nomEmploye, - ca.email as emailEmploye, - ca.Actif as employeActif, - s.Nom as service, - GROUP_CONCAT(DISTINCT tc.Nom SEPARATOR ', ') as typesConge, - CONCAT(v.prenom, ' ', v.nom) as nomValidateur - FROM DemandeConge dc - JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id - LEFT JOIN Services s ON ca.ServiceId = s.Id - LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId - LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id - LEFT JOIN CollaborateurAD v ON dc.ValidateurADId = v.id - WHERE 1=1 - `; - const params = []; + await transaction.begin(); - if (statut) { - query += ' AND dc.Statut = ?'; - params.push(statut); + const { collaborateurId, dateDebut, dateFin, typesConge, commentaire, saisieManuelle, periodeJournee } = req.body; + const currentYear = new Date().getFullYear(); + + console.log('📝 Création demande:', { collaborateurId, dateDebut, dateFin, saisieManuelle, periodeJournee }); + + // Vérifier que le collaborateur est actif + const collabResult = await transaction.request() + .input('collabId', sql.Int, collaborateurId) + .query('SELECT Actif, prenom, nom, email FROM CollaborateurAD WHERE id = @collabId'); + + const collab = collabResult.recordset; + + if (collab.length === 0 || collab[0].Actif === 0) { + await transaction.rollback(); + return res.status(403).json({ error: 'Impossible de créer une demande pour un compte désactivé' }); } - if (dateDebut) { - query += ' AND dc.DateDebut >= ?'; - params.push(dateDebut); + // ── Vérification anti-doublon avant création ────────────────── + const doublonCheck = await transaction.request() + .input('collabId', sql.Int, collaborateurId) + .input('dateDebut', sql.Date, dateDebut) + .input('dateFin', sql.Date, dateFin) + .query(` + SELECT Id FROM DemandeConge + WHERE CollaborateurADId = @collabId + AND Statut IN ('Validée', 'Validé', 'En attente') + AND DateDebut <= @dateFin AND DateFin >= @dateDebut + `); + if (doublonCheck.recordset.length > 0) { + await transaction.rollback(); + return res.status(409).json({ + error: `Une demande existe déjà pour ${collab[0].prenom} ${collab[0].nom} sur cette période.` + }); } - if (dateFin) { - query += ' AND dc.DateFin <= ?'; - params.push(dateFin); - } - if (service) { - query += ' AND s.Id = ?'; - params.push(service); + const totalJours = typesConge.reduce((sum, type) => sum + parseFloat(type.nombreJours), 0); + const statut = saisieManuelle ? 'Validée' : 'En attente'; + + console.log(`📝 Création demande - Saisie manuelle: ${saisieManuelle}, Statut: ${statut}`); + + // GÉNÉRATION MANUELLE DE L'ID POUR DEMANDECONGE + const maxIdResult = await transaction.request() + .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeConge'); + + const demandeId = maxIdResult.recordset[0].NextId; + console.log(`📝 Nouvel ID DemandeConge généré: ${demandeId}`); + + if (saisieManuelle) { + await transaction.request() + .input('id', sql.Int, demandeId) + .input('collaborateurId', sql.Int, collaborateurId) + .input('dateDebut', sql.Date, dateDebut) + .input('dateFin', sql.Date, dateFin) + .input('commentaire', sql.NVarChar, commentaire || 'Saisie manuelle RH') + .input('totalJours', sql.Decimal(10, 2), totalJours) + .input('validateurId', sql.Int, req.user.id) + .query(` + INSERT INTO DemandeConge + (Id, CollaborateurADId, DateDebut, DateFin, Statut, Commentaire, DateDemande, NombreJours, ValidateurADId, DateValidation) + VALUES (@id, @collaborateurId, @dateDebut, @dateFin, 'Validée', @commentaire, GETDATE(), @totalJours, @validateurId, GETDATE()) + `); + } else { + await transaction.request() + .input('id', sql.Int, demandeId) + .input('collaborateurId', sql.Int, collaborateurId) + .input('dateDebut', sql.Date, dateDebut) + .input('dateFin', sql.Date, dateFin) + .input('commentaire', sql.NVarChar, commentaire || '') + .input('totalJours', sql.Decimal(10, 2), totalJours) + .query(` + INSERT INTO DemandeConge + (Id, CollaborateurADId, DateDebut, DateFin, Statut, Commentaire, DateDemande, NombreJours) + VALUES (@id, @collaborateurId, @dateDebut, @dateFin, 'En attente', @commentaire, GETDATE(), @totalJours) + `); } - query += ' GROUP BY dc.Id ORDER BY dc.DateDemande DESC'; + console.log(`✅ DemandeConge créée - ID: ${demandeId}`); + + // GÉNÉRATION MANUELLE DE L'ID POUR DEMANDECONGETUPE avec PeriodeJournee + for (const type of typesConge) { + const maxTypeIdResult = await transaction.request() + .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeCongeType'); + + const typeId = maxTypeIdResult.recordset[0].NextId; + + await transaction.request() + .input('id', sql.Int, typeId) + .input('demandeId', sql.Int, demandeId) + .input('typeCongeId', sql.Int, type.typeId) + .input('nombreJours', sql.Decimal(10, 2), type.nombreJours) + .input('periode', sql.NVarChar(50), periodeJournee || 'Journée entière') + .query('INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee) VALUES (@id, @demandeId, @typeCongeId, @nombreJours, @periode)'); + + console.log(` ✅ DemandeCongeType créée - ID: ${typeId}, TypeCongeId: ${type.typeId}, Période: ${periodeJournee || 'Journée entière'}`); + } + + // ⭐ Si saisie manuelle : déduction avec DeductionDetails (même logique que serverGTA) + if (saisieManuelle) { + console.log('💼 Saisie manuelle - Déduction via DeductionDetails...'); + + // ⭐ NOUVEAU : Pré-charger les noms des types pour détecter le télétravail + const typeIds = typesConge.map(t => t.typeId); + const typesInfoResult = await transaction.request() + .query(`SELECT Id, Nom FROM TypeConge WHERE Id IN (${typeIds.join(',')})`); + const typeNomMap = {}; + typesInfoResult.recordset.forEach(t => { typeNomMap[t.Id] = t.Nom; }); + + // Déterminer l'exercice CP courant (Annee = année de FIN d'exercice) + // Ex : avant 01/06/currentYear → anneeN=currentYear ; après → anneeN=currentYear+1 + const dateBasculeCP = new Date(currentYear, 5, 1); // 01/06 + const apresBasculement = new Date() >= dateBasculeCP; + const anneeN = apresBasculement ? currentYear + 1 : currentYear; + const anneeN1 = anneeN - 1; + + // Exercice du congé (pour détecter l'anticipé) + const dateDebutObj = new Date(dateDebut); + const moisDebut = dateDebutObj.getMonth() + 1; + const anneeDebut = dateDebutObj.getFullYear(); + const anneeExConge = moisDebut >= 6 ? anneeDebut + 1 : anneeDebut; + const isAnticipe = anneeExConge > anneeN; + + // Exercice RTT = année civile du congé + const anneeRTT = anneeDebut; + const isAnticipeRTT = anneeRTT > currentYear; + + for (const type of typesConge) { + const typeId = type.typeId; + const nombreJours = parseFloat(type.nombreJours); + const typeNom = typeNomMap[typeId] || ''; + + // ⭐ NOUVEAU : Télétravail = jour travaillé, pas une absence → aucune déduction + if (estTeletravail(typeNom)) { + console.log(`🏠 Télétravail (${typeNom}) - ${nombreJours}j - Aucune déduction de compteur`); + continue; + } + + // ── CP (typeId === 1) ────────────────────────────────────────── + if (typeId === 1) { + console.log(`\n🔄 CP - ${nombreJours}j à déduire | isAnticipe=${isAnticipe} | anneeExConge=${anneeExConge} | anneeN=${anneeN}`); + let joursRestants = nombreJours; + + if (!isAnticipe) { + // ── MODE NORMAL : N-1 puis N ────────────────────────── + // 1️⃣ CP N-1 + const cptN1Res = await transaction.request() + .input('collabId', sql.Int, collaborateurId) + .input('typeId', sql.Int, typeId) + .input('annee', sql.Int, anneeN1) + .query(`SELECT TOP 1 Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`); + + if (cptN1Res.recordset.length > 0) { + const cptN1 = cptN1Res.recordset[0]; + const soldeN1 = parseFloat(cptN1.Solde || 0); + const aDeduireN1 = Math.min(soldeN1, joursRestants); + if (aDeduireN1 > 0) { + await transaction.request() + .input('ded', sql.Decimal(10, 2), aDeduireN1) + .input('id', sql.Int, cptN1.Id) + .query(`UPDATE CompteurConges SET Solde=Solde-@ded, SoldeReporte=CASE WHEN SoldeReporte-@ded<0 THEN 0 ELSE SoldeReporte-@ded END, DerniereMiseAJour=GETDATE() WHERE Id=@id`); + await transaction.request() + .input('demandeId', sql.Int, demandeId) + .input('typeId', sql.Int, typeId) + .input('annee', sql.Int, anneeN1) + .input('jours', sql.Decimal(10, 2), aDeduireN1) + .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'Année N-1', @jours)`); + console.log(` ✅ CP Année N-1 (${anneeN1}) : -${aDeduireN1}j`); + joursRestants -= aDeduireN1; + } + } + + // 2️⃣ CP N + if (joursRestants > 0) { + const cptNRes = await transaction.request() + .input('collabId', sql.Int, collaborateurId) + .input('typeId', sql.Int, typeId) + .input('annee', sql.Int, anneeN) + .query(`SELECT TOP 1 Id, Solde FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`); + if (cptNRes.recordset.length > 0) { + await transaction.request() + .input('ded', sql.Decimal(10, 2), joursRestants) + .input('id', sql.Int, cptNRes.recordset[0].Id) + .query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE Id=@id`); + await transaction.request() + .input('demandeId', sql.Int, demandeId) + .input('typeId', sql.Int, typeId) + .input('annee', sql.Int, anneeN) + .input('jours', sql.Decimal(10, 2), joursRestants) + .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'Année N', @jours)`); + console.log(` ✅ CP Année N (${anneeN}) : -${joursRestants}j`); + joursRestants = 0; + } else { + console.warn(` ⚠️ Compteur CP N (${anneeN}) introuvable`); + } + } + + } else { + // ── MODE ANTICIPÉ : tout tracé sur anneeN ───────────── + // Projeter solde CP N jusqu'au 31/05/anneeN + const TAUX_CP = 25 / 360; + const cptNRes = await transaction.request() + .input('collabId', sql.Int, collaborateurId) + .input('typeId', sql.Int, typeId) + .input('annee', sql.Int, anneeN) + .query(`SELECT TOP 1 Id, Solde FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`); + const soldeNActuel = cptNRes.recordset.length > 0 ? Math.max(0, parseFloat(cptNRes.recordset[0].Solde || 0)) : 0; + + const finExN = new Date(anneeN, 4, 31); + const joursRestantsExN = Math.max(0, Math.floor((finExN - new Date()) / 86400000)); + const acquisRestantsN = parseFloat(Math.min(25 - soldeNActuel, joursRestantsExN * TAUX_CP).toFixed(2)); + const soldeNProj = parseFloat(Math.min(25, soldeNActuel + acquisRestantsN).toFixed(2)); + + // Part sur solde réel actuel → TypeDeduction='Anticip' + const aDeduireAnticip = Math.min(soldeNActuel, joursRestants); + if (aDeduireAnticip > 0 && cptNRes.recordset.length > 0) { + await transaction.request() + .input('ded', sql.Decimal(10, 2), aDeduireAnticip) + .input('id', sql.Int, cptNRes.recordset[0].Id) + .query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE Id=@id`); + await transaction.request() + .input('demandeId', sql.Int, demandeId) + .input('typeId', sql.Int, typeId) + .input('annee', sql.Int, anneeN) + .input('jours', sql.Decimal(10, 2), aDeduireAnticip) + .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'Anticip', @jours)`); + console.log(` ✅ CP Anticip (${anneeN}) : -${aDeduireAnticip}j sur solde réel`); + joursRestants -= aDeduireAnticip; + } + + // Part sur acquisitions futures → TypeDeduction='AnticipFutur' + if (joursRestants > 0) { + const aDeduireAnticipFutur = Math.min(acquisRestantsN, joursRestants); + if (aDeduireAnticipFutur > 0) { + await transaction.request() + .input('demandeId', sql.Int, demandeId) + .input('typeId', sql.Int, typeId) + .input('annee', sql.Int, anneeN) + .input('jours', sql.Decimal(10, 2), aDeduireAnticipFutur) + .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'AnticipFutur', @jours)`); + console.log(` ✅ CP AnticipFutur (${anneeN}) : ${aDeduireAnticipFutur}j tracé (acquisitions futures)`); + joursRestants -= aDeduireAnticipFutur; + } + } + + // Part sur CP N+1 (après 01/06/anneeN) → TypeDeduction='AnticipFutur' sur anneeExConge + if (joursRestants > 0) { + await transaction.request() + .input('demandeId', sql.Int, demandeId) + .input('typeId', sql.Int, typeId) + .input('annee', sql.Int, anneeExConge) + .input('jours', sql.Decimal(10, 2), joursRestants) + .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'AnticipFutur', @jours)`); + console.log(` ✅ CP AnticipFutur N+1 (${anneeExConge}) : ${joursRestants}j tracé`); + joursRestants = 0; + } + } + + if (joursRestants > 0) { + console.warn(` ⚠️ CP : ${joursRestants}j non couverts (solde insuffisant, traçage forcé sur anneeN)`); + await transaction.request() + .input('demandeId', sql.Int, demandeId) + .input('typeId', sql.Int, typeId) + .input('annee', sql.Int, anneeN) + .input('jours', sql.Decimal(10, 2), joursRestants) + .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'AnticipFutur', @jours)`); + } + + // ── RTT (typeId === 2) ───────────────────────────────────────── + } else if (typeId === 2) { + console.log(`\n🔄 RTT - ${nombreJours}j à déduire | isAnticipeRTT=${isAnticipeRTT} | anneeRTT=${anneeRTT}`); + let joursRestants = nombreJours; + + const cptRTTRes = await transaction.request() + .input('collabId', sql.Int, collaborateurId) + .input('typeId', sql.Int, typeId) + .input('annee', sql.Int, anneeRTT) + .query(`SELECT TOP 1 Id, Solde FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`); + + if (!isAnticipeRTT) { + // ── MODE NORMAL RTT ────────────────────────────────────── + if (cptRTTRes.recordset.length > 0) { + await transaction.request() + .input('ded', sql.Decimal(10, 2), joursRestants) + .input('id', sql.Int, cptRTTRes.recordset[0].Id) + .query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE Id=@id`); + await transaction.request() + .input('demandeId', sql.Int, demandeId) + .input('typeId', sql.Int, typeId) + .input('annee', sql.Int, anneeRTT) + .input('jours', sql.Decimal(10, 2), joursRestants) + .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'Année N', @jours)`); + console.log(` ✅ RTT Année N (${anneeRTT}) : -${joursRestants}j`); + joursRestants = 0; + } else { + console.warn(` ⚠️ Compteur RTT (${anneeRTT}) introuvable`); + } + } else { + // ── MODE ANTICIPÉ RTT ──────────────────────────────────── + const soldeRTTActuel = cptRTTRes.recordset.length > 0 ? Math.max(0, parseFloat(cptRTTRes.recordset[0].Solde || 0)) : 0; + + // Part sur solde réel → AnticipRTT + const aDeduireAnticipRTT = Math.min(soldeRTTActuel, joursRestants); + if (aDeduireAnticipRTT > 0 && cptRTTRes.recordset.length > 0) { + await transaction.request() + .input('ded', sql.Decimal(10, 2), aDeduireAnticipRTT) + .input('id', sql.Int, cptRTTRes.recordset[0].Id) + .query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE Id=@id`); + await transaction.request() + .input('demandeId', sql.Int, demandeId) + .input('typeId', sql.Int, typeId) + .input('annee', sql.Int, anneeRTT) + .input('jours', sql.Decimal(10, 2), aDeduireAnticipRTT) + .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'AnticipRTT', @jours)`); + console.log(` ✅ RTT AnticipRTT (${anneeRTT}) : -${aDeduireAnticipRTT}j`); + joursRestants -= aDeduireAnticipRTT; + } + + // Part sur acquisitions futures → AnticipFuturRTT + if (joursRestants > 0) { + await transaction.request() + .input('demandeId', sql.Int, demandeId) + .input('typeId', sql.Int, typeId) + .input('annee', sql.Int, anneeRTT) + .input('jours', sql.Decimal(10, 2), joursRestants) + .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'AnticipFuturRTT', @jours)`); + console.log(` ✅ RTT AnticipFuturRTT (${anneeRTT}) : ${joursRestants}j tracé`); + joursRestants = 0; + } + } + + // ── Autres types (Récupération, JPOSF, etc.) ────────────────── + } else { + const anneeType = anneeDebut; // année civile du congé + const cptRes = await transaction.request() + .input('collabId', sql.Int, collaborateurId) + .input('typeId', sql.Int, typeId) + .input('annee', sql.Int, anneeType) + .query(`SELECT TOP 1 Id FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`); + + if (cptRes.recordset.length > 0) { + await transaction.request() + .input('ded', sql.Decimal(10, 2), nombreJours) + .input('id', sql.Int, cptRes.recordset[0].Id) + .query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE Id=@id`); + } else { + // Fallback : update sans filtre d'année (comportement original) + await transaction.request() + .input('ded', sql.Decimal(10, 2), nombreJours) + .input('collabId', sql.Int, collaborateurId) + .input('typeId', sql.Int, typeId) + .query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=YEAR(GETDATE())`); + } + await transaction.request() + .input('demandeId', sql.Int, demandeId) + .input('typeId', sql.Int, typeId) + .input('annee', sql.Int, anneeType) + .input('jours', sql.Decimal(10, 2), nombreJours) + .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'Année N', @jours)`); + console.log(` ✅ Type ${typeId} : -${nombreJours}j (Année N ${anneeType})`); + } + } + + // GÉNÉRATION MANUELLE DE L'ID POUR HISTORIQUEACTIONS + try { + const maxHistIdResult = await transaction.request() + .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM HistoriqueActions'); + + const histId = maxHistIdResult.recordset[0].NextId; + + await transaction.request() + .input('id', sql.Int, histId) + .input('userId', sql.Int, req.user.id) + .input('details', sql.NVarChar, `Demande créée et validée automatiquement par ${req.user.role}`) + .input('demandeId', sql.Int, demandeId) + .query(` + INSERT INTO HistoriqueActions (Id, CollaborateurADId, Action, Details, DemandeCongeId, DateAction) + VALUES (@id, @userId, 'Saisie manuelle validée', @details, @demandeId, GETDATE()) + `); + } catch (histError) { + console.log('⚠️ Tentative sans ID pour HistoriqueActions...'); + await transaction.request() + .input('userId', sql.Int, req.user.id) + .input('details', sql.NVarChar, `Demande créée et validée automatiquement par ${req.user.role}`) + .input('demandeId', sql.Int, demandeId) + .query(` + INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId, DateAction) + VALUES (@userId, 'Saisie manuelle validée', @details, @demandeId, GETDATE()) + `); + } + } + + await transaction.commit(); + + // Notifications SSE + if (saisieManuelle) { + notifyClients({ + type: 'demande-validated', + demandeId: demandeId, + statut: 'Validée', + collaborateurId: collaborateurId, + timestamp: new Date().toISOString() + }, collaborateurId); + + notifyClients({ + type: 'compteur-updated', + collaborateurId: collaborateurId, + action: 'saisie_manuelle_rh', + timestamp: new Date().toISOString() + }, collaborateurId); + + // Webhooks + try { + await webhookManager.sendWebhook( + WEBHOOKS.COLLABORATEURS_URL, + EVENTS.DEMANDE_VALIDATED, + { demandeId, statut: 'Validée', collaborateurId, validateurId: req.user.id } + ); + await webhookManager.sendWebhook( + WEBHOOKS.COLLABORATEURS_URL, + EVENTS.COMPTEUR_UPDATED, + { collaborateurId } + ); + } catch (webhookError) { + console.error('⚠️ Erreur webhook:', webhookError.message); + } + + // Email + try { + const accessToken = await getGraphToken(); + if (accessToken && collab[0].email) { + const collaborateurNom = `${collab[0].prenom} ${collab[0].nom}`; + const dateDebutFormatted = new Date(dateDebut).toLocaleDateString('fr-FR'); + const dateFinFormatted = new Date(dateFin).toLocaleDateString('fr-FR'); + const datesPeriode = dateDebutFormatted === dateFinFormatted + ? `${dateDebutFormatted}${periodeJournee && periodeJournee !== 'Journée entière' ? ` (${periodeJournee})` : ''}` + : `du ${dateDebutFormatted} au ${dateFinFormatted}`; + + // Récupérer les noms des types de congé + const typesCongeNoms = []; + for (const type of typesConge) { + const nomRes = await pool.request() + .input('typeId', sql.Int, type.typeId) + .query('SELECT Nom FROM TypeConge WHERE Id = @typeId'); + if (nomRes.recordset.length > 0) typesCongeNoms.push(nomRes.recordset[0].Nom); + } + + const { subject, body } = genererEmailSaisieManuelle( + collaborateurNom, + datesPeriode, + totalJours, + typesCongeNoms, + commentaire, + periodeJournee + ); + + await sendMailGraph(accessToken, 'gtanoreply@ensup.eu', collab[0].email, subject, body); + } + } catch (emailError) { + console.error('⚠️ Erreur email:', emailError.message); + } + } else { + notifyClients({ + type: 'demande-created', + demandeId: demandeId, + collaborateurId: collaborateurId, + timestamp: new Date().toISOString() + }); + } + + console.log(`✅ Demande ${demandeId} créée avec statut: ${statut}`); + + res.json({ + id: demandeId, + message: saisieManuelle ? 'Demande créée et validée automatiquement' : 'Demande créée avec succès', + statut: statut + }); - const [demandes] = await pool.query(query, params); - res.json(demandes); } catch (error) { + await transaction.rollback(); + console.error('❌ Erreur création demande:', error); + res.status(500).json({ error: error.message }); + } + }); + // ================================================ + // ROUTES COMPTE-RENDU D'ACTIVITÉS + // ================================================ + + // GET /api/compte-rendu/services - Liste des services avec forfaits jour + app.get('/compte-rendu/services', authenticateToken, async (req, res) => { + try { + if (!['Admin', 'RH'].includes(req.user.role)) { + return res.status(403).json({ error: 'Accès non autorisé' }); + } + + const result = await pool.request().query(` + SELECT + s.Id, + s.Nom, + COUNT(DISTINCT ca.id) as nombreForfaitJour + FROM Services s + LEFT JOIN CollaborateurAD ca ON s.Id = ca.ServiceId + AND ca.TypeContrat = 'forfait_jour' + AND (ca.Actif = 1 OR ca.Actif IS NULL) + GROUP BY s.Id, s.Nom + HAVING COUNT(DISTINCT ca.id) > 0 + ORDER BY s.Nom + `); + + res.json(result.recordset); + } catch (error) { + console.error('❌ Erreur /compte-rendu/services:', error); res.status(500).json({ error: error.message }); } }); + // GET /api/compte-rendu/stats - Stats globales des comptes-rendus + app.get('/compte-rendu/stats', authenticateToken, async (req, res) => { + try { + if (!['Admin', 'RH'].includes(req.user.role)) { + return res.status(403).json({ error: 'Accès non autorisé' }); + } + + const { annee, mois, service_id } = req.query; + + const request = pool.request() + .input('annee', sql.Int, parseInt(annee)) + .input('mois', sql.Int, parseInt(mois)); + + let serviceFilter = ''; + if (service_id && service_id !== 'all') { + serviceFilter = 'AND ca.ServiceId = @serviceId'; + request.input('serviceId', sql.Int, parseInt(service_id)); + } + + // Compter les forfaits jour actifs + const totalResult = await request.query(` + SELECT COUNT(DISTINCT ca.id) as total + FROM CollaborateurAD ca + WHERE ca.TypeContrat = 'forfait_jour' + AND (ca.Actif = 1 OR ca.Actif IS NULL) + ${serviceFilter} + `); + + const totalCollaborateurs = totalResult.recordset[0].total; + + // Compter les mensuels validés (verrouillés) + const validesRequest = pool.request() + .input('annee', sql.Int, parseInt(annee)) + .input('mois', sql.Int, parseInt(mois)); + + if (service_id && service_id !== 'all') { + validesRequest.input('serviceId', sql.Int, parseInt(service_id)); + } + + const validesResult = await validesRequest.query(` + SELECT COUNT(DISTINCT m.CollaborateurADId) as valides + FROM CompteRenduMensuel m + JOIN CollaborateurAD ca ON m.CollaborateurADId = ca.id + WHERE m.Annee = @annee + AND m.Mois = @mois + AND m.Verrouille = 1 + AND ca.TypeContrat = 'forfait_jour' + AND (ca.Actif = 1 OR ca.Actif IS NULL) + ${serviceFilter} + `); + + const mensuelsValides = validesResult.recordset[0].valides || 0; + const mensuelsEnAttente = totalCollaborateurs - mensuelsValides; + + res.json({ + totalCollaborateurs, + mensuelsValides, + mensuelsEnAttente + }); + } catch (error) { + console.error('❌ Erreur /compte-rendu/stats:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Route GET /api/compte-rendu-activites + app.get('/compte-rendu-activites', authenticateToken, async (req, res) => { + try { + const { userid, annee, mois } = req.query; + + if (!userid || !annee || !mois) { + return res.status(400).json({ + success: false, + message: 'Paramètres manquants : userid, annee et mois requis' + }); + } + + // Vérifier si l'utilisateur est RH/Admin + if (!['RH', 'Admin'].includes(req.user.role)) { + return res.status(403).json({ error: 'Accès réservé aux RH et Administrateurs' }); + } + + const collaborateurId = parseInt(userid); + const anneeInt = parseInt(annee); + const moisInt = parseInt(mois); + + // Récupérer les jours du mois + const joursResult = await pool.request() + .input('collaborateurId', sql.Int, collaborateurId) + .input('annee', sql.Int, anneeInt) + .input('mois', sql.Int, moisInt) + .query(` + SELECT + JourDate, JourTravaille, ReposQuotidienRespect, + ReposHebdomadaireRespect, CommentaireRepos, + DateSaisie, DateModification + FROM CompteRenduActivites + WHERE CollaborateurADId = @collaborateurId + AND Annee = @annee + AND Mois = @mois + ORDER BY JourDate + `); + // Récupérer les statistiques mensuelles + const mensuelResult = await pool.request() + .input('collaborateurId', sql.Int, collaborateurId) + .input('annee', sql.Int, anneeInt) + .input('mois', sql.Int, moisInt) + .query(` + SELECT + CAST(NbJoursTravailles AS DECIMAL(10,2)) as NbJoursTravailles, + NbJoursNonRespectsReposQuotidien, + NbJoursNonRespectsReposHebdo, + Verrouille + FROM CompteRenduMensuel + WHERE CollaborateurADId = @collaborateurId + AND Annee = @annee + AND Mois = @mois + `); + + const jours = joursResult.recordset; + const mensuel = mensuelResult.recordset.length > 0 ? mensuelResult.recordset[0] : null; + + res.json({ + success: true, + jours: jours, + mensuel: mensuel + }); + + } catch (error) { + console.error('Erreur /compte-rendu-activites:', error); + res.status(500).json({ + success: false, + message: 'Erreur serveur', + error: error.message + }); + } + }); + + + + + // GET /api/export-compte-rendu-pdf - Export PDF + // ================================================ + // ROUTE CORRIGÉE : GET /api/export-compte-rendu-pdf + // ================================================ + + // ================================================ + // ROUTE EXPORT PDF COMPTE-RENDU (DESIGN MODERNE) + // ================================================ + app.get('/export-compte-rendu-pdf', authenticateToken, async (req, res) => { + try { + if (!['Admin', 'RH'].includes(req.user.role)) { + return res.status(403).json({ error: 'Accès non autorisé' }); + } + + const { user_id, annee, mois } = req.query; + + if (!user_id || !annee || !mois) { + return res.status(400).json({ + success: false, + message: 'Paramètres manquants (user_id, annee, mois requis)' + }); + } + + console.log(`📄 Génération PDF moderne: User=${user_id}, ${mois}/${annee}`); + + // ✅ DÉFINIR LES HEADERS + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', + `attachment; filename="compte-rendu-${annee}-${String(mois).padStart(2, '0')}.pdf"`); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + + // ✅ REQUÊTES SQL SERVER + const collabResult = await pool.request() + .input('userId', sql.Int, parseInt(user_id)) + .query(` + SELECT + ca.prenom, + ca.nom, + ca.email, + COALESCE(s.Nom, 'Non défini') as service + FROM CollaborateurAD ca + LEFT JOIN Services s ON ca.ServiceId = s.Id + WHERE ca.id = @userId + `); + + if (collabResult.recordset.length === 0) { + return res.status(404).json({ + success: false, + message: 'Collaborateur non trouvé' + }); + } + + const collab = collabResult.recordset[0]; + + // Récupérer le mensuel + const mensuelResult = await pool.request() + .input('userId', sql.Int, parseInt(user_id)) + .input('annee', sql.Int, parseInt(annee)) + .input('mois', sql.Int, parseInt(mois)) + .query(` + SELECT + CAST(NbJoursTravailles AS DECIMAL(10,2)) as NbJoursTravailles, + NbJoursNonRespectsReposQuotidien, + NbJoursNonRespectsReposHebdo, + Verrouille, + DateValidation + FROM CompteRenduMensuel +WHERE CollaborateurADId = @userId + AND Annee = @annee + AND Mois = @mois + `); + + const mensuel = mensuelResult.recordset.length > 0 + ? mensuelResult.recordset[0] + : null; + + // Récupérer les jours + const joursResult = await pool.request() + .input('userId', sql.Int, parseInt(user_id)) + .input('annee', sql.Int, parseInt(annee)) + .input('mois', sql.Int, parseInt(mois)) + .query(` + SELECT + JourDate, + JourTravaille, + ReposQuotidienRespect, + ReposHebdomadaireRespect, + CommentaireRepos + FROM CompteRenduActivites +WHERE CollaborateurADId = @userId + AND Annee = @annee + AND Mois = @mois + ORDER BY JourDate + `); + const jours = joursResult.recordset || []; + + console.log(`✅ Données: ${jours.length} jours`); + + // ✅ CRÉER LE PDF + const doc = new PDFDocument({ + bufferPages: true, + margin: 60, + size: 'A4' + }); + + doc.pipe(res); + + const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', + 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; + + // ===== TITRE PRINCIPAL (CYAN) ===== + doc.fontSize(24) + .fillColor('#06b6d4') // Cyan + .text('Compte-rendu d\'activités', { align: 'center' }); + + doc.moveDown(0.5); + + // ===== SOUS-TITRE (GRIS) ===== + doc.fontSize(16) + .fillColor('#6b7280') + .text(`${monthNames[parseInt(mois) - 1]} ${annee}`, { align: 'center' }); + + doc.moveDown(2); + + // ===== SECTION COLLABORATEUR ===== + doc.fontSize(12) + .fillColor('#000000') + .text('Collaborateur', { underline: true }); + + doc.moveDown(0.3); + + doc.fontSize(10) + .fillColor('#374151') + .text(`Nom: ${collab.prenom} ${collab.nom}`) + .text(`Email: ${collab.email}`) + .text(`Service: ${collab.service}`); + + doc.moveDown(1.5); + + // ===== ENCADRÉ RÉSUMÉ (FOND CYAN CLAIR) ===== + doc.fontSize(12) + .fillColor('#000000') + .text('Résumé du mois', { underline: true }); + + doc.moveDown(0.5); + + const resumeY = doc.y; + const resumeHeight = 80; + + // Rectangle avec fond cyan très clair et bordure cyan + doc.rect(60, resumeY, 475, resumeHeight) + .fillAndStroke('#e0f2fe', '#06b6d4'); + + // Contenu de l'encadré (2 colonnes) + const col1X = 75; + const col2X = 320; + const textY = resumeY + 15; + + doc.fontSize(10) + .fillColor('#374151'); + + // Colonne 1 + doc.text(`Jours travaillés: ${mensuel?.NbJoursTravailles || 0}`, col1X, textY); + doc.text(`Non-respect repos quotidien: ${mensuel?.NbJoursNonRespectsReposQuotidien || 0}`, col1X, textY + 20); + doc.text(`Non-respect repos hebdomadaire: ${mensuel?.NbJoursNonRespectsReposHebdo || 0}`, col1X, textY + 40); + + // Colonne 2 + const statutText = mensuel?.Verrouille ? 'Verrouillé' : 'Ouvert'; + doc.text(`Statut: ${statutText}`, col2X, textY); + + if (mensuel?.DateValidation) { + const dateVal = new Date(mensuel.DateValidation); + const dateStr = dateVal.toLocaleDateString('fr-FR'); + doc.text(`Date validation: ${dateStr}`, col2X, textY + 20); + } + + doc.y = resumeY + resumeHeight + 20; + doc.moveDown(1); + + // ===== TABLEAU DÉTAIL DES JOURS ===== + doc.fontSize(12) + .fillColor('#000000') + .text('Détail des jours', { align: 'center' }); + + doc.moveDown(0.5); + + const tableTop = doc.y; + const colWidth = { + date: 120, + travaille: 80, + repos: 120, + commentaire: 155 + }; + + let currentX = 60; + const tableHeaderY = tableTop; + + // En-têtes du tableau + doc.fontSize(9) + .fillColor('#6b7280'); + + doc.text('Date', currentX, tableHeaderY, { width: colWidth.date, align: 'left' }); + currentX += colWidth.date; + + doc.text('Travaillé', currentX, tableHeaderY, { width: colWidth.travaille, align: 'center' }); + currentX += colWidth.travaille; + + doc.text('Respect des repos', currentX, tableHeaderY, { width: colWidth.repos, align: 'center' }); + currentX += colWidth.repos; + + doc.text('Commentaire', currentX, tableHeaderY, { width: colWidth.commentaire, align: 'left' }); + + // Ligne de séparation sous les en-têtes + doc.moveTo(60, tableHeaderY + 15) + .lineTo(535, tableHeaderY + 15) + .stroke('#d1d5db'); + + let currentY = tableHeaderY + 25; + const rowHeight = 20; + + // Lignes du tableau + jours.forEach((jour, index) => { + // Vérifier nouvelle page + if (currentY > 720) { + doc.addPage(); + currentY = 60; + } + + // Alterner le fond des lignes + if (index % 2 === 0) { + doc.rect(60, currentY - 3, 475, rowHeight) + .fill('#f9fafb'); + } + + currentX = 60; + + doc.fontSize(8) + .fillColor('#374151'); + + // Date + const dateStr = new Date(jour.JourDate).toLocaleDateString('fr-FR'); + doc.text(dateStr, currentX, currentY, { width: colWidth.date, align: 'left' }); + currentX += colWidth.date; + + // Travaillé (coche simple) + const travailleText = jour.JourTravaille ? '✓' : '-'; + doc.text(travailleText, currentX, currentY, { width: colWidth.travaille, align: 'center' }); + currentX += colWidth.travaille; + + // Respect des repos (coche verte ou rien) + const respectOk = jour.ReposQuotidienRespect && jour.ReposHebdomadaireRespect; + if (respectOk) { + // Dessiner une coche verte circulaire + const centerX = currentX + colWidth.repos / 2; + const centerY = currentY + 5; + + doc.circle(centerX, centerY, 6) + .fillAndStroke('#22c55e', '#16a34a'); + + doc.fontSize(8) + .fillColor('#ffffff') + .text('✓', centerX - 3, centerY - 4, { width: 6, align: 'center' }); + } else { + doc.fillColor('#374151') + .text('-', currentX, currentY, { width: colWidth.repos, align: 'center' }); + } + currentX += colWidth.repos; + + // Commentaire + const comment = jour.CommentaireRepos || '-'; + doc.fillColor('#374151') + .text(comment.substring(0, 25), currentX, currentY, { width: colWidth.commentaire, align: 'left' }); + + currentY += rowHeight; + }); + + // Ligne de séparation finale + doc.moveTo(60, currentY) + .lineTo(535, currentY) + .stroke('#d1d5db'); + + // ===== PIED DE PAGE ===== + doc.moveDown(2); + doc.fontSize(8) + .fillColor('#9ca3af') + .text( + `Document généré automatiquement le ${new Date().toLocaleDateString('fr-FR')}`, + 60, + 750, + { align: 'center', width: 475 } + ); + + doc.end(); + + console.log(`✅ PDF moderne généré pour ${collab.nom}`); + + } catch (error) { + console.error('❌ Erreur export PDF:', error); + + if (!res.headersSent) { + res.status(500).json({ + success: false, + message: error.message || 'Erreur lors de l\'export PDF' + }); + } + } + }); + + + // GET /api/compte-rendu/cumul-annuel - Cumul annuel d'un collaborateur + app.get('/compte-rendu/cumul-annuel', authenticateToken, async (req, res) => { + try { + if (!['Admin', 'RH'].includes(req.user.role)) { + return res.status(403).json({ error: 'Accès non autorisé' }); + } + + const { user_id, annee } = req.query; + + if (!user_id || !annee) { + return res.status(400).json({ + success: false, + message: 'Paramètres manquants (user_id, annee)' + }); + } + + console.log(`📊 Calcul cumul annuel: User=${user_id}, Année=${annee}`); + + // Récupérer les données annuelles + const cumulResult = await pool.request() + .input('userId', sql.Int, parseInt(user_id)) + .input('annee', sql.Int, parseInt(annee)) + .query(` + SELECT + SUM(CAST(NbJoursTravailles AS DECIMAL(10,2))) as totalJoursTravailles, + SUM(NbJoursNonRespectsReposQuotidien) as totalReposQuotidien, + SUM(NbJoursNonRespectsReposHebdo) as totalReposHebdo, + COUNT(CASE WHEN Verrouille = 1 THEN 1 END) as moisValides, + COUNT(*) as moisSaisis +FROM CompteRenduMensuel +WHERE CollaborateurADId = @userId + AND Annee = @annee + `); + + const cumul = cumulResult.recordset[0]; + + res.json({ + success: true, + cumul: { + totalJoursTravailles: cumul.totalJoursTravailles || 0, + totalReposQuotidien: cumul.totalReposQuotidien || 0, + totalReposHebdo: cumul.totalReposHebdo || 0, + moisValides: cumul.moisValides || 0, + moisSaisis: cumul.moisSaisis || 0 + } + }); + + } catch (error) { + console.error('❌ Erreur cumul annuel:', error); + res.status(500).json({ + success: false, + message: error.message + }); + } + }); + + + + // ================================================ + // ROUTE POST /compteurs/saisir-jposf - VERSION AVEC GÉNÉRATION MANUELLE D'ID + // ================================================ + app.post('/compteurs/saisir-jposf', authenticateToken, async (req, res) => { + const transaction = new sql.Transaction(pool); + + try { + console.log('\n📥 ========== DÉBUT SAISIE JPOSF =========='); + + await transaction.begin(); + + if (!['Admin', 'RH'].includes(req.user.role)) { + await transaction.rollback(); + return res.status(403).json({ success: false, error: 'Accès non autorisé' }); + } + + const { collaborateur_id, date, periode_journee, commentaire } = req.body; + + if (!collaborateur_id || !date || !periode_journee) { + await transaction.rollback(); + return res.status(400).json({ success: false, message: 'Données manquantes' }); + } + + console.log('📋 Données:', { collaborateur_id, date, periode_journee }); + + // Récupérer le collaborateur + const collabResult = await transaction.request() + .input('id', sql.Int, parseInt(collaborateur_id)) + .query('SELECT id, prenom, nom, email, Actif FROM CollaborateurAD WHERE id = @id'); + + if (collabResult.recordset.length === 0) { + await transaction.rollback(); + return res.status(404).json({ success: false, message: 'Collaborateur non trouvé' }); + } + + const collab = collabResult.recordset[0]; + + if (collab.Actif === false || collab.Actif === 0) { + await transaction.rollback(); + return res.status(403).json({ success: false, message: 'Compte désactivé' }); + } + + const currentYear = new Date().getFullYear(); + const nombreJours = periode_journee === 'Journée entière' ? 1 : 0.5; + + // Récupérer les types de congé + const jposfTypeResult = await transaction.request() + .query("SELECT Id FROM TypeConge WHERE Nom = 'JPOSF'"); + + if (jposfTypeResult.recordset.length === 0) { + await transaction.rollback(); + return res.status(500).json({ success: false, message: 'Type JPOSF non trouvé' }); + } + + const recupTypeResult = await transaction.request() + .query("SELECT Id FROM TypeConge WHERE Nom = 'Récupération'"); + + if (recupTypeResult.recordset.length === 0) { + await transaction.rollback(); + return res.status(500).json({ success: false, message: 'Type Récupération non trouvé' }); + } + + const jposfTypeId = jposfTypeResult.recordset[0].Id; + const recupTypeId = recupTypeResult.recordset[0].Id; + + console.log(`📋 Types: JPOSF=${jposfTypeId}, Récupération=${recupTypeId}`); + + // ⭐ ÉTAPE 1: Mettre à jour le compteur Récupération + const compteurResult = await transaction.request() + .input('collaborateurId', sql.Int, parseInt(collaborateur_id)) + .input('recupTypeId', sql.Int, recupTypeId) + .input('currentYear', sql.Int, currentYear) + .query(` + SELECT Id, Total, Solde FROM CompteurConges + WHERE CollaborateurADId = @collaborateurId + AND TypeCongeId = @recupTypeId + AND Annee = @currentYear + `); + + let nouveauTotalRecup, nouveauSoldeRecup; + + if (compteurResult.recordset.length > 0) { + // ✅ Compteur existe : UPDATE + const compteur = compteurResult.recordset[0]; + nouveauTotalRecup = parseFloat(compteur.Total || 0) + nombreJours; + nouveauSoldeRecup = parseFloat(compteur.Solde || 0) + nombreJours; + + await transaction.request() + .input('total', sql.Decimal(10, 2), nouveauTotalRecup) + .input('solde', sql.Decimal(10, 2), nouveauSoldeRecup) + .input('id', sql.Int, compteur.Id) + .query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id'); + + console.log(`✅ Compteur Récup mis à jour: ${nouveauSoldeRecup}j`); + } else { + // ⭐ CORRECTION : Générer l'ID manuellement pour CompteurConges + const maxCompteurIdResult = await transaction.request() + .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM CompteurConges'); + + const compteurId = maxCompteurIdResult.recordset[0].NextId; + console.log(`📝 Nouvel ID CompteurConges: ${compteurId}`); + + nouveauTotalRecup = nombreJours; + nouveauSoldeRecup = nombreJours; + + // ✅ INSERT avec ID explicite + await transaction.request() + .input('id', sql.Int, compteurId) + .input('collaborateurId', sql.Int, parseInt(collaborateur_id)) + .input('recupTypeId', sql.Int, recupTypeId) + .input('currentYear', sql.Int, currentYear) + .input('total', sql.Decimal(10, 2), nouveauTotalRecup) + .input('solde', sql.Decimal(10, 2), nouveauSoldeRecup) + .query(` + INSERT INTO CompteurConges + (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) + VALUES (@id, @collaborateurId, @recupTypeId, @currentYear, @total, @solde, 0, GETDATE()) + `); + + console.log(`✅ Compteur Récup créé: ${nouveauSoldeRecup}j`); + } + + // ⭐ ÉTAPE 2: Générer l'ID pour DemandeConge + const maxDemandeIdResult = await transaction.request() + .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeConge'); + + const demandeId = maxDemandeIdResult.recordset[0].NextId; + console.log(`📝 Nouvel ID DemandeConge: ${demandeId}`); + + const commentaireFinal = commentaire?.trim() || `JPOSF - ${periode_journee}`; + + // Créer la demande JPOSF + await transaction.request() + .input('id', sql.Int, demandeId) + .input('collaborateurId', sql.Int, parseInt(collaborateur_id)) + .input('dateDebut', sql.Date, date) + .input('dateFin', sql.Date, date) + .input('commentaire', sql.NVarChar(500), commentaireFinal) + .input('totalJours', sql.Decimal(10, 2), nombreJours) + .input('validateurId', sql.Int, req.user.id) + .query(` + INSERT INTO DemandeConge + (Id, CollaborateurADId, DateDebut, DateFin, Statut, Commentaire, DateDemande, NombreJours, ValidateurADId, DateValidation) + VALUES (@id, @collaborateurId, @dateDebut, @dateFin, 'Validée', @commentaire, GETDATE(), @totalJours, @validateurId, GETDATE()) + `); + + console.log(`✅ DemandeConge créée`); + + // ⭐ ÉTAPE 3: Générer l'ID pour DemandeCongeType + const maxTypeIdResult = await transaction.request() + .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeCongeType'); + + const demandeCongeTypeId = maxTypeIdResult.recordset[0].NextId; + console.log(`📝 Nouvel ID DemandeCongeType: ${demandeCongeTypeId}`); + + // Lier avec le type JPOSF + await transaction.request() + .input('id', sql.Int, demandeCongeTypeId) + .input('demandeId', sql.Int, demandeId) + .input('typeId', sql.Int, jposfTypeId) + .input('nombreJours', sql.Decimal(10, 2), nombreJours) + .query('INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours) VALUES (@id, @demandeId, @typeId, @nombreJours)'); + + console.log(`✅ DemandeCongeType créée`); + + // Historique (avec gestion d'erreur si la table a IDENTITY ou non) + try { + const maxHistIdResult = await transaction.request() + .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM HistoriqueActions'); + + const histId = maxHistIdResult.recordset[0].NextId; + + await transaction.request() + .input('id', sql.Int, histId) + .input('userId', sql.Int, req.user.id) + .input('details', sql.NVarChar(500), `Saisie JPOSF - ${date} (${periode_journee}) : +${nombreJours}j pour ${collab.prenom} ${collab.nom}`) + .input('demandeId', sql.Int, demandeId) + .query(` + INSERT INTO HistoriqueActions (Id, CollaborateurADId, Action, Details, DemandeCongeId, DateAction) + VALUES (@id, @userId, 'Saisie JPOSF', @details, @demandeId, GETDATE()) + `); + } catch (histError) { + // Fallback sans ID si la table a IDENTITY + console.log('⚠️ Fallback historique sans ID...'); + await transaction.request() + .input('userId', sql.Int, req.user.id) + .input('details', sql.NVarChar(500), `Saisie JPOSF - ${date} (${periode_journee}) : +${nombreJours}j pour ${collab.prenom} ${collab.nom}`) + .input('demandeId', sql.Int, demandeId) + .query(` + INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId, DateAction) + VALUES (@userId, 'Saisie JPOSF', @details, @demandeId, GETDATE()) + `); + } + + console.log(`✅ Historique créé`); + + await transaction.commit(); + console.log('✅ Transaction COMMIT'); + + // Notifications SSE + // ========== Notifications SSE ========== + try { + // ⭐ CORRECTION: Envoyer les détails complets de la demande pour affichage calendrier + notifyClients({ + type: 'demande-validated', + demandeId: demandeId, + statut: 'Validée', + typeConge: 'JPOSF', + couleurHex: '#d946ef', + date: date, + dateDebut: date, + dateFin: date, + periode: periode_journee, + periodeJournee: periode_journee, + nombreJours: nombreJours, + collaborateurId: parseInt(collaborateur_id), + timestamp: new Date().toISOString() + }, parseInt(collaborateur_id)); + + notifyClients({ + type: 'compteur-updated', + collaborateurId: parseInt(collaborateur_id), + typeConge: 'Récupération', + action: 'saisie_jposf_rh', + nouveauSolde: nouveauSoldeRecup, + timestamp: new Date().toISOString() + }, parseInt(collaborateur_id)); + + console.log('✅ Notifications SSE envoyées avec détails complets pour calendrier'); + } catch (sseError) { + console.error('⚠️ Erreur SSE (non bloquant):', sseError.message); + } + // Webhooks + try { + if (typeof webhookManager !== 'undefined' && WEBHOOKS && EVENTS) { + await webhookManager.sendWebhook(WEBHOOKS.COLLABORATEURS_URL, EVENTS.DEMANDE_VALIDATED, { + demandeId, statut: 'Validée', typeConge: 'JPOSF', collaborateurId: parseInt(collaborateur_id) + }); + await webhookManager.sendWebhook(WEBHOOKS.COLLABORATEURS_URL, EVENTS.COMPTEUR_UPDATED, { + collaborateurId: parseInt(collaborateur_id), typeConge: 'Récupération' + }); + } + } catch (webhookError) { + console.error('⚠️ Erreur webhook:', webhookError.message); + } + + // Email + try { + const accessToken = await getGraphToken(); + if (accessToken && collab.email) { + const subject = '📅 JPOSF enregistré'; + const body = ` +
+
+

📅 JPOSF enregistré

+
+
+

Bonjour ${collab.prenom} ${collab.nom},

+
+

Date : ${new Date(date).toLocaleDateString('fr-FR')}

+

Période : ${periode_journee}

+

Jours ajoutés : ${nombreJours}j

+
+

+${nombreJours}j ajoutés au compteur Récupération

+
+
`; + await sendMailGraph(accessToken, 'gtanoreply@ensup.eu', collab.email, subject, body); + } + } catch (emailError) { + console.error('⚠️ Erreur email:', emailError.message); + } + + console.log('✅ ========== JPOSF ENREGISTRÉ ==========\n'); + + return res.json({ + success: true, + message: 'JPOSF enregistré avec succès', + collaborateur: `${collab.prenom} ${collab.nom}`, + date: date, + periode: periode_journee, + jours_ajoutes: nombreJours, + nouveau_solde_recup: nouveauSoldeRecup, + demandeId: demandeId + }); + + } catch (error) { + console.error('❌ ERREUR JPOSF:', error.message); + console.error('Stack:', error.stack); + try { await transaction.rollback(); } catch (e) { } + + return res.status(500).json({ + success: false, + message: 'Erreur serveur', + error: error.message + }); + } + }); + + // ⭐ ROUTE GET /api/jposf - Ajoutez cette route dans server.js + app.get('/jposf', authenticateToken, async (req, res) => { + try { + const currentYear = new Date().getFullYear(); + + console.log('📊 GET /api/jposf - Année:', currentYear); + + const result = await pool.request() + .input('currentYear', sql.Int, currentYear) + .query(` + SELECT + MIN(dc.Id) AS Id, + dc.CollaborateurADId, + CONCAT(ca.prenom, ' ', ca.nom) AS employe, + ca.email, + COALESCE(s.Nom, 'Sans service') AS service, + COUNT(DISTINCT dc.Id) AS totalEvenements, + SUM(dct.NombreJours) AS solde + FROM DemandeConge dc + JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id + LEFT JOIN Services s ON ca.ServiceId = s.Id + JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId + JOIN TypeConge tc ON dct.TypeCongeId = tc.Id + WHERE tc.Nom = 'JPOSF' + AND dc.Statut = 'Validée' + AND YEAR(dc.DateDebut) = @currentYear + AND (ca.Actif = 1 OR ca.Actif IS NULL) + GROUP BY + dc.CollaborateurADId, + ca.prenom, + ca.nom, + ca.email, + s.Nom + ORDER BY ca.nom, ca.prenom + `); + + console.log('📊 JPOSF trouvés:', result.recordset.length); + res.json(result.recordset); + + } catch (error) { + console.error('❌ Erreur GET /api/jposf:', error); + res.status(500).json({ error: error.message }); + } + }); + + + + // ROUTE GET /demandes avec pagination et filtres + + // Dans server.js, remplacer la route GET /demandes + app.get('/demandes', authenticateToken, async (req, res) => { + try { + const { limit, statut, serviceid, campusid } = req.query; + const request = pool.request(); + + // Filtres WHERE + let whereClause = "WHERE (ca.Actif = 1 OR ca.Actif IS NULL)"; + + if (statut && statut !== 'all') { + whereClause += " AND dc.Statut = @statut"; + request.input('statut', sql.VarChar, statut); + } + + if (serviceid && serviceid !== 'all') { + whereClause += " AND ca.ServiceId = @serviceid"; + request.input('serviceid', sql.Int, parseInt(serviceid)); + } + + if (campusid && campusid !== 'all') { + whereClause += " AND ca.CampusId = @campusid"; + request.input('campusid', sql.Int, parseInt(campusid)); + } + + let topClause = ''; + if (limit) { + topClause = `TOP ${parseInt(limit)}`; + } + + // Requête principale + const query = ` + SELECT ${topClause} + dc.Id, + dc.CollaborateurADId, + dc.DateDebut, + dc.DateFin, + dc.Statut, + dc.Commentaire, + dc.DateDemande, + dc.NombreJours, + dc.ValidateurADId, + dc.DateValidation, + dc.CommentaireValidation, + CONCAT(ca.prenom, ' ', ca.nom) AS nomEmploye, + ca.email AS emailEmploye, + s.Nom AS service, + c.Nom AS campus, + -- Agréger les types de congé (pour affichage simple) + STUFF(( + SELECT DISTINCT ', ' + tc2.Nom + FROM DemandeCongeType dct2 + JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id + WHERE dct2.DemandeCongeId = dc.Id + ORDER BY ', ' + tc2.Nom + FOR XML PATH('') + ), 1, 2, '') AS typesConge + FROM DemandeConge dc + JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id + LEFT JOIN Services s ON ca.ServiceId = s.Id + LEFT JOIN Campus c ON ca.CampusId = c.Id + ${whereClause} + ORDER BY dc.DateDemande DESC + `; + + const result = await request.query(query); + const demandes = result.recordset; + + // Récupérer les détails de tous les types de congé + const detailsQuery = ` + SELECT + dct.DemandeCongeId, + tc.Nom AS typeConge, + dct.NombreJours AS nombreJours, + dct.PeriodeJournee, + dc.DateDebut AS dateDebut, + dc.DateFin AS dateFin + FROM DemandeCongeType dct + JOIN TypeConge tc ON dct.TypeCongeId = tc.Id + JOIN DemandeConge dc ON dct.DemandeCongeId = dc.Id + WHERE dct.DemandeCongeId IN (${demandes.map(d => d.Id).join(',') || '0'}) + ORDER BY dct.DemandeCongeId, tc.Nom + `; + + const detailsResult = await pool.request().query(detailsQuery); + + // Grouper les détails par demande + const detailsParDemande = {}; + detailsResult.recordset.forEach(detail => { + if (!detailsParDemande[detail.DemandeCongeId]) { + detailsParDemande[detail.DemandeCongeId] = []; + } + detailsParDemande[detail.DemandeCongeId].push({ + typeConge: detail.typeConge, + nombreJours: parseFloat(detail.nombreJours), + dateDebut: detail.dateDebut, + dateFin: detail.dateFin + }); + }); + + // Enrichir chaque demande avec ses détails + const demandesEnrichies = demandes.map(demande => ({ + Id: demande.Id, + nomEmploye: demande.nomEmploye, + emailEmploye: demande.emailEmploye, + service: demande.service, + Campus: demande.campus, + typesConge: demande.typesConge || 'Non spécifié', + detailsConges: detailsParDemande[demande.Id] || [], // ✅ Toujours présent + DateDebut: demande.DateDebut, + DateFin: demande.DateFin, + NombreJours: demande.NombreJours, + Statut: demande.Statut, + Commentaire: demande.Commentaire, + DateDemande: demande.DateDemande + })); + + console.log(`${demandesEnrichies.length} demandes avec détails`); + res.json(demandesEnrichies); + + } catch (error) { + console.error('Erreur GET /demandes:', error); + res.status(500).json({ error: error.message }); + } + }); + + app.get('/demandes/:id', authenticateToken, async (req, res) => { try { - const [demandes] = await pool.query(` - SELECT - dc.*, - CONCAT(ca.prenom, ' ', ca.nom) AS nomEmploye, - ca.email AS emailEmploye, - ca.Actif AS employeActif, - ca.DateSortie AS employeDateSortie, - s.Nom AS service, - s.Id AS serviceId, - CONCAT(v.prenom, ' ', v.nom) AS nomValidateur, - v.email AS emailValidateur - FROM DemandeConge dc - JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id - LEFT JOIN Services s ON ca.ServiceId = s.Id - LEFT JOIN CollaborateurAD v ON dc.ValidateurADId = v.id - WHERE dc.Id = ? - `, [req.params.id]); + const result = await pool.request() + .input('id', sql.Int, req.params.id) + .query(` + SELECT + dc.*, + CONCAT(ca.prenom, ' ', ca.nom) AS nomEmploye, + ca.email AS emailEmploye, + ca.Actif AS employeActif, + ca.DateSortie AS employeDateSortie, + s.Nom AS service, + s.Id AS serviceId, + CONCAT(v.prenom, ' ', v.nom) AS nomValidateur, + v.email AS emailValidateur + FROM DemandeConge dc + JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id + LEFT JOIN Services s ON ca.ServiceId = s.Id + LEFT JOIN CollaborateurAD v ON dc.ValidateurADId = v.id + WHERE dc.Id = @id + `); + + const demandes = result.recordset; if (demandes.length === 0) { return res.status(404).json({ error: 'Demande non trouvée' }); } - const [types] = await pool.query(` - SELECT - dct.TypeCongeId as typeId, - dct.NombreJours as nombreJours, - tc.Nom AS nom, - tc.CouleurHex AS couleur - FROM DemandeCongeType dct - JOIN TypeConge tc ON dct.TypeCongeId = tc.Id - WHERE dct.DemandeCongeId = ? - `, [req.params.id]); + const typesResult = await pool.request() + .input('id', sql.Int, req.params.id) + .query(` + SELECT + dct.TypeCongeId as typeId, + dct.NombreJours as nombreJours, + tc.Nom AS nom, + tc.CouleurHex AS couleur + FROM DemandeCongeType dct + JOIN TypeConge tc ON dct.TypeCongeId = tc.Id + WHERE dct.DemandeCongeId = @id + `); + + const types = typesResult.recordset; res.json({ ...demandes[0], @@ -612,80 +2469,109 @@ try { }); } catch (error) { console.error('❌ Erreur détails demande:', error); - console.error(' Code:', error.code); - console.error(' SQL:', error.sqlMessage); - res.status(500).json({ error: error.message, details: error.sqlMessage }); - } - }); - - app.post('/demandes', authenticateToken, async (req, res) => { - const conn = await pool.getConnection(); - try { - await conn.beginTransaction(); - - const { collaborateurId, dateDebut, dateFin, typesConge, commentaire } = req.body; - - // ✅ Vérifier que le collaborateur est actif - const [collab] = await conn.query( - 'SELECT Actif FROM CollaborateurAD WHERE id = ?', - [collaborateurId] - ); - - if (collab.length === 0 || collab[0].Actif === 0) { - await conn.rollback(); - return res.status(403).json({ error: 'Impossible de créer une demande pour un compte désactivé' }); - } - - const totalJours = typesConge.reduce((sum, type) => sum + parseFloat(type.nombreJours), 0); - - const [result] = await conn.query( - `INSERT INTO DemandeConge (CollaborateurADId, DateDebut, DateFin, Statut, Commentaire, DateDemande, NombreJours) - VALUES (?, ?, ?, 'En attente', ?, NOW(), ?)`, - [collaborateurId, dateDebut, dateFin, commentaire || '', totalJours] - ); - - const demandeId = result.insertId; - - for (const type of typesConge) { - await conn.query( - 'INSERT INTO DemandeCongeType (DemandeCongeId, TypeCongeId, NombreJours) VALUES (?, ?, ?)', - [demandeId, type.typeId, type.nombreJours] - ); - } - - await conn.commit(); - - notifyClients({ - type: 'demande-created', - demandeId: demandeId, - collaborateurId: collaborateurId, - timestamp: new Date().toISOString() - }); - - res.json({ id: demandeId, message: 'Demande créée avec succès' }); - } catch (error) { - await conn.rollback(); res.status(500).json({ error: error.message }); - } finally { - conn.release(); } }); + // ================================================ +// ROUTE GET /demandes/check-doublon +// Vérifie si un ou plusieurs collaborateurs ont déjà une demande +// (Validée ou En attente) qui chevauche la période demandée. +// Usage: GET /demandes/check-doublon?collaborateurIds=12,45&dateDebut=2026-08-01&dateFin=2026-08-05 +// ================================================ +app.get('/demandes/check-doublon', authenticateToken, async (req, res) => { + try { + const { collaborateurIds, dateDebut, dateFin } = req.query; + + if (!collaborateurIds || !dateDebut || !dateFin) { + return res.json({ success: false, message: 'Paramètres requis: collaborateurIds, dateDebut, dateFin' }); + } + + const ids = collaborateurIds + .split(',') + .map(id => parseInt(id.trim(), 10)) + .filter(id => !isNaN(id)); + + if (ids.length === 0) { + return res.json({ success: false, message: 'Aucun ID collaborateur valide' }); + } + + const doublonsParCollab = {}; + + for (const collabId of ids) { + const result = await pool.request() + .input('collabId', sql.Int, collabId) + .input('dateDebut', sql.Date, dateDebut) + .input('dateFin', sql.Date, dateFin) + .query(` + SELECT + dc.Id, + CONVERT(VARCHAR(10), dc.DateDebut, 23) AS dateDebut, + CONVERT(VARCHAR(10), dc.DateFin, 23) AS dateFin, + dc.Statut, + dc.NombreJours, + STUFF(( + SELECT DISTINCT ', ' + tc2.Nom + FROM DemandeCongeType dct2 + JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id + WHERE dct2.DemandeCongeId = dc.Id + FOR XML PATH('') + ), 1, 2, '') AS types + FROM DemandeConge dc + WHERE dc.CollaborateurADId = @collabId + AND dc.Statut IN ('Validée', 'Validé', 'En attente') + AND dc.DateDebut <= @dateFin + AND dc.DateFin >= @dateDebut + ORDER BY dc.DateDebut + `); + + if (result.recordset.length > 0) { + doublonsParCollab[collabId] = result.recordset.map(d => ({ + id: d.Id, + dateDebut: d.dateDebut, + dateFin: d.dateFin, + statut: d.Statut, + nombreJours: d.NombreJours, + types: d.types + })); + } + } + + res.json({ + success: true, + hasDoublon: Object.keys(doublonsParCollab).length > 0, + doublons: doublonsParCollab + }); + + } catch (error) { + console.error('❌ Erreur check-doublon:', error); + res.status(500).json({ success: false, message: error.message }); + } +}); + + + // ================================================ + // ROUTE PUT /demandes/:id - AVEC VALIDATION AUTOMATIQUE + // Remplacer la route existante dans server.js (ligne ~1438) + // ================================================ app.put('/demandes/:id', authenticateToken, async (req, res) => { - const conn = await pool.getConnection(); + const transaction = new sql.Transaction(pool); try { - await conn.beginTransaction(); + await transaction.begin(); const { id } = req.params; const { dateDebut, dateFin, typesConge, commentaire } = req.body; - const [demande] = await conn.query( - 'SELECT Statut, CollaborateurADId FROM DemandeConge WHERE Id = ?', - [id] - ); + console.log('📝 Modification demande:', { id, dateDebut, dateFin, typesConge }); + + const demandeResult = await transaction.request() + .input('id', sql.Int, id) + .query('SELECT Statut, CollaborateurADId FROM DemandeConge WHERE Id = @id'); + + const demande = demandeResult.recordset; if (demande.length === 0) { - await conn.rollback(); + await transaction.rollback(); return res.status(404).json({ error: 'Demande non trouvée' }); } @@ -697,73 +2583,323 @@ try { const estRoleAutorise = rolesAutorises.includes(req.user.role); if (!estAuteur && !estRoleAutorise) { - await conn.rollback(); + await transaction.rollback(); return res.status(403).json({ error: 'Accès non autorisé', details: 'Seuls les Admin, RH, Validateurs ou l\'auteur peuvent modifier cette demande' }); } + // ⭐ RESTAURER LES COMPTEURS SI LA DEMANDE ÉTAIT VALIDÉE if (ancienStatut === 'Validée') { console.log('⚠️ Modification d\'une demande validée - restauration des compteurs'); - const [ancienTypes] = await conn.query( - 'SELECT TypeCongeId, NombreJours FROM DemandeCongeType WHERE DemandeCongeId = ?', - [id] - ); + const ancienTypesResult = await transaction.request() + .input('id', sql.Int, id) + .query('SELECT TypeCongeId, NombreJours FROM DemandeCongeType WHERE DemandeCongeId = @id'); + + const ancienTypes = ancienTypesResult.recordset; + const currentYear = new Date().getFullYear(); for (const type of ancienTypes) { - await conn.query( - `UPDATE CompteurConges - SET Solde = Solde + ? - WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = YEAR(NOW())`, - [type.NombreJours, collaborateurId, type.TypeCongeId] - ); + // ⭐ Parser correctement les décimales + const nombreJoursARestaurer = parseFloat(type.NombreJours); + + // Restaurer avec priorité CP N-1 puis CP N + if (type.TypeCongeId === 1) { + console.log(`\n🔄 Restauration CP - ${nombreJoursARestaurer}j à restaurer`); + let joursRestantsARestaurer = nombreJoursARestaurer; + + // D'abord : Restaurer dans CP N + const cpNResult = await transaction.request() + .input('collaborateurId', sql.Int, collaborateurId) + .input('typeId', sql.Int, type.TypeCongeId) + .input('currentYear', sql.Int, currentYear) + .query(` + SELECT Id, Solde, Total + FROM CompteurConges + WHERE CollaborateurADId = @collaborateurId + AND TypeCongeId = @typeId + AND Annee = @currentYear + `); + + if (cpNResult.recordset.length > 0) { + const compteurN = cpNResult.recordset[0]; + const soldeN = parseFloat(compteurN.Solde || 0); + const totalN = parseFloat(compteurN.Total || 0); + const aRestaurerN = Math.min(joursRestantsARestaurer, totalN - soldeN); + + if (aRestaurerN > 0) { + await transaction.request() + .input('restauration', sql.Decimal(10, 2), aRestaurerN) + .input('compteurId', sql.Int, compteurN.Id) + .query(` + UPDATE CompteurConges + SET Solde = Solde + @restauration, + DerniereMiseAJour = GETDATE() + WHERE Id = @compteurId + `); + + console.log(` ✅ CP N (${currentYear}) : +${aRestaurerN}j restaurés`); + joursRestantsARestaurer -= aRestaurerN; + } + } + + // Ensuite : Si besoin, restaurer dans CP N-1 + if (joursRestantsARestaurer > 0) { + console.log(` 🔄 Il reste ${joursRestantsARestaurer}j, restauration dans CP N-1`); + + const cpN1Result = await transaction.request() + .input('collaborateurId', sql.Int, collaborateurId) + .input('typeId', sql.Int, type.TypeCongeId) + .input('previousYear', sql.Int, currentYear - 1) + .query(` + SELECT Id, Solde, Total + FROM CompteurConges + WHERE CollaborateurADId = @collaborateurId + AND TypeCongeId = @typeId + AND Annee = @previousYear + `); + + if (cpN1Result.recordset.length > 0) { + const compteurN1 = cpN1Result.recordset[0]; + const soldeN1 = parseFloat(compteurN1.Solde || 0); + const totalN1 = parseFloat(compteurN1.Total || 0); + const aRestaurerN1 = Math.min(joursRestantsARestaurer, totalN1 - soldeN1); + + if (aRestaurerN1 > 0) { + await transaction.request() + .input('restauration', sql.Decimal(10, 2), aRestaurerN1) + .input('compteurId', sql.Int, compteurN1.Id) + .query(` + UPDATE CompteurConges + SET Solde = Solde + @restauration, + DerniereMiseAJour = GETDATE() + WHERE Id = @compteurId + `); + + console.log(` ✅ CP N-1 (${currentYear - 1}) : +${aRestaurerN1}j restaurés`); + } + } + } + } else { + // Autres types : restauration simple + await transaction.request() + .input('nombreJours', sql.Decimal(10, 2), nombreJoursARestaurer) + .input('collaborateurId', sql.Int, collaborateurId) + .input('typeCongeId', sql.Int, type.TypeCongeId) + .query(` + UPDATE CompteurConges + SET Solde = Solde + @nombreJours, + DerniereMiseAJour = GETDATE() + WHERE CollaborateurADId = @collaborateurId + AND TypeCongeId = @typeCongeId + AND Annee = YEAR(GETDATE()) + `); + + console.log(` ✅ Type ${type.TypeCongeId} : +${nombreJoursARestaurer}j restaurés`); + } } } - const totalJours = typesConge.reduce((sum, type) => sum + parseFloat(type.nombreJours), 0); + // ⭐ Calculer le total avec support des décimales + const totalJours = typesConge.reduce((sum, type) => { + return sum + parseFloat(type.nombreJours || 0); + }, 0); - const nouveauStatut = (ancienStatut === 'Validée' || ancienStatut === 'Refusée') - ? 'En attente' - : ancienStatut; + const nouveauStatut = 'Validée'; - await conn.query( - `UPDATE DemandeConge - SET DateDebut = ?, - DateFin = ?, - Commentaire = ?, - NombreJours = ?, - Statut = ?, - ValidateurADId = NULL, - DateValidation = NULL, - CommentaireValidation = NULL - WHERE Id = ?`, - [dateDebut, dateFin, commentaire || '', totalJours, nouveauStatut, id] - ); + console.log(`📝 Mise à jour demande ${id} - Nouveau statut: ${nouveauStatut}`); - await conn.query('DELETE FROM DemandeCongeType WHERE DemandeCongeId = ?', [id]); + // Mise à jour de la demande + await transaction.request() + .input('dateDebut', sql.Date, dateDebut) + .input('dateFin', sql.Date, dateFin) + .input('commentaire', sql.NVarChar, commentaire || '') + .input('totalJours', sql.Decimal(10, 2), totalJours) + .input('nouveauStatut', sql.VarChar, nouveauStatut) + .input('validateurId', sql.Int, req.user.id) + .input('id', sql.Int, id) + .query(` + UPDATE DemandeConge + SET DateDebut = @dateDebut, + DateFin = @dateFin, + Commentaire = @commentaire, + NombreJours = @totalJours, + Statut = @nouveauStatut, + ValidateurADId = @validateurId, + DateValidation = GETDATE(), + CommentaireValidation = 'Validation automatique après modification' + WHERE Id = @id + `); + // Supprimer les anciens types + await transaction.request() + .input('id', sql.Int, id) + .query('DELETE FROM DemandeCongeType WHERE DemandeCongeId = @id'); + + console.log('✅ Anciens types supprimés'); + + // Recréer les types avec génération manuelle d'ID for (const type of typesConge) { - await conn.query( - 'INSERT INTO DemandeCongeType (DemandeCongeId, TypeCongeId, NombreJours) VALUES (?, ?, ?)', - [id, type.typeId, type.nombreJours] - ); + const maxTypeIdResult = await transaction.request() + .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeCongeType'); + + const typeId = maxTypeIdResult.recordset[0].NextId; + + // ⭐ Parser correctement le nombre de jours + const nombreJours = parseFloat(type.nombreJours || 0); + + await transaction.request() + .input('id', sql.Int, typeId) + .input('demandeId', sql.Int, id) + .input('typeId', sql.Int, type.typeId) + .input('nombreJours', sql.Decimal(10, 2), nombreJours) + .query('INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours) VALUES (@id, @demandeId, @typeId, @nombreJours)'); + + console.log(`✅ Type ${type.typeId} recréé avec ID ${typeId} - ${nombreJours}j`); } - const actionDetails = ancienStatut === 'Validée' - ? `Modification par ${req.user.role} - demande validée remise en attente` - : ancienStatut === 'Refusée' - ? `Modification par ${req.user.role} - demande refusée remise en attente` - : `Modification par ${req.user.role}`; + // ⭐ DÉDUIRE LES NOUVEAUX COMPTEURS (AVEC PRIORITÉ CP N-1 puis CP N) + console.log('💼 Déduction des nouveaux compteurs avec priorité CP N-1...'); - await conn.query( - `INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId) - VALUES (?, ?, ?, ?)`, - [req.user.id, 'Modification demande', actionDetails, id] - ); + const currentYear = new Date().getFullYear(); - await conn.commit(); + for (const type of typesConge) { + const nombreJours = parseFloat(type.nombreJours || 0); + + // SI C'EST UN TYPE CP (ID = 1), GÉRER LA PRIORITÉ CP N-1 PUIS CP N + if (type.typeId === 1) { + console.log(`\n🔄 Traitement CP - ${nombreJours}j à déduire`); + let joursRestantsADeduire = nombreJours; + + // 1️⃣ D'ABORD : Pioche dans CP N-1 + const cpN1Result = await transaction.request() + .input('collaborateurId', sql.Int, collaborateurId) + .input('typeId', sql.Int, type.typeId) + .input('previousYear', sql.Int, currentYear - 1) + .query(` + SELECT Id, Solde + FROM CompteurConges + WHERE CollaborateurADId = @collaborateurId + AND TypeCongeId = @typeId + AND Annee = @previousYear + `); + + if (cpN1Result.recordset.length > 0) { + const compteurN1 = cpN1Result.recordset[0]; + const soldeN1 = parseFloat(compteurN1.Solde || 0); + + if (soldeN1 > 0) { + const aDeduireN1 = Math.min(soldeN1, joursRestantsADeduire); + + await transaction.request() + .input('deduction', sql.Decimal(10, 2), aDeduireN1) + .input('compteurId', sql.Int, compteurN1.Id) + .query(` + UPDATE CompteurConges + SET Solde = Solde - @deduction, + DerniereMiseAJour = GETDATE() + WHERE Id = @compteurId + `); + + console.log(` ✅ CP N-1 (${currentYear - 1}) : -${aDeduireN1}j`); + joursRestantsADeduire -= aDeduireN1; + } + } + + // 2️⃣ ENSUITE : S'il reste des jours, pioche dans CP N + if (joursRestantsADeduire > 0) { + console.log(` 🔄 Il reste ${joursRestantsADeduire}j, pioche dans CP N`); + + const cpNResult = await transaction.request() + .input('collaborateurId', sql.Int, collaborateurId) + .input('typeId', sql.Int, type.typeId) + .input('currentYear', sql.Int, currentYear) + .query(` + SELECT Id, Solde + FROM CompteurConges + WHERE CollaborateurADId = @collaborateurId + AND TypeCongeId = @typeId + AND Annee = @currentYear + `); + + if (cpNResult.recordset.length > 0) { + const compteurN = cpNResult.recordset[0]; + + await transaction.request() + .input('deduction', sql.Decimal(10, 2), joursRestantsADeduire) + .input('compteurId', sql.Int, compteurN.Id) + .query(` + UPDATE CompteurConges + SET Solde = Solde - @deduction, + DerniereMiseAJour = GETDATE() + WHERE Id = @compteurId + `); + + console.log(` ✅ CP N (${currentYear}) : -${joursRestantsADeduire}j`); + } + } + } else { + // Pour les autres types (RTT, Récupération, etc.) + await transaction.request() + .input('nombreJours', sql.Decimal(10, 2), nombreJours) + .input('collaborateurId', sql.Int, collaborateurId) + .input('typeId', sql.Int, type.typeId) + .query(` + UPDATE CompteurConges + SET Solde = Solde - @nombreJours, + DerniereMiseAJour = GETDATE() + WHERE CollaborateurADId = @collaborateurId + AND TypeCongeId = @typeId + AND Annee = YEAR(GETDATE()) + `); + + console.log(` ✅ Type ${type.typeId} : -${nombreJours}j`); + } + } + + const actionDetails = `Modification et validation automatique par ${req.user.role}`; + + // Historique + try { + const maxHistIdResult = await transaction.request() + .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM HistoriqueActions'); + + const histId = maxHistIdResult.recordset[0].NextId; + + await transaction.request() + .input('id', sql.Int, histId) + .input('userId', sql.Int, req.user.id) + .input('details', sql.NVarChar, actionDetails) + .input('demandeId', sql.Int, id) + .query(` + INSERT INTO HistoriqueActions (Id, CollaborateurADId, Action, Details, DemandeCongeId, DateAction) + VALUES (@id, @userId, 'Modification et validation', @details, @demandeId, GETDATE()) + `); + } catch (histError) { + console.log('⚠️ Fallback historique sans ID...'); + await transaction.request() + .input('userId', sql.Int, req.user.id) + .input('details', sql.NVarChar, actionDetails) + .input('demandeId', sql.Int, id) + .query(` + INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId, DateAction) + VALUES (@userId, 'Modification et validation', @details, @demandeId, GETDATE()) + `); + } + + await transaction.commit(); + console.log('✅ Modification et validation terminées'); + + // Notifications SSE + notifyClients({ + type: 'demande-validated', + demandeId: parseInt(id), + statut: 'Validée', + collaborateurId: collaborateurId, + timestamp: new Date().toISOString() + }, collaborateurId); notifyClients({ type: 'demande-updated', @@ -781,64 +2917,58 @@ try { timestamp: new Date().toISOString() }); + // Webhooks try { await webhookManager.sendWebhook( WEBHOOKS.COLLABORATEURS_URL, - EVENTS.DEMANDE_UPDATED, + EVENTS.DEMANDE_VALIDATED, { demandeId: parseInt(id), + statut: 'Validée', collaborateurId: collaborateurId, - nouveauStatut: nouveauStatut, - ancienStatut: ancienStatut + validateurId: req.user.id } ); - if (nouveauStatut !== ancienStatut) { - await webhookManager.sendWebhook( - WEBHOOKS.COLLABORATEURS_URL, - EVENTS.COMPTEUR_UPDATED, - { collaborateurId: collaborateurId } - ); - } + await webhookManager.sendWebhook( + WEBHOOKS.COLLABORATEURS_URL, + EVENTS.COMPTEUR_UPDATED, + { collaborateurId: collaborateurId } + ); } catch (webhookError) { console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message); } - const message = nouveauStatut !== ancienStatut - ? `Demande modifiée avec succès et remise en attente (était: ${ancienStatut})` - : 'Demande modifiée avec succès'; - res.json({ - message, - nouveauStatut, - ancienStatut + message: 'Demande modifiée et validée automatiquement', + nouveauStatut: nouveauStatut, + ancienStatut: ancienStatut }); - console.log(`✅ Demande ${id} modifiée par ${req.user.role} - Ancien: ${ancienStatut}, Nouveau: ${nouveauStatut}`); + console.log(`✅ Demande ${id} modifiée et validée par ${req.user.role}`); } catch (error) { - await conn.rollback(); - console.error('Erreur modification demande:', error); + await transaction.rollback(); + console.error('❌ Erreur modification demande:', error); res.status(500).json({ error: error.message }); - } finally { - conn.release(); } }); app.delete('/demandes/:id', authenticateToken, async (req, res) => { - const conn = await pool.getConnection(); + const transaction = new sql.Transaction(pool); try { - await conn.beginTransaction(); + await transaction.begin(); const { id } = req.params; - const [demande] = await conn.query( - 'SELECT Statut, CollaborateurADId FROM DemandeConge WHERE Id = ?', - [id] - ); + const demandeResult = await transaction.request() + .input('id', sql.Int, id) + .query('SELECT Statut, CollaborateurADId FROM DemandeConge WHERE Id = @id'); + + const demande = demandeResult.recordset; if (demande.length === 0) { - await conn.rollback(); + await transaction.rollback(); return res.status(404).json({ error: 'Demande non trouvée' }); } @@ -849,23 +2979,29 @@ try { const estRoleAutorise = rolesAutorises.includes(req.user.role); if (!estAuteur && !estRoleAutorise) { - await conn.rollback(); + await transaction.rollback(); return res.status(403).json({ error: 'Accès non autorisé', details: 'Seuls les Admin, RH, Validateurs ou l\'auteur peuvent supprimer cette demande' }); } - // ✅ AJOUT: Supprimer d'abord les entrées d'historique - await conn.query('DELETE FROM HistoriqueActions WHERE DemandeCongeId = ?', [id]); + // Supprimer d'abord les entrées d'historique + await transaction.request() + .input('id', sql.Int, id) + .query('DELETE FROM HistoriqueActions WHERE DemandeCongeId = @id'); // Supprimer les types de congé - await conn.query('DELETE FROM DemandeCongeType WHERE DemandeCongeId = ?', [id]); + await transaction.request() + .input('id', sql.Int, id) + .query('DELETE FROM DemandeCongeType WHERE DemandeCongeId = @id'); // Supprimer la demande - await conn.query('DELETE FROM DemandeConge WHERE Id = ?', [id]); + await transaction.request() + .input('id', sql.Int, id) + .query('DELETE FROM DemandeConge WHERE Id = @id'); - await conn.commit(); + await transaction.commit(); notifyClients({ type: 'demande-deleted', @@ -890,30 +3026,28 @@ try { console.log(`✅ Demande ${id} supprimée par ${req.user.role}`); res.json({ message: 'Demande supprimée avec succès' }); } catch (error) { - await conn.rollback(); + await transaction.rollback(); console.error('❌ Erreur suppression demande:', error); - // ✅ AJOUT: Log plus détaillé de l'erreur - console.error(' Code erreur:', error.code); - console.error(' SQL Message:', error.sqlMessage); res.status(500).json({ error: error.message }); - } finally { - conn.release(); } }); // ⭐ ROUTE VALIDATION AVEC WEBHOOKS + // ⭐ ROUTE VALIDATION AVEC WEBHOOKS - CORRIGÉE app.put('/demandes/:id/valider', authenticateToken, async (req, res) => { - const conn = await pool.getConnection(); + const transaction = new sql.Transaction(pool); try { - await conn.beginTransaction(); + await transaction.begin(); const { id } = req.params; const { statut, commentaire } = req.body; console.log('\n🔍 === DÉBUT VALIDATION DEMANDE ==='); - const [demandeInfo] = await conn.query( - `SELECT + const demandeInfoResult = await transaction.request() + .input('id', sql.Int, id) + .query(` + SELECT dc.Id, dc.CollaborateurADId, dc.DateDebut, @@ -923,55 +3057,99 @@ try { ca.nom, ca.email as collaborateur_email, ca.Actif, - GROUP_CONCAT(tc.Nom SEPARATOR ', ') as TypeConge + STRING_AGG(tc.Nom, ', ') as TypeConge FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id - WHERE dc.Id = ? - GROUP BY dc.Id - LIMIT 1`, - [id] - ); + WHERE dc.Id = @id + GROUP BY dc.Id, dc.CollaborateurADId, dc.DateDebut, dc.DateFin, dc.NombreJours, + ca.prenom, ca.nom, ca.email, ca.Actif + `); + + const demandeInfo = demandeInfoResult.recordset; if (demandeInfo.length === 0) { - await conn.rollback(); + await transaction.rollback(); return res.status(404).json({ error: 'Demande non trouvée' }); } const demande = demandeInfo[0]; const collaborateurId = demande.CollaborateurADId; - await conn.query( - `UPDATE DemandeConge - SET Statut = ?, CommentaireValidation = ?, ValidateurADId = ?, DateValidation = NOW() - WHERE Id = ?`, - [statut, commentaire || '', req.user.id, id] - ); + await transaction.request() + .input('statut', sql.VarChar, statut) + .input('commentaire', sql.NVarChar, commentaire || '') + .input('validateurId', sql.Int, req.user.id) + .input('id', sql.Int, id) + .query(` + UPDATE DemandeConge + SET Statut = @statut, + CommentaireValidation = @commentaire, + ValidateurADId = @validateurId, + DateValidation = GETDATE() + WHERE Id = @id + `); if (statut === 'Validée') { - const [types] = await conn.query( - 'SELECT TypeCongeId, NombreJours FROM DemandeCongeType WHERE DemandeCongeId = ?', - [id] - ); + const typesResult = await transaction.request() + .input('id', sql.Int, id) + .query('SELECT TypeCongeId, NombreJours FROM DemandeCongeType WHERE DemandeCongeId = @id'); + + const types = typesResult.recordset; + const currentYear = new Date().getFullYear(); for (const type of types) { - await conn.query( - `UPDATE CompteurConges - SET Solde = Solde - ? - WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = YEAR(NOW())`, - [type.NombreJours, collaborateurId, type.TypeCongeId] - ); + await transaction.request() + .input('nombreJours', sql.Decimal(10, 2), type.NombreJours) + .input('collaborateurId', sql.Int, collaborateurId) + .input('typeCongeId', sql.Int, type.TypeCongeId) + .query(` + UPDATE CompteurConges + SET Solde = Solde - @nombreJours + WHERE CollaborateurADId = @collaborateurId + AND TypeCongeId = @typeCongeId + AND Annee = YEAR(GETDATE()) + `); + + // ✅ AJOUT : Insérer dans DeductionDetails pour cohérence avec /annuler + await transaction.request() + .input('demandeId', sql.Int, id) + .input('typeId', sql.Int, type.TypeCongeId) + .input('annee', sql.Int, currentYear) + .input('jours', sql.Decimal(10, 2), type.NombreJours) + .query(` + INSERT INTO DeductionDetails + (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) + VALUES (@demandeId, @typeId, @annee, 'Année N', @jours) + `); } } - await conn.query( - `INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId) - VALUES (?, ?, ?, ?)`, - [req.user.id, statut === 'Validée' ? 'Validation congé' : 'Refus congé', commentaire, id] - ); + // ⭐ CORRECTION: Génération manuelle de l'ID pour HistoriqueActions + try { + const maxHistIdResult = await transaction.request() + .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM HistoriqueActions'); - await conn.commit(); + const histId = maxHistIdResult.recordset[0].NextId; + + await transaction.request() + .input('id', sql.Int, histId) + .input('userId', sql.Int, req.user.id) + .input('action', sql.VarChar, statut === 'Validée' ? 'Validation congé' : 'Refus congé') + .input('commentaire', sql.NVarChar, commentaire || '') + .input('demandeId', sql.Int, id) + .query(` + INSERT INTO HistoriqueActions (Id, CollaborateurADId, Action, Details, DemandeCongeId, DateAction) + VALUES (@id, @userId, @action, @commentaire, @demandeId, GETDATE()) + `); + + console.log(`✅ Historique validation créé avec ID: ${histId}`); + } catch (histError) { + console.error('⚠️ Erreur historique (non bloquant):', histError.message); + } + + await transaction.commit(); // ⭐ ENVOI EMAIL const accessToken = await getGraphToken(); @@ -992,35 +3170,35 @@ try { const body = statut === 'Validée' ? `
-
-

✅ Demande approuvée

+
+

✅ Demande approuvée

+
+
+

Bonjour ${collaborateurNom},

+

Votre demande de congé a été approuvée par ${validateurNom}.

+
+

Type : ${demande.TypeConge}

+

Période : ${datesPeriode}

+

Durée : ${demande.NombreJours} jour(s)

+ ${commentaire ? `

Commentaire : ${commentaire}

` : ''}
-
-

Bonjour ${collaborateurNom},

-

Votre demande de congé a été approuvée par ${validateurNom}.

-
-

Type : ${demande.TypeConge}

-

Période : ${datesPeriode}

-

Durée : ${demande.NombreJours} jour(s)

- ${commentaire ? `

Commentaire : ${commentaire}

` : ''} -
-
-
` +
+
` : `
-
-

❌ Demande refusée

+
+

❌ Demande refusée

+
+
+

Bonjour ${collaborateurNom},

+

Votre demande de congé a été refusée par ${validateurNom}.

+
+

Type : ${demande.TypeConge}

+

Période : ${datesPeriode}

+

Durée : ${demande.NombreJours} jour(s)

+ ${commentaire ? `

Motif du refus : ${commentaire}

` : ''}
-
-

Bonjour ${collaborateurNom},

-

Votre demande de congé a été refusée par ${validateurNom}.

-
-

Type : ${demande.TypeConge}

-

Période : ${datesPeriode}

-

Durée : ${demande.NombreJours} jour(s)

- ${commentaire ? `

Motif du refus : ${commentaire}

` : ''} -
-
-
`; +
+
`; await sendMailGraph(accessToken, fromEmail, demande.collaborateur_email, subject, body); } @@ -1064,77 +3242,231 @@ try { res.json({ message: 'Demande mise à jour' }); } catch (error) { - await conn.rollback(); + await transaction.rollback(); console.error('\n❌ ERREUR VALIDATION:', error); res.status(500).json({ error: error.message }); - } finally { - conn.release(); + } + }); + + // Route pour export paie par plage de dates + app.get('/export/paie-plage', authenticateToken, async (req, res) => { + const { dateDebut, dateFin } = req.query; + + if (!dateDebut || !dateFin) { + return res.status(400).json({ error: 'Date de début et date de fin requises' }); + } + + console.log(`📊 Export paie - Plage: ${dateDebut} au ${dateFin}`); + + try { + const debut = new Date(dateDebut); + const fin = new Date(dateFin); + + if (isNaN(debut.getTime()) || isNaN(fin.getTime())) { + return res.status(400).json({ error: 'Dates invalides' }); + } + if (debut > fin) { + return res.status(400).json({ error: 'La date de début doit être antérieure à la date de fin' }); + } + + const result = await pool.request() + .input('dateDebut', sql.Date, dateDebut) + .input('dateFin', sql.Date, dateFin) + .query(` + SELECT + ca.id AS collaborateurId, + CONCAT(ca.prenom, ' ', ca.nom) AS employe, + ca.email, + COALESCE(s.Nom, 'Non défini') AS service, + tc.Nom AS typeConge, + dct.NombreJours, + dct.PeriodeJournee, + -- ⭐ Dates précises du TYPE de congé + COALESCE(dct.DateDebut, dc.DateDebut) AS dateDebutType, + COALESCE(dct.DateFin, dc.DateFin) AS dateFinType, + dc.DateDebut, + dc.DateFin, + dc.Statut + FROM DemandeConge dc + JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id + LEFT JOIN Services s ON ca.ServiceId = s.Id + JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId + JOIN TypeConge tc ON dct.TypeCongeId = tc.Id + WHERE dc.Statut = 'Validée' + AND (ca.Actif = 1 OR ca.Actif IS NULL) + AND ( + (COALESCE(dct.DateDebut, dc.DateDebut) BETWEEN @dateDebut AND @dateFin) + OR (COALESCE(dct.DateFin, dc.DateFin) BETWEEN @dateDebut AND @dateFin) + OR (COALESCE(dct.DateDebut, dc.DateDebut) <= @dateDebut AND COALESCE(dct.DateFin, dc.DateFin) >= @dateFin) + ) + ORDER BY ca.nom, ca.prenom, COALESCE(dct.DateDebut, dc.DateDebut) + `); + + console.log(`📊 ${result.recordset.length} entrées trouvées`); + const dataFormatted = agregerLignesParEmploye(result.recordset); + console.log(`✅ Export paie-plage formaté: ${dataFormatted.length} employés`); + res.json(dataFormatted); + + } catch (error) { + console.error('❌ Erreur export paie plage:', error); + res.status(500).json({ error: 'Erreur lors de la génération du rapport', details: error.message }); + } + }); + + // ================================================ + // ROUTE GET /export/paie-saisie + // Filtre par date de SAISIE de la demande (DateDemande) + // À ajouter dans server.js après la route /export/paie-plage + // ================================================ + + app.get('/export/paie-saisie', authenticateToken, async (req, res) => { + const { dateSaisieDebut, dateSaisieFin } = req.query; + + console.log(`📊 Export paie-saisie: saisie entre ${dateSaisieDebut} et ${dateSaisieFin}`); + + if (!dateSaisieDebut || !dateSaisieFin) { + return res.status(400).json({ error: 'Date de saisie début et fin requises' }); + } + + try { + // ✅ DEBUG : voir ce qu'il y a en base pour juin + const debug = await pool.request() + .input('dateSaisieDebut', sql.Date, dateSaisieDebut) + .input('dateSaisieFin', sql.Date, dateSaisieFin) + .query(` + SELECT + CONCAT(ca.prenom, ' ', ca.nom) AS employe, + dc.DateDemande, + dc.DateDebut, + dc.DateFin, + COALESCE(dct.DateDebut, dc.DateDebut) AS dateDebutType, + COALESCE(dct.DateFin, dc.DateFin) AS dateFinType, + tc.Nom AS typeConge, + dc.Statut + FROM DemandeConge dc + JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id + JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId + JOIN TypeConge tc ON dct.TypeCongeId = tc.Id + WHERE dc.Statut = 'Validée' + AND (ca.Actif = 1 OR ca.Actif IS NULL) + AND COALESCE(dct.DateDebut, dc.DateDebut) <= @dateSaisieFin + AND COALESCE(dct.DateFin, dc.DateFin) >= @dateSaisieDebut + `); + + console.log('🔍 DEBUG - Résultats sans filtre saisie:'); + debug.recordset.forEach(r => { + console.log(` 👤 ${r.employe} | Saisie: ${r.DateDemande} | Congé: ${r.DateDebut} → ${r.DateFin} | Type dates: ${r.dateDebutType} → ${r.dateFinType} | Type: ${r.typeConge}`); + }); + + const result = await pool.request() + .input('dateSaisieDebut', sql.Date, dateSaisieDebut) + .input('dateSaisieFin', sql.Date, dateSaisieFin) + .query(` + SELECT + ca.id AS collaborateurId, + CONCAT(ca.prenom, ' ', ca.nom) AS employe, + ca.email, + COALESCE(s.Nom, 'Non défini') AS service, + tc.Nom AS typeConge, + dct.NombreJours, + dct.PeriodeJournee, + COALESCE(dct.DateDebut, dc.DateDebut) AS dateDebutType, + COALESCE(dct.DateFin, dc.DateFin) AS dateFinType, + dc.DateDebut, + dc.DateFin, + dc.Statut, + dc.DateDemande + FROM DemandeConge dc + JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id + LEFT JOIN Services s ON ca.ServiceId = s.Id + JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId + JOIN TypeConge tc ON dct.TypeCongeId = tc.Id + WHERE dc.Statut = 'Validée' + AND (ca.Actif = 1 OR ca.Actif IS NULL) + AND COALESCE(dct.DateDebut, dc.DateDebut) <= @dateSaisieFin + AND COALESCE(dct.DateFin, dc.DateFin) >= @dateSaisieDebut + AND CAST(dc.DateDemande AS DATE) <= @dateSaisieFin + ORDER BY ca.nom, ca.prenom, + COALESCE(dct.DateDebut, dc.DateDebut) + `); + + console.log(`📊 ${result.recordset.length} entrées trouvées`); + const dataFormatted = agregerLignesParEmploye(result.recordset); + console.log(`✅ Export paie-saisie formaté: ${dataFormatted.length} employés`); + res.json(dataFormatted); + + } catch (error) { + console.error('❌ Erreur export paie-saisie:', error); + res.status(500).json({ error: 'Erreur lors de la génération du rapport', details: error.message }); } }); // ================================================ // ROUTES EMPLOYÉS // ================================================ - // ROUTES EMPLOYÉS app.get('/employes', authenticateToken, async (req, res) => { try { - const includeInactifs = req.query.includeinactifs === 'true'; - const serviceId = req.query.serviceid; + const currentYear = new Date().getFullYear(); + const previousYear = currentYear - 1; - let query = ` - SELECT - ca.id, ca.nom, ca.prenom, - CONCAT(ca.prenom, ' ', ca.nom) AS nomComplet, - ca.email, ca.role, ca.Actif, - ca.DateSortie, ca.TypeContrat, - s.Nom AS service, s.Id AS serviceId, - COALESCE(c.Nom, 'Sans campus') AS campus, c.Id AS campusId, - MAX(cc1.Solde) AS soldeCP, - MAX(cc2.Solde) AS soldeRTT - FROM CollaborateurAD ca - LEFT JOIN Services s ON ca.ServiceId = s.Id - LEFT JOIN Campus c ON ca.CampusId = c.Id - LEFT JOIN CompteurConges cc1 - ON ca.id = cc1.CollaborateurADId - AND cc1.TypeCongeId = 1 - AND cc1.Annee = YEAR(NOW()) - LEFT JOIN CompteurConges cc2 - ON ca.id = cc2.CollaborateurADId - AND cc2.TypeCongeId = 2 - AND cc2.Annee = YEAR(NOW()) - WHERE 1=1 - `; + const result = await pool.request() + .input('currentYear', sql.Int, currentYear) + .input('previousYear', sql.Int, previousYear) + .query(` + SELECT + ca.id, + ca.nom, + ca.prenom, + ca.email, + ca.role, + COALESCE(s.Nom, 'Sans service') as service, + ca.Actif, + ca.TypeContrat, + + -- CP Année N + (SELECT TOP 1 COALESCE(Solde, 0) + FROM CompteurConges cc1 + WHERE cc1.CollaborateurADId = ca.id + AND cc1.TypeCongeId = (SELECT TOP 1 Id FROM TypeConge WHERE Nom LIKE '%Congé%payé%' OR Nom LIKE '%CP%' ORDER BY Id) + AND cc1.Annee = @currentYear) as soldeCPN, + + -- CP Année N-1 + (SELECT TOP 1 COALESCE(Solde, 0) + FROM CompteurConges cc2 + WHERE cc2.CollaborateurADId = ca.id + AND cc2.TypeCongeId = (SELECT TOP 1 Id FROM TypeConge WHERE Nom LIKE '%Congé%payé%' OR Nom LIKE '%CP%' ORDER BY Id) + AND cc2.Annee = @previousYear) as soldeCPNMoins1, + + -- RTT + (SELECT TOP 1 COALESCE(Solde, 0) + FROM CompteurConges cc3 + WHERE cc3.CollaborateurADId = ca.id + AND cc3.TypeCongeId = (SELECT TOP 1 Id FROM TypeConge WHERE Nom = 'RTT' ORDER BY Id) + AND cc3.Annee = @currentYear) as soldeRTT, + + -- Récupération + (SELECT TOP 1 COALESCE(Solde, 0) + FROM CompteurConges cc4 + WHERE cc4.CollaborateurADId = ca.id + AND cc4.TypeCongeId = (SELECT TOP 1 Id FROM TypeConge WHERE Nom LIKE '%Récup%' OR Nom LIKE '%Recup%' ORDER BY Id) + AND cc4.Annee = @currentYear) as soldeRecup + + FROM CollaborateurAD ca + LEFT JOIN Services s ON ca.ServiceId = s.Id + WHERE ca.Actif = 1 OR ca.Actif IS NULL + ORDER BY ca.nom, ca.prenom + `); - const params = []; - - if (!includeInactifs) { - query += ` AND (ca.Actif = 1 OR ca.Actif IS NULL)`; - } - - if (serviceId && serviceId !== 'all') { - query += ` AND ca.ServiceId = ?`; - params.push(serviceId); - } - - query += ` - GROUP BY ca.id, ca.nom, ca.prenom, ca.email, ca.role, - ca.Actif, ca.DateSortie, ca.TypeContrat, - s.Nom, s.Id, c.Nom, c.Id - ORDER BY c.Nom, s.Nom, ca.nom, ca.prenom - `; - - // ✅ FIX : Destructurer pour obtenir uniquement les résultats - const [employes] = await pool.query(query, params); - - console.log(`✅ ${employes.length} employés trouvés`); - res.json(employes); + console.log(`✅ ${result.recordset.length} employés récupérés avec compteurs`); + res.json(result.recordset); } catch (error) { - console.error('❌ Erreur GET /employes:', error); + console.error('❌ Erreur /api/employes:', error); res.status(500).json({ error: error.message }); } }); + // ✅ NOUVELLE ROUTE: Désactiver un collaborateur app.post('/employes/desactiver', authenticateToken, async (req, res) => { try { @@ -1150,21 +3482,20 @@ try { const dateSortie = date_sortie || new Date().toISOString().split('T')[0]; - const [collab] = await pool.query( - 'SELECT prenom, nom, email FROM CollaborateurAD WHERE id = ?', - [collaborateur_id] - ); + const collabResult = await pool.request() + .input('id', sql.Int, collaborateur_id) + .query('SELECT prenom, nom, email FROM CollaborateurAD WHERE id = @id'); + + const collab = collabResult.recordset; if (collab.length === 0) { return res.json({ success: false, message: 'Collaborateur non trouvé' }); } - await pool.query( - `UPDATE CollaborateurAD - SET Actif = FALSE, DateSortie = ? - WHERE id = ?`, - [dateSortie, collaborateur_id] - ); + await pool.request() + .input('dateSortie', sql.Date, dateSortie) + .input('id', sql.Int, collaborateur_id) + .query('UPDATE CollaborateurAD SET Actif = 0, DateSortie = @dateSortie WHERE id = @id'); res.json({ success: true, @@ -1191,21 +3522,19 @@ try { return res.json({ success: false, message: 'ID collaborateur manquant' }); } - const [collab] = await pool.query( - 'SELECT prenom, nom, email FROM CollaborateurAD WHERE id = ?', - [collaborateur_id] - ); + const collabResult = await pool.request() + .input('id', sql.Int, collaborateur_id) + .query('SELECT prenom, nom, email FROM CollaborateurAD WHERE id = @id'); + + const collab = collabResult.recordset; if (collab.length === 0) { return res.json({ success: false, message: 'Collaborateur non trouvé' }); } - await pool.query( - `UPDATE CollaborateurAD - SET Actif = TRUE, DateSortie = NULL - WHERE id = ?`, - [collaborateur_id] - ); + await pool.request() + .input('id', sql.Int, collaborateur_id) + .query('UPDATE CollaborateurAD SET Actif = 1, DateSortie = NULL WHERE id = @id'); res.json({ success: true, @@ -1220,429 +3549,108 @@ try { app.get('/types-conge', authenticateToken, async (req, res) => { try { - const [types] = await pool.query( + const result = await pool.request().query( 'SELECT Id as id, Nom as nom, CouleurHex as couleur FROM TypeConge WHERE Actif = 1 ORDER BY Nom' ); - res.json(types); + res.json(result.recordset); } catch (error) { res.status(500).json({ error: error.message }); } }); + // Suite - Routes export, compteurs, équipes, stats, etc. + app.get('/export/paie', authenticateToken, async (req, res) => { try { - const { mois, annee } = req.query; + const { mois, annee, dateSaisieDebut, dateSaisieFin } = req.query; - console.log(`📊 Export paie demandé : ${mois}/${annee}`); - - // Date de début et fin du mois const premierJour = `${annee}-${String(mois).padStart(2, '0')}-01`; const dernierJour = new Date(annee, mois, 0).getDate(); const dernierJourMois = `${annee}-${String(mois).padStart(2, '0')}-${dernierJour}`; - console.log(`📅 Période : ${premierJour} à ${dernierJourMois}`); + // Le filtre création est actif uniquement si les deux dates sont fournies + const filtreCreationActif = dateSaisieDebut && dateSaisieFin; - // Récupérer toutes les demandes validées qui chevauchent le mois - const [demandes] = await pool.query(` - SELECT - ca.id as collaborateurId, - CONCAT(ca.prenom, ' ', ca.nom) as employe, + console.log(`📊 Export paie: ${mois}/${annee} (${premierJour} -> ${dernierJourMois})${filtreCreationActif ? ` | Filtre création: ${dateSaisieDebut} -> ${dateSaisieFin}` : ''}`); + + const request = pool.request() + .input('premierJour', sql.Date, premierJour) + .input('dernierJourMois', sql.Date, dernierJourMois); + + // Construire la clause WHERE du filtre de création + let whereCreation = ''; + if (filtreCreationActif) { + request + .input('dateSaisieDebut', sql.Date, dateSaisieDebut) + .input('dateSaisieFin', sql.Date, dateSaisieFin); + whereCreation = `AND CAST(dc.DateDemande AS DATE) >= @dateSaisieDebut + AND CAST(dc.DateDemande AS DATE) <= @dateSaisieFin`; + } + + const result = await request.query(` + SELECT + ca.id AS collaborateurId, + CONCAT(ca.prenom, ' ', ca.nom) AS employe, ca.email, - s.Nom as service, - tc.Nom as typeConge, + COALESCE(s.Nom, 'Non assigné') AS service, + tc.Nom AS typeConge, dct.NombreJours, + dct.PeriodeJournee, + -- Dates précises du TYPE de congé + COALESCE(dct.DateDebut, dc.DateDebut) AS dateDebutType, + COALESCE(dct.DateFin, dc.DateFin) AS dateFinType, + -- Dates de la demande (pour le filtre période) dc.DateDebut, dc.DateFin, dc.Statut FROM DemandeConge dc - JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id - LEFT JOIN Services s ON ca.ServiceId = s.Id + JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id + LEFT JOIN Services s ON ca.ServiceId = s.Id JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId - JOIN TypeConge tc ON dct.TypeCongeId = tc.Id + JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dc.Statut = 'Validée' - AND ( - (dc.DateDebut BETWEEN ? AND ?) OR - (dc.DateFin BETWEEN ? AND ?) OR - (dc.DateDebut <= ? AND dc.DateFin >= ?) - ) AND (ca.Actif = 1 OR ca.Actif IS NULL) - ORDER BY ca.nom, ca.prenom, dc.DateDebut - `, [premierJour, dernierJourMois, premierJour, dernierJourMois, premierJour, dernierJourMois]); - - console.log(`📋 Nombre de demandes trouvées : ${demandes.length}`); - - if (demandes.length > 0) { - console.log('🔍 Exemple de demande:', demandes[0]); - } - - // Regrouper par employé - const employesMap = new Map(); - - demandes.forEach(row => { - const key = row.collaborateurId; - - if (!employesMap.has(key)) { - employesMap.set(key, { - employe: row.employe, - email: row.email, - service: row.service || 'Non assigné', - rtt: { nb: 0, dates: [] }, - cp: { nb: 0, dates: [] }, - aap: { nb: 0, dates: [] }, - am: { nb: 0, dates: [] }, - autres: [] - }); - } - - const employe = employesMap.get(key); - - // Formatter les dates - const dateDebut = new Date(row.DateDebut); - const dateFin = new Date(row.DateFin); - - const formatDate = (date) => { - return String(date.getDate()).padStart(2, '0') + '/' + - String(date.getMonth() + 1).padStart(2, '0'); - }; - - const dateDebutStr = formatDate(dateDebut); - const dateFinStr = formatDate(dateFin); - const periode = dateDebutStr === dateFinStr ? dateDebutStr : `${dateDebutStr}-${dateFinStr}`; - - // ✅ CORRECTION : Convertir en nombre - const nombreJours = parseFloat(row.NombreJours); - - // Catégoriser selon le type de congé - const typeConge = row.typeConge.toLowerCase(); - - console.log(` Type détecté: "${row.typeConge}" (${nombreJours}j)`); - - if (typeConge.includes('rtt')) { - employe.rtt.nb += nombreJours; // ✅ nombreJours est maintenant un nombre - employe.rtt.dates.push(periode); - console.log(` ✅ Classé en RTT (total: ${employe.rtt.nb}j)`); - } else if (typeConge.includes('cp') || typeConge.includes('congé') || typeConge.includes('conge')) { - employe.cp.nb += nombreJours; // ✅ nombreJours est maintenant un nombre - employe.cp.dates.push(periode); - console.log(` ✅ Classé en CP (total: ${employe.cp.nb}j)`); - } else if (typeConge.includes('aap') || typeConge.includes('absence autorisée') || - typeConge.includes('enfant malade') || typeConge.includes('récup') || - typeConge.includes('recuperation')) { - employe.aap.nb += nombreJours; - employe.aap.dates.push(periode); - console.log(` ✅ Classé en AAP (total: ${employe.aap.nb}j)`); - } else if (typeConge.includes('maladie') || typeConge.includes('arrêt') || typeConge.includes('arret')) { - employe.am.nb += nombreJours; - employe.am.dates.push(periode); - console.log(` ✅ Classé en AM (total: ${employe.am.nb}j)`); - } else { - // Autres types - const existingAutre = employe.autres.find(a => a.type === row.typeConge); - if (existingAutre) { - existingAutre.nb += nombreJours; - existingAutre.dates.push(periode); - } else { - employe.autres.push({ - type: row.typeConge, - nb: nombreJours, - dates: [periode] - }); - } - console.log(` ✅ Classé en Autres (total: ${nombreJours}j)`); - } - }); - - // Formatter les dates (joindre avec " ; ") - const dataFormatted = Array.from(employesMap.values()).map(emp => ({ - employe: emp.employe, - email: emp.email, - service: emp.service, - rtt: { nb: emp.rtt.nb, dates: emp.rtt.dates.join(' ; ') }, - cp: { nb: emp.cp.nb, dates: emp.cp.dates.join(' ; ') }, - aap: { nb: emp.aap.nb, dates: emp.aap.dates.join(' ; ') }, - am: { nb: emp.am.nb, dates: emp.am.dates.join(' ; ') }, - autres: emp.autres.map(a => ({ - type: a.type, - nb: a.nb, - dates: a.dates.join(' ; ') - })) - })); - - console.log(`✅ ${dataFormatted.length} collaborateurs dans le rapport`); + AND ( + -- La période du TYPE chevauche le mois + (COALESCE(dct.DateDebut, dc.DateDebut) BETWEEN @premierJour AND @dernierJourMois) + OR (COALESCE(dct.DateFin, dc.DateFin) BETWEEN @premierJour AND @dernierJourMois) + OR (COALESCE(dct.DateDebut, dc.DateDebut) <= @premierJour AND COALESCE(dct.DateFin, dc.DateFin) >= @dernierJourMois) + ) + ${whereCreation} + ORDER BY ca.nom, ca.prenom, COALESCE(dct.DateDebut, dc.DateDebut) + `); + console.log(`📊 ${result.recordset.length} entrées trouvées`); + const dataFormatted = agregerLignesParEmploye(result.recordset); + console.log(`✅ Export formaté: ${dataFormatted.length} employés`); res.json(dataFormatted); + } catch (error) { console.error('❌ Erreur export paie:', error); res.status(500).json({ error: error.message }); } }); - - - - // ======================================== - // ROUTE COMPLÈTE : /reinitializeAllCounters - // ======================================== - app.post('/reinitializeAllCounters', async (req, res) => { - const conn = await pool.getConnection(); - try { - await conn.beginTransaction(); - - console.log('🔄 Réinitialisation de tous les compteurs...'); - - const collaborateurs = await conn.query(` - SELECT id, prenom, nom, DateEntree, TypeContrat, CampusId, SocieteId, role - FROM CollaborateurAD - WHERE actif = 1 OR actif IS NULL - `); - - console.log(`📋 ${collaborateurs.length} collaborateurs trouvés`); - - const dateRefParam = req.body.dateReference; - const today = dateRefParam ? new Date(dateRefParam) : new Date(); - const currentYear = today.getFullYear(); - const previousYear = currentYear - 1; - const results = []; - - // Récupérer les types de congés - const cpType = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['Congé payé']); - const rttType = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['RTT']); - - for (const collab of collaborateurs) { - console.log(`\n${'='.repeat(60)}`); - console.log(`👤 ${collab.prenom} ${collab.nom} (ID: ${collab.id})`); - console.log(` Type contrat: ${collab.TypeContrat || '37h'}`); - console.log(` Date entrée: ${collab.DateEntree ? new Date(collab.DateEntree).toISOString().split('T')[0] : 'NULL'}`); - console.log(` Rôle: ${collab.role || 'Non défini'}`); - - const dateEntree = collab.DateEntree; - const typeContrat = collab.TypeContrat || '37h'; - const isApprenti = collab.role === 'Apprenti'; - - // ========================================== - // CALCUL CP - // ========================================== - let acquisCP = calculerAcquisitionCP(today, dateEntree); - - // ✅ PROTECTION : Si NaN, forcer à 0 - if (isNaN(acquisCP) || !isFinite(acquisCP)) { - console.warn(`⚠️ Acquisition CP invalide - Forcé à 0`); - acquisCP = 0; - } - - // ========================================== - // CALCUL RTT - // ========================================== - let acquisRTT = 0; - if (!isApprenti) { - try { - const rttData = await calculerAcquisitionRTT(conn, collab.id, today); - acquisRTT = rttData.acquisition; - - // ✅ PROTECTION : Si NaN, forcer à 0 - if (isNaN(acquisRTT) || !isFinite(acquisRTT)) { - console.warn(`⚠️ Acquisition RTT invalide - Forcé à 0`); - acquisRTT = 0; - } - } catch (error) { - console.error(`❌ Erreur calcul RTT:`, error.message); - acquisRTT = 0; - } - } - - // ========================================== - // MISE À JOUR CP N - // ========================================== - if (cpType.length > 0) { - const deductionsCP = await conn.query(` - SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalConsomme - FROM DeductionDetails dd - JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id - WHERE dc.CollaborateurADId = ? - AND dd.TypeCongeId = ? - AND dd.Annee = ? - AND dc.Statut != 'Refusé' - `, [collab.id, cpType[0].Id, currentYear]); - - let totalConsomme = parseFloat(deductionsCP[0].totalConsomme || 0); - - // ✅ PROTECTION - if (isNaN(totalConsomme) || !isFinite(totalConsomme)) { - console.warn(`⚠️ Total consommé CP invalide - Forcé à 0`); - totalConsomme = 0; - } - - const compteurExisting = await conn.query(` - SELECT SoldeReporte FROM CompteurConges - WHERE CollaborateurADId = ? - AND TypeCongeId = ? - AND Annee = ? - `, [collab.id, cpType[0].Id, currentYear]); - - let soldeReporte = compteurExisting.length > 0 - ? parseFloat(compteurExisting[0].SoldeReporte || 0) - : 0; - - // ✅ PROTECTION - if (isNaN(soldeReporte) || !isFinite(soldeReporte)) { - console.warn(`⚠️ Solde reporté CP invalide - Forcé à 0`); - soldeReporte = 0; - } - - let nouveauSolde = Math.max(0, acquisCP + soldeReporte - totalConsomme); - - // ✅ PROTECTION FINALE - if (isNaN(nouveauSolde) || !isFinite(nouveauSolde)) { - console.warn(`⚠️ Nouveau solde CP invalide - Forcé à 0`); - nouveauSolde = 0; - } - - console.log(` 📊 CP - Acquis: ${acquisCP.toFixed(2)}j, Consommé: ${totalConsomme.toFixed(2)}j, Reporté: ${soldeReporte.toFixed(2)}j, Solde: ${nouveauSolde.toFixed(2)}j`); - - if (compteurExisting.length > 0) { - await conn.query(` - UPDATE CompteurConges - SET Total = ?, Solde = ?, DerniereMiseAJour = NOW() - WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? - `, [acquisCP, nouveauSolde, collab.id, cpType[0].Id, currentYear]); - } else { - await conn.query(` - INSERT INTO CompteurConges - (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) - VALUES (?, ?, ?, ?, ?, 0, NOW()) - `, [collab.id, cpType[0].Id, currentYear, acquisCP, nouveauSolde]); - } - - // Créer CP N-1 si nécessaire - const cpN1 = await conn.query(` - SELECT Id FROM CompteurConges - WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? - `, [collab.id, cpType[0].Id, previousYear]); - - if (cpN1.length === 0) { - await conn.query(` - INSERT INTO CompteurConges - (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) - VALUES (?, ?, ?, 0, 0, 0, NOW()) - `, [collab.id, cpType[0].Id, previousYear]); - } - } - - // ========================================== - // MISE À JOUR RTT N - // ========================================== - if (rttType.length > 0 && !isApprenti) { - const deductionsRTT = await conn.query(` - SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalConsomme - FROM DeductionDetails dd - JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id - WHERE dc.CollaborateurADId = ? - AND dd.TypeCongeId = ? - AND dd.Annee = ? - AND dc.Statut != 'Refusé' - `, [collab.id, rttType[0].Id, currentYear]); - - let totalConsomme = parseFloat(deductionsRTT[0].totalConsomme || 0); - - // ✅ PROTECTION - if (isNaN(totalConsomme) || !isFinite(totalConsomme)) { - console.warn(`⚠️ Total consommé RTT invalide - Forcé à 0`); - totalConsomme = 0; - } - - let nouveauSolde = Math.max(0, acquisRTT - totalConsomme); - - // ✅ PROTECTION FINALE - if (isNaN(nouveauSolde) || !isFinite(nouveauSolde)) { - console.warn(`⚠️ Nouveau solde RTT invalide - Forcé à 0`); - nouveauSolde = 0; - } - - console.log(` 📊 RTT - Acquis: ${acquisRTT.toFixed(2)}j, Consommé: ${totalConsomme.toFixed(2)}j, Solde: ${nouveauSolde.toFixed(2)}j`); - - const compteurExisting = await conn.query(` - SELECT Id FROM CompteurConges - WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? - `, [collab.id, rttType[0].Id, currentYear]); - - if (compteurExisting.length > 0) { - await conn.query(` - UPDATE CompteurConges - SET Total = ?, Solde = ?, DerniereMiseAJour = NOW() - WHERE Id = ? - `, [acquisRTT, nouveauSolde, compteurExisting[0].Id]); - } else { - await conn.query(` - INSERT INTO CompteurConges - (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) - VALUES (?, ?, ?, ?, ?, 0, NOW()) - `, [collab.id, rttType[0].Id, currentYear, acquisRTT, nouveauSolde]); - } - - // Créer RTT N-1 si nécessaire - const rttN1 = await conn.query(` - SELECT Id FROM CompteurConges - WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? - `, [collab.id, rttType[0].Id, previousYear]); - - if (rttN1.length === 0) { - await conn.query(` - INSERT INTO CompteurConges - (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) - VALUES (?, ?, ?, 0, 0, 0, NOW()) - `, [collab.id, rttType[0].Id, previousYear]); - } - } - - // Ajouter au résultat - results.push({ - collaborateur: `${collab.prenom} ${collab.nom}`, - typecontrat: typeContrat, - cpacquis: acquisCP.toFixed(2), - rttacquis: acquisRTT.toFixed(2) - }); - } - - await conn.commit(); - console.log('\n✅ Réinitialisation terminée'); - - res.json({ - success: true, - message: `Compteurs réinitialisés pour ${collaborateurs.length} collaborateurs`, - datereference: today.toISOString().split('T')[0], - totalcollaborateurs: collaborateurs.length, - results: results - }); - - } catch (error) { - await conn.rollback(); - console.error('❌ Erreur réinitialisation:', error); - res.status(500).json({ - success: false, - message: 'Erreur lors de la réinitialisation', - error: error.message - }); - } finally { - conn.release(); - } - }); // Route pour obtenir les informations d'un collaborateur app.get('/employes/:id/info', authenticateToken, async (req, res) => { try { - const [employe] = await pool.query( - `SELECT - id, - nom, - prenom, - email, - role, - TypeContrat, - Actif, - DateSortie, - ServiceId - FROM CollaborateurAD - WHERE id = ?`, - [req.params.id] - ); + const result = await pool.request() + .input('id', sql.Int, req.params.id) + .query(` + SELECT + id, + nom, + prenom, + email, + role, + TypeContrat, + Actif, + DateSortie, + ServiceId + FROM CollaborateurAD + WHERE id = @id + `); + + const employe = result.recordset; if (employe.length === 0) { return res.status(404).json({ error: 'Employé non trouvé' }); @@ -1666,22 +3674,23 @@ try { } }); - // ✅ Modifier la route GET /api/compteurs pour inclure les infos sur les apprentis + // ✅ Modifier la route GET /compteurs pour inclure les infos sur les apprentis app.get('/compteurs', authenticateToken, async (req, res) => { try { const includeInactifs = req.query.include_inactifs === 'true'; + const { user_id } = req.query; let query = ` SELECT - cc.Id as id, - cc.CollaborateurADId as collaborateurId, - cc.TypeCongeId as typeCongeId, + cc.Id AS id, + cc.CollaborateurADId AS collaborateurId, + cc.TypeCongeId AS typeCongeId, CONCAT(ca.prenom, ' ', ca.nom) AS employe, - ca.email, - ca.Actif, - ca.role, - ca.TypeContrat, - s.Nom AS service, + ca.email AS email, + ca.Actif AS Actif, + ca.role AS role, + ca.TypeContrat AS TypeContrat, + COALESCE(s.Nom, 'Sans service') AS service, tc.Nom AS typeConge, cc.Annee AS annee, cc.Total AS total, @@ -1692,53 +3701,84 @@ try { JOIN CollaborateurAD ca ON cc.CollaborateurADId = ca.id LEFT JOIN Services s ON ca.ServiceId = s.Id JOIN TypeConge tc ON cc.TypeCongeId = tc.Id + WHERE 1=1 + AND (ca.description IS NULL OR ca.description NOT LIKE '%stagiaire%') `; + const request = pool.request(); + + // Filtre par utilisateur si fourni + if (user_id) { + query += ' AND cc.CollaborateurADId = @userId'; + request.input('userId', sql.Int, parseInt(user_id)); + } + + // Filtre actif/inactif if (!includeInactifs) { - query += ' WHERE (ca.Actif = 1 OR ca.Actif IS NULL)'; + query += ' AND (ca.Actif = 1 OR ca.Actif IS NULL)'; } query += ' ORDER BY ca.Actif DESC, ca.nom, ca.prenom, tc.Nom'; - const [compteurs] = await pool.query(query); + const result = await request.query(query); + const compteurs = result.recordset; - // Ajouter l'indicateur apprenti à chaque compteur - const compteursAvecStatut = compteurs.map(c => ({ - ...c, + console.log(`✅ ${compteurs.length} compteurs trouvés`); + + // ⭐ DEBUG: Afficher les types de congés trouvés + const typesConges = [...new Set(compteurs.map(c => c.typeConge))]; + console.log('📊 Types de congés:', typesConges); + + // Ajouter l'indicateur apprenti et formater les données + const compteursFormates = compteurs.map(c => ({ + id: c.id, + collaborateurId: c.collaborateurId, + typeCongeId: c.typeCongeId, + employe: c.employe, + email: c.email, + service: c.service || 'Sans service', + typeConge: c.typeConge, + annee: c.annee, + total: parseFloat(c.total) || 0, + solde: parseFloat(c.solde) || 0, + soldeReporte: parseFloat(c.soldeReporte) || 0, + consomme: parseFloat(c.consomme) || 0, estApprenti: c.TypeContrat === 'Apprentissage' || c.role === 'Apprenti' || - c.role?.toLowerCase().includes('apprenti') + (c.role || '').toLowerCase().includes('apprenti') })); - res.json(compteursAvecStatut); + res.json(compteursFormates); + } catch (error) { + console.error('❌ Erreur récupération compteurs:', error); res.status(500).json({ error: error.message }); } }); - app.put('/compteurs/:id', authenticateToken, async (req, res) => { - const conn = await pool.getConnection(); + const transaction = new sql.Transaction(pool); try { - await conn.beginTransaction(); + await transaction.begin(); if (!['Admin', 'RH', 'Validateur'].includes(req.user.role)) { - await conn.rollback(); - conn.release(); + await transaction.rollback(); return res.status(403).json({ error: 'Accès non autorisé' }); } const { id } = req.params; const { total, solde } = req.body; - // ✅ CONVERTIR EN NOMBRES const totalNum = parseFloat(total); const soldeNum = parseFloat(solde); - const [existing] = await conn.query('SELECT * FROM CompteurConges WHERE Id = ?', [id]); + const existingResult = await transaction.request() + .input('id', sql.Int, id) + .query('SELECT * FROM CompteurConges WHERE Id = @id'); + + const existing = existingResult.recordset; if (existing.length === 0) { - await conn.rollback(); - conn.release(); + await transaction.rollback(); return res.status(404).json({ error: 'Compteur non trouvé' }); } @@ -1747,20 +3787,20 @@ try { const annee = existing[0].Annee; // Récupérer le nom du type de congé - const [typeInfo] = await conn.query( - 'SELECT Nom FROM TypeConge WHERE Id = ?', - [typeCongeId] - ); + const typeInfoResult = await transaction.request() + .input('typeCongeId', sql.Int, typeCongeId) + .query('SELECT Nom FROM TypeConge WHERE Id = @typeCongeId'); - const typeConge = typeInfo.length > 0 ? typeInfo[0].Nom : 'Inconnu'; + const typeConge = typeInfoResult.recordset.length > 0 ? typeInfoResult.recordset[0].Nom : 'Inconnu'; - // ⭐ MISE À JOUR AVEC TRANSACTION - await conn.query( - `UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = NOW() WHERE Id = ?`, - [totalNum, soldeNum, id] - ); + // Mise à jour + await transaction.request() + .input('total', sql.Decimal(10, 2), totalNum) + .input('solde', sql.Decimal(10, 2), soldeNum) + .input('id', sql.Int, id) + .query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id'); - await conn.commit(); + await transaction.commit(); // Notifier les clients SSE notifyClients({ @@ -1775,7 +3815,7 @@ try { timestamp: new Date().toISOString() }, collaborateurId); - // ⭐ WEBHOOK AMÉLIORÉ AVEC TOUTES LES INFOS + // Webhook try { await webhookManager.sendWebhook( WEBHOOKS.COLLABORATEURS_URL, @@ -1803,114 +3843,46 @@ try { solde: soldeNum }); } catch (error) { - await conn.rollback(); + await transaction.rollback(); console.error('Erreur modification compteur:', error); res.status(500).json({ error: error.message }); - } finally { - conn.release(); } }); - app.get('/compteurs', authenticateToken, async (req, res) => { - try { - const { user_id } = req.query; - const includeInactifs = req.query.include_inactifs === 'true'; - let query = ` - SELECT - cc.Id as id, - cc.CollaborateurADId as collaborateurId, - cc.TypeCongeId as typeCongeId, - CONCAT(ca.prenom, ' ', ca.nom) AS employe, - ca.email, - ca.Actif, - ca.role, - ca.TypeContrat, - s.Nom AS service, - tc.Nom AS typeConge, - cc.Annee AS annee, - cc.Total AS total, - cc.Solde AS solde, - cc.SoldeReporte AS soldeReporte, - (cc.Total - cc.Solde) AS consomme - FROM CompteurConges cc - JOIN CollaborateurAD ca ON cc.CollaborateurADId = ca.id - LEFT JOIN Services s ON ca.ServiceId = s.Id - JOIN TypeConge tc ON cc.TypeCongeId = tc.Id - WHERE 1=1 - `; - - const params = []; - - // Filtre par utilisateur si fourni - if (user_id) { - query += ' AND cc.CollaborateurADId = ?'; - params.push(user_id); - } - - // Filtre actif/inactif - if (!includeInactifs) { - query += ' AND (ca.Actif = 1 OR ca.Actif IS NULL)'; - } - - query += ' ORDER BY ca.Actif DESC, ca.nom, ca.prenom, tc.Nom'; - - const [compteurs] = await pool.query(query, params); - - // Ajouter l'indicateur apprenti - const compteursAvecStatut = compteurs.map(c => ({ - ...c, - estApprenti: c.TypeContrat === 'Apprentissage' || - c.role === 'Apprenti' || - c.role?.toLowerCase().includes('apprenti') - })); - - res.json(compteursAvecStatut); - } catch (error) { - console.error('❌ Erreur récupération compteurs:', error); - res.status(500).json({ error: error.message }); - } - }); // ================================================ // ROUTE POUR AJOUTER DES JOURS DE RÉCUPÉRATION // ================================================ - app.post('/compteurs/ajouter-recup', authenticateToken, async (req, res) => { - const conn = await pool.getConnection(); + const transaction = new sql.Transaction(pool); try { - await conn.beginTransaction(); + await transaction.begin(); if (!['Admin', 'RH'].includes(req.user.role)) { - await conn.rollback(); - conn.release(); + await transaction.rollback(); return res.status(403).json({ error: 'Accès non autorisé' }); } const { collaborateurId, nombreJours, commentaire } = req.body; if (!collaborateurId || !nombreJours || nombreJours <= 0) { - await conn.rollback(); - conn.release(); + await transaction.rollback(); return res.json({ success: false, message: 'Données invalides' }); } - console.log('📥 Ajout de récupération:', { - collaborateurId, - nombreJours, - commentaire - }); + console.log('📥 Ajout de récupération:', { collaborateurId, nombreJours, commentaire }); // Récupérer les infos du collaborateur - const [collaborateur] = await conn.query( - 'SELECT prenom, nom, email, Actif FROM CollaborateurAD WHERE id = ?', - [collaborateurId] - ); + const collaborateurResult = await transaction.request() + .input('id', sql.Int, collaborateurId) + .query('SELECT prenom, nom, email, Actif FROM CollaborateurAD WHERE id = @id'); + + const collaborateur = collaborateurResult.recordset; if (collaborateur.length === 0) { - await conn.rollback(); - conn.release(); + await transaction.rollback(); return res.status(404).json({ success: false, message: 'Collaborateur non trouvé' @@ -1918,8 +3890,7 @@ try { } if (collaborateur[0].Actif === 0) { - await conn.rollback(); - conn.release(); + await transaction.rollback(); return res.status(403).json({ success: false, message: 'Impossible d\'ajouter des jours à un compte désactivé' @@ -1930,14 +3901,13 @@ try { const currentYear = new Date().getFullYear(); // Récupérer l'ID du type "Récupération" - const [recupType] = await conn.query( - 'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', - ['Récupération'] - ); + const recupTypeResult = await transaction.request() + .query("SELECT Id FROM TypeConge WHERE Nom = 'Récupération'"); + + const recupType = recupTypeResult.recordset; if (recupType.length === 0) { - await conn.rollback(); - conn.release(); + await transaction.rollback(); return res.status(500).json({ success: false, message: 'Type de congé "Récupération" non trouvé' @@ -1947,48 +3917,85 @@ try { const recupTypeId = recupType[0].Id; // Vérifier si le compteur existe déjà - const [compteurExisting] = await conn.query( - `SELECT Id, Total, Solde FROM CompteurConges - WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, - [collaborateurId, recupTypeId, currentYear] - ); + const compteurExistingResult = await transaction.request() + .input('collaborateurId', sql.Int, collaborateurId) + .input('recupTypeId', sql.Int, recupTypeId) + .input('currentYear', sql.Int, currentYear) + .query(` + SELECT Id, Total, Solde FROM CompteurConges + WHERE CollaborateurADId = @collaborateurId + AND TypeCongeId = @recupTypeId + AND Annee = @currentYear + `); + + const compteurExisting = compteurExistingResult.recordset; + + let nouveauTotal, nouveauSolde; if (compteurExisting.length > 0) { - // Mettre à jour le compteur existant - await conn.query( - `UPDATE CompteurConges - SET Total = Total + ?, - Solde = Solde + ?, - DerniereMiseAJour = NOW() - WHERE Id = ?`, - [nombreJours, nombreJours, compteurExisting[0].Id] - ); + nouveauTotal = parseFloat(compteurExisting[0].Total) + nombreJours; + nouveauSolde = parseFloat(compteurExisting[0].Solde) + nombreJours; - console.log(`✅ Compteur Récupération mis à jour: +${nombreJours}j (nouveau solde: ${(parseFloat(compteurExisting[0].Solde) + nombreJours).toFixed(2)}j)`); + // Mettre à jour le compteur existant + await transaction.request() + .input('total', sql.Decimal(10, 2), nouveauTotal) + .input('solde', sql.Decimal(10, 2), nouveauSolde) + .input('id', sql.Int, compteurExisting[0].Id) + .query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id'); + + console.log(`✅ Compteur Récupération mis à jour: +${nombreJours}j (nouveau solde: ${nouveauSolde.toFixed(2)}j)`); } else { + nouveauTotal = nombreJours; + nouveauSolde = nombreJours; + // Créer un nouveau compteur - await conn.query( - `INSERT INTO CompteurConges - (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) - VALUES (?, ?, ?, ?, ?, 0, NOW())`, - [collaborateurId, recupTypeId, currentYear, nombreJours, nombreJours] - ); + await transaction.request() + .input('collaborateurId', sql.Int, collaborateurId) + .input('recupTypeId', sql.Int, recupTypeId) + .input('currentYear', sql.Int, currentYear) + .input('total', sql.Decimal(10, 2), nouveauTotal) + .input('solde', sql.Decimal(10, 2), nouveauSolde) + .query(` + INSERT INTO CompteurConges + (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) + VALUES (@collaborateurId, @recupTypeId, @currentYear, @total, @solde, 0, GETDATE()) + `); console.log(`✅ Compteur Récupération créé: ${nombreJours}j`); } // Enregistrer dans l'historique - await conn.query( - `INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction) - VALUES (?, ?, ?, NOW())`, - [ - req.user.id, - 'Ajout récupération', - `Ajout de ${nombreJours}j de récupération pour ${collab.prenom} ${collab.nom}${commentaire ? ` - ${commentaire}` : ''}` - ] - ); + await transaction.request() + .input('userId', sql.Int, req.user.id) + .input('details', sql.NVarChar, `Ajout de ${nombreJours}j de récupération pour ${collab.prenom} ${collab.nom}${commentaire ? ` - ${commentaire}` : ''}`) + .query(` + INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction) + VALUES (@userId, 'Ajout récupération', @details, GETDATE()) + `); - await conn.commit(); + await transaction.commit(); + + // Webhook + try { + await webhookManager.sendWebhook( + WEBHOOKS.COLLABORATEURS_URL, + EVENTS.COMPTEUR_UPDATED, + { + collaborateurId: collaborateurId, + typeCongeId: recupTypeId, + typeConge: 'Récupération', + typeUpdate: 'ajout_manuel_rh', + annee: currentYear, + nouveauTotal: nouveauTotal, + nouveauSolde: nouveauSolde, + source: 'bouton_plus_rh', + timestamp: new Date().toISOString() + } + ); + console.log('✅ Webhook GTA-RH envoyé (compteur récup mis à jour)'); + } catch (webhookError) { + console.error('❌ Erreur webhook (non bloquant):', webhookError.message); + } // Notifier les clients SSE notifyClients({ @@ -2000,18 +4007,7 @@ try { timestamp: new Date().toISOString() }, collaborateurId); - // Envoyer webhook au serveur collaborateurs - try { - await webhookManager.sendWebhook( - WEBHOOKS.COLLABORATEURS_URL, - EVENTS.COMPTEUR_UPDATED, - { collaborateurId: collaborateurId } - ); - } catch (webhookError) { - console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message); - } - - // Envoyer un email de notification au collaborateur + // Envoyer un email const accessToken = await getGraphToken(); if (accessToken && collab.email) { const fromEmail = 'noreply@ensup.eu'; @@ -2029,7 +4025,7 @@ try { ${commentaire ? `

Motif : ${commentaire}

` : ''}

Année : ${currentYear}

-

Ces jours sont désormais disponibles dans votre solde de récupération.

+

Ces jours sont désormais disponibles et peuvent être utilisés via une demande de congé.

`; @@ -2042,8 +4038,6 @@ try { } } - conn.release(); - res.json({ success: true, message: 'Jours de récupération ajoutés avec succès', @@ -2053,8 +4047,7 @@ try { }); } catch (error) { - await conn.rollback(); - if (conn) conn.release(); + await transaction.rollback(); console.error('❌ Erreur ajout récupération:', error); res.status(500).json({ success: false, @@ -2064,47 +4057,42 @@ try { } }); + // Suite - Routes équipes, stats, historique + // ================================================ // ROUTE POUR RETIRER DES JOURS DE RÉCUPÉRATION // ================================================ - app.post('/compteurs/retirer-recup', authenticateToken, async (req, res) => { - const conn = await pool.getConnection(); + const transaction = new sql.Transaction(pool); try { - await conn.beginTransaction(); + await transaction.begin(); if (!['Admin', 'RH'].includes(req.user.role)) { - await conn.rollback(); - conn.release(); + await transaction.rollback(); return res.status(403).json({ error: 'Accès non autorisé' }); } const { collaborateurId, nombreJours, commentaire } = req.body; if (!collaborateurId || !nombreJours || nombreJours <= 0) { - await conn.rollback(); - conn.release(); + await transaction.rollback(); return res.json({ success: false, message: 'Données invalides' }); } - console.log('📤 Retrait de récupération:', { - collaborateurId, - nombreJours, - commentaire - }); + console.log('📤 Retrait de récupération:', { collaborateurId, nombreJours, commentaire }); // Récupérer les infos du collaborateur - const [collaborateur] = await conn.query( - 'SELECT prenom, nom, email FROM CollaborateurAD WHERE id = ?', - [collaborateurId] - ); + const collaborateurResult = await transaction.request() + .input('id', sql.Int, collaborateurId) + .query('SELECT prenom, nom, email FROM CollaborateurAD WHERE id = @id'); + + const collaborateur = collaborateurResult.recordset; if (collaborateur.length === 0) { - await conn.rollback(); - conn.release(); + await transaction.rollback(); return res.status(404).json({ success: false, message: 'Collaborateur non trouvé' @@ -2115,14 +4103,13 @@ try { const currentYear = new Date().getFullYear(); // Récupérer l'ID du type "Récupération" - const [recupType] = await conn.query( - 'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', - ['Récupération'] - ); + const recupTypeResult = await transaction.request() + .query("SELECT Id FROM TypeConge WHERE Nom = 'Récupération'"); + + const recupType = recupTypeResult.recordset; if (recupType.length === 0) { - await conn.rollback(); - conn.release(); + await transaction.rollback(); return res.status(500).json({ success: false, message: 'Type de congé "Récupération" non trouvé' @@ -2132,15 +4119,21 @@ try { const recupTypeId = recupType[0].Id; // Vérifier le solde actuel - const [compteur] = await conn.query( - `SELECT Id, Total, Solde FROM CompteurConges - WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, - [collaborateurId, recupTypeId, currentYear] - ); + const compteurResult = await transaction.request() + .input('collaborateurId', sql.Int, collaborateurId) + .input('recupTypeId', sql.Int, recupTypeId) + .input('currentYear', sql.Int, currentYear) + .query(` + SELECT Id, Total, Solde FROM CompteurConges + WHERE CollaborateurADId = @collaborateurId + AND TypeCongeId = @recupTypeId + AND Annee = @currentYear + `); + + const compteur = compteurResult.recordset; if (compteur.length === 0 || compteur[0].Solde < nombreJours) { - await conn.rollback(); - conn.release(); + await transaction.rollback(); return res.status(400).json({ success: false, message: 'Solde de récupération insuffisant', @@ -2148,30 +4141,50 @@ try { }); } + const nouveauTotal = Math.max(0, parseFloat(compteur[0].Total) - nombreJours); + const nouveauSolde = Math.max(0, parseFloat(compteur[0].Solde) - nombreJours); + // Retirer les jours - await conn.query( - `UPDATE CompteurConges - SET Total = GREATEST(0, Total - ?), - Solde = GREATEST(0, Solde - ?), - DerniereMiseAJour = NOW() - WHERE Id = ?`, - [nombreJours, nombreJours, compteur[0].Id] - ); + await transaction.request() + .input('total', sql.Decimal(10, 2), nouveauTotal) + .input('solde', sql.Decimal(10, 2), nouveauSolde) + .input('id', sql.Int, compteur[0].Id) + .query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id'); console.log(`✅ ${nombreJours}j retirés du compteur Récupération`); // Enregistrer dans l'historique - await conn.query( - `INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction) - VALUES (?, ?, ?, NOW())`, - [ - req.user.id, - 'Retrait récupération', - `Retrait de ${nombreJours}j de récupération pour ${collab.prenom} ${collab.nom}${commentaire ? ` - ${commentaire}` : ''}` - ] - ); + await transaction.request() + .input('userId', sql.Int, req.user.id) + .input('details', sql.NVarChar, `Retrait de ${nombreJours}j de récupération pour ${collab.prenom} ${collab.nom}${commentaire ? ` - ${commentaire}` : ''}`) + .query(` + INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction) + VALUES (@userId, 'Retrait récupération', @details, GETDATE()) + `); - await conn.commit(); + await transaction.commit(); + + // Webhook + try { + await webhookManager.sendWebhook( + WEBHOOKS.COLLABORATEURS_URL, + EVENTS.COMPTEUR_UPDATED, + { + collaborateurId: collaborateurId, + typeCongeId: recupTypeId, + typeConge: 'Récupération', + typeUpdate: 'retrait_manuel_rh', + annee: currentYear, + nouveauTotal: nouveauTotal, + nouveauSolde: nouveauSolde, + source: 'bouton_moins_rh', + timestamp: new Date().toISOString() + } + ); + console.log('✅ Webhook GTA-RH envoyé (compteur récup mis à jour)'); + } catch (webhookError) { + console.error('❌ Erreur webhook (non bloquant):', webhookError.message); + } // Notifier les clients SSE notifyClients({ @@ -2183,19 +4196,6 @@ try { timestamp: new Date().toISOString() }, collaborateurId); - // Envoyer webhook - try { - await webhookManager.sendWebhook( - WEBHOOKS.COLLABORATEURS_URL, - EVENTS.COMPTEUR_UPDATED, - { collaborateurId: collaborateurId } - ); - } catch (webhookError) { - console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message); - } - - conn.release(); - res.json({ success: true, message: 'Jours de récupération retirés avec succès', @@ -2205,8 +4205,7 @@ try { }); } catch (error) { - await conn.rollback(); - if (conn) conn.release(); + await transaction.rollback(); console.error('❌ Erreur retrait récupération:', error); res.status(500).json({ success: false, @@ -2218,7 +4217,7 @@ try { app.get('/equipes', authenticateToken, async (req, res) => { try { - const [equipes] = await pool.query(` + const result = await pool.request().query(` SELECT s.Id, s.Nom as nomService, @@ -2226,68 +4225,78 @@ try { COUNT(DISTINCT CASE WHEN (ca.Actif = 1 OR ca.Actif IS NULL) THEN ca.id END) as nombreMembres, COUNT(DISTINCT CASE WHEN dc.Statut = 'En attente' AND (ca.Actif = 1 OR ca.Actif IS NULL) THEN dc.Id END) as demandesEnAttente, COALESCE( - -- D'abord essayer ServiceAffectation - (SELECT GROUP_CONCAT(DISTINCT c1.Nom ORDER BY c1.Nom SEPARATOR ',') - FROM ServiceAffectation sa1 - JOIN Campus c1 ON sa1.CampusId = c1.Id - WHERE sa1.ServiceId = s.Id), - -- Sinon utiliser les campus des collaborateurs - (SELECT GROUP_CONCAT(DISTINCT c2.Nom ORDER BY c2.Nom SEPARATOR ',') - FROM CollaborateurAD ca2 - JOIN Campus c2 ON ca2.CampusId = c2.Id - WHERE ca2.ServiceId = s.Id AND (ca2.Actif = 1 OR ca2.Actif IS NULL)), + STUFF(( + SELECT DISTINCT ',' + c1.Nom + FROM ServiceAffectation sa1 + JOIN Campus c1 ON sa1.CampusId = c1.Id + WHERE sa1.ServiceId = s.Id + ORDER BY ',' + c1.Nom + FOR XML PATH('') + ), 1, 1, ''), + STUFF(( + SELECT DISTINCT ',' + c2.Nom + FROM CollaborateurAD ca2 + JOIN Campus c2 ON ca2.CampusId = c2.Id + WHERE ca2.ServiceId = s.Id AND (ca2.Actif = 1 OR ca2.Actif IS NULL) + ORDER BY ',' + c2.Nom + FOR XML PATH('') + ), 1, 1, ''), 'Non assigné' ) as campus FROM Services s LEFT JOIN CollaborateurAD ca ON s.Id = ca.ServiceId AND (ca.Actif = 1 OR ca.Actif IS NULL) LEFT JOIN DemandeConge dc ON ca.id = dc.CollaborateurADId AND dc.Statut = 'En attente' GROUP BY s.Id, s.Nom - HAVING nombreMembres > 0 OR EXISTS ( - SELECT 1 FROM ServiceAffectation sa2 WHERE sa2.ServiceId = s.Id - ) + HAVING COUNT(DISTINCT CASE WHEN (ca.Actif = 1 OR ca.Actif IS NULL) THEN ca.id END) > 0 + OR EXISTS (SELECT 1 FROM ServiceAffectation sa2 WHERE sa2.ServiceId = s.Id) ORDER BY s.Nom `); + const equipes = result.recordset; res.json(equipes); } catch (error) { - console.error('❌ Erreur /api/equipes:', error); + console.error('❌ Erreur /equipes:', error); res.status(500).json({ error: error.message }); } }); app.get('/equipes/:id', authenticateToken, async (req, res) => { try { - // ✅ CORRECTION : Retirer [equipes] et utiliser directement equipes - const equipes = await pool.query(` - SELECT - s.Id, - s.Nom, - s.Nom as nomService, - COUNT(DISTINCT CASE - WHEN (ca.Actif = 1 OR ca.Actif IS NULL) - THEN ca.id - END) as nombreMembres, - COUNT(DISTINCT CASE - WHEN dc.Statut = 'En attente' - AND (ca.Actif = 1 OR ca.Actif IS NULL) - THEN dc.Id - END) as demandesEnAttente, - COALESCE( - GROUP_CONCAT(DISTINCT c.Nom ORDER BY c.Nom SEPARATOR ', '), - 'Non assigné' - ) as campus - FROM Services s - LEFT JOIN CollaborateurAD ca - ON s.Id = ca.ServiceId - LEFT JOIN DemandeConge dc - ON ca.id = dc.CollaborateurADId - LEFT JOIN ServiceAffectation sa - ON s.Id = sa.ServiceId - LEFT JOIN Campus c - ON sa.CampusId = c.Id - WHERE s.Id = ? - GROUP BY s.Id, s.Nom - `, [req.params.id]); + const result = await pool.request() + .input('id', sql.Int, req.params.id) + .query(` + SELECT + s.Id, + s.Nom, + s.Nom as nomService, + COUNT(DISTINCT CASE + WHEN (ca.Actif = 1 OR ca.Actif IS NULL) + THEN ca.id + END) as nombreMembres, + COUNT(DISTINCT CASE + WHEN dc.Statut = 'En attente' + AND (ca.Actif = 1 OR ca.Actif IS NULL) + THEN dc.Id + END) as demandesEnAttente, + COALESCE( + STUFF(( + SELECT DISTINCT ', ' + c.Nom + FROM ServiceAffectation sa + JOIN Campus c ON sa.CampusId = c.Id + WHERE sa.ServiceId = s.Id + ORDER BY ', ' + c.Nom + FOR XML PATH('') + ), 1, 2, ''), + 'Non assigné' + ) as campus + FROM Services s + LEFT JOIN CollaborateurAD ca ON s.Id = ca.ServiceId + LEFT JOIN DemandeConge dc ON ca.id = dc.CollaborateurADId + WHERE s.Id = @id + GROUP BY s.Id, s.Nom + `); + + const equipes = result.recordset; if (equipes.length === 0) { return res.status(404).json({ error: 'Service non trouvé' }); @@ -2300,37 +4309,34 @@ try { } }); - app.get('/equipes/:id/membres', authenticateToken, async (req, res) => { try { - // ✅ CORRECTION : Ajout de "=" dans les JOIN - const membres = await pool.query(` - SELECT - ca.id, - CONCAT(ca.prenom, ' ', ca.nom) as nom, - ca.email, - ca.role, - ca.Actif, - ca.DateSortie, - ca.TypeContrat, - MAX(COALESCE(cc.Solde, 0)) as soldeCP, - MAX(COALESCE(cc2.Solde, 0)) as soldeRTT - FROM CollaborateurAD ca - LEFT JOIN CompteurConges cc - ON ca.id = cc.CollaborateurADId - AND cc.TypeCongeId = 1 - AND cc.Annee = YEAR(NOW()) - LEFT JOIN CompteurConges cc2 - ON ca.id = cc2.CollaborateurADId - AND cc2.TypeCongeId = 2 - AND cc2.Annee = YEAR(NOW()) - WHERE ca.ServiceId = ? - AND (ca.Actif = 1 OR ca.Actif IS NULL) - GROUP BY ca.id, ca.prenom, ca.nom, ca.email, ca.role, - ca.Actif, ca.DateSortie, ca.TypeContrat - ORDER BY ca.nom, ca.prenom - `, [req.params.id]); + const result = await pool.request() + .input('id', sql.Int, req.params.id) + .query(` + SELECT + ca.id, + CONCAT(ca.prenom, ' ', ca.nom) as nom, + ca.email, + ca.role, + ca.Actif, + ca.DateSortie, + ca.TypeContrat, + (SELECT MAX(Solde) FROM CompteurConges cc + WHERE cc.CollaborateurADId = ca.id + AND cc.TypeCongeId = 1 + AND cc.Annee = YEAR(GETDATE())) as soldeCP, + (SELECT MAX(Solde) FROM CompteurConges cc2 + WHERE cc2.CollaborateurADId = ca.id + AND cc2.TypeCongeId = 2 + AND cc2.Annee = YEAR(GETDATE())) as soldeRTT + FROM CollaborateurAD ca + WHERE ca.ServiceId = @id + AND (ca.Actif = 1 OR ca.Actif IS NULL) + ORDER BY ca.nom, ca.prenom + `); + const membres = result.recordset; console.log(`${membres.length} membres trouvés pour le service ${req.params.id}`); res.json(membres); } catch (error) { @@ -2339,1406 +4345,102 @@ try { } }); - - app.get('/stats', authenticateToken, async (req, res) => { try { - const [stats] = await pool.query(` + const result = await pool.request().query(` SELECT COUNT(CASE WHEN Statut = 'En attente' AND (ca.Actif = 1 OR ca.Actif IS NULL) THEN 1 END) as enAttente, - COUNT(CASE WHEN Statut = 'Validée' AND MONTH(DateValidation) = MONTH(NOW()) AND (ca.Actif = 1 OR ca.Actif IS NULL) THEN 1 END) as valideeCeMois, + COUNT(CASE WHEN Statut = 'Validée' AND MONTH(DateValidation) = MONTH(GETDATE()) AND (ca.Actif = 1 OR ca.Actif IS NULL) THEN 1 END) as valideeCeMois, COUNT(DISTINCT ca.ServiceId) as nombreEquipes FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id WHERE (ca.Actif = 1 OR ca.Actif IS NULL) `); + const stats = result.recordset; res.json(stats[0]); } catch (error) { res.status(500).json({ error: error.message }); } }); + // Route /historique modifiée pour inclure le collaborateur concerné ET la personne qui a réalisé l'action + app.get('/historique', authenticateToken, async (req, res) => { try { const { dateDebut, dateFin, action } = req.query; - let query = ` - SELECT - ha.Id, - CONCAT(ca.prenom, ' ', ca.nom) AS collaborateur, - ha.Action, - ha.Details, - ha.DateAction, - ha.AdresseIP, - ha.DemandeCongeId - FROM HistoriqueActions ha - JOIN CollaborateurAD ca ON ha.CollaborateurADId = ca.id - WHERE 1=1 - `; + const request = pool.request(); - const params = []; + let whereClause = 'WHERE 1=1'; if (dateDebut) { - query += ' AND ha.DateAction >= ?'; - params.push(dateDebut); + whereClause += ' AND ha.DateAction >= @dateDebut'; + request.input('dateDebut', sql.DateTime, dateDebut); } if (dateFin) { - query += ' AND ha.DateAction <= ?'; - params.push(dateFin); + whereClause += ' AND ha.DateAction <= @dateFin'; + request.input('dateFin', sql.DateTime, dateFin); } if (action && action !== 'all') { - query += ' AND ha.Action = ?'; - params.push(action); + whereClause += ' AND ha.Action = @action'; + request.input('action', sql.VarChar, action); } - query += ' ORDER BY ha.DateAction DESC LIMIT 500'; + // ✅ REQUÊTE MODIFIÉE : Inclut le collaborateur concerné ET la personne qui a réalisé l'action + const query = ` + SELECT TOP 500 + ha.Id, + -- Personne qui a réalisé l'action (celui qui est dans HistoriqueActions.CollaborateurADId) + CONCAT(ca.prenom, ' ', ca.nom) AS realisePar, + ca.email AS realiseParEmail, + -- Collaborateur concerné par l'action (récupéré via la demande de congé) + CASE + WHEN dc.CollaborateurADId IS NOT NULL + THEN CONCAT(collab_concerne.prenom, ' ', collab_concerne.nom) + ELSE CONCAT(ca.prenom, ' ', ca.nom) + END AS collaborateur, + CASE + WHEN dc.CollaborateurADId IS NOT NULL + THEN collab_concerne.email + ELSE ca.email + END AS collaborateurEmail, + ha.Action, + ha.Details, + ha.DateAction, + ha.AdresseIP, + ha.DemandeCongeId + FROM HistoriqueActions ha + -- Jointure pour récupérer la personne qui a réalisé l'action + JOIN CollaborateurAD ca ON ha.CollaborateurADId = ca.id + -- Jointure optionnelle pour récupérer la demande de congé associée + LEFT JOIN DemandeConge dc ON ha.DemandeCongeId = dc.Id + -- Jointure optionnelle pour récupérer le collaborateur concerné par la demande + LEFT JOIN CollaborateurAD collab_concerne ON dc.CollaborateurADId = collab_concerne.id + ${whereClause} + ORDER BY ha.DateAction DESC + `; - const [historique] = await pool.query(query, params); + console.log('📊 Requête historique SQL:', query); + + const result = await request.query(query); + const historique = result.recordset || []; + + console.log(`✅ ${historique.length} entrées d'historique trouvées`); + + // ⭐ TOUJOURS retourner un tableau res.json(historique); } catch (error) { + console.error('❌ Erreur /historique:', error); res.status(500).json({ error: error.message }); } }); - // ================================================ - // ROUTES COMPTES-RENDUS D'ACTIVITÉS (RH) + // FONCTIONS DE CALCUL CP ET RTT // ================================================ - // GET - Stats globales des comptes-rendus - app.get('/compte-rendu/stats', authenticateToken, async (req, res) => { - const { annee, mois, service_id } = req.query; - - console.log('📊 Route /api/compte-rendu/stats appelée'); - console.log(' Année:', annee, 'Mois:', mois, 'Service:', service_id); - - try { - let query = ` - SELECT - COUNT(DISTINCT ca.id) as totalCollaborateurs, - COALESCE(SUM(CASE WHEN crm.Verrouille = 1 OR EXISTS( - SELECT 1 FROM CompteRenduActivites cra - WHERE cra.CollaborateurADId = ca.id - AND cra.Annee = ? AND cra.Mois = ? - ) THEN 1 ELSE 0 END), 0) as mensuelsValides, - COALESCE(COUNT(DISTINCT ca.id) - SUM(CASE WHEN crm.Verrouille = 1 OR EXISTS( - SELECT 1 FROM CompteRenduActivites cra - WHERE cra.CollaborateurADId = ca.id - AND cra.Annee = ? AND cra.Mois = ? - ) THEN 1 ELSE 0 END), 0) as mensuelsEnAttente - FROM CollaborateurAD ca - LEFT JOIN CompteRenduMensuel crm ON ca.id = crm.CollaborateurADId - AND crm.Annee = ? - AND crm.Mois = ? - WHERE ca.TypeContrat = 'forfait_jour' - AND (ca.Actif = 1 OR ca.Actif IS NULL) - `; - - const params = [annee, mois, annee, mois, annee, mois]; - - // Ajouter le filtre service si fourni - if (service_id && service_id !== 'all') { - query += ' AND ca.ServiceId = ?'; - params.push(service_id); - } - - const [stats] = await pool.query(query, params); - - console.log('✅ Stats récupérées:', stats[0]); - - res.json({ - totalCollaborateurs: stats[0].totalCollaborateurs || 0, - mensuelsValides: stats[0].mensuelsValides || 0, - mensuelsEnAttente: stats[0].mensuelsEnAttente || 0 - }); - } catch (error) { - console.error('❌ Erreur stats:', error); - console.error(' Message:', error.message); - console.error(' Code:', error.code); - - // Retourner des stats par défaut en cas d'erreur - res.json({ - totalCollaborateurs: 0, - mensuelsValides: 0, - mensuelsEnAttente: 0, - error: error.message - }); - } - }); - - - // Liste des collaborateurs en forfait jour avec leur statut de saisie - app.get('/compte-rendu/collaborateurs', authenticateToken, async (req, res) => { - try { - if (!['RH', 'Admin'].includes(req.user.role)) { - return res.status(403).json({ error: 'Accès non autorisé' }); - } - - const { annee, mois, service_id } = req.query; - - let query = ` - SELECT - ca.id, - ca.nom, - ca.prenom, - CONCAT(ca.prenom, ' ', ca.nom) AS nomComplet, - ca.email, - s.Nom AS service, - s.Id AS serviceId, - crm.Statut as statutMensuel, - crm.Verrouille, - crm.NbJoursTravailles, - crm.NbJoursNonRespectsReposQuotidien, - crm.NbJoursNonRespectsReposHebdo, - crm.DateValidation, - (SELECT COUNT(*) FROM CompteRenduActivites cra - WHERE cra.CollaborateurADId = ca.id - AND cra.Annee = ? AND cra.Mois = ?) as nbJoursSaisis - FROM CollaborateurAD ca - LEFT JOIN Services s ON ca.ServiceId = s.Id - LEFT JOIN CompteRenduMensuel crm ON ca.id = crm.CollaborateurADId - AND crm.Annee = ? AND crm.Mois = ? - WHERE ca.TypeContrat = 'forfait_jour' - AND (ca.Actif = 1 OR ca.Actif IS NULL) - `; - - const params = [annee, mois, annee, mois]; - - // 🆕 Ajouter le filtre service si fourni - if (service_id && service_id !== 'all') { - query += ' AND ca.ServiceId = ?'; - params.push(service_id); - } - - query += ' ORDER BY ca.nom, ca.prenom'; - - const [collaborateurs] = await pool.query(query, params); - - res.json(collaborateurs); - } catch (error) { - console.error('Erreur liste collaborateurs compte-rendu:', error); - res.status(500).json({ error: error.message }); - } - }); - - // Verrouiller un compte-rendu (depuis RH) - app.post('/compte-rendu/verrouiller', authenticateToken, async (req, res) => { - try { - if (!['RH', 'Admin'].includes(req.user.role)) { - return res.status(403).json({ error: 'Accès non autorisé' }); - } - - const { collaborateur_id, annee, mois } = req.body; - - console.log('🔒 Verrouillage manuel par RH'); - console.log(' Collaborateur:', collaborateur_id, 'Année:', annee, 'Mois:', mois); - - // Calculer les stats - const [stats] = await pool.query(` - SELECT - COUNT(*) as nbJours, - SUM(CASE WHEN NOT ReposQuotidienRespect THEN 1 ELSE 0 END) as nbNonRespectQuotidien, - SUM(CASE WHEN NOT ReposHebdomadaireRespect THEN 1 ELSE 0 END) as nbNonRespectHebdo - FROM CompteRenduActivites - WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ? AND JourTravaille = TRUE - `, [collaborateur_id, annee, mois]); - - // Créer ou mettre à jour le mensuel - await pool.query(` - INSERT INTO CompteRenduMensuel - (CollaborateurADId, Annee, Mois, NbJoursTravailles, - NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo, - Statut, DateValidation, Verrouille) - VALUES (?, ?, ?, ?, ?, ?, 'Validé', NOW(), TRUE) - ON DUPLICATE KEY UPDATE - NbJoursTravailles = VALUES(NbJoursTravailles), - NbJoursNonRespectsReposQuotidien = VALUES(NbJoursNonRespectsReposQuotidien), - NbJoursNonRespectsReposHebdo = VALUES(NbJoursNonRespectsReposHebdo), - Statut = 'Validé', - DateValidation = NOW(), - Verrouille = TRUE, - DateModification = NOW() - `, [collaborateur_id, annee, mois, stats[0].nbJours, stats[0].nbNonRespectQuotidien, stats[0].nbNonRespectHebdo]); - - // Enregistrer l'action dans l'historique - await pool.query(` - INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction) - VALUES (?, ?, ?, NOW()) - `, [ - req.user.id, - 'Verrouillage compte-rendu', - `Verrouillage du mois ${mois}/${annee} pour le collaborateur ${collaborateur_id}` - ]); - - console.log(' ✅ Mois verrouillé avec succès'); - - res.json({ - success: true, - message: 'Compte-rendu verrouillé avec succès' - }); - - } catch (error) { - console.error('❌ Erreur verrouillage compte-rendu:', error); - res.status(500).json({ - success: false, - error: error.message - }); - } - }); - - // Déverrouiller un compte-rendu (depuis RH) - app.post('/compte-rendu/deverrouiller', authenticateToken, async (req, res) => { - try { - if (!['RH', 'Admin'].includes(req.user.role)) { - return res.status(403).json({ error: 'Accès non autorisé' }); - } - - const { collaborateur_id, annee, mois } = req.body; - - console.log('🔓 Déverrouillage manuel par RH'); - console.log(' Collaborateur:', collaborateur_id, 'Année:', annee, 'Mois:', mois); - - // Mettre à jour le statut - await pool.query(` - UPDATE CompteRenduMensuel - SET Verrouille = FALSE, - Statut = 'En cours', - DateModification = NOW() - WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ? - `, [collaborateur_id, annee, mois]); - - // Enregistrer l'action dans l'historique - await pool.query(` - INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction) - VALUES (?, ?, ?, NOW()) - `, [ - req.user.id, - 'Déverrouillage compte-rendu', - `Déverrouillage du mois ${mois}/${annee} pour le collaborateur ${collaborateur_id}` - ]); - - console.log(' ✅ Mois déverrouillé avec succès'); - - res.json({ - success: true, - message: 'Compte-rendu déverrouillé. Le collaborateur peut maintenant modifier ses saisies.' - }); - - } catch (error) { - console.error('❌ Erreur déverrouillage compte-rendu:', error); - res.status(500).json({ - success: false, - error: error.message - }); - } - }); - - - // Export Excel de tous les comptes-rendus - app.get('/compte-rendu/export-excel', authenticateToken, async (req, res) => { - try { - if (!['RH', 'Admin'].includes(req.user.role)) { - return res.status(403).json({ error: 'Accès non autorisé' }); - } - - const { annee, mois, service_id } = req.query; - - let query = ` - SELECT - CONCAT(ca.prenom, ' ', ca.nom) AS Collaborateur, - ca.email AS Email, - s.Nom AS Service, - crm.NbJoursTravailles AS 'Jours travaillés', - crm.NbJoursNonRespectsReposQuotidien AS 'Non-respect repos quotidien', - crm.NbJoursNonRespectsReposHebdo AS 'Non-respect repos hebdo', - crm.Statut AS Statut, - crm.Verrouille AS Verrouillé, - DATE_FORMAT(crm.DateValidation, '%d/%m/%Y %H:%i') AS 'Date validation' - FROM CollaborateurAD ca - LEFT JOIN Services s ON ca.ServiceId = s.Id - LEFT JOIN CompteRenduMensuel crm ON ca.id = crm.CollaborateurADId - AND crm.Annee = ? AND crm.Mois = ? - WHERE ca.TypeContrat = 'forfait_jour' - AND (ca.Actif = 1 OR ca.Actif IS NULL) - `; - - const params = [annee, mois]; - - // 🆕 Ajouter le filtre service si fourni - if (service_id && service_id !== 'all') { - query += ' AND ca.ServiceId = ?'; - params.push(service_id); - } - - query += ' ORDER BY s.Nom, ca.nom, ca.prenom'; - - const [data] = await pool.query(query, params); - - res.json(data); - } catch (error) { - console.error('Erreur export Excel:', error); - res.status(500).json({ error: error.message }); - } - }); - - app.get('/compte-rendu/services', authenticateToken, async (req, res) => { - try { - if (!['RH', 'Admin'].includes(req.user.role)) { - return res.status(403).json({ error: 'Accès non autorisé' }); - } - - const [services] = await pool.query(` - SELECT - s.Id, - s.Nom, - COUNT(DISTINCT ca.id) as nombreForfaitJour - FROM Services s - LEFT JOIN CollaborateurAD ca ON s.Id = ca.ServiceId - AND ca.TypeContrat = 'forfait_jour' - AND (ca.Actif = 1 OR ca.Actif IS NULL) - GROUP BY s.Id, s.Nom - HAVING nombreForfaitJour > 0 - ORDER BY s.Nom - `); - - res.json(services); - } catch (error) { - console.error('Erreur liste services:', error); - res.status(500).json({ error: error.message }); - } - }); - app.get('/compte-rendu-activites', authenticateToken, async (req, res) => { - try { - const { user_id, annee, mois } = req.query; - - console.log('📥 GET /api/compte-rendu-activites'); - console.log(' User ID:', user_id, 'Année:', annee, 'Mois:', mois); - - if (!user_id || !annee || !mois) { - return res.status(400).json({ - success: false, - message: 'Paramètres manquants (user_id, annee, mois)' - }); - } - - // Récupérer les jours du compte-rendu - const [jours] = await pool.query(` - SELECT - Id, - CollaborateurADId, - JourDate, - JourTravaille, - ReposQuotidienRespect, - ReposHebdomadaireRespect, - CommentaireRepos, - DateSaisie, - DateModification, - Annee, - Mois - FROM CompteRenduActivites - WHERE CollaborateurADId = ? - AND Annee = ? - AND Mois = ? - ORDER BY JourDate ASC - `, [user_id, annee, mois]); - - console.log(` ✅ ${jours.length} jours trouvés`); - - // 🆕 SI DES JOURS EXISTENT, CRÉER/METTRE À JOUR AUTOMATIQUEMENT LE RÉCAPITULATIF MENSUEL VERROUILLÉ - let mensuelData = null; - - if (jours.length > 0) { - // Calculer les statistiques - const nbJoursTravailles = jours.filter(j => j.JourTravaille).length; - const nbNonRespectQuotidien = jours.filter(j => !j.ReposQuotidienRespect && j.JourTravaille).length; - const nbNonRespectHebdo = jours.filter(j => !j.ReposHebdomadaireRespect && j.JourTravaille).length; - - // Vérifier si le mensuel existe déjà - const [existingMensuel] = await pool.query(` - SELECT * FROM CompteRenduMensuel - WHERE CollaborateurADId = ? - AND Annee = ? - AND Mois = ? - LIMIT 1 - `, [user_id, annee, mois]); - - if (existingMensuel.length === 0) { - // 🆕 CRÉER AUTOMATIQUEMENT UN MENSUEL VERROUILLÉ - await pool.query(` - INSERT INTO CompteRenduMensuel - (CollaborateurADId, Annee, Mois, NbJoursTravailles, - NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo, - Statut, DateValidation, Verrouille, DateModification) - VALUES (?, ?, ?, ?, ?, ?, 'Validé', NOW(), TRUE, NOW()) - `, [user_id, annee, mois, nbJoursTravailles, nbNonRespectQuotidien, nbNonRespectHebdo]); - - console.log(' ✅ Mensuel créé automatiquement et verrouillé'); - - mensuelData = { - CollaborateurADId: parseInt(user_id), - Annee: parseInt(annee), - Mois: parseInt(mois), - NbJoursTravailles: nbJoursTravailles, - NbJoursNonRespectsReposQuotidien: nbNonRespectQuotidien, - NbJoursNonRespectsReposHebdo: nbNonRespectHebdo, - Statut: 'Validé', - Verrouille: 1, - DateValidation: new Date(), - DateModification: new Date() - }; - } else { - // 🆕 METTRE À JOUR LES STATS ET VERROUILLER AUTOMATIQUEMENT - await pool.query(` - UPDATE CompteRenduMensuel - SET NbJoursTravailles = ?, - NbJoursNonRespectsReposQuotidien = ?, - NbJoursNonRespectsReposHebdo = ?, - Statut = 'Validé', - Verrouille = TRUE, - DateValidation = NOW(), - DateModification = NOW() - WHERE CollaborateurADId = ? - AND Annee = ? - AND Mois = ? - `, [nbJoursTravailles, nbNonRespectQuotidien, nbNonRespectHebdo, user_id, annee, mois]); - - console.log(' ✅ Mensuel mis à jour et verrouillé automatiquement'); - - mensuelData = { - ...existingMensuel[0], - NbJoursTravailles: nbJoursTravailles, - NbJoursNonRespectsReposQuotidien: nbNonRespectQuotidien, - NbJoursNonRespectsReposHebdo: nbNonRespectHebdo, - Statut: 'Validé', - Verrouille: 1, - DateValidation: new Date(), - DateModification: new Date() - }; - } - } else { - // Pas de jours saisis, vérifier quand même s'il existe un mensuel - const [existingMensuel] = await pool.query(` - SELECT * FROM CompteRenduMensuel - WHERE CollaborateurADId = ? - AND Annee = ? - AND Mois = ? - LIMIT 1 - `, [user_id, annee, mois]); - - mensuelData = existingMensuel.length > 0 ? existingMensuel[0] : null; - console.log(' ℹ️ Aucun jour saisi pour ce mois'); - } - - res.json({ - success: true, - jours: jours, - mensuel: mensuelData, - collaborateurId: parseInt(user_id), - annee: parseInt(annee), - mois: parseInt(mois), - autoValidated: jours.length > 0 // Indicateur de validation automatique - }); - - } catch (error) { - console.error('❌ Erreur GET /api/compte-rendu-activites:', error); - res.status(500).json({ - success: false, - message: 'Erreur serveur', - error: error.message - }); - } - }); - - - // ⭐ NOUVELLE ROUTE : Saisie de récupération par les RH pour un collaborateur - app.post('/compteurs/saisir-recup-collaborateur', authenticateToken, async (req, res) => { - const conn = await pool.getConnection(); - try { - await conn.beginTransaction(); - - if (!['Admin', 'RH'].includes(req.user.role)) { - await conn.rollback(); - conn.release(); - return res.status(403).json({ error: 'Accès non autorisé' }); - } - - const { - collaborateur_id, - date, - periode_journee, // 'Matin', 'Après-midi', 'Journée entière' - commentaire - } = req.body; - - console.log('📥 === SAISIE RÉCUP PAR RH ==='); - console.log('Collaborateur:', collaborateur_id); - console.log('Date:', date); - console.log('Période:', periode_journee); - - if (!collaborateur_id || !date || !periode_journee) { - await conn.rollback(); - conn.release(); - return res.json({ - success: false, - message: 'Données manquantes' - }); - } - - const dateObj = new Date(date); - - // Calculer le nombre de jours selon la période - let nombre_heures; - switch (periode_journee) { - case 'Matin': - case 'Après-midi': - nombre_heures = 0.5; - break; - case 'Journée entière': - nombre_heures = 1; - break; - default: - await conn.rollback(); - conn.release(); - return res.json({ - success: false, - message: 'Période invalide' - }); - } - - // Vérifier que ce jour/période n'a pas déjà été saisi - const [existing] = await conn.query(` - SELECT dc.Id - FROM DemandeConge dc - JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId - JOIN TypeConge tc ON dct.TypeCongeId = tc.Id - WHERE dc.CollaborateurADId = ? - AND dc.DateDebut = ? - AND tc.Nom = 'Récupération' - AND dct.PeriodeJournee = ? - `, [collaborateur_id, date, periode_journee]); - - if (existing.length > 0) { - await conn.rollback(); - conn.release(); - return res.json({ - success: false, - message: `Cette date (${periode_journee}) a déjà été déclarée` - }); - } - - // Récupérer infos collaborateur - const [userInfo] = await conn.query( - 'SELECT prenom, nom, email FROM CollaborateurAD WHERE id = ?', - [collaborateur_id] - ); - - if (userInfo.length === 0) { - await conn.rollback(); - conn.release(); - return res.json({ - success: false, - message: 'Collaborateur non trouvé' - }); - } - - const user = userInfo[0]; - const userName = `${user.prenom} ${user.nom}`; - const dateFormatted = dateObj.toLocaleDateString('fr-FR'); - - // Récupérer le type Récupération - // 🎨 Récupérer le type Récupération avec sa couleur - const [recupType] = await conn.query( - 'SELECT Id, CouleurHex FROM TypeConge WHERE Nom = ? LIMIT 1', - ['Récupération'] - ); - - if (recupType.length === 0) { - await conn.rollback(); - conn.release(); - return res.json({ - success: false, - message: 'Type Récupération non trouvé. Veuillez créer ce type de congé.' - }); - } - - const recupTypeId = recupType[0].Id; - const couleurRecup = recupType[0].CouleurHex || '#d946ef'; // Fuchsia par défaut - const currentYear = dateObj.getFullYear(); - - console.log(`🎨 Type Récupération trouvé - ID: ${recupTypeId}, Couleur: ${couleurRecup}`); - - // CRÉER LA DEMANDE (validée automatiquement avec ValidateurADId) - const commentaireFinal = commentaire || `🎯Jour travaillé (${periode_journee}) - Saisi par RH`; - - const [result] = await conn.query(` - INSERT INTO DemandeConge - (CollaborateurADId, DateDebut, DateFin, - Statut, DateDemande, DateValidation, ValidateurADId, Commentaire, NombreJours) - VALUES (?, ?, ?, 'Validée', NOW(), NOW(), ?, ?, ?) -`, [collaborateur_id, date, date, req.user.id, commentaireFinal, nombre_heures]); - - const demandeId = result.insertId; - - // SAUVEGARDER DANS DemandeCongeType - await conn.query(` - INSERT INTO DemandeCongeType - (DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee) - VALUES (?, ?, ?, ?) - `, [demandeId, recupTypeId, nombre_heures, periode_journee]); - - // ACCUMULER DANS LE COMPTEUR - const [compteur] = await conn.query(` - SELECT Id, Total, Solde - FROM CompteurConges - WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? - `, [collaborateur_id, recupTypeId, currentYear]); - - if (compteur.length > 0) { - await conn.query(` - UPDATE CompteurConges - SET Total = Total + ?, - Solde = Solde + ?, - DerniereMiseAJour = NOW() - WHERE Id = ? - `, [nombre_heures, nombre_heures, compteur[0].Id]); - - console.log(`✅ Compteur mis à jour: ${parseFloat(compteur[0].Solde) + nombre_heures}j`); - } else { - await conn.query(` - INSERT INTO CompteurConges - (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) - VALUES (?, ?, ?, ?, ?, 0, NOW()) - `, [collaborateur_id, recupTypeId, currentYear, nombre_heures, nombre_heures]); - - console.log(`✅ Compteur créé: ${nombre_heures}j`); - } - - // ENREGISTRER L'ACCUMULATION - await conn.query(` - INSERT INTO DeductionDetails - (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) - VALUES (?, ?, ?, 'Accum Récup', ?) - `, [demandeId, recupTypeId, currentYear, nombre_heures]); - - // CRÉER NOTIFICATION POUR LE COLLABORATEUR - await conn.query(` - INSERT INTO Notifications - (CollaborateurADId, Type, Titre, Message, DemandeCongeId, DateCreation, Lu) - VALUES (?, 'Success', '✅ Récupération enregistrée', ?, ?, NOW(), 0) - `, [ - collaborateur_id, - `Date ${dateFormatted} (${periode_journee}) enregistrée par RH : +${nombre_heures}j de récupération`, - demandeId - ]); - - // ENREGISTRER L'ACTION DANS L'HISTORIQUE - await conn.query( - `INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction) - VALUES (?, ?, ?, NOW())`, - [ - req.user.id, - 'Saisie récupération RH', - `Ajout de ${nombre_heures}j (${periode_journee}) pour ${userName} - ${dateFormatted}` - ] - ); - - await conn.commit(); - - // ⭐ ENVOYER WEBHOOK AU SERVEUR COLLABORATEURS - // ⭐ ENVOYER WEBHOOKS AU SERVEUR COLLABORATEURS - // ⭐ ENVOYER WEBHOOKS AU SERVEUR COLLABORATEURS - try { - // 1. Notifier la création de la demande validée (IMPORTANT pour le calendrier) - await webhookManager.sendWebhook( - WEBHOOKS.COLLABORATEURS_URL, - EVENTS.DEMANDE_VALIDATED, - { - demandeId: demandeId, - statut: 'Validée', - collaborateurId: collaborateur_id, - validateurId: req.user.id, - typeConge: 'Récupération', - type: 'Récupération',// ✅ Envoyer le nom exact - couleurHex: couleurRecup, // ⭐ AJOUTER LA COULEUR - date: date, - periode: periode_journee, - nombre_heures: nombre_heures - } - ); - - // 2. Notifier la mise à jour du compteur - await webhookManager.sendWebhook( - WEBHOOKS.COLLABORATEURS_URL, - EVENTS.COMPTEUR_UPDATED, - { - collaborateurId: collaborateur_id, - typeUpdate: 'recup_ajoutee', - date: date, - periode: periode_journee, - jours: nombre_heures - } - ); - - console.log('✅ Webhooks envoyés au serveur collaborateurs'); - } catch (webhookError) { - console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message); - } - // Notifier les clients SSE (IMPORTANT pour rafraîchir le calendrier) - notifyClients({ - type: 'demande-validated', - demandeId: demandeId, - statut: 'Validée', - collaborateurId: collaborateur_id, - typeConge: 'Récupération', - timestamp: new Date().toISOString() - }, collaborateur_id); - - notifyClients({ - type: 'compteur-updated', - collaborateurId: collaborateur_id, - typeConge: 'Récupération', - action: 'ajout_rh', - nombreJours: nombre_heures, - timestamp: new Date().toISOString() - }, collaborateur_id); - - // ENVOYER EMAIL AU COLLABORATEUR - const accessToken = await getGraphToken(); - if (accessToken && user.email) { - const fromEmail = 'noreply@ensup.eu'; - const subject = '✅ Récupération enregistrée par RH'; - const body = ` -
-
-

✅ Récupération ajoutée

-
-
-

Bonjour ${userName},

-

Le service RH a enregistré une récupération pour vous.

-
-

Date : ${dateFormatted}

-

Période : ${periode_journee}

-

Jours ajoutés : ${nombre_heures} jour${nombre_heures > 1 ? 's' : ''}

- ${commentaire ? `

Commentaire : ${commentaire}

` : ''} -
-

Ces jours sont désormais disponibles dans votre solde de récupération.

-
-
- `; - - try { - await sendMailGraph(accessToken, fromEmail, user.email, subject, body); - console.log('✅ Email de notification envoyé'); - } catch (emailError) { - console.error('❌ Erreur envoi email:', emailError); - } - } - - conn.release(); - res.json({ - success: true, - message: `Récupération enregistrée pour ${userName}`, - collaborateur: userName, - date: dateFormatted, - periode: periode_journee, - jours_ajoutes: nombre_heures, - demande_id: demandeId, - couleur: couleurRecup // 🎨 Retourner la couleur - }); - - } catch (error) { - await conn.rollback(); - if (conn) conn.release(); - console.error('❌ Erreur saisie récup RH:', error); - res.status(500).json({ - success: false, - message: 'Erreur serveur', - error: error.message - }); - } - }); - // 🆕 BONUS : Route pour exporter le PDF d'un compte-rendu - app.get('/export-compte-rendu-pdf', authenticateToken, async (req, res) => { - try { - if (!['RH', 'Admin'].includes(req.user.role)) { - return res.status(403).json({ error: 'Accès non autorisé' }); - } - - const { user_id, annee, mois } = req.query; - - console.log('📄 Export PDF compte-rendu'); - console.log(' User ID:', user_id, 'Année:', annee, 'Mois:', mois); - - // Récupérer les infos du collaborateur - const [collaborateur] = await pool.query(` - SELECT - ca.id, - ca.nom, - ca.prenom, - ca.email, - s.Nom as service - FROM CollaborateurAD ca - LEFT JOIN Services s ON ca.ServiceId = s.Id - WHERE ca.id = ? - `, [user_id]); - - if (collaborateur.length === 0) { - return res.status(404).json({ error: 'Collaborateur non trouvé' }); - } - - const collab = collaborateur[0]; - - // Récupérer les jours - const [jours] = await pool.query(` - SELECT - JourDate, - JourTravaille, - ReposQuotidienRespect, - ReposHebdomadaireRespect, - CommentaireRepos - FROM CompteRenduActivites - WHERE CollaborateurADId = ? - AND Annee = ? - AND Mois = ? - ORDER BY JourDate ASC - `, [user_id, annee, mois]); - - // Récupérer le mensuel - const [mensuel] = await pool.query(` - SELECT - NbJoursTravailles, - NbJoursNonRespectsReposQuotidien, - NbJoursNonRespectsReposHebdo, - Statut, - Verrouille, - DateValidation - FROM CompteRenduMensuel - WHERE CollaborateurADId = ? - AND Annee = ? - AND Mois = ? - LIMIT 1 - `, [user_id, annee, mois]); - - const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', - 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; - - // GÉNÉRER LE PDF - const doc = new PDFDocument({ - margin: 50, - size: 'A4' - }); - - // Headers pour le téléchargement - res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', `attachment; filename=compte-rendu-${collab.nom}-${annee}-${String(mois).padStart(2, '0')}.pdf`); - - // Pipe le PDF directement dans la réponse - doc.pipe(res); - - // ===== EN-TÊTE ===== - doc.fontSize(20) - .fillColor('#0ea5e9') - .text('Compte-rendu d\'activités', { align: 'center' }) - .moveDown(0.5); - - doc.fontSize(14) - .fillColor('#6b7280') - .text(`${monthNames[parseInt(mois) - 1]} ${annee}`, { align: 'center' }) - .moveDown(2); - - // ===== INFORMATIONS COLLABORATEUR ===== - doc.fontSize(12) - .fillColor('#000000') - .text('Collaborateur', { underline: true }) - .moveDown(0.3); - - doc.fontSize(10) - .fillColor('#374151') - .text(`Nom: ${collab.prenom} ${collab.nom}`) - .text(`Email: ${collab.email}`) - .text(`Service: ${collab.service || 'Non défini'}`) - .moveDown(1.5); - - // ===== RÉSUMÉ MENSUEL ===== - if (mensuel.length > 0) { - const m = mensuel[0]; - - doc.fontSize(12) - .fillColor('#000000') - .text('Résumé du mois', { underline: true }) - .moveDown(0.3); - - // Rectangle de fond pour les stats - const y = doc.y; - doc.rect(50, y, 495, 80) - .fillAndStroke('#f0f9ff', '#0ea5e9'); - - doc.fillColor('#000000') - .fontSize(10) - .text(`Jours travaillés: ${m.NbJoursTravailles || 0}`, 60, y + 10) - .text(`Non-respect repos quotidien: ${m.NbJoursNonRespectsReposQuotidien || 0}`, 60, y + 30) - .text(`Non-respect repos hebdomadaire: ${m.NbJoursNonRespectsReposHebdo || 0}`, 60, y + 50); - - const statutText = m.Verrouille ? 'Verrouillé' : 'Ouvert'; - doc.text(`Statut: ${statutText}`, 300, y + 10); - - if (m.DateValidation) { - const dateValidation = new Date(m.DateValidation).toLocaleDateString('fr-FR'); - doc.text(`Date validation: ${dateValidation}`, 300, y + 30); - } - - doc.y = y + 90; - doc.moveDown(1); - } - - // ===== DÉTAIL DES JOURS ===== - if (jours.length > 0) { - doc.fontSize(12) - .fillColor('#000000') - .text('Détail des jours', { underline: true }) - .moveDown(0.5); - - // ✅ DÉFINIR LES POSITIONS DES COLONNES - const cols = { - date: 60, - travaille: 150, - repos: 285, // Centre de la colonne "Respect des repos" - commentaire: 420 - }; - - // En-têtes du tableau (bien alignés) - const headerY = doc.y; - doc.fontSize(9) - .fillColor('#6b7280'); - - doc.text('Date', cols.date, headerY, { width: 80, continued: false }); - doc.text('Travaillé', cols.travaille - 10, headerY, { width: 90, align: 'center', continued: false }); - doc.text('Respect des repos', cols.repos - 60, headerY, { width: 120, align: 'center', continued: false }); - doc.text('Commentaire', cols.commentaire, headerY, { width: 125, continued: false }); - - doc.moveDown(0.3); - doc.moveTo(50, doc.y) - .lineTo(545, doc.y) - .stroke('#d1d5db'); - doc.moveDown(0.3); - - // 🎨 Fonction pour dessiner un cercle avec coche verte - const drawGreenCheck = (x, y) => { - // Cercle vert - doc.circle(x, y, 8) - .fillAndStroke('#22c55e', '#16a34a'); - - // Coche blanche - doc.strokeColor('#ffffff') - .lineWidth(2) - .moveTo(x - 4, y) - .lineTo(x - 1, y + 3) - .lineTo(x + 4, y - 4) - .stroke(); - - // Remettre la couleur de trait par défaut - doc.strokeColor('#000000').lineWidth(1); - }; - - // 🎨 Fonction pour dessiner un cercle avec X rouge - const drawRedX = (x, y) => { - // Cercle rouge - doc.circle(x, y, 8) - .fillAndStroke('#ef4444', '#dc2626'); - - // X blanc - doc.strokeColor('#ffffff') - .lineWidth(2) - .moveTo(x - 4, y - 4) - .lineTo(x + 4, y + 4) - .stroke(); - - doc.moveTo(x + 4, y - 4) - .lineTo(x - 4, y + 4) - .stroke(); - - // Remettre la couleur de trait par défaut - doc.strokeColor('#000000').lineWidth(1); - }; - - // Lignes du tableau - jours.forEach((jour, index) => { - if (doc.y > 700) { - doc.addPage(); - doc.y = 50; - } - - const jourDate = new Date(jour.JourDate).toLocaleDateString('fr-FR'); - const travaille = jour.JourTravaille ? '✓' : '✗'; - - // Vérifier si tout est respecté - const toutRespect = jour.ReposQuotidienRespect && jour.ReposHebdomadaireRespect; - const commentaire = jour.CommentaireRepos || '-'; - - const rowY = doc.y; - - // Alterner les couleurs de fond - if (index % 2 === 0) { - doc.rect(50, rowY - 2, 495, 20) - .fill('#f9fafb'); - } - - // Date - doc.fontSize(8) - .fillColor('#000000') - .text(jourDate, cols.date, rowY + 3, { width: 80, continued: false }); - - // Travaillé (centré) - doc.text(travaille, cols.travaille - 10, rowY + 3, { width: 90, align: 'center', continued: false }); - - // 🎨 AFFICHER LE CERCLE AVEC COCHE OU X (centré) - if (toutRespect) { - // ✅ Cercle vert avec coche blanche - drawGreenCheck(cols.repos, rowY + 7); - } else { - // ❌ Cercle rouge avec X blanc - drawRedX(cols.repos, rowY + 7); - - // Ajouter les détails en petit texte rouge - const details = []; - if (!jour.ReposQuotidienRespect) details.push('Q'); - if (!jour.ReposHebdomadaireRespect) details.push('H'); - - doc.fontSize(6) - .fillColor('#ef4444') - .text(details.join(' '), cols.repos + 12, rowY + 4, { width: 50, continued: false }); - } - - // Commentaire - doc.fillColor('#000000') - .fontSize(7) - .text(commentaire.substring(0, 30), cols.commentaire, rowY + 3, { width: 125, continued: false }); - - doc.moveDown(0.8); - }); - } else { - doc.fontSize(10) - .fillColor('#6b7280') - .text('Aucune saisie pour ce mois', { align: 'center' }) - .moveDown(1); - } - - // ===== PIED DE PAGE ===== - doc.fontSize(8) - .fillColor('#9ca3af') - .text( - `Document généré le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}`, - 50, - 750, - { align: 'center' } - ); - - // Finaliser le PDF - doc.end(); - - console.log('✅ PDF généré avec succès'); - - } catch (error) { - console.error('❌ Erreur export PDF:', error); - - // Si le document n'a pas encore été envoyé - if (!res.headersSent) { - res.status(500).json({ error: error.message }); - } - } - }); - app.post('/v2/compteurs/update', async (req, res) => { - const conn = await pool.getConnection(); - - try { - const { collaborateurId, typeConge, annee, nouveauTotal } = req.body; - - // Sécurité RH - if (!['RH', 'Admin'].includes(req.user?.role)) { - return res.status(403).json({ error: 'Accès refusé' }); - } - - // Conversion type CP/RTT → Id - const [typeRow] = await conn.query( - 'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', - [typeConge === 'CP' ? 'Congé payé' : 'RTT'] - ); - - if (typeRow.length === 0) { - return res.status(400).json({ error: 'Type de congé inconnu' }); - } - - const typeCongeId = typeRow[0].Id; - - // Récupération compteur existant - const [compteur] = await conn.query(` - SELECT Id, Total, Solde, SoldeReporte - FROM CompteurConges - WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? - `, [collaborateurId, typeCongeId, annee]); - - if (compteur.length === 0) { - return res.status(404).json({ error: 'Compteur non trouvé' }); - } - - const compteurId = compteur[0].Id; - - // Mise à jour propre V2 - await conn.query(` - UPDATE CompteurConges - SET Total = ?, Solde = ?, DerniereMiseAJour = NOW() - WHERE Id = ? - `, [nouveauTotal, nouveauTotal, compteurId]); - - // Webhook SSE → pour que le collaborateur voit la mise à jour - notifyCollabClients({ - type: 'compteur-updated', - collaborateurId, - typeConge, - annee, - nouveauTotal, - timestamp: new Date().toISOString() - }, collaborateurId); - - res.json({ success: true, message: "Compteur mis à jour (V2)" }); - - } catch (error) { - console.error('Erreur update compteur V2:', error); - res.status(500).json({ error: 'Erreur serveur' }); - } finally { - conn.release(); - } - }); - - // ============================================================ - // ENDPOINTS API V2 - REQUÊTES CORRIGÉES - // Ajustées selon la structure réelle de la base DemandeConge - // ============================================================ - - app.post('/v2/compteurs/update', async (req, res) => { - const conn = await pool.getConnection(); - - try { - const { collaborateurId, typeConge, annee, nouveauTotal } = req.body; - - // Sécurité RH - if (!['RH', 'Admin'].includes(req.user?.role)) { - return res.status(403).json({ error: 'Accès refusé' }); - } - - // Conversion type CP/RTT → Id - const [typeRow] = await conn.query( - 'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', - [typeConge === 'CP' ? 'Congé payé' : 'RTT'] - ); - - if (typeRow.length === 0) { - return res.status(400).json({ error: 'Type de congé inconnu' }); - } - - const typeCongeId = typeRow[0].Id; - - // Récupération compteur existant - const [compteur] = await conn.query(` - SELECT Id, Total, Solde, SoldeReporte - FROM CompteurConges - WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? - `, [collaborateurId, typeCongeId, annee]); - - if (compteur.length === 0) { - return res.status(404).json({ error: 'Compteur non trouvé' }); - } - - const compteurId = compteur[0].Id; - - // Mise à jour propre V2 - await conn.query(` - UPDATE CompteurConges - SET Total = ?, Solde = ?, DerniereMiseAJour = NOW() - WHERE Id = ? - `, [nouveauTotal, nouveauTotal, compteurId]); - - // Webhook SSE → pour que le collaborateur voit la mise à jour - notifyCollabClients({ - type: 'compteur-updated', - collaborateurId, - typeConge, - annee, - nouveauTotal, - timestamp: new Date().toISOString() - }, collaborateurId); - - res.json({ success: true, message: "Compteur mis à jour (V2)" }); - - } catch (error) { - console.error('Erreur update compteur V2:', error); - res.status(500).json({ error: 'Erreur serveur' }); - } finally { - conn.release(); - } - }); - - app.get('/v2/compteurs', async (req, res) => { - const conn = await pool.getConnection(); - try { - // ============================================================ - // CORRECTIONS APPORTÉES : - // - c.Nom → c.nom (minuscule dans CollaborateurAD) - // - c.Prenom → c.prenom (minuscule) - // - c.Email → c.email (minuscule) - // - c.Service → c.service (minuscule) - // - c.Id → c.id (minuscule) - // - c.Role → c.role (minuscule) - // - c.EstApprenti → SUPPRIMÉ (n'existe pas dans la table) - // ============================================================ - const [rows] = await conn.query(` - SELECT - cc.Id AS id, - cc.CollaborateurADId AS collaborateurId, - cc.TypeCongeId AS typeCongeId, - CONCAT(c.nom, ' ', c.prenom) AS employe, - c.email AS email, - c.service AS service, - tc.Nom AS typeConge, - cc.Annee AS annee, - cc.Total AS total, - cc.Solde AS solde, - cc.SoldeReporte AS soldeReporte, - (cc.Total - cc.Solde) AS consomme, - c.TypeContrat AS typeContrat, - c.role AS role - FROM CompteurConges cc - INNER JOIN CollaborateurAD c ON cc.CollaborateurADId = c.id - INNER JOIN TypeConge tc ON cc.TypeCongeId = tc.Id - ORDER BY c.nom ASC, cc.Annee DESC - `); - - return res.json(rows); - - } catch (error) { - console.error("❌ Erreur GET compteurs V2 :", error); - return res.status(500).json({ message: "Erreur serveur V2" }); - } finally { - conn.release(); - } - }); - - app.put('/v2/compteurs/update', async (req, res) => { - const conn = await pool.getConnection(); - try { - const { - collaborateurId, - typeConge, - annee, - nouveauTotal, - nouveauSolde, - source - } = req.body; - - if (!collaborateurId || !typeConge || !annee) { - return res.status(400).json({ message: "Champs manquants" }); - } - - // Trouver l'ID du type de congé - // Note: Utiliser = au lieu de LIKE pour plus de précision - const [typeRow] = await conn.query( - "SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1", - [typeConge === 'CP' ? 'Congé payé' : 'RTT'] - ); - if (typeRow.length === 0) { - return res.status(404).json({ message: "Type de congé introuvable" }); - } - const typeCongeId = typeRow[0].Id; - - // Trouver compteur - const [comp] = await conn.query( - `SELECT Id FROM CompteurConges - WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, - [collaborateurId, typeCongeId, annee] - ); - if (comp.length === 0) { - return res.status(404).json({ message: "Compteur introuvable" }); - } - - // Maj compteur - await conn.query(` - UPDATE CompteurConges - SET Total = ?, Solde = ?, DerniereMiseAJour = NOW() - WHERE Id = ? - `, [nouveauTotal, nouveauSolde, comp[0].Id]); - - res.json({ success: true, message: "Compteur mis à jour (V2)" }); - - } catch (err) { - console.error("Erreur update compteur V2:", err); - res.status(500).json({ message: "Erreur serveur" }); - } finally { - conn.release(); - } - }); - - app.post('/v2/compteurs/reinitialiser', async (req, res) => { - const conn = await pool.getConnection(); - try { - const { dateReference, collaborateurId } = req.body; - - if (!dateReference) { - return res.status(400).json({ message: "dateReference manquante" }); - } - - // Appel à ta fonction métier V2 (acquisition + arrêté) - await recalculerTousLesCompteursV2(conn, dateReference, collaborateurId); - - res.json({ message: "Réinitialisation V2 effectuée" }); - - } catch (err) { - console.error("Erreur réinitialisation V2:", err); - res.status(500).json({ message: "Erreur serveur" }); - } finally { - conn.release(); - } - }); - - app.post('/v2/compteurs/initialiser-tous-manuel', async (req, res) => { - const conn = await pool.getConnection(); - try { - const { - anneeActuelle, - cpActuel, - rttActuel, - anneePrecedente, - cpPrecedent, - rttPrecedent - } = req.body; - - if (!anneeActuelle || cpActuel == null) - return res.status(400).json({ message: "Champs manquants" }); - - const result = await initialiserCompteursManuelV2( - conn, - anneeActuelle, - cpActuel, - rttActuel, - anneePrecedente, - cpPrecedent, - rttPrecedent - ); - - res.json(result); - - } catch (err) { - console.error("Erreur init manuelle V2:", err); - res.status(500).json({ message: "Erreur serveur" }); - } finally { - conn.release(); - } - }); - - // ============================================ - // CORRECTION : Gérer les dates d'entrée nulles - // ============================================ - /** * Calcule l'acquisition CP avec la formule Excel exacte */ @@ -3759,11 +4461,10 @@ try { // 2. Ajuster si date d'entrée postérieure let dateDebutAcquis = new Date(exerciceDebut); - if (dateEntree && dateEntree !== null) { // ✅ AJOUT : Vérifier que dateEntree n'est pas null + if (dateEntree && dateEntree !== null) { const entree = new Date(dateEntree); entree.setHours(0, 0, 0, 0); - // ✅ AJOUT : Vérifier que la date est valide if (!isNaN(entree.getTime()) && entree > exerciceDebut) { dateDebutAcquis = entree; } @@ -3780,16 +4481,17 @@ try { /** * Calcule l'acquisition RTT avec la configuration variable */ - async function calculerAcquisitionRTT(conn, collaborateurId, dateReference = new Date()) { + async function calculerAcquisitionRTT(collaborateurId, dateReference = new Date()) { const d = new Date(dateReference); d.setHours(0, 0, 0, 0); const annee = d.getFullYear(); // 1. Récupérer les infos du collaborateur - const collabInfo = await conn.query( - `SELECT TypeContrat, DateEntree, role FROM CollaborateurAD WHERE id = ?`, - [collaborateurId] - ); + const collabInfoResult = await pool.request() + .input('collaborateurId', sql.Int, collaborateurId) + .query('SELECT TypeContrat, DateEntree, role FROM CollaborateurAD WHERE id = @collaborateurId'); + + const collabInfo = collabInfoResult.recordset; if (collabInfo.length === 0) { throw new Error(`Collaborateur ${collaborateurId} non trouvé`); @@ -3810,17 +4512,16 @@ try { } // 3. Récupérer la configuration RTT - const config = await getConfigurationRTT(conn, annee, typeContrat); + const config = await getConfigurationRTT(annee, typeContrat); // 4. Début d'acquisition : 01/01/N ou date d'entrée let dateDebutAcquis = new Date(annee, 0, 1); // 01/01/N dateDebutAcquis.setHours(0, 0, 0, 0); - if (dateEntree && dateEntree !== null) { // ✅ AJOUT : Vérifier que dateEntree n'est pas null + if (dateEntree && dateEntree !== null) { const entree = new Date(dateEntree); entree.setHours(0, 0, 0, 0); - // ✅ AJOUT : Vérifier que la date est valide if (!isNaN(entree.getTime())) { if (entree.getFullYear() === annee && entree > dateDebutAcquis) { dateDebutAcquis = entree; @@ -3858,6 +4559,7 @@ try { typeContrat: typeContrat }; } + function dateDifMonths(date1, date2) { const d1 = new Date(date1); const d2 = new Date(date2); @@ -3888,13 +4590,13 @@ try { b1.setHours(0, 0, 0, 0); b2.setHours(0, 0, 0, 0); - // ✅ AJOUT : Vérifier que les dates sont valides + // Vérifier que les dates sont valides if (isNaN(b1.getTime()) || isNaN(b2.getTime())) { console.error('❌ Date invalide dans calculerAcquisitionFormuleExcel'); return 0; } - // ✅ AJOUT : Vérifier que coeffMensuel est un nombre + // Vérifier que coeffMensuel est un nombre if (isNaN(coeffMensuel) || coeffMensuel <= 0) { console.error('❌ Coefficient mensuel invalide:', coeffMensuel); return 0; @@ -3929,19 +4631,22 @@ try { return Math.round(acquisition * 100) / 100; } + /** - * Récupère la configuration RTT pour une année et un type de contrat - */ - async function getConfigurationRTT(conn, annee, typeContrat = '37h') { + * Récupère la configuration RTT pour une année et un type de contrat + */ + async function getConfigurationRTT(annee, typeContrat = '37h') { try { - // Chercher en base de données - const config = await conn.query( - `SELECT JoursAnnuels, AcquisitionMensuelle - FROM ConfigurationRTT - WHERE Annee = ? AND TypeContrat = ? - LIMIT 1`, - [annee, typeContrat] - ); + const result = await pool.request() + .input('annee', sql.Int, annee) + .input('typeContrat', sql.VarChar, typeContrat) + .query(` + SELECT JoursAnnuels, AcquisitionMensuelle + FROM ConfigurationRTT + WHERE Annee = @annee AND TypeContrat = @typeContrat + `); + + const config = result.recordset; if (config.length > 0) { return { @@ -3994,14 +4699,951 @@ try { }; } - // 📊 ROUTE POUR L'ESPACE RH - Tous les compteurs détaillés - app.get('/getAllDetailedCounters', async (req, res) => { - const conn = await pool.getConnection(); - try { - console.log('📊 Récupération de TOUS les compteurs détaillés (Version Fixe V4)'); + // Suite et fin - Réinitialisation compteurs, routes API V2, etc. - // ✅ DESTRUCTURER pour obtenir les résultats - const [collaborateurs] = await conn.query(` + // ======================================== + // ROUTE COMPLÈTE : /reinitializeAllCounters + // ======================================== + app.post('/reinitializeAllCounters', async (req, res) => { + const transaction = new sql.Transaction(pool); + try { + await transaction.begin(); + + console.log('🔄 Réinitialisation de tous les compteurs...'); + + const collaborateursResult = await transaction.request().query(` + SELECT id, prenom, nom, DateEntree, TypeContrat, CampusId, SocieteId, role + FROM CollaborateurAD + WHERE Actif = 1 OR Actif IS NULL + `); + + const collaborateurs = collaborateursResult.recordset; + + console.log(`📋 ${collaborateurs.length} collaborateurs trouvés`); + + const dateRefParam = req.body.dateReference; + const today = dateRefParam ? new Date(dateRefParam) : new Date(); + const currentYear = today.getFullYear(); + const previousYear = currentYear - 1; + const results = []; + + // Récupérer les types de congés + const cpTypeResult = await transaction.request() + .query("SELECT Id FROM TypeConge WHERE Nom = 'Congé payé'"); + const cpType = cpTypeResult.recordset; + + const rttTypeResult = await transaction.request() + .query("SELECT Id FROM TypeConge WHERE Nom = 'RTT'"); + const rttType = rttTypeResult.recordset; + + for (const collab of collaborateurs) { + console.log(`\n${'='.repeat(60)}`); + console.log(`👤 ${collab.prenom} ${collab.nom} (ID: ${collab.id})`); + console.log(` Type contrat: ${collab.TypeContrat || '37h'}`); + console.log(` Date entrée: ${collab.DateEntree ? new Date(collab.DateEntree).toISOString().split('T')[0] : 'NULL'}`); + console.log(` Rôle: ${collab.role || 'Non défini'}`); + + const dateEntree = collab.DateEntree; + const typeContrat = collab.TypeContrat || '37h'; + const isApprenti = collab.role === 'Apprenti'; + + // ========================================== + // CALCUL CP + // ========================================== + let acquisCP = calculerAcquisitionCP(today, dateEntree); + + if (isNaN(acquisCP) || !isFinite(acquisCP)) { + console.warn(`⚠️ Acquisition CP invalide - Forcé à 0`); + acquisCP = 0; + } + + // ========================================== + // CALCUL RTT + // ========================================== + let acquisRTT = 0; + if (!isApprenti) { + try { + const rttData = await calculerAcquisitionRTT(collab.id, today); + acquisRTT = rttData.acquisition; + + if (isNaN(acquisRTT) || !isFinite(acquisRTT)) { + console.warn(`⚠️ Acquisition RTT invalide - Forcé à 0`); + acquisRTT = 0; + } + } catch (error) { + console.error(`❌ Erreur calcul RTT:`, error.message); + acquisRTT = 0; + } + } + + // ========================================== + // MISE À JOUR CP N + // ========================================== + if (cpType.length > 0) { + const deductionsCPResult = await transaction.request() + .input('collabId', sql.Int, collab.id) + .input('typeId', sql.Int, cpType[0].Id) + .input('currentYear', sql.Int, currentYear) + .query(` + SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalConsomme + FROM DeductionDetails dd + JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id + WHERE dc.CollaborateurADId = @collabId + AND dd.TypeCongeId = @typeId + AND dd.Annee = @currentYear + AND dc.Statut != 'Refusé' + `); + + let totalConsomme = parseFloat(deductionsCPResult.recordset[0].totalConsomme || 0); + + if (isNaN(totalConsomme) || !isFinite(totalConsomme)) { + console.warn(`⚠️ Total consommé CP invalide - Forcé à 0`); + totalConsomme = 0; + } + + const compteurExistingResult = await transaction.request() + .input('collabId', sql.Int, collab.id) + .input('typeId', sql.Int, cpType[0].Id) + .input('currentYear', sql.Int, currentYear) + .query(` + SELECT SoldeReporte FROM CompteurConges + WHERE CollaborateurADId = @collabId + AND TypeCongeId = @typeId + AND Annee = @currentYear + `); + + const compteurExisting = compteurExistingResult.recordset; + + let soldeReporte = compteurExisting.length > 0 + ? parseFloat(compteurExisting[0].SoldeReporte || 0) + : 0; + + if (isNaN(soldeReporte) || !isFinite(soldeReporte)) { + console.warn(`⚠️ Solde reporté CP invalide - Forcé à 0`); + soldeReporte = 0; + } + + let nouveauSolde = Math.max(0, acquisCP + soldeReporte - totalConsomme); + + if (isNaN(nouveauSolde) || !isFinite(nouveauSolde)) { + console.warn(`⚠️ Nouveau solde CP invalide - Forcé à 0`); + nouveauSolde = 0; + } + + console.log(` 📊 CP - Acquis: ${acquisCP.toFixed(2)}j, Consommé: ${totalConsomme.toFixed(2)}j, Reporté: ${soldeReporte.toFixed(2)}j, Solde: ${nouveauSolde.toFixed(2)}j`); + + if (compteurExisting.length > 0) { + await transaction.request() + .input('total', sql.Decimal(10, 2), acquisCP) + .input('solde', sql.Decimal(10, 2), nouveauSolde) + .input('collabId', sql.Int, collab.id) + .input('typeId', sql.Int, cpType[0].Id) + .input('currentYear', sql.Int, currentYear) + .query(` + UPDATE CompteurConges + SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() + WHERE CollaborateurADId = @collabId AND TypeCongeId = @typeId AND Annee = @currentYear + `); + } else { + await transaction.request() + .input('collabId', sql.Int, collab.id) + .input('typeId', sql.Int, cpType[0].Id) + .input('currentYear', sql.Int, currentYear) + .input('total', sql.Decimal(10, 2), acquisCP) + .input('solde', sql.Decimal(10, 2), nouveauSolde) + .query(` + INSERT INTO CompteurConges + (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) + VALUES (@collabId, @typeId, @currentYear, @total, @solde, 0, GETDATE()) + `); + } + + // Créer CP N-1 si nécessaire + const cpN1Result = await transaction.request() + .input('collabId', sql.Int, collab.id) + .input('typeId', sql.Int, cpType[0].Id) + .input('previousYear', sql.Int, previousYear) + .query(` + SELECT Id FROM CompteurConges + WHERE CollaborateurADId = @collabId AND TypeCongeId = @typeId AND Annee = @previousYear + `); + + const cpN1 = cpN1Result.recordset; + + if (cpN1.length === 0) { + await transaction.request() + .input('collabId', sql.Int, collab.id) + .input('typeId', sql.Int, cpType[0].Id) + .input('previousYear', sql.Int, previousYear) + .query(` + INSERT INTO CompteurConges + (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) + VALUES (@collabId, @typeId, @previousYear, 0, 0, 0, GETDATE()) + `); + } + } + + // ========================================== + // MISE À JOUR RTT N + // ========================================== + if (rttType.length > 0 && !isApprenti) { + const deductionsRTTResult = await transaction.request() + .input('collabId', sql.Int, collab.id) + .input('typeId', sql.Int, rttType[0].Id) + .input('currentYear', sql.Int, currentYear) + .query(` + SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalConsomme + FROM DeductionDetails dd + JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id + WHERE dc.CollaborateurADId = @collabId + AND dd.TypeCongeId = @typeId + AND dd.Annee = @currentYear + AND dc.Statut != 'Refusé' + `); + + let totalConsomme = parseFloat(deductionsRTTResult.recordset[0].totalConsomme || 0); + + if (isNaN(totalConsomme) || !isFinite(totalConsomme)) { + console.warn(`⚠️ Total consommé RTT invalide - Forcé à 0`); + totalConsomme = 0; + } + + let nouveauSolde = Math.max(0, acquisRTT - totalConsomme); + + if (isNaN(nouveauSolde) || !isFinite(nouveauSolde)) { + console.warn(`⚠️ Nouveau solde RTT invalide - Forcé à 0`); + nouveauSolde = 0; + } + + console.log(` 📊 RTT - Acquis: ${acquisRTT.toFixed(2)}j, Consommé: ${totalConsomme.toFixed(2)}j, Solde: ${nouveauSolde.toFixed(2)}j`); + + const compteurExistingResult = await transaction.request() + .input('collabId', sql.Int, collab.id) + .input('typeId', sql.Int, rttType[0].Id) + .input('currentYear', sql.Int, currentYear) + .query(` + SELECT Id FROM CompteurConges + WHERE CollaborateurADId = @collabId AND TypeCongeId = @typeId AND Annee = @currentYear + `); + + const compteurExisting = compteurExistingResult.recordset; + + if (compteurExisting.length > 0) { + await transaction.request() + .input('total', sql.Decimal(10, 2), acquisRTT) + .input('solde', sql.Decimal(10, 2), nouveauSolde) + .input('id', sql.Int, compteurExisting[0].Id) + .query(` + UPDATE CompteurConges + SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() + WHERE Id = @id + `); + } else { + await transaction.request() + .input('collabId', sql.Int, collab.id) + .input('typeId', sql.Int, rttType[0].Id) + .input('currentYear', sql.Int, currentYear) + .input('total', sql.Decimal(10, 2), acquisRTT) + .input('solde', sql.Decimal(10, 2), nouveauSolde) + .query(` + INSERT INTO CompteurConges + (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) + VALUES (@collabId, @typeId, @currentYear, @total, @solde, 0, GETDATE()) + `); + } + + // Créer RTT N-1 si nécessaire + const rttN1Result = await transaction.request() + .input('collabId', sql.Int, collab.id) + .input('typeId', sql.Int, rttType[0].Id) + .input('previousYear', sql.Int, previousYear) + .query(` + SELECT Id FROM CompteurConges + WHERE CollaborateurADId = @collabId AND TypeCongeId = @typeId AND Annee = @previousYear + `); + + const rttN1 = rttN1Result.recordset; + + if (rttN1.length === 0) { + await transaction.request() + .input('collabId', sql.Int, collab.id) + .input('typeId', sql.Int, rttType[0].Id) + .input('previousYear', sql.Int, previousYear) + .query(` + INSERT INTO CompteurConges + (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) + VALUES (@collabId, @typeId, @previousYear, 0, 0, 0, GETDATE()) + `); + } + } + + // Ajouter au résultat + results.push({ + collaborateur: `${collab.prenom} ${collab.nom}`, + typecontrat: typeContrat, + cpacquis: acquisCP.toFixed(2), + rttacquis: acquisRTT.toFixed(2) + }); + } + + await transaction.commit(); + console.log('\n✅ Réinitialisation terminée'); + + res.json({ + success: true, + message: `Compteurs réinitialisés pour ${collaborateurs.length} collaborateurs`, + datereference: today.toISOString().split('T')[0], + totalcollaborateurs: collaborateurs.length, + results: results + }); + + } catch (error) { + await transaction.rollback(); + console.error('❌ Erreur réinitialisation:', error); + res.status(500).json({ + success: false, + message: 'Erreur lors de la réinitialisation', + error: error.message + }); + } + }); + + // ============================================================ + // ENDPOINTS API V2 + // ============================================================ + + app.get('/v2/compteurs', async (req, res) => { + try { + const result = await pool.request().query(` + SELECT + cc.Id AS id, + cc.CollaborateurADId AS collaborateurId, + cc.TypeCongeId AS typeCongeId, + CONCAT(c.nom, ' ', c.prenom) AS employe, + c.email AS email, + c.service AS service, + tc.Nom AS typeConge, + cc.Annee AS annee, + cc.Total AS total, + cc.Solde AS solde, + cc.SoldeReporte AS soldeReporte, + (cc.Total - cc.Solde) AS consomme, + c.TypeContrat AS typeContrat, + c.role AS role + FROM CompteurConges cc + INNER JOIN CollaborateurAD c ON cc.CollaborateurADId = c.id + INNER JOIN TypeConge tc ON cc.TypeCongeId = tc.Id + WHERE (c.description IS NULL OR c.description NOT LIKE '%stagiaire%') + ORDER BY c.nom ASC, cc.Annee DESC + `); + + return res.json(result.recordset); + + } catch (error) { + console.error("❌ Erreur GET compteurs V2 :", error); + return res.status(500).json({ message: "Erreur serveur V2" }); + } + }); + + app.put('/v2/compteurs/update', async (req, res) => { + try { + const { + collaborateurId, + typeConge, + annee, + nouveauTotal, + nouveauSolde, + source + } = req.body; + + if (!collaborateurId || !typeConge || !annee) { + return res.status(400).json({ message: "Champs manquants" }); + } + + console.log('📝 Modification compteur V2:', { collaborateurId, typeConge, annee, nouveauTotal, nouveauSolde }); + + // Mapper le nom exact du type de congé + let nomTypeConge; + switch (typeConge) { + case 'CP': + nomTypeConge = 'Congé payé'; + break; + case 'RTT': + nomTypeConge = 'RTT'; + break; + case 'Récupération': + case 'Recuperation': + case 'recup': + nomTypeConge = 'Récupération'; + break; + default: + nomTypeConge = typeConge; + } + + console.log(` Recherche du type: "${nomTypeConge}"`); + + // Trouver l'ID du type de congé + const typeRowResult = await pool.request() + .input('nomTypeConge', sql.NVarChar, nomTypeConge) + .query("SELECT Id FROM TypeConge WHERE Nom = @nomTypeConge"); + + const typeRow = typeRowResult.recordset; + + if (typeRow.length === 0) { + console.error(`❌ Type de congé "${nomTypeConge}" introuvable`); + return res.status(404).json({ + message: `Type de congé "${nomTypeConge}" introuvable`, + nomRecherche: nomTypeConge + }); + } + + const typeCongeId = typeRow[0].Id; + console.log(` ✅ Type trouvé - ID: ${typeCongeId}`); + + // Trouver compteur + const compResult = await pool.request() + .input('collaborateurId', sql.Int, collaborateurId) + .input('typeCongeId', sql.Int, typeCongeId) + .input('annee', sql.Int, annee) + .query(` + SELECT Id FROM CompteurConges + WHERE CollaborateurADId = @collaborateurId + AND TypeCongeId = @typeCongeId + AND Annee = @annee + `); + + const comp = compResult.recordset; + + if (comp.length === 0) { + console.error(`❌ Compteur introuvable pour collaborateur ${collaborateurId}, type ${typeCongeId}, année ${annee}`); + return res.status(404).json({ + message: "Compteur introuvable", + details: { collaborateurId, typeCongeId, annee } + }); + } + + // Mise à jour compteur + await pool.request() + .input('total', sql.Decimal(10, 2), nouveauTotal) + .input('solde', sql.Decimal(10, 2), nouveauSolde) + .input('id', sql.Int, comp[0].Id) + .query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id'); + + console.log(` ✅ Compteur mis à jour - Total: ${nouveauTotal}, Solde: ${nouveauSolde}`); + + // Notifier les clients SSE + notifyClients({ + type: 'compteur-updated', + collaborateurId: collaborateurId, + typeConge: nomTypeConge, + annee: annee, + nouveauTotal: nouveauTotal, + nouveauSolde: nouveauSolde, + source: source || 'RH_UI', + timestamp: new Date().toISOString() + }, collaborateurId); + + // Envoyer webhook + try { + if (typeof webhookManager !== 'undefined' && WEBHOOKS && EVENTS) { + await webhookManager.sendWebhook( + WEBHOOKS.COLLABORATEURS_URL, + EVENTS.COMPTEUR_UPDATED, + { + collaborateurId: collaborateurId, + typeConge: nomTypeConge, + typeUpdate: 'modification_manuelle_rh', + annee: annee, + nouveauTotal: nouveauTotal, + nouveauSolde: nouveauSolde, + source: source || 'RH_UI', + timestamp: new Date().toISOString() + } + ); + console.log(' ✅ Webhook envoyé au serveur collaborateurs'); + } + } catch (webhookError) { + console.error('⚠️ Erreur webhook (non bloquant):', webhookError.message); + } + + res.json({ + success: true, + message: "Compteur mis à jour (V2)", + details: { + typeConge: nomTypeConge, + typeCongeId: typeCongeId, + nouveauTotal: nouveauTotal, + nouveauSolde: nouveauSolde + } + }); + + } catch (err) { + console.error("❌ Erreur update compteur V2:", err); + res.status(500).json({ + message: "Erreur serveur", + error: err.message + }); + } + }); + + // ================================================ + // ROUTE POUR SAISIR UN JPOSF + // ================================================ + app.post('/compteurs/saisir-jposf', authenticateToken, async (req, res) => { + const transaction = new sql.Transaction(pool); + + try { + console.log('\n📥 ========== DÉBUT SAISIE JPOSF =========='); + console.log('📥 Body reçu:', JSON.stringify(req.body, null, 2)); + console.log('📥 User:', req.user); + + await transaction.begin(); + console.log('✅ Transaction démarrée'); + + // Vérifier les permissions + if (!['Admin', 'RH'].includes(req.user.role)) { + console.log('❌ Accès refusé - Role:', req.user.role); + await transaction.rollback(); + return res.status(403).json({ + success: false, + error: 'Accès non autorisé', + message: 'Seuls les Admin et RH peuvent saisir des JPOSF' + }); + } + + const { collaborateur_id, date, periode_journee, commentaire } = req.body; + + // Validation des données + if (!collaborateur_id) { + console.log('❌ collaborateur_id manquant'); + await transaction.rollback(); + return res.status(400).json({ + success: false, + message: 'ID du collaborateur manquant' + }); + } + + if (!date) { + console.log('❌ date manquante'); + await transaction.rollback(); + return res.status(400).json({ + success: false, + message: 'Date manquante' + }); + } + + if (!periode_journee) { + console.log('❌ periode_journee manquante'); + await transaction.rollback(); + return res.status(400).json({ + success: false, + message: 'Période de journée manquante' + }); + } + + console.log('📋 Données validées:', { collaborateur_id, date, periode_journee }); + + // ========== ÉTAPE 1: Récupérer le collaborateur ========== + console.log('\n🔍 ÉTAPE 1: Récupération du collaborateur...'); + + const collabResult = await transaction.request() + .input('id', sql.Int, parseInt(collaborateur_id)) + .query('SELECT id, prenom, nom, email, Actif FROM CollaborateurAD WHERE id = @id'); + + console.log(' Résultat:', collabResult.recordset.length, 'enregistrement(s)'); + + if (collabResult.recordset.length === 0) { + console.log('❌ Collaborateur non trouvé'); + await transaction.rollback(); + return res.status(404).json({ + success: false, + message: `Collaborateur ID ${collaborateur_id} non trouvé` + }); + } + + const collab = collabResult.recordset[0]; + console.log(' ✅ Collaborateur trouvé:', collab.prenom, collab.nom); + + if (collab.Actif === false || collab.Actif === 0) { + console.log('❌ Collaborateur inactif'); + await transaction.rollback(); + return res.status(403).json({ + success: false, + message: 'Impossible d\'ajouter des jours à un compte désactivé' + }); + } + + const currentYear = new Date().getFullYear(); + const nombreJours = periode_journee === 'Journée entière' ? 1 : 0.5; + + console.log(' Année:', currentYear); + console.log(' Nombre de jours:', nombreJours); + + // ========== ÉTAPE 2: Récupérer les types de congé ========== + console.log('\n🔍 ÉTAPE 2: Récupération des types de congé...'); + + const jposfTypeResult = await transaction.request() + .query("SELECT Id, Nom FROM TypeConge WHERE Nom = 'JPOSF'"); + + console.log(' JPOSF trouvé:', jposfTypeResult.recordset.length > 0); + + if (jposfTypeResult.recordset.length === 0) { + console.log('❌ Type JPOSF non trouvé dans TypeConge'); + + // Lister les types disponibles pour debug + const allTypesResult = await transaction.request() + .query("SELECT Id, Nom FROM TypeConge WHERE Actif = 1 OR Actif IS NULL"); + console.log(' Types disponibles:', allTypesResult.recordset.map(t => t.Nom).join(', ')); + + await transaction.rollback(); + return res.status(500).json({ + success: false, + message: 'Type de congé "JPOSF" non trouvé dans la base de données', + details: 'Contactez l\'administrateur pour créer ce type de congé' + }); + } + + const recupTypeResult = await transaction.request() + .query("SELECT Id, Nom FROM TypeConge WHERE Nom = 'Récupération'"); + + console.log(' Récupération trouvé:', recupTypeResult.recordset.length > 0); + + if (recupTypeResult.recordset.length === 0) { + console.log('❌ Type Récupération non trouvé dans TypeConge'); + await transaction.rollback(); + return res.status(500).json({ + success: false, + message: 'Type de congé "Récupération" non trouvé dans la base de données', + details: 'Contactez l\'administrateur pour créer ce type de congé' + }); + } + + const jposfTypeId = jposfTypeResult.recordset[0].Id; + const recupTypeId = recupTypeResult.recordset[0].Id; + + console.log(' ✅ JPOSF TypeId:', jposfTypeId); + console.log(' ✅ Récupération TypeId:', recupTypeId); + + // ========== ÉTAPE 3: Mettre à jour le compteur Récupération ========== + console.log('\n🔍 ÉTAPE 3: Mise à jour du compteur Récupération...'); + + const compteurRecupResult = await transaction.request() + .input('collaborateurId', sql.Int, parseInt(collaborateur_id)) + .input('recupTypeId', sql.Int, recupTypeId) + .input('currentYear', sql.Int, currentYear) + .query(` + SELECT Id, Total, Solde FROM CompteurConges + WHERE CollaborateurADId = @collaborateurId + AND TypeCongeId = @recupTypeId + AND Annee = @currentYear + `); + + let nouveauTotalRecup, nouveauSoldeRecup; + + if (compteurRecupResult.recordset.length > 0) { + const compteur = compteurRecupResult.recordset[0]; + nouveauTotalRecup = parseFloat(compteur.Total || 0) + nombreJours; + nouveauSoldeRecup = parseFloat(compteur.Solde || 0) + nombreJours; + + console.log(' Compteur existant - Ancien Total:', compteur.Total, '-> Nouveau:', nouveauTotalRecup); + console.log(' Compteur existant - Ancien Solde:', compteur.Solde, '-> Nouveau:', nouveauSoldeRecup); + + await transaction.request() + .input('total', sql.Decimal(10, 2), nouveauTotalRecup) + .input('solde', sql.Decimal(10, 2), nouveauSoldeRecup) + .input('id', sql.Int, compteur.Id) + .query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id'); + + console.log(' ✅ Compteur Récupération mis à jour'); + } else { + nouveauTotalRecup = nombreJours; + nouveauSoldeRecup = nombreJours; + + console.log(' Création nouveau compteur - Total:', nouveauTotalRecup, 'Solde:', nouveauSoldeRecup); + + await transaction.request() + .input('collaborateurId', sql.Int, parseInt(collaborateur_id)) + .input('recupTypeId', sql.Int, recupTypeId) + .input('currentYear', sql.Int, currentYear) + .input('total', sql.Decimal(10, 2), nouveauTotalRecup) + .input('solde', sql.Decimal(10, 2), nouveauSoldeRecup) + .query(` + INSERT INTO CompteurConges + (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) + VALUES (@collaborateurId, @recupTypeId, @currentYear, @total, @solde, 0, GETDATE()) + `); + + console.log(' ✅ Compteur Récupération créé'); + } + + // ========== ÉTAPE 4: Créer la demande JPOSF ========== + console.log('\n🔍 ÉTAPE 4: Création de la demande JPOSF...'); + + // Préparer le commentaire + const commentaireFinal = commentaire && commentaire.trim() + ? commentaire.trim() + : `JPOSF - ${periode_journee}`; + + console.log(' Commentaire:', commentaireFinal); + console.log(' Date:', date); + console.log(' ValidateurId:', req.user.id); + + // ⭐ MÉTHODE ROBUSTE: INSERT puis SELECT séparé + try { + // D'abord, faire l'INSERT sans OUTPUT + await transaction.request() + .input('collaborateurId', sql.Int, parseInt(collaborateur_id)) + .input('dateDebut', sql.Date, date) + .input('dateFin', sql.Date, date) + .input('commentaire', sql.NVarChar(500), commentaireFinal) + .input('totalJours', sql.Decimal(10, 2), nombreJours) + .input('validateurId', sql.Int, req.user.id) + .query(` + INSERT INTO DemandeConge + (CollaborateurADId, DateDebut, DateFin, Statut, Commentaire, DateDemande, NombreJours, ValidateurADId, DateValidation) + VALUES (@collaborateurId, @dateDebut, @dateFin, 'Validée', @commentaire, GETDATE(), @totalJours, @validateurId, GETDATE()) + `); + + console.log(' ✅ INSERT DemandeConge réussi'); + + // Ensuite, récupérer l'ID avec SCOPE_IDENTITY ou en cherchant le dernier enregistrement + const lastIdResult = await transaction.request() + .query('SELECT SCOPE_IDENTITY() AS Id'); + + let demandeId = lastIdResult.recordset[0]?.Id; + + // Si SCOPE_IDENTITY ne fonctionne pas, fallback + if (!demandeId) { + console.log(' ⚠️ SCOPE_IDENTITY() a retourné NULL, utilisation du fallback...'); + + const fallbackResult = await transaction.request() + .input('collaborateurId', sql.Int, parseInt(collaborateur_id)) + .input('dateDebut', sql.Date, date) + .query(` + SELECT TOP 1 Id FROM DemandeConge + WHERE CollaborateurADId = @collaborateurId + AND DateDebut = @dateDebut + AND Statut = 'Validée' + ORDER BY DateDemande DESC, Id DESC + `); + + demandeId = fallbackResult.recordset[0]?.Id; + } + + console.log(' DemandeId récupéré:', demandeId); + + if (!demandeId) { + throw new Error('Impossible de récupérer l\'ID de la demande créée'); + } + + // ========== ÉTAPE 5: Lier avec le type JPOSF ========== + console.log('\n🔍 ÉTAPE 5: Liaison avec le type JPOSF...'); + + await transaction.request() + .input('demandeId', sql.Int, demandeId) + .input('typeId', sql.Int, jposfTypeId) + .input('nombreJours', sql.Decimal(10, 2), nombreJours) + .input('periode', sql.NVarChar(50), periode_journee) // ⭐ AJOUTÉ + .query('INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee) VALUES (@id, @demandeId, @typeId, @nombreJours, @periode)'); + + + console.log(' ✅ DemandeCongeType créé'); + + // ========== ÉTAPE 6: Historique ========== + console.log('\n🔍 ÉTAPE 6: Enregistrement dans l\'historique...'); + + await transaction.request() + .input('userId', sql.Int, req.user.id) + .input('details', sql.NVarChar(500), `Saisie JPOSF - ${date} (${periode_journee}) : +${nombreJours}j pour ${collab.prenom} ${collab.nom}`) + .input('demandeId', sql.Int, demandeId) + .query(` + INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId, DateAction) + VALUES (@userId, 'Saisie JPOSF', @details, @demandeId, GETDATE()) + `); + + console.log(' ✅ Historique enregistré'); + + // ========== COMMIT ========== + await transaction.commit(); + console.log('\n✅ ========== TRANSACTION COMMIT =========='); + + // ========== Notifications SSE ========== + try { + notifyClients({ + type: 'demande-validated', + demandeId: demandeId, + statut: 'Validée', + typeConge: 'JPOSF', + couleurHex: '#d946ef', + collaborateurId: parseInt(collaborateur_id), + timestamp: new Date().toISOString() + }, parseInt(collaborateur_id)); + + notifyClients({ + type: 'compteur-updated', + collaborateurId: parseInt(collaborateur_id), + typeConge: 'Récupération', + action: 'saisie_jposf_rh', + nouveauSolde: nouveauSoldeRecup, + timestamp: new Date().toISOString() + }, parseInt(collaborateur_id)); + + console.log('✅ Notifications SSE envoyées'); + } catch (sseError) { + console.error('⚠️ Erreur SSE (non bloquant):', sseError.message); + } + + // ========== Webhooks ========== + try { + if (typeof webhookManager !== 'undefined' && WEBHOOKS && EVENTS) { + await webhookManager.sendWebhook( + WEBHOOKS.COLLABORATEURS_URL, + EVENTS.DEMANDE_VALIDATED, + { + demandeId: demandeId, + statut: 'Validée', + typeConge: 'JPOSF', + collaborateurId: parseInt(collaborateur_id), + validateurId: req.user.id + } + ); + + await webhookManager.sendWebhook( + WEBHOOKS.COLLABORATEURS_URL, + EVENTS.COMPTEUR_UPDATED, + { + collaborateurId: parseInt(collaborateur_id), + typeConge: 'Récupération', + typeUpdate: 'saisie_jposf' + } + ); + console.log('✅ Webhooks envoyés'); + } + } catch (webhookError) { + console.error('⚠️ Erreur webhook (non bloquant):', webhookError.message); + } + + // ========== Email ========== + try { + const accessToken = await getGraphToken(); + if (accessToken && collab.email) { + const fromEmail = 'gtanoreply@ensup.eu'; + const subject = '📅 JPOSF enregistré'; + const body = ` +
+
+

📅 JPOSF enregistré

+
+
+

Bonjour ${collab.prenom} ${collab.nom},

+

Une Journée Portes Ouvertes a été enregistrée pour vous :

+
+

Date : ${new Date(date).toLocaleDateString('fr-FR')}

+

Période : ${periode_journee}

+

Jours ajoutés : ${nombreJours}j

+ ${commentaire ? `

Commentaire : ${commentaire}

` : ''} +
+

+${nombreJours}j ont été ajoutés à votre compteur de récupération

+
+
+ `; + + await sendMailGraph(accessToken, fromEmail, collab.email, subject, body); + console.log('✅ Email envoyé à', collab.email); + } + } catch (emailError) { + console.error('⚠️ Erreur email (non bloquant):', emailError.message); + } + + // ========== Réponse succès ========== + console.log('\n✅ ========== JPOSF ENREGISTRÉ AVEC SUCCÈS ==========\n'); + + return res.json({ + success: true, + message: 'JPOSF enregistré avec succès', + collaborateur: `${collab.prenom} ${collab.nom}`, + date: date, + periode: periode_journee, + jours_ajoutes: nombreJours, + nouveau_solde_recup: nouveauSoldeRecup, + demandeId: demandeId + }); + + } catch (insertError) { + console.error('\n❌ ERREUR lors de l\'INSERT DemandeConge:', insertError.message); + console.error(' Stack:', insertError.stack); + throw insertError; + } + + } catch (error) { + console.error('\n❌ ========== ERREUR SAISIE JPOSF =========='); + console.error('Message:', error.message); + console.error('Stack:', error.stack); + + try { + await transaction.rollback(); + console.log('✅ Transaction rollback effectué'); + } catch (rollbackError) { + console.error('⚠️ Erreur rollback:', rollbackError.message); + } + + return res.status(500).json({ + success: false, + message: 'Erreur serveur lors de la saisie du JPOSF', + error: error.message, + details: process.env.NODE_ENV === 'development' ? error.stack : undefined + }); + } + }); + + // ✅ CORRECTION COMPLÈTE - Route /getAllDetailedCounters +app.get('/getAllDetailedCounters', async (req, res) => { + try { + console.log('📊 Récupération de TOUS les compteurs détaillés'); + + const today = new Date(); + const currentYearSys = today.getFullYear(); // 2026 + + // ── Détection bascule CP ────────────────────────────────────────── + const cpTypeForCheck = await pool.request() + .query("SELECT TOP 1 Id FROM TypeConge WHERE Nom = 'Congé payé'"); + + let currentYear = currentYearSys; + let previousYear = currentYearSys - 1; + + if (cpTypeForCheck.recordset.length > 0) { + const cpTypeId = cpTypeForCheck.recordset[0].Id; + const checkBascule = await pool.request() + .input('cpTypeId', sql.Int, cpTypeId) + .input('anneeN1', sql.Int, currentYearSys + 1) + .query(` + SELECT TOP 1 Id FROM CompteurConges + WHERE TypeCongeId = @cpTypeId + AND Annee = @anneeN1 + `); + + if (checkBascule.recordset.length > 0) { + currentYear = currentYearSys + 1; // 2027 + previousYear = currentYearSys; // 2026 + console.log('✅ Bascule CP détectée — currentYear=2027, previousYear=2026'); + } + } + + const anneeRTT = currentYearSys; + const anneeRecup = currentYearSys; + + console.log(`📅 CP N=${currentYear}, CP N-1=${previousYear}, RTT=${anneeRTT}, Récup=${anneeRecup}`); + + const typesCongeResult = await pool.request().query(` + SELECT Id, Nom FROM TypeConge WHERE Nom IN ('Congé payé', 'RTT', 'Récupération') + `); + + const typeCongeMap = {}; + typesCongeResult.recordset.forEach(tc => { + typeCongeMap[tc.Nom] = tc.Id; + }); + + const collaborateursResult = await pool.request().query(` SELECT ca.id, ca.prenom, @@ -4010,213 +5652,1508 @@ try { ca.role, ca.TypeContrat, ca.DateEntree, - s.Nom as service, + COALESCE(s.Nom, 'Non assigné') as service, s.Id as serviceId, - COALESCE(c.Nom, 'Sans campus') as campus, - c.Id as campusId + ca.CampusId as campusId, + COALESCE(camp.Nom, 'Sans campus') as campusNom, + ca.SocieteId as societeId, + COALESCE(soc.Nom, 'Sans société') as societe FROM CollaborateurAD ca LEFT JOIN Services s ON ca.ServiceId = s.Id - LEFT JOIN Campus c ON ca.CampusId = c.Id - WHERE ca.Actif = 1 OR ca.Actif IS NULL - ORDER BY c.Nom, s.Nom, ca.nom, ca.prenom + LEFT JOIN Campus camp ON ca.CampusId = camp.Id + LEFT JOIN Societe soc ON ca.SocieteId = soc.Id + WHERE (ca.Actif = 1 OR ca.Actif IS NULL) + AND (ca.description IS NULL OR ca.description NOT LIKE '%stagiaire%') + ORDER BY soc.Nom, camp.Nom, s.Nom, ca.nom, ca.prenom `); - const resultats = []; - const currentYear = new Date().getFullYear(); - const previousYear = currentYear - 1; + const collaborateurs = collaborateursResult.recordset; + const resultats = []; - // ✅ DESTRUCTURER pour les types de congé - const [cpType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = 'Congé payé' LIMIT 1`); - const [rttType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = 'RTT' LIMIT 1`); - const [recupType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = 'Récupération' LIMIT 1`); + console.log(`📋 ${collaborateurs.length} collaborateurs trouvés`); - for (const collab of collaborateurs) { - // ✅ CP Année N - DESTRUCTURER - const [cpN] = await conn.query(` - SELECT Total, Solde - FROM CompteurConges - WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? - `, [collab.id, cpType[0].Id, currentYear]); + for (const collab of collaborateurs) { + const campusNom = collab.campusNom; + + // ==================== CP N ==================== + if (typeCongeMap['Congé payé']) { + const cpNResult = await pool.request() + .input('collabId', sql.Int, collab.id) + .input('typeId', sql.Int, typeCongeMap['Congé payé']) + .input('annee', sql.Int, currentYear) + .query(` + SELECT Total, Solde, SoldeReporte + FROM CompteurConges + WHERE CollaborateurADId = @collabId + AND TypeCongeId = @typeId + AND Annee = @annee + `); + + const cpN = cpNResult.recordset; let cpTotalN, cpSoldeN; if (cpN.length > 0) { cpTotalN = parseFloat(cpN[0].Total); - cpSoldeN = parseFloat(cpN[0].Solde); + const soldeReporte = parseFloat(cpN[0].SoldeReporte || 0); + // Solde réel = Solde - SoldeReporte + cpSoldeN = Math.max(0, parseFloat(cpN[0].Solde) - soldeReporte); } else { - cpTotalN = calculerAcquisitionCP(new Date(), collab.DateEntree); - const [consomme] = await conn.query(` - SELECT COALESCE(SUM(JoursUtilises), 0) AS total - FROM DeductionDetails dd - JOIN DemandeConge dc ON dc.Id = dd.DemandeCongeId - WHERE dc.CollaborateurADId = ? - AND dd.TypeCongeId = ? - AND dd.Annee = ? - AND dd.TypeDeduction NOT IN ('Accum. Récup', 'N Anticipé') - AND dc.Statut != 'Refusé' - `, [collab.id, cpType[0].Id, currentYear]); - cpSoldeN = Math.max(0, cpTotalN - consomme[0].total); + if (currentYear > currentYearSys) { + // Bascule faite mais pas de compteur 2027 pour ce collab → 0 + cpTotalN = 0; + cpSoldeN = 0; + } else { + cpTotalN = calculerAcquisitionCP(new Date(), collab.DateEntree); + const consommeResult = await pool.request() + .input('collabId', sql.Int, collab.id) + .input('typeId', sql.Int, typeCongeMap['Congé payé']) + .input('annee', sql.Int, currentYear) + .query(` + SELECT COALESCE(SUM(JoursUtilises), 0) AS total + FROM DeductionDetails dd + JOIN DemandeConge dc ON dc.Id = dd.DemandeCongeId + WHERE dc.CollaborateurADId = @collabId + AND dd.TypeCongeId = @typeId + AND dd.Annee = @annee + AND dd.TypeDeduction NOT IN ('Accum. Récup', 'N Anticipé') + AND dc.Statut != 'Refusé' + `); + cpSoldeN = Math.max(0, cpTotalN - consommeResult.recordset[0].total); + } } resultats.push({ collaborateurId: collab.id, employe: `${collab.prenom} ${collab.nom}`, email: collab.email, - service: collab.service || 'Non assigné', - campus: collab.campus, + service: collab.service, + campus: campusNom, + societe: collab.societe, + typeCongeId: typeCongeMap['Congé payé'], typeConge: 'Congé payé', annee: currentYear, total: parseFloat(cpTotalN.toFixed(2)), solde: parseFloat(cpSoldeN.toFixed(2)), - consomme: parseFloat((cpTotalN - cpSoldeN).toFixed(2)), + consomme: Math.max(0, parseFloat((cpTotalN - cpSoldeN).toFixed(2))), role: collab.role, - typeContrat: collab.TypeContrat + typeContrat: collab.TypeContrat, + estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage' }); - // ✅ CP N-1 - DESTRUCTURER - const [cpN1] = await conn.query(` - SELECT Total, Solde - FROM CompteurConges - WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? - `, [collab.id, cpType[0].Id, previousYear]); + // ==================== CP N-1 ==================== + const cpN1Result = await pool.request() + .input('collabId', sql.Int, collab.id) + .input('typeId', sql.Int, typeCongeMap['Congé payé']) + .input('annee', sql.Int, previousYear) + .query(` + SELECT Total, Solde, SoldeReporte + FROM CompteurConges + WHERE CollaborateurADId = @collabId + AND TypeCongeId = @typeId + AND Annee = @annee + `); - if (cpN1.length > 0 && cpN1[0].Solde > 0) { + const cpN1 = cpN1Result.recordset; + + if (cpN1.length > 0 ) { const total = parseFloat(cpN1[0].Total); const solde = parseFloat(cpN1[0].Solde); resultats.push({ collaborateurId: collab.id, employe: `${collab.prenom} ${collab.nom}`, email: collab.email, - service: collab.service || 'Non assigné', - campus: collab.campus, + service: collab.service, + campus: campusNom, + societe: collab.societe, + typeCongeId: typeCongeMap['Congé payé'], typeConge: 'Congé payé', annee: previousYear, total, solde, - consomme: total - solde, + consomme: Math.max(0, parseFloat((total - solde).toFixed(2))), role: collab.role, - typeContrat: collab.TypeContrat - }); - } - - // ✅ RTT Année N (sauf apprentis) - DESTRUCTURER - if (collab.role !== 'Apprenti') { - const [rttN] = await conn.query(` - SELECT Total, Solde - FROM CompteurConges - WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? - `, [collab.id, rttType[0].Id, currentYear]); - - let rttTotalN, rttSoldeN; - if (rttN.length > 0) { - rttTotalN = parseFloat(rttN[0].Total); - rttSoldeN = parseFloat(rttN[0].Solde); - } else { - const rtt = await calculerAcquisitionRTT(conn, collab.id, new Date()); - const [consommeRTT] = await conn.query(` - SELECT COALESCE(SUM(JoursUtilises), 0) AS total - FROM DeductionDetails dd - JOIN DemandeConge dc ON dc.Id = dd.DemandeCongeId - WHERE dc.CollaborateurADId = ? - AND dd.TypeCongeId = ? - AND dd.Annee = ? - AND dd.TypeDeduction NOT IN ('Accum. Récup', 'Récup Dosée') - `, [collab.id, rttType[0].Id, currentYear]); - rttTotalN = rtt.acquisition; - rttSoldeN = Math.max(0, rttTotalN - consommeRTT[0].total); - } - - resultats.push({ - collaborateurId: collab.id, - employe: `${collab.prenom} ${collab.nom}`, - email: collab.email, - service: collab.service || 'Non assigné', - campus: collab.campus, - typeConge: 'RTT', - annee: currentYear, - total: parseFloat(rttTotalN.toFixed(2)), - solde: parseFloat(rttSoldeN.toFixed(2)), - consomme: parseFloat((rttTotalN - rttSoldeN).toFixed(2)), - role: collab.role, - typeContrat: collab.TypeContrat - }); - } - - // ✅ RÉCUPÉRATION Année N - DESTRUCTURER - const [accum] = await conn.query(` - SELECT COALESCE(SUM(JoursUtilises), 0) AS total - FROM DeductionDetails dd - JOIN DemandeConge dc ON dc.Id = dd.DemandeCongeId - WHERE dc.CollaborateurADId = ? - AND dd.TypeCongeId = ? - AND dd.Annee = ? - AND dd.TypeDeduction IN ('Accum. Récup', 'Accum. Recup') - `, [collab.id, recupType[0].Id, currentYear]); - - const [consomm] = await conn.query(` - SELECT COALESCE(SUM(JoursUtilises), 0) AS total - FROM DeductionDetails dd - JOIN DemandeConge dc ON dc.Id = dd.DemandeCongeId - WHERE dc.CollaborateurADId = ? - AND dd.TypeCongeId = ? - AND dd.Annee = ? - AND dd.TypeDeduction IN ('Récup Dosée', 'Recup Dosee') - `, [collab.id, recupType[0].Id, currentYear]); - - const recupTotal = parseFloat(accum[0].total); - const recupCons = parseFloat(consomm[0].total); - const recupSolde = Math.max(0, recupTotal - recupCons); - - if (recupTotal > 0 || recupCons > 0) { - resultats.push({ - collaborateurId: collab.id, - employe: `${collab.prenom} ${collab.nom}`, - email: collab.email, - service: collab.service || 'Non assigné', - campus: collab.campus, - typeConge: 'Récupération', - annee: currentYear, - total: recupTotal, - solde: recupSolde, - consomme: recupCons, - role: collab.role, - typeContrat: collab.TypeContrat + typeContrat: collab.TypeContrat, + estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage' }); } } - conn.release(); - res.json(resultats); - } catch (err) { - conn.release(); - console.error('❌ Erreur GET ALL:', err); - res.status(500).json({ error: err.message }); + // ==================== RTT — toujours anneeRTT (2026) ==================== + if (typeCongeMap['RTT'] && collab.role !== 'Apprenti' && collab.TypeContrat !== 'Apprentissage') { + const rttNResult = await pool.request() + .input('collabId', sql.Int, collab.id) + .input('typeId', sql.Int, typeCongeMap['RTT']) + .input('annee', sql.Int, anneeRTT) + .query(` + SELECT Total, Solde + FROM CompteurConges + WHERE CollaborateurADId = @collabId + AND TypeCongeId = @typeId + AND Annee = @annee + `); + + const rttN = rttNResult.recordset; + + let rttTotalN, rttSoldeN; + if (rttN.length > 0) { + rttTotalN = parseFloat(rttN[0].Total); + rttSoldeN = parseFloat(rttN[0].Solde); + } else { + const rtt = await calculerAcquisitionRTT(collab.id, new Date()); + const consommeRTTResult = await pool.request() + .input('collabId', sql.Int, collab.id) + .input('typeId', sql.Int, typeCongeMap['RTT']) + .input('annee', sql.Int, anneeRTT) + .query(` + SELECT COALESCE(SUM(JoursUtilises), 0) AS total + FROM DeductionDetails dd + JOIN DemandeConge dc ON dc.Id = dd.DemandeCongeId + WHERE dc.CollaborateurADId = @collabId + AND dd.TypeCongeId = @typeId + AND dd.Annee = @annee + AND dd.TypeDeduction NOT IN ('Accum. Récup', 'Récup Dosée') + AND dc.Statut != 'Refusé' + `); + rttTotalN = rtt.acquisition; + rttSoldeN = Math.max(0, rttTotalN - consommeRTTResult.recordset[0].total); + } + + resultats.push({ + collaborateurId: collab.id, + employe: `${collab.prenom} ${collab.nom}`, + email: collab.email, + service: collab.service, + campus: campusNom, + societe: collab.societe, + typeCongeId: typeCongeMap['RTT'], + typeConge: 'RTT', + annee: anneeRTT, + total: parseFloat(rttTotalN.toFixed(2)), + solde: parseFloat(rttSoldeN.toFixed(2)), + consomme: Math.max(0, parseFloat((rttTotalN - rttSoldeN).toFixed(2))), + role: collab.role, + typeContrat: collab.TypeContrat, + estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage' + }); + } + + // ==================== Récupération — toujours anneeRecup (2026) ==================== + if (typeCongeMap['Récupération']) { + const recupNResult = await pool.request() + .input('collabId', sql.Int, collab.id) + .input('typeId', sql.Int, typeCongeMap['Récupération']) + .input('annee', sql.Int, anneeRecup) + .query(` + SELECT Total, Solde + FROM CompteurConges + WHERE CollaborateurADId = @collabId + AND TypeCongeId = @typeId + AND Annee = @annee + `); + + const recupN = recupNResult.recordset; + + if (recupN.length > 0) { + const recupTotal = parseFloat(recupN[0].Total); + const recupSolde = parseFloat(recupN[0].Solde); + + resultats.push({ + collaborateurId: collab.id, + employe: `${collab.prenom} ${collab.nom}`, + email: collab.email, + service: collab.service, + campus: campusNom, + societe: collab.societe, + typeCongeId: typeCongeMap['Récupération'], + typeConge: 'Récupération', + annee: anneeRecup, + total: recupTotal, + solde: recupSolde, + consomme: Math.max(0, parseFloat((recupTotal - recupSolde).toFixed(2))), + role: collab.role, + typeContrat: collab.TypeContrat, + estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage' + }); + } + } + } + + console.log(`✅ ${resultats.length} compteurs détaillés générés`); + res.json(resultats); + } catch (err) { + console.error('❌ Erreur GET ALL:', err); + res.status(500).json({ error: err.message }); + } +}); + + + // ================================================ + // ROUTES POUR LA GESTION DES COLLABORATEURS - Adaptées à votre structure BDD + // ================================================ + + // GET /api/collaborateurs/details - Liste complète des collaborateurs avec toutes leurs infos + app.get('/collaborateurs/details', authenticateToken, async (req, res) => { + try { + if (!['Admin', 'RH', 'Validateur'].includes(req.user.role)) { + return res.status(403).json({ error: 'Accès non autorisé' }); + } + + const currentYear = new Date().getFullYear(); + const previousYear = currentYear - 1; + + const result = await pool.request() + .input('currentYear', sql.Int, currentYear) + .input('previousYear', sql.Int, previousYear) + .query(` + SELECT + ca.id, + ca.nom, + ca.prenom, + ca.email, + ca.description as poste, + ca.role, + ca.TypeContrat as typeContrat, + ca.DateEntree as dateEntree, + ca.actif as Actif, + + -- Société + COALESCE(soc.Nom, 'Sans société') as societe, + ca.SocieteId as societeId, + + -- Campus + COALESCE(camp.Nom, 'Sans campus') as campus, + ca.CampusId as campusId, + + -- Service + COALESCE(serv.Nom, 'Sans service') as service, + ca.ServiceId as serviceId, + + -- Hiérarchie - ✅ CORRECTION : Utiliser [SuperieurIdn+2] avec crochets + hv.SuperieurId as nPlus1Id, + CONCAT(n1.prenom, ' ', n1.nom) as nPlus1Nom, + hv.[SuperieurIdn+2] as nPlus2Id, -- ✅ CROCHETS pour le + + CONCAT(n2.prenom, ' ', n2.nom) as nPlus2Nom, + NULL as nPlus3Id, + NULL as nPlus3Nom, + + -- Compteurs + compteurs.soldeCPN, + compteurs.soldeCPNMoins1, + compteurs.soldeRTT, + compteurs.soldeRecup + + FROM CollaborateurAD ca + LEFT JOIN Societe soc ON ca.SocieteId = soc.Id + LEFT JOIN Campus camp ON ca.CampusId = camp.Id + LEFT JOIN Services serv ON ca.ServiceId = serv.Id + LEFT JOIN HierarchieValidationAD hv ON ca.id = hv.CollaborateurId + LEFT JOIN CollaborateurAD n1 ON hv.SuperieurId = n1.id + LEFT JOIN CollaborateurAD n2 ON hv.[SuperieurIdn+2] = n2.id -- ✅ CROCHETS + OUTER APPLY ( + SELECT + MAX(CASE WHEN tc.Nom = 'Congé payé' AND cc.Annee = @currentYear THEN cc.Solde END) as soldeCPN, + MAX(CASE WHEN tc.Nom = 'Congé payé' AND cc.Annee = @previousYear THEN cc.Solde END) as soldeCPNMoins1, + MAX(CASE WHEN tc.Nom = 'RTT' AND cc.Annee = @currentYear THEN cc.Solde END) as soldeRTT, + MAX(CASE WHEN tc.Nom = 'Récupération' AND cc.Annee = @currentYear THEN cc.Solde END) as soldeRecup + FROM CompteurConges cc + JOIN TypeConge tc ON cc.TypeCongeId = tc.Id + WHERE cc.CollaborateurADId = ca.id + AND tc.Nom IN ('Congé payé', 'RTT', 'Récupération') + AND cc.Annee IN (@currentYear, @previousYear) + ) compteurs + WHERE ca.actif = 1 OR ca.actif IS NULL + ORDER BY soc.Nom, camp.Nom, serv.Nom, ca.nom, ca.prenom + `); + + const collaborateurs = result.recordset.map(row => ({ + ...row, + soldeCPN: row.soldeCPN || 0, + soldeCPNMoins1: row.soldeCPNMoins1 || 0, + soldeRTT: row.soldeRTT || 0, + soldeRecup: row.soldeRecup || 0 + })); + + console.log(`✅ ${collaborateurs.length} collaborateurs récupérés avec détails`); + res.json(collaborateurs); + } catch (error) { + console.error('❌ Erreur /collaborateurs/details:', error); + res.status(500).json({ error: error.message }); } }); - // ✅ ROUTE STATS MANQUANTE - app.get('/api/stats', authenticateToken, async (req, res) => { + // GET /api/societes - Liste des sociétés + app.get('/societes', authenticateToken, async (req, res) => { try { - console.log('📊 GET /api/stats appelé par', req.user.email); + const result = await pool.request().query(` + SELECT Id as id, Nom as nom + FROM Societe + ORDER BY Nom + `); + res.json(result.recordset); + } catch (error) { + console.error('❌ Erreur /societes:', error); + res.status(500).json({ error: error.message }); + } + }); - // Stats simples pour test - const stats = { - totalEmployes: 0, - demandesEnAttente: 0, - demandesValidees: 0, - timestamp: new Date().toISOString() + const CAMPUS_MAPPING = { + 1: 'Cergy', + 2: 'Saint-Quentin-en-Yvelines', + 3: 'Marseille', + 4: 'Nantes' + // Ajouter les autres campus selon tes besoins + }; + + // GET /api/campus - Liste des campus + app.get('/campus', authenticateToken, async (req, res) => { + try { + const result = await pool.request().query(` + SELECT Id as id, Nom as nom + FROM Campus + ORDER BY Nom + `); + + console.log('✅ Campus récupérés:', result.recordset); + res.json(result.recordset); + } catch (error) { + console.error('❌ Erreur /campus:', error); + res.status(500).json({ error: error.message }); + } + }); + + + + + // GET /api/services - Liste des services + app.get('/services', authenticateToken, async (req, res) => { + try { + const result = await pool.request().query(` + SELECT Id as id, Nom as nom + FROM Services + ORDER BY Nom + `); + res.json(result.recordset); + } catch (error) { + console.error('❌ Erreur /services:', error); + res.status(500).json({ error: error.message }); + } + }); + + + // PUT /api/collaborateurs/:id - Modifier un collaborateur + // GET /api/collaborateurs/validateurs - Liste des collaborateurs actifs pouvant être validateurs + app.get('/collaborateurs/validateurs', authenticateToken, async (req, res) => { + try { + const result = await pool.request().query(` + SELECT id, CONCAT(prenom, ' ', nom) as nom, email, role + FROM CollaborateurAD + WHERE (Actif = 1 OR Actif IS NULL) + AND ( + role LIKE '%Admin%' + OR role LIKE '%RH%' + OR role LIKE '%Validat%' + OR role LIKE '%Directeur%' + OR role LIKE '%Directrice%' + OR role LIKE '%President%' + OR role LIKE '%Président%' + ) + ORDER BY nom, prenom + `); + res.json(result.recordset); + } catch (error) { + console.error('❌ Erreur /collaborateurs/validateurs:', error); + res.status(500).json({ error: error.message }); + } + }); + + app.put('/collaborateurs/:id', authenticateToken, async (req, res) => { + const transaction = new sql.Transaction(pool); + try { + await transaction.begin(); + + if (!['Admin', 'RH'].includes(req.user.role)) { + await transaction.rollback(); + return res.status(403).json({ error: 'Accès non autorisé' }); + } + + const { id } = req.params; + const { + nom, + prenom, + email, + societeId, + campusId, + serviceId, + poste, + role, + typeContrat, + dateEntree, + nPlus1Id, + nPlus2Id + } = req.body; + + console.log('Modification collaborateur', id, req.body); + + // Vérifier que le collaborateur existe + const existingResult = await transaction.request() + .input('id', sql.Int, id) + .query(`SELECT id, prenom, nom FROM CollaborateurAD WHERE id = @id`); + + if (existingResult.recordset.length === 0) { + await transaction.rollback(); + return res.status(404).json({ error: 'Collaborateur non trouvé' }); + } + + // Mise à jour de CollaborateurAD + await transaction.request() + .input('id', sql.Int, id) + .input('nom', sql.NVarChar(100), nom) + .input('prenom', sql.NVarChar(100), prenom) + .input('email', sql.NVarChar(255), email) + .input('societeId', sql.Int, societeId) + .input('campusId', sql.Int, campusId) + .input('serviceId', sql.Int, serviceId) + .input('typeContrat', sql.NVarChar(50), typeContrat) + .input('dateEntree', sql.Date, dateEntree || null) + .input('description', sql.NVarChar(500), poste || null) + .input('role', sql.NVarChar(100), role || null) + .query(` + UPDATE CollaborateurAD + SET nom = @nom, + prenom = @prenom, + email = @email, + SocieteId = @societeId, + CampusId = @campusId, + ServiceId = @serviceId, + TypeContrat = @typeContrat, + DateEntree = @dateEntree, + description = @description, + role = @role + WHERE id = @id + `); + + // Mise à jour de la hiérarchie - ✅ CORRECTION avec crochets + const hierarchieExistResult = await transaction.request() + .input('collaborateurId', sql.Int, id) + .query(`SELECT CollaborateurId FROM HierarchieValidationAD WHERE CollaborateurId = @collaborateurId`); + + if (hierarchieExistResult.recordset.length > 0) { + // UPDATE + await transaction.request() + .input('collaborateurId', sql.Int, id) + .input('superieurId', sql.Int, nPlus1Id) + .input('superieurIdN2', sql.Int, nPlus2Id) + .query(` + UPDATE HierarchieValidationAD + SET SuperieurId = @superieurId, + [SuperieurIdn+2] = @superieurIdN2 + WHERE CollaborateurId = @collaborateurId + `); + } else { + // INSERT + if (nPlus1Id || nPlus2Id) { + await transaction.request() + .input('collaborateurId', sql.Int, id) + .input('superieurId', sql.Int, nPlus1Id) + .input('superieurIdN2', sql.Int, nPlus2Id) + .query(` + INSERT INTO HierarchieValidationAD (CollaborateurId, SuperieurId, [SuperieurIdn+2]) + VALUES (@collaborateurId, @superieurId, @superieurIdN2) + `); + } + } + + // Enregistrer dans l'historique + try { + await transaction.request() + .input('userId', sql.Int, req.user.id) + .input('details', sql.NVarChar(500), `Modification des informations de ${prenom} ${nom}`) + .query(` + INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction) + VALUES (@userId, 'Modification collaborateur', @details, GETDATE()) + `); + } catch (histError) { + console.warn('Erreur historique non bloquant:', histError.message); + } + + await transaction.commit(); + console.log(`✅ Collaborateur ${id} mis à jour`); + res.json({ success: true, message: 'Collaborateur mis à jour avec succès' }); + + } catch (error) { + await transaction.rollback(); + console.error('❌ Erreur PUT /collaborateurs/:id:', error); + res.status(500).json({ error: error.message }); + } + }); + + // GET /api/snapshot/historique/:employeeId + app.get('/snapshot/historique/:employeeId', authenticateToken, async (req, res) => { + try { + const { employeeId } = req.params; + const { typeCongeId, typeConge, annee } = req.query; + + const request = pool.request() + .input('employeeId', sql.Int, parseInt(employeeId)); + + let where = 'WHERE s.CollaborateurADId = @employeeId'; + + // ⭐ Filtrer par ID ou par nom selon ce qui est fourni + if (typeCongeId) { + request.input('typeCongeId', sql.Int, parseInt(typeCongeId)); + where += ' AND s.TypeCongeId = @typeCongeId'; + } else if (typeConge) { + request.input('typeCongeNom', sql.NVarChar, typeConge); + where += ' AND tc.Nom = @typeCongeNom'; + } + + if (annee) { + request.input('annee', sql.Int, parseInt(annee)); + where += ' AND s.Annee = @annee'; + } + + const result = await request.query(` + SELECT + s.DateSnapshot, + s.Solde, + s.SoldeAnticipe, + s.Total, + s.SoldeReporte, + ISNULL(s.IsAnticipe, 0) AS IsAnticipe, + tc.Nom AS typeConge, + s.Annee, + -- ⭐ Récupérer SoldeAnticipe depuis CompteurConges (valeur actuelle) + ISNULL(cc.SoldeAnticipe, 0) AS SoldeAnticipeActuel + FROM SnapshotQuotidienCompteurs s + JOIN TypeConge tc ON s.TypeCongeId = tc.Id + LEFT JOIN CompteurConges cc + ON cc.CollaborateurADId = s.CollaborateurADId + AND cc.TypeCongeId = s.TypeCongeId + AND cc.Annee = s.Annee + ${where} + ORDER BY s.DateSnapshot ASC +`); + res.json(result.recordset || []); + + } catch (error) { + console.error('❌ Erreur snapshot historique:', error); + res.status(500).json({ error: error.message }); + } + }); + + // ================================================ + // ⭐ ROUTE GET /soldes-previsionnel + // Calcule les soldes CP et RTT projetés à la date de début du congé, + // en lisant DeductionDetails exactement comme serverGTA. + // Gère NORMAL et ANTICIPÉ (CP + RTT). + // Usage: GET /api/soldes-previsionnel?userid=X&dateDebut=YYYY-MM-DD&joursCP=N&joursRTT=N + // ================================================ + app.get('/soldes-previsionnel', authenticateToken, async (req, res) => { + const { userid, dateDebut, joursCP, joursRTT } = req.query; + if (!userid || !dateDebut) { + return res.json({ success: false, message: 'Paramètres requis: userid, dateDebut' }); + } + const userIdInt = parseInt(userid, 10); + if (isNaN(userIdInt)) return res.json({ success: false, message: 'userid invalide' }); + + try { + const today = new Date(); today.setHours(0, 0, 0, 0); + const currentYear = today.getFullYear(); + const dateDebutObj = new Date(dateDebut); dateDebutObj.setHours(0, 0, 0, 0); + const moisDebut = dateDebutObj.getMonth() + 1; + const anneeDebut = dateDebutObj.getFullYear(); + + // ── Infos collaborateur ──────────────────────────────────────── + const collabRes = await pool.request() + .input('userId', sql.Int, userIdInt) + .query(`SELECT TOP 1 role, DateEntree, TypeContrat FROM CollaborateurAD WHERE id=@userId`); + if (!collabRes.recordset.length) throw new Error('Collaborateur introuvable'); + const isApprenti = collabRes.recordset[0].role === 'Apprenti'; + const dateEntree = collabRes.recordset[0].DateEntree ?? null; + const typeContrat = collabRes.recordset[0].TypeContrat ?? '37h'; + + // ── Exercice CP (Annee = année de FIN d'exercice) ───────────── + const dateBasculeCP = new Date(currentYear, 5, 1); dateBasculeCP.setHours(0, 0, 0, 0); + const apresBasculement = today >= dateBasculeCP; + const anneeN = apresBasculement ? currentYear + 1 : currentYear; + const anneeN1 = anneeN - 1; + const anneeExConge = moisDebut >= 6 ? anneeDebut + 1 : anneeDebut; + const isAnticipe = anneeExConge > anneeN; + + // ── Exercice RTT (année civile) ──────────────────────────────── + const anneeRTT = anneeDebut; + const isAnticipeRTT = anneeRTT > currentYear; + + // ── TypeConge IDs ────────────────────────────────────────────── + const cpTypeRes = await pool.request() + .input('nom', sql.NVarChar, 'Congé payé') + .query(`SELECT TOP 1 Id FROM TypeConge WHERE Nom=@nom`); + if (!cpTypeRes.recordset.length) throw new Error('Type CP introuvable'); + const cpTypeId = cpTypeRes.recordset[0].Id; + + const rttTypeRes = await pool.request() + .input('nom', sql.NVarChar, 'RTT') + .query(`SELECT TOP 1 Id FROM TypeConge WHERE Nom=@nom`); + const rttTypeId = rttTypeRes.recordset[0]?.Id ?? null; + + // ── Helper : lire compteur CP ────────────────────────────────── + const lireCompteurCP = async (annee) => { + const r = await pool.request() + .input('userId', sql.Int, userIdInt) + .input('typeId', sql.Int, cpTypeId) + .input('annee', sql.Int, annee) + .query(`SELECT TOP 1 CAST(COALESCE(Solde,0) AS FLOAT) AS Solde, CAST(COALESCE(SoldeReporte,0) AS FLOAT) AS SoldeReporte, CAST(COALESCE(Total,0) AS FLOAT) AS Total FROM CompteurConges WHERE CollaborateurADId=@userId AND TypeCongeId=@typeId AND Annee=@annee ORDER BY Id DESC`); + if (!r.recordset.length) return null; + const row = r.recordset[0]; + const solde = parseFloat(row.Solde); + const report = parseFloat(row.SoldeReporte); + return { solde, reporte: report, total: parseFloat(row.Total), dispoN1: Math.max(0, solde), dispoN: Math.max(0, solde - report) }; }; - // TODO: Requêtes SQL réelles ici - res.json(stats); + // ── Helper : lire compteur RTT ───────────────────────────────── + const lireCompteurRTT = async (annee) => { + if (!rttTypeId) return null; + const r = await pool.request() + .input('userId', sql.Int, userIdInt) + .input('typeId', sql.Int, rttTypeId) + .input('annee', sql.Int, annee) + .query(`SELECT TOP 1 CAST(COALESCE(Solde,0) AS FLOAT) AS Solde, CAST(COALESCE(Total,0) AS FLOAT) AS Total FROM CompteurConges WHERE CollaborateurADId=@userId AND TypeCongeId=@typeId AND Annee=@annee ORDER BY Id DESC`); + if (!r.recordset.length) return null; + return { solde: parseFloat(r.recordset[0].Solde), total: parseFloat(r.recordset[0].Total) }; + }; + + // ── Compteurs CP ─────────────────────────────────────────────── + const cptN1 = await lireCompteurCP(anneeN1); + const cptN = await lireCompteurCP(anneeN); + + const soldeNActuel = cptN ? Math.max(0, parseFloat(cptN.solde || 0)) : 0; + const soldeN1Actuel = cptN1 ? Math.max(0, parseFloat(cptN1.solde || 0)) : 0; + + // ── Déductions futures sur CP N (après dateDebutObj) ────────── + const dejaConsoFuturNRes = await pool.request() + .input('userId', sql.Int, userIdInt) + .input('typeId', sql.Int, cpTypeId) + .input('annee', sql.Int, anneeN) + .input('dateDebut', sql.Date, dateDebutObj) // ← dateDebutObj au lieu de today + .query(` + SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total + FROM DeductionDetails dd + JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id + WHERE dc.CollaborateurADId = @userId + AND dd.TypeCongeId = @typeId + AND dd.Annee = @annee + AND dc.DateDebut > @dateDebut + AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée') + `); + const dejaConsoFuturN = parseFloat(dejaConsoFuturNRes.recordset[0]?.total ?? 0); + + // ── Déductions futures sur CP N-1 (après dateDebutObj) ──────── + const dejaConsoFuturN1Res = await pool.request() + .input('userId', sql.Int, userIdInt) + .input('typeId', sql.Int, cpTypeId) + .input('annee', sql.Int, anneeN1) + .input('dateDebut', sql.Date, dateDebutObj) // ← dateDebutObj au lieu de today + .query(` + SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total + FROM DeductionDetails dd + JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id + WHERE dc.CollaborateurADId = @userId + AND dd.TypeCongeId = @typeId + AND dd.Annee = @annee + AND dc.DateDebut > @dateDebut + AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée') + `); + const dejaConsoFuturN1 = parseFloat(dejaConsoFuturN1Res.recordset[0]?.total ?? 0); + + // ── Soldes disponibles ───────────────────────────────────────── + const cpN1Dispo = Math.max(0, soldeN1Actuel - dejaConsoFuturN1); + const cpNAcquisAujourdHui = Math.max(0, soldeNActuel - dejaConsoFuturN); + + let totalCPDisponible; + let cpNAnticipeDispo = 0; + let acquisCPADateDebut = 0; + let cpSoldeADateDebut = 0; + + if (!isAnticipe) { + // ── MODE NORMAL : calculerAcquisitionFormuleExcel au jour près ── + const acquisADateDebut = calculerAcquisitionFormuleExcel( + today, + dateDebutObj, + 25 / 12 + ); + + const soldeNALaDate = parseFloat( + Math.min(25, soldeNActuel + acquisADateDebut).toFixed(2) + ); + + acquisCPADateDebut = soldeNALaDate; + cpSoldeADateDebut = Math.max(0, soldeNALaDate - dejaConsoFuturN); + cpNAnticipeDispo = Math.max(0, cpSoldeADateDebut - cpNAcquisAujourdHui); + + totalCPDisponible = parseFloat( + Math.max(0, cpN1Dispo + cpSoldeADateDebut).toFixed(2) + ); + + console.log(`🔍 CP NORMAL: solde actuel=${soldeNActuel}j + acq futures=${acquisADateDebut.toFixed(2)}j = à date ${soldeNALaDate}j - conso future=${dejaConsoFuturN}j = ${cpSoldeADateDebut}j | N-1=${cpN1Dispo}j | Total=${totalCPDisponible}j`); + + } else { + // ── MODE ANTICIPÉ ──────────────────────────────────────────── + // Fin exercice N = 31/05/currentYear (toujours l'année civile courante) + const finExN = new Date(currentYear, 4, 31); + finExN.setHours(23, 59, 59, 0); + const debutNp1 = new Date(currentYear, 5, 1); // 01/06/currentYear + debutNp1.setHours(0, 0, 0, 0); + + // Acquisitions depuis aujourd'hui jusqu'au 31/05 (peut être 0 si passé) + const acquisJusquAuFinExN = today <= finExN + ? calculerAcquisitionFormuleExcel(today, finExN, 25 / 12) + : 0; + + const soldeNAFinEx = parseFloat( + Math.min(25, soldeNActuel + acquisJusquAuFinExN).toFixed(2) + ); + + // Acquisitions N+1 depuis 01/06/currentYear jusqu'à dateDebutObj + const acquisNp1 = dateDebutObj > debutNp1 + ? parseFloat(Math.min(25, calculerAcquisitionFormuleExcel(debutNp1, dateDebutObj, 25 / 12)).toFixed(2)) + : 0; + + // Déductions anticipées APRÈS dateDebutObj uniquement + const dejaConsoAnticipRes = await pool.request() + .input('userId', sql.Int, userIdInt) + .input('typeId', sql.Int, cpTypeId) + .input('annee', sql.Int, anneeN) + .input('dateDebut', sql.Date, dateDebutObj) + .query(` + SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total + FROM DeductionDetails dd + JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id + WHERE dc.CollaborateurADId = @userId + AND dd.TypeCongeId = @typeId + AND dd.Annee = @annee + AND dd.TypeDeduction IN ('Anticip','AnticipFutur') + AND dc.DateDebut > @dateDebut + AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée') + `); + const dejaConsoAnticip = parseFloat(dejaConsoAnticipRes.recordset[0]?.total ?? 0); + + // Déductions sur N+1 après dateDebutObj + const dejaConsoNp1Res = await pool.request() + .input('userId', sql.Int, userIdInt) + .input('typeId', sql.Int, cpTypeId) + .input('annee', sql.Int, anneeExConge) + .input('dateDebut', sql.Date, dateDebutObj) + .query(` + SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total + FROM DeductionDetails dd + JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id + WHERE dc.CollaborateurADId = @userId + AND dd.TypeCongeId = @typeId + AND dd.Annee = @annee + AND dc.DateDebut > @dateDebut + AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée') + `); + const dejaConsoNp1 = parseFloat(dejaConsoNp1Res.recordset[0]?.total ?? 0); + const dispoNp1 = parseFloat(Math.max(0, acquisNp1 - dejaConsoNp1).toFixed(2)); + + acquisCPADateDebut = soldeNAFinEx; + cpSoldeADateDebut = Math.max(0, soldeNAFinEx - dejaConsoAnticip); + cpNAnticipeDispo = dispoNp1; + + totalCPDisponible = parseFloat( + Math.max(0, cpN1Dispo + cpSoldeADateDebut + dispoNp1).toFixed(2) + ); + + console.log(`🔍 CP ANTICIPÉ: solde=${soldeNActuel}j + acq→31/05=${acquisJusquAuFinExN.toFixed(2)}j = ${soldeNAFinEx}j - conso après dateDebut=${dejaConsoAnticip}j | N+1 (01/06→${dateDebut})=${dispoNp1}j | Total=${totalCPDisponible}j`); + } + + console.log(`🔍 CP — N-1: ${cpN1Dispo.toFixed(2)}j | N acquis: ${cpNAcquisAujourdHui.toFixed(2)}j | Anticipé: ${cpNAnticipeDispo.toFixed(2)}j | Total: ${totalCPDisponible.toFixed(2)}j`); + + // ── RTT ──────────────────────────────────────────────────────── + let rttDispo = 0, acquisRTTADateDebut = 0, rttNAcquisAujourdHui = 0, rttAnticipeDispo = 0; + + if (rttTypeId && !isApprenti) { + const rttData = await calculerAcquisitionRTT(userIdInt, dateDebutObj); + acquisRTTADateDebut = rttData.acquisition; + + const cptRTT = await lireCompteurRTT(anneeRTT); + rttNAcquisAujourdHui = cptRTT ? Math.max(0, cptRTT.solde) : 0; + + const rttConsoFuturRes = await pool.request() + .input('userId', sql.Int, userIdInt) + .input('typeId', sql.Int, rttTypeId) + .input('annee', sql.Int, anneeRTT) + .input('dateDebut', sql.Date, dateDebutObj) // ← dateDebutObj + .query(` + SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total + FROM DeductionDetails dd + JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id + WHERE dc.CollaborateurADId = @userId + AND dd.TypeCongeId = @typeId + AND dd.Annee = @annee + AND dc.DateDebut > @dateDebut + AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée') + `); + const rttConsoFutur = parseFloat(rttConsoFuturRes.recordset[0]?.total ?? 0); + + if (!isAnticipeRTT) { + const cfgRTT = await getConfigurationRTT(anneeRTT, typeContrat); + const acquisFutRTT = parseFloat( + Math.min(cfgRTT.joursAnnuels - rttNAcquisAujourdHui, + calculerAcquisitionFormuleExcel(today, dateDebutObj, cfgRTT.joursAnnuels / 12) + ).toFixed(2) + ); + const rttALaDate = parseFloat( + Math.min(cfgRTT.joursAnnuels, rttNAcquisAujourdHui + acquisFutRTT).toFixed(2) + ); + rttAnticipeDispo = Math.max(0, rttALaDate - rttNAcquisAujourdHui); + rttDispo = Math.max(0, rttALaDate - rttConsoFutur); + + } else { + const cfgRTT = await getConfigurationRTT(anneeRTT, typeContrat); + const finExRTT = new Date(anneeRTT, 11, 31); finExRTT.setHours(23, 59, 59, 0); + const acquisRest = Math.min( + cfgRTT.joursAnnuels - rttNAcquisAujourdHui, + calculerAcquisitionFormuleExcel(today, finExRTT, cfgRTT.joursAnnuels / 12) + ); + const soldeProj = Math.min(cfgRTT.joursAnnuels, rttNAcquisAujourdHui + acquisRest); + const dispoRTTN = Math.max(0, soldeProj - rttConsoFutur); + + const anneeRTTNp1 = anneeRTT + 1; + const cfgRTTNp1 = await getConfigurationRTT(anneeRTTNp1, typeContrat); + const debutNp1RTT = new Date(anneeRTTNp1, 0, 1); debutNp1RTT.setHours(0, 0, 0, 0); + const acquisNp1RTT = parseFloat( + Math.min(cfgRTTNp1.joursAnnuels, + calculerAcquisitionFormuleExcel(debutNp1RTT, dateDebutObj, cfgRTTNp1.joursAnnuels / 12) + ).toFixed(2) + ); + + const rttConsoNp1Res = await pool.request() + .input('userId', sql.Int, userIdInt) + .input('typeId', sql.Int, rttTypeId) + .input('annee', sql.Int, anneeRTTNp1) + .input('dateDebut', sql.Date, dateDebutObj) // ← dateDebutObj + .query(` + SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total + FROM DeductionDetails dd + JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id + WHERE dc.CollaborateurADId = @userId + AND dd.TypeCongeId = @typeId + AND dd.Annee = @annee + AND dc.DateDebut > @dateDebut + AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée') + `); + const rttConsoNp1 = parseFloat(rttConsoNp1Res.recordset[0]?.total ?? 0); + const dispoNp1RTT = parseFloat(Math.max(0, acquisNp1RTT - rttConsoNp1).toFixed(2)); + + rttDispo = parseFloat((dispoRTTN + dispoNp1RTT).toFixed(2)); + rttAnticipeDispo = dispoNp1RTT; + console.log(`🔍 RTT ANTICIPÉ: N=${dispoRTTN.toFixed(2)}j + N+1=${dispoNp1RTT.toFixed(2)}j = ${rttDispo.toFixed(2)}j`); + } + console.log(`🔍 RTT (${anneeRTT}) — Acquis à dateDebut: ${acquisRTTADateDebut.toFixed(2)}j | En base: ${rttNAcquisAujourdHui.toFixed(2)}j | Anticipé: ${rttAnticipeDispo.toFixed(2)}j | Dispo: ${rttDispo.toFixed(2)}j`); + } + + // ── Vérification suffisance ──────────────────────────────────── + const joursNecessairesCP = parseFloat(joursCP) || 0; + const joursNecessairesRTT = parseFloat(joursRTT) || 0; + const suffisantCP = joursNecessairesCP <= 0 || totalCPDisponible >= joursNecessairesCP; + const suffisantRTT = joursNecessairesRTT <= 0 || rttDispo >= joursNecessairesRTT; + const suffisant = suffisantCP && suffisantRTT; + const deficitCP = suffisantCP ? 0 : parseFloat((joursNecessairesCP - totalCPDisponible).toFixed(2)); + const deficitRTT = suffisantRTT ? 0 : parseFloat((joursNecessairesRTT - rttDispo).toFixed(2)); + + let modeLabel = ''; + if (!isAnticipe) modeLabel = apresBasculement ? 'NORMAL POST-BASCULEMENT' : 'NORMAL'; + else if (!apresBasculement) modeLabel = 'ANTICIPÉ CLASSIQUE'; + else modeLabel = 'ANTICIPÉ POST-BASCULEMENT'; + + const exerciceDemande = anneeN; + const exerciceConges = anneeExConge - 1; + + res.json({ + success: true, + previsionnel: { + mode: modeLabel, + isAnticipe, + isAnticipeRTT, + apresBasculement, + exerciceDemande: `${exerciceDemande}-${exerciceDemande + 1}`, + exerciceConges: `${exerciceConges}-${exerciceConges + 1}`, + anneeN1, anneeN, anneeRTT, + + cpN1Reporte: parseFloat(cpN1Dispo.toFixed(2)), + cpNSolde: parseFloat(cpNAcquisAujourdHui.toFixed(2)), + cpNSoldeALaDate: parseFloat(cpSoldeADateDebut.toFixed(2)), + cpNAnticipeAutorise: parseFloat(cpNAnticipeDispo.toFixed(2)), + acquisCPADateDebut: parseFloat(acquisCPADateDebut.toFixed(2)), + cpNAcquisAujourdHui: parseFloat(cpNAcquisAujourdHui.toFixed(2)), + cpNAnticipeDispo: parseFloat(cpNAnticipeDispo.toFixed(2)), + totalCPDisponible, + + acquisRTTADateDebut: parseFloat(acquisRTTADateDebut.toFixed(2)), + rttNAcquisAujourdHui: parseFloat(rttNAcquisAujourdHui.toFixed(2)), + rttAnticipeDispo: parseFloat(rttAnticipeDispo.toFixed(2)), + rttDispo: parseFloat(rttDispo.toFixed(2)), + + joursNecessairesCP, joursNecessairesRTT, + totalDisponible: totalCPDisponible, + suffisant, suffisantCP, suffisantRTT, + deficitCP, deficitRTT, + + message: (() => { + const parts = []; + if (cpN1Dispo > 0) parts.push(`N-1(${anneeN1}): ${cpN1Dispo.toFixed(2)}j`); + if (cpNAcquisAujourdHui > 0) parts.push(`N acquis(${anneeN}): ${cpNAcquisAujourdHui.toFixed(2)}j`); + if (cpNAnticipeDispo > 0) parts.push(`N anticipé: ${cpNAnticipeDispo.toFixed(2)}j`); + if (rttDispo > 0) parts.push(`RTT(${anneeRTT}): ${rttDispo.toFixed(2)}j`); + return `${modeLabel} — ${parts.join(' + ')} → CP total: ${totalCPDisponible.toFixed(2)}j`; + })() + } + }); + } catch (error) { - console.error('Erreur /api/stats:', error); - res.status(500).json({ error: 'Erreur stats' }); + console.error('❌ Erreur soldes-previsionnel:', error); + res.status(500).json({ success: false, message: error.message }); + } + }); + // ================================================ + // ROUTE PUT /demandes/:id/annuler - ANNULATION SANS SUPPRESSION + // ================================================ + + + app.put('/demandes/:id/annuler', authenticateToken, async (req, res) => { + const demandeId = parseInt(req.params.id); + const { commentaire } = req.body; + + console.log(`🚫 Annulation demande #${demandeId}`); + + const transaction = new sql.Transaction(); + + try { + await transaction.begin(); + + // 1. Récupérer la demande + const demandeResult = await transaction.request() + .input('demandeId', sql.Int, demandeId) + .query(` + SELECT + dc.*, + c.Email as EmailEmploye, + c.Nom, + c.Prenom + FROM DemandeConge dc + INNER JOIN CollaborateurAD c ON dc.CollaborateurADId = c.Id + WHERE dc.Id = @demandeId + `); + + if (demandeResult.recordset.length === 0) { + await transaction.rollback(); + return res.status(404).json({ error: 'Demande non trouvée' }); + } + + const demande = demandeResult.recordset[0]; + const collaborateurId = demande.CollaborateurADId; + const ancienStatut = demande.Statut; + + console.log(`📋 Demande trouvée - Statut actuel: ${ancienStatut}`); + + // 2. Récupérer les types de congés + const typesResult = await transaction.request() + .input('demandeId', sql.Int, demandeId) + .query(` + SELECT + dct.Id, + dct.TypeCongeId, + dct.NombreJours, + tc.Nom as TypeConge + FROM DemandeCongeType dct + INNER JOIN TypeConge tc ON dct.TypeCongeId = tc.Id + WHERE dct.DemandeCongeId = @demandeId + `); + + console.log(`📊 ${typesResult.recordset.length} type(s) de congé trouvé(s)`); + + // 3. ⭐ RESTAURATION VIA DeductionDetails (même logique que serverGTA) + if (ancienStatut === 'En attente' || ancienStatut === 'Validée') { + console.log('💰 Restauration des compteurs via DeductionDetails...'); + + // Lire toutes les lignes DeductionDetails de cette demande + const deductionsResult = await transaction.request() + .input('demandeId', sql.Int, demandeId) + .query(` + SELECT dd.Id AS DeductionId, dd.TypeCongeId, dd.Annee, + dd.TypeDeduction, dd.JoursUtilises, tc.Nom AS TypeNom + FROM DeductionDetails dd + JOIN TypeConge tc ON dd.TypeCongeId = tc.Id + WHERE dd.DemandeCongeId = @demandeId + ORDER BY dd.Id DESC + `); + + const deductions = deductionsResult.recordset; + console.log(`📊 ${deductions.length} ligne(s) DeductionDetails trouvée(s)`); + + const restorations = []; + + for (const ded of deductions) { + const { DeductionId, TypeCongeId, Annee, TypeDeduction, JoursUtilises, TypeNom } = ded; + const jours = parseFloat(JoursUtilises); + console.log(`\n🔍 ${TypeNom} - ${TypeDeduction} - ${jours}j (Année: ${Annee})`); + + // Helper : lire compteur + const lireCpt = async (annee) => { + const r = await transaction.request() + .input('collabId', sql.Int, collaborateurId) + .input('typeId', sql.Int, TypeCongeId) + .input('annee', sql.Int, annee) + .query(`SELECT TOP 1 Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`); + return r.recordset[0] ?? null; + }; + + // ── Année N-1 → Solde + SoldeReporte ───────────────── + if (TypeDeduction === 'Année N-1' || TypeDeduction === 'Anne N-1') { + const cpt = await lireCpt(Annee); + if (cpt) { + await transaction.request() + .input('jours', sql.Decimal(10, 2), jours) + .input('id', sql.Int, cpt.Id) + .query(`UPDATE CompteurConges SET Solde=Solde+@jours, SoldeReporte=SoldeReporte+@jours, DerniereMiseAJour=GETDATE() WHERE Id=@id`); + console.log(`✅ Année N-1 restaurée: +${jours}j (Solde + SoldeReporte)`); + } + await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`); + restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours }); + continue; + } + + // ── Année N → Solde seulement ───────────────────────── + if (TypeDeduction === 'Année N' || TypeDeduction === 'Anne N') { + const cpt = await lireCpt(Annee); + if (cpt) { + await transaction.request() + .input('jours', sql.Decimal(10, 2), jours) + .input('id', sql.Int, cpt.Id) + .query(`UPDATE CompteurConges SET Solde=Solde+@jours, DerniereMiseAJour=GETDATE() WHERE Id=@id`); + console.log(`✅ Année N restaurée: +${jours}j`); + } + await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`); + restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours }); + continue; + } + + // ── Anticip (jours réels déduits du solde CP N) → remettre ── + if (TypeDeduction === 'Anticip') { + const cpt = await lireCpt(Annee); + if (cpt) { + await transaction.request() + .input('jours', sql.Decimal(10, 2), jours) + .input('id', sql.Int, cpt.Id) + .query(`UPDATE CompteurConges SET Solde=Solde+@jours, DerniereMiseAJour=GETDATE() WHERE Id=@id`); + console.log(`✅ Anticip restauré: +${jours}j sur CompteurConges`); + } + await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`); + restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours }); + continue; + } + + // ── AnticipFutur (tracé uniquement, solde jamais touché) → juste supprimer ── + if (TypeDeduction === 'AnticipFutur') { + await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`); + restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours }); + console.log(`✅ AnticipFutur supprimé: ${jours}j tracé retiré (aucun solde à remettre)`); + continue; + } + + // ── AnticipRTT (solde RTT N réel déduit) → remettre ── + if (TypeDeduction === 'AnticipRTT') { + const cpt = await lireCpt(Annee); + if (cpt) { + await transaction.request() + .input('jours', sql.Decimal(10, 2), jours) + .input('id', sql.Int, cpt.Id) + .query(`UPDATE CompteurConges SET Solde=Solde+@jours, DerniereMiseAJour=GETDATE() WHERE Id=@id`); + console.log(`✅ AnticipRTT restauré: +${jours}j sur RTT ${Annee}`); + } + await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`); + restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours }); + continue; + } + + // ── AnticipFuturRTT (tracé uniquement) → juste supprimer ── + if (TypeDeduction === 'AnticipFuturRTT') { + await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`); + restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours }); + console.log(`✅ AnticipFuturRTT supprimé: ${jours}j tracé retiré (aucun solde à remettre)`); + continue; + } + + // ── Cas non reconnu → log warning ─────────────────── + console.warn(`⚠️ TypeDeduction non géré: "${TypeDeduction}" — ignoré`); + } + + // ── Fallback : si aucune ligne DeductionDetails (anciennes demandes) ── + // On applique l'ancienne logique simple pour rester compatible + if (deductions.length === 0) { + console.log('⚠️ Aucune DeductionDetails — fallback restauration simple'); + for (const type of typesResult.recordset) { + const nombreJours = parseFloat(type.NombreJours); + const typeCongeId = type.TypeCongeId; + const anneeDemande = new Date(demande.DateDebut).getFullYear(); + const cptFallback = await transaction.request() + .input('collabId', sql.Int, collaborateurId) + .input('typeId', sql.Int, typeCongeId) + .input('annee', sql.Int, anneeDemande) + .query(`SELECT TOP 1 Id, Solde FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`); + if (cptFallback.recordset.length > 0) { + await transaction.request() + .input('jours', sql.Decimal(10, 2), nombreJours) + .input('id', sql.Int, cptFallback.recordset[0].Id) + .query(`UPDATE CompteurConges SET Solde=Solde+@jours, DerniereMiseAJour=GETDATE() WHERE Id=@id`); + console.log(`✅ Fallback: ${type.TypeConge} +${nombreJours}j`); + } + } + } + + // Historique global + try { + await transaction.request() + .input('collabId', sql.Int, collaborateurId) + .input('details', sql.NVarChar(500), `Annulation demande #${demandeId} - Restauration compteurs via DeductionDetails`) + .input('demandeId', sql.Int, demandeId) + .query(`INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId, DateAction) VALUES (@collabId, 'Restauration compteur', @details, @demandeId, GETDATE())`); + } catch (histErr) { + console.warn('⚠️ Historique non créé (non bloquant):', histErr.message); + } + + console.log(`✅ Restauration terminée: ${restorations.length} opération(s)`); + } else { + console.log(`ℹ️ Pas de restauration de compteurs (statut était "${ancienStatut}")`); + } + + // 4. Mettre à jour le statut de la demande + await transaction.request() + .input('demandeId', sql.Int, demandeId) + .input('commentaire', sql.NVarChar, commentaire || 'Annulée par le service RH') + .query(` + UPDATE DemandeConge + SET Statut = 'Annulée', + Commentaire = @commentaire, + DateValidation = GETDATE() + WHERE Id = @demandeId + `); + + await transaction.commit(); + + console.log(`✅ Demande #${demandeId} annulée avec succès`); + + // 5. ✅ CORRECTION : Envoyer un email avec Microsoft Graph + const emailEmploye = demande.EmailEmploye; + const nomComplet = `${demande.Prenom} ${demande.Nom}`; + + try { + const accessToken = await getGraphToken(); + + if (accessToken && emailEmploye) { + const subject = '🚫 Votre demande de congé a été annulée'; + const body = ` +
+
+

🚫 Annulation de demande de congé

+
+
+

Bonjour ${nomComplet},

+
+

Période : ${new Date(demande.DateDebut).toLocaleDateString('fr-FR')} au ${new Date(demande.DateFin).toLocaleDateString('fr-FR')}

+

Statut : Annulée

+ ${commentaire ? `

Motif : ${commentaire}

` : ''} +
+ ${(ancienStatut === 'En attente' || ancienStatut === 'Validée') ? '

ℹ️ Les jours de congés ont été automatiquement restitués à votre compteur.

' : ''} +

Pour toute question, veuillez contacter le service RH.

+
+
+ `; + + await sendMailGraph(accessToken, 'gtanoreply@ensup.eu', emailEmploye, subject, body); + console.log(`📧 Email d'annulation envoyé à ${emailEmploye}`); + } + } catch (emailError) { + console.error('❌ Erreur envoi email (non bloquant):', emailError.message); + } + + // 6. Notification SSE + notifyClients({ + type: 'demande-cancelled', + demandeId: demandeId, + collaborateurId: collaborateurId, + message: 'Demande annulée' + }); + + // 7. Webhook (optionnel) + try { + if (typeof webhookManager !== 'undefined' && (ancienStatut === 'En attente' || ancienStatut === 'Validée')) { + await webhookManager.sendWebhook( + WEBHOOKS.COLLABORATEURS.URL, + EVENTS.DEMANDE_CANCELLED, + { + demandeId: demandeId, + collaborateur: nomComplet, + dateDebut: demande.DateDebut, + dateFin: demande.DateFin, + ancienStatut: ancienStatut, + compteursRestaurés: true + } + ); + } + } catch (webhookError) { + console.error('⚠️ Erreur webhook (non bloquant):', webhookError.message); + } + + res.json({ + success: true, + message: 'Demande annulée avec succès', + compteursRestaurés: ancienStatut === 'En attente' || ancienStatut === 'Validée' + }); + + } catch (error) { + // ✅ CORRECTION : Vérifier si la transaction est toujours active avant rollback + try { + if (transaction._acquiredConnection) { + await transaction.rollback(); + console.log('🔄 Transaction annulée (rollback)'); + } + } catch (rollbackError) { + console.error('⚠️ Erreur rollback:', rollbackError.message); + } + + console.error('❌ Erreur annulation demande:', error); + res.status(500).json({ + error: 'Erreur lors de l\'annulation de la demande', + details: error.message + }); } }); + // PUT /api/compte-rendu/jour - RH modifie un jour du compte-rendu d'activité + app.put('/compte-rendu/jour', authenticateToken, async (req, res) => { + const transaction = new sql.Transaction(pool); + try { + await transaction.begin(); + if (!['Admin', 'RH'].includes(req.user.role)) { + await transaction.rollback(); + return res.status(403).json({ success: false, error: 'Accès non autorisé' }); + } + + const { collaborateurid, jourdate, jourtravaille, reposquotidien, reposhebdo, commentaire } = req.body; + + if (!collaborateurid || !jourdate) { + await transaction.rollback(); + return res.status(400).json({ success: false, error: 'collaborateurid et jourdate sont requis' }); + } + + const collaborateurId = parseInt(collaborateurid); + const dateObj = new Date(jourdate); + const annee = dateObj.getFullYear(); + const mois = dateObj.getMonth() + 1; + + // UPSERT du jour dans CompteRenduActivites + const existing = await transaction.request() + .input('collaborateurId', sql.Int, collaborateurId) + .input('jourDate', sql.Date, jourdate) + .query(`SELECT Id FROM CompteRenduActivites + WHERE CollaborateurADId = @collaborateurId AND JourDate = @jourDate`); + + if (existing.recordset.length > 0) { + await transaction.request() + .input('collaborateurId', sql.Int, collaborateurId) + .input('jourDate', sql.Date, jourdate) + .input('jourTravaille', sql.Bit, jourtravaille ? 1 : 0) + .input('reposQ', sql.Bit, reposquotidien ? 1 : 0) + .input('reposH', sql.Bit, reposhebdo ? 1 : 0) + .input('commentaire', sql.NVarChar(500), commentaire || '') + .query(`UPDATE CompteRenduActivites SET + JourTravaille = @jourTravaille, + ReposQuotidienRespect = @reposQ, + ReposHebdomadaireRespect = @reposH, + CommentaireRepos = @commentaire, + DateModification = GETDATE() + WHERE CollaborateurADId = @collaborateurId AND JourDate = @jourDate`); + } else { + // ✅ Générer l'ID manuellement + const maxIdResult = await transaction.request() + .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM CompteRenduActivites'); + const newId = maxIdResult.recordset[0].NextId; + + await transaction.request() + .input('id', sql.Int, newId) + .input('collaborateurId', sql.Int, collaborateurId) + .input('jourDate', sql.Date, jourdate) + .input('annee', sql.Int, annee) + .input('mois', sql.Int, mois) + .input('jourTravaille', sql.Bit, jourtravaille ? 1 : 0) + .input('reposQ', sql.Bit, reposquotidien ? 1 : 0) + .input('reposH', sql.Bit, reposhebdo ? 1 : 0) + .input('commentaire', sql.NVarChar(500), commentaire || '') + .query(`INSERT INTO CompteRenduActivites + (Id, CollaborateurADId, JourDate, Annee, Mois, JourTravaille, + ReposQuotidienRespect, ReposHebdomadaireRespect, CommentaireRepos, DateSaisie, DateModification) + VALUES + (@id, @collaborateurId, @jourDate, @annee, @mois, @jourTravaille, + @reposQ, @reposH, @commentaire, GETDATE(), GETDATE())`); + } + + // Recalculer et mettre à jour CompteRenduMensuel + const statsResult = await transaction.request() + .input('collaborateurId', sql.Int, collaborateurId) + .input('annee', sql.Int, annee) + .input('mois', sql.Int, mois) + .query(`SELECT + COUNT(CASE WHEN JourTravaille = 1 THEN 1 END) AS NbJoursTravailles, + COUNT(CASE WHEN JourTravaille = 1 AND ReposQuotidienRespect = 0 THEN 1 END) AS NbReposQ, + COUNT(CASE WHEN JourTravaille = 1 AND ReposHebdomadaireRespect = 0 THEN 1 END) AS NbReposH + FROM CompteRenduActivites + WHERE CollaborateurADId = @collaborateurId AND Annee = @annee AND Mois = @mois`); + + const stats = statsResult.recordset[0]; + + const mensuelExist = await transaction.request() + .input('collaborateurId', sql.Int, collaborateurId) + .input('annee', sql.Int, annee) + .input('mois', sql.Int, mois) + .query(`SELECT Id FROM CompteRenduMensuel + WHERE CollaborateurADId = @collaborateurId AND Annee = @annee AND Mois = @mois`); + + if (mensuelExist.recordset.length > 0) { + await transaction.request() + .input('collaborateurId', sql.Int, collaborateurId) + .input('annee', sql.Int, annee) + .input('mois', sql.Int, mois) + .input('nbJours', sql.Decimal(10, 2), stats.NbJoursTravailles) + .input('nbReposQ', sql.Int, stats.NbReposQ) + .input('nbReposH', sql.Int, stats.NbReposH) + .query(`UPDATE CompteRenduMensuel SET + NbJoursTravailles = @nbJours, + NbJoursNonRespectsReposQuotidien = @nbReposQ, + NbJoursNonRespectsReposHebdo = @nbReposH + WHERE CollaborateurADId = @collaborateurId AND Annee = @annee AND Mois = @mois`); + } else { + // ✅ Générer l'ID manuellement pour CompteRenduMensuel aussi + const maxMensuelIdResult = await transaction.request() + .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM CompteRenduMensuel'); + const newMensuelId = maxMensuelIdResult.recordset[0].NextId; + + await transaction.request() + .input('id', sql.Int, newMensuelId) + .input('collaborateurId', sql.Int, collaborateurId) + .input('annee', sql.Int, annee) + .input('mois', sql.Int, mois) + .input('nbJours', sql.Decimal(10, 2), stats.NbJoursTravailles) + .input('nbReposQ', sql.Int, stats.NbReposQ) + .input('nbReposH', sql.Int, stats.NbReposH) + .query(`INSERT INTO CompteRenduMensuel + (Id, CollaborateurADId, Annee, Mois, NbJoursTravailles, + NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo, Verrouille) + VALUES (@id, @collaborateurId, @annee, @mois, @nbJours, @nbReposQ, @nbReposH, 0)`); + } + + await transaction.commit(); + res.json({ success: true, message: 'Jour mis à jour avec succès' }); + + } catch (error) { + try { await transaction.rollback(); } catch (e) { } + console.error('Erreur PUT compte-rendu/jour', error); + res.status(500).json({ success: false, error: error.message }); + } + }); + + // Verrouiller un mois + app.post('/compte-rendu/verrouiller', authenticateToken, async (req, res) => { + try { + const { collaborateurid, annee, mois } = req.body; + + // Upsert : créer la ligne si elle n'existe pas, puis verrouiller + const existing = await pool.request() + .input('collaborateurId', sql.Int, collaborateurid) + .input('annee', sql.Int, annee) + .input('mois', sql.Int, mois) + .query(`SELECT Id FROM CompteRenduMensuel + WHERE CollaborateurADId = @collaborateurId + AND Annee = @annee AND Mois = @mois`); + + if (existing.recordset.length > 0) { + await pool.request() + .input('collaborateurId', sql.Int, collaborateurid) + .input('annee', sql.Int, annee) + .input('mois', sql.Int, mois) + .query(`UPDATE CompteRenduMensuel + SET Verrouille = 1 + WHERE CollaborateurADId = @collaborateurId + AND Annee = @annee AND Mois = @mois`); + } else { + // Créer l'entrée mensuelle si elle n'existe pas encore + await pool.request() + .input('collaborateurId', sql.Int, collaborateurid) + .input('annee', sql.Int, annee) + .input('mois', sql.Int, mois) + .query(`INSERT INTO CompteRenduMensuel + (CollaborateurADId, Annee, Mois, NbJoursTravailles, + NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo, Verrouille) + VALUES (@collaborateurId, @annee, @mois, 0, 0, 0, 1)`); + } + + res.json({ success: true, message: 'Mois verrouillé' }); + } catch (error) { + console.error('Erreur verrouillage:', error); + res.status(500).json({ success: false, error: error.message }); + } + }); + + // Déverrouiller un mois + app.post('/compte-rendu/deverrouiller', authenticateToken, async (req, res) => { + try { + const { collaborateurid, annee, mois } = req.body; + + await pool.request() + .input('collaborateurId', sql.Int, collaborateurid) + .input('annee', sql.Int, annee) + .input('mois', sql.Int, mois) + .query(`UPDATE CompteRenduMensuel + SET Verrouille = 0 + WHERE CollaborateurADId = @collaborateurId + AND Annee = @annee AND Mois = @mois`); + + res.json({ success: true, message: 'Mois déverrouillé' }); + } catch (error) { + console.error('Erreur déverrouillage:', error); + res.status(500).json({ success: false, error: error.message }); + } + }); + // ================================================ + // CRON — Snapshot quotidien à 23h55 heure de Paris + // ================================================ + function planifierSnapshotQuotidien() { + const maintenant = new Date(); + const prochainDeclenchement = new Date(); + + prochainDeclenchement.setHours(23, 55, 0, 0); + + // Si 23h55 est déjà passé aujourd'hui, programmer pour demain + if (prochainDeclenchement <= maintenant) { + prochainDeclenchement.setDate(prochainDeclenchement.getDate() + 1); + } + + const delai = prochainDeclenchement - maintenant; + + console.log(`[CRON] ✅ Snapshot planifié dans ${Math.round(delai / 1000 / 60)} minutes`); + + setTimeout(async () => { + await executerSnapshotQuotidien(); + // Re-planifier pour le lendemain + planifierSnapshotQuotidien(); + }, delai); + } + + planifierSnapshotQuotidien(); // ================================================ // DÉMARRAGE DU SERVEUR @@ -4224,41 +7161,42 @@ try { console.log('✅ 99. Toutes les routes définies, démarrage du serveur...'); - const server = app.listen(PORT, () => { + const server = app.listen(PORT, '0.0.0.0', () => { // ← AJOUT DE '0.0.0.0' console.log('\n================================================'); - console.log(`✅ SERVEUR RH DÉMARRÉ sur http://localhost:${PORT}`); - console.log('🔔 Server-Sent Events activés sur /api/events'); + console.log(`✅ SERVEUR RH DÉMARRÉ sur http://0.0.0.0:${PORT}`); // ← Changé localhost en 0.0.0.0 + console.log('🔔 Server-Sent Events activés sur /events'); console.log('🔗 WEBHOOKS configurés:'); console.log(` - Serveur Collaborateurs: ${WEBHOOKS.COLLABORATEURS_URL}`); - console.log(` - Route webhook receiver: POST /api/webhook/receive`); + console.log(` - Route webhook receiver: POST /webhook/receive`); console.log(''); console.log('📋 Routes disponibles:'); - console.log(' - GET /api/events (SSE)'); - console.log(' - POST /api/webhook/receive'); - console.log(' - POST /api/auth/login ✅ AVEC FILTRE ACTIF'); - console.log(' - GET /api/demandes'); - console.log(' - GET /api/demandes/:id'); - console.log(' - POST /api/demandes'); - console.log(' - PUT /api/demandes/:id'); - console.log(' - DELETE /api/demandes/:id'); - console.log(' - PUT /api/demandes/:id/valider'); - console.log(' - GET /api/employes?include_inactifs=true ✅ MODIFIÉ'); - console.log(' - POST /api/employes/desactiver 🆕 NOUVEAU'); - console.log(' - POST /api/employes/reactiver 🆕 NOUVEAU'); - console.log(' - GET /api/types-conge'); - console.log(' - GET /api/export/paie'); - console.log(' - GET /api/compteurs?include_inactifs=true ✅ MODIFIÉ'); - console.log(' - POST /api/compteurs/reinitialiser'); - console.log(' - POST /api/compteurs/initialiser-manuel'); - console.log(' - POST /api/compteurs/initialiser-tous-manuel ✅ MODIFIÉ'); - console.log(' - PUT /api/compteurs/:id'); - console.log(' - POST /api/compteurs/ajouter-recup 🆕 NOUVEAU'); - console.log(' - POST /api/compteurs/retirer-recup 🆕 NOUVEAU'); - console.log(' - GET /api/equipes ✅ MODIFIÉ'); - console.log(' - GET /api/equipes/:id ✅ MODIFIÉ'); - console.log(' - GET /api/equipes/:id/membres ✅ MODIFIÉ'); - console.log(' - GET /api/stats ✅ MODIFIÉ'); - console.log(' - GET /api/historique'); + console.log(' - GET /events (SSE)'); + console.log(' - POST /webhook/receive'); + console.log(' - POST /login-dev ✅ SQL SERVER'); + console.log(' - GET /demandes'); + console.log(' - GET /demandes/:id'); + console.log(' - POST /demandes'); + console.log(' - PUT /demandes/:id'); + console.log(' - DELETE /demandes/:id'); + console.log(' - PUT /demandes/:id/valider'); + console.log(' - GET /employes'); + console.log(' - POST /employes/desactiver'); + console.log(' - POST /employes/reactiver'); + console.log(' - GET /types-conge'); + console.log(' - GET /export/paie'); + console.log(' - GET /compteurs'); + console.log(' - POST /reinitializeAllCounters ✅ SQL SERVER'); + console.log(' - PUT /compteurs/:id'); + console.log(' - POST /compteurs/ajouter-recup'); + console.log(' - POST /compteurs/retirer-recup'); + console.log(' - GET /equipes'); + console.log(' - GET /equipes/:id'); + console.log(' - GET /equipes/:id/membres'); + console.log(' - GET /stats'); + console.log(' - GET /historique'); + console.log(' - GET /v2/compteurs ✅ API V2'); + console.log(' - PUT /v2/compteurs/update ✅ API V2'); + console.log(' - GET /getAllDetailedCounters ✅ SQL SERVER'); console.log('================================================'); }); diff --git a/docker-compose.yml b/docker-compose.yml index 3b4d5d1..57b17a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,46 +1,45 @@ -version: '3.8' # ← AJOUTÉ (manquant !) -services: +networks: + gtarh: + external: false + +services: backend: - build: + build: context: ./Backend dockerfile: DockerFileGTARH.backend + image: ouijdaneim/gtarh-backend:latest container_name: gtarh-backend + environment: + - USER_UID=1000 + - USER_GID=1000 + - DB_SERVER=192.168.0.3 + - DB_DATABASE=gta + - DB_USER=gta_app + - DB_PASSWORD=GTA2025!Secure + - DB_ENCRYPT=true + - DB_TRUST_SERVER_CERTIFICATE=true + - PORT=3000 + - NODE_ENV=production + restart: always + networks: + - gtarh ports: - "8013:3000" - env_file: - - ./Backend/.env - environment: - - DB_HOST=192.168.0.3 - - NODE_ENV=production - - PORT=3000 - - COLLABORATEURS_URL=http://gtarh-backend:3000 - - RH_URL=http://gtarh-backend:3000 - - WEBHOOK_SECRET=secret-rh-2025 - networks: - - gtarh-network - restart: unless-stopped - + frontend: build: context: . dockerfile: ./src/DockerFileGTARH.Frontend args: - - VITE_API_URL=https://mygta-rh.ensup-adm.net:8013 + - VITE_API_URL=https://mygta-rh.ensup-adm.net + image: ouijdaneim/gtarh-frontend:latest container_name: gtarh-frontend + environment: + - NODE_ENV=development + restart: always + networks: + - gtarh ports: - "3014:3014" - environment: - - NODE_ENV=production depends_on: - - backend - networks: - - gtarh-network - restart: unless-stopped - -networks: - gtarh-network: - driver: bridge - -volumes: - mysql-data: - driver: local + - backend \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 124e157..849ccc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,8 @@ "lucide-react": "^0.462.0", "mysql2": "^3.15.1", "next-themes": "^0.3.0", + "pdfkit": "^0.17.2", + "pdfkit-table": "^0.1.99", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -3275,6 +3277,15 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@swc/helpers": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", + "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@swc/types": { "version": "0.1.25", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", @@ -4216,6 +4227,15 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/browserslist": { "version": "4.26.3", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", @@ -4475,6 +4495,15 @@ "url": "https://polar.sh/cva" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4661,6 +4690,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4871,6 +4906,12 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -5423,7 +5464,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-equals": { @@ -5566,6 +5606,23 @@ "dev": true, "license": "ISC" }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -6134,6 +6191,13 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jpeg-exif": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", + "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6372,6 +6436,25 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -7104,6 +7187,28 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pdfkit": { + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz", + "integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==", + "license": "MIT", + "dependencies": { + "crypto-js": "^4.2.0", + "fontkit": "^2.0.4", + "jpeg-exif": "^1.1.4", + "linebreak": "^1.1.0", + "png-js": "^1.0.0" + } + }, + "node_modules/pdfkit-table": { + "version": "0.1.99", + "resolved": "https://registry.npmjs.org/pdfkit-table/-/pdfkit-table-0.1.99.tgz", + "integrity": "sha512-BaunFJ7cvKqIPONzX9FXUpeC/3iJeLo0v2nVNjCccAxdMXKvDMBjxyF4wZhKOYlmP+nYZcikf9ByVJeCzjNK6g==", + "license": "MIT", + "dependencies": { + "pdfkit": "latest" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7140,6 +7245,11 @@ "node": ">= 6" } }, + "node_modules/png-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz", + "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -7792,6 +7902,12 @@ "node": ">=4" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -8469,6 +8585,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -8634,6 +8756,32 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index eadf2ed..d06ced8 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,8 @@ "lucide-react": "^0.462.0", "mysql2": "^3.15.1", "next-themes": "^0.3.0", + "pdfkit": "^0.17.2", + "pdfkit-table": "^0.1.99", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", diff --git a/src/components/ValidateurCombobox.tsx b/src/components/ValidateurCombobox.tsx new file mode 100644 index 0000000..63f8cfa --- /dev/null +++ b/src/components/ValidateurCombobox.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +interface Validateur { + id: number; + nom: string; + email: string; + role: string; +} + +interface ValidateurComboboxProps { + validateurs: Validateur[]; + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +export function ValidateurCombobox({ validateurs, value, onChange, placeholder = "Sélectionner..." }: ValidateurComboboxProps) { + const [open, setOpen] = useState(false); + const selected = validateurs.find((v) => String(v.id) === value); + + return ( + + + + + + + + + Aucun validateur trouvé. + + {validateurs.map((v) => ( + { + onChange(String(v.id) === value ? "" : String(v.id)); + setOpen(false); + }} + > + +
+ {v.nom} ({v.role}) + {v.email} +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/pages/CompteRenduRH.tsx b/src/pages/CompteRenduRH.tsx index 4c78993..fcce973 100644 --- a/src/pages/CompteRenduRH.tsx +++ b/src/pages/CompteRenduRH.tsx @@ -1,24 +1,192 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect } from 'react'; import { - Calendar, Lock, Unlock, Download, Menu, AlertCircle, - ChevronLeft, ChevronRight, FileText, RefreshCw, - Eye, Search, Users, CheckCircle, XCircle, Clock, LogOut, ArrowLeft, Filter + Calendar, Lock, Unlock, Download, + ChevronLeft, ChevronRight, RefreshCw, + Search, Users, CheckCircle, XCircle, Clock, ArrowLeft, + TrendingUp, Building2, MapPin, Briefcase, FileText, Edit3 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +// ─── Composant ligne éditable ─────────────────────────────────────────────── +const JourEditRow = ({ jour, collaborateurId, token, onSaved }: any) => { + const [travaille, setTravaille] = useState(!!jour.JourTravaille); + const [reposQ, setReposQ] = useState(!!jour.ReposQuotidienRespect); + const [reposH, setReposH] = useState(!!jour.ReposHebdomadaireRespect); + const [commentaire, setCommentaire] = useState(jour.CommentaireRepos || ''); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [dirty, setDirty] = useState(false); + + const dateObj = new Date(jour.JourDate); + const isWeekend = dateObj.getDay() === 0 || dateObj.getDay() === 6; + const dateStr = dateObj.toLocaleDateString('fr-FR', { + weekday: 'short', day: '2-digit', month: '2-digit' + }); + const nonSaisi = !jour.DateSaisie; // jour jamais rempli par le collaborateur + + const mark = (fn: () => void) => { fn(); setDirty(true); setSaved(false); }; + + const handleSave = async () => { + setSaving(true); + try { + const res = await fetch('/api/compte-rendu/jour', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + // ✅ CORRECTION : noms de champs alignés avec le backend + body: JSON.stringify({ + collaborateurid: collaborateurId, + jourdate: jour.JourDate, + jourtravaille: travaille, + reposquotidien: reposQ, + reposhebdo: reposH, + commentaire + }) + }); + const data = await res.json(); + if (data.success) { + setSaved(true); + setDirty(false); + setTimeout(() => setSaved(false), 2000); + onSaved(); + toast.success('Jour mis à jour'); + } else { + toast.error(data.error || 'Erreur serveur'); + } + } catch { + toast.error('Erreur serveur'); + } finally { + setSaving(false); + } + }; + + return ( + + {/* Date */} + + {dateStr} + {isWeekend && (WE)} + {/* ✅ Badge "Non saisi" pour les jours vides */} + {nonSaisi && ( + + Non saisi + + )} + + + {/* Jour travaillé */} + + mark(() => { + const v = e.target.checked; + setTravaille(v); + if (!v) { setReposQ(false); setReposH(false); } + })} + className="w-4 h-4 accent-blue-600 cursor-pointer" + /> + + + {/* Repos quotidien */} + + mark(() => setReposQ(e.target.checked))} + disabled={!travaille} + className="w-4 h-4 accent-green-600 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed" + /> + + + {/* Repos hebdo */} + + mark(() => setReposH(e.target.checked))} + disabled={!travaille} + className="w-4 h-4 accent-green-600 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed" + /> + + + {/* Commentaire */} + + mark(() => setCommentaire(e.target.value))} + placeholder="Commentaire..." + className="w-full text-xs border border-border rounded px-2 py-1 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-400" + /> + + + {/* Bouton sauvegarde */} + + {saved ? ( + + ) : ( + + )} + + + ); +}; + +// ─── Génère tous les jours d'un mois (y compris non saisis) ───────────────── +const genererJoursDuMois = (annee: number, mois: number, joursExistants: any[]) => { + const jours: any[] = []; + const nbJours = new Date(annee, mois, 0).getDate(); + + for (let d = 1; d <= nbJours; d++) { + const dateStr = `${annee}-${String(mois).padStart(2, '0')}-${String(d).padStart(2, '0')}`; + const existing = joursExistants.find(j => j.JourDate?.substring(0, 10) === dateStr); + if (existing) { + jours.push(existing); + } else { + // Jour non saisi → ligne vide modifiable par les RH + jours.push({ + JourDate: dateStr, + JourTravaille: false, + ReposQuotidienRespect: false, + ReposHebdomadaireRespect: false, + CommentaireRepos: '', + DateSaisie: null, // null = non saisi + DateModification: null + }); + } + } + return jours; +}; + +// ─── Composant principal ──────────────────────────────────────────────────── const CompteRenduRH = () => { const navigate = useNavigate(); const [user, setUser] = useState(null); const [token, setToken] = useState(''); - // États const [currentDate, setCurrentDate] = useState(new Date()); - const [viewMode, setViewMode] = useState('mois'); const [isLoading, setIsLoading] = useState(true); const [collaborateurs, setCollaborateurs] = useState([]); + const [allCollaborateurs, setAllCollaborateurs] = useState([]); const [selectedCollab, setSelectedCollab] = useState(null); const [compteRenduData, setCompteRenduData] = useState([]); const [mensuelData, setMensuelData] = useState(null); @@ -26,645 +194,358 @@ const CompteRenduRH = () => { const [searchTerm, setSearchTerm] = useState(''); const [showDetailModal, setShowDetailModal] = useState(false); const [selectedJour, setSelectedJour] = useState(null); + const [cumulAnnuel, setCumulAnnuel] = useState(null); - // 🆕 NOUVEAUX ÉTATS POUR LE FILTRE - const [services, setServices] = useState([]); - const [selectedService, setSelectedService] = useState('all'); + // Filtres + const [filterSociete, setFilterSociete] = useState('all'); + const [filterCampus, setFilterCampus] = useState('all'); + const [filterService, setFilterService] = useState('all'); const annee = currentDate.getFullYear(); const mois = currentDate.getMonth() + 1; - // ✅ ÉTAPE 1 : Charger l'utilisateur et vérifier l'accès + // ── Auth useEffect(() => { const userData = localStorage.getItem('user'); const userToken = localStorage.getItem('token'); - - if (!userData || !userToken) { - toast.error("Session expirée, reconnexion nécessaire"); - navigate('/'); - return; - } - + if (!userData || !userToken) { toast.error('Session expirée'); navigate('/'); return; } const parsedUser = JSON.parse(userData); - if (!['RH', 'Admin'].includes(parsedUser?.role)) { - toast.error("Accès réservé aux RH et Administrateurs"); + toast.error('Accès réservé aux RH et Administrateurs'); navigate('/dashboard'); return; } - setUser(parsedUser); setToken(userToken); }, [navigate]); - // 🆕 ÉTAPE 1.5 : Charger la liste des services + // ── Chargement collaborateurs useEffect(() => { if (!token) return; - - const loadServices = async () => { - try { - const response = await fetch('/api/compte-rendu/services', { - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - if (response.ok) { - const data = await response.json(); - console.log('📋 Services disponibles:', data); - setServices(data); - } - } catch (error) { - console.error('❌ Erreur chargement services:', error); - } - }; - - loadServices(); - }, [token]); - - // ✅ ÉTAPE 2 : Charger les collaborateurs (avec filtre service côté client) - useEffect(() => { - if (!token) return; - - const loadCollaborateurs = async () => { - console.log('📥 Chargement des collaborateurs...'); + const load = async () => { setIsLoading(true); - try { - // ✅ Charger TOUS les employés - const response = await fetch('/api/employes', { - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - console.log('📊 Réponse API employes:', response.status); - - if (!response.ok) { - const errorText = await response.text(); - console.error('❌ Erreur API:', errorText); - throw new Error(`Erreur ${response.status}: ${errorText}`); - } - - const data = await response.json(); - console.log('📋 Collaborateurs reçus:', data.length); - - // ✅ Filtrer les forfaits jour actifs - let forfaitJour = data.filter((emp: any) => { - const isForfaitJour = emp.TypeContrat === 'forfait_jour'; - const isActif = emp.Actif === 1 || emp.Actif === true || emp.Actif === null; - return isForfaitJour && isActif; - }); - - // 🆕 APPLIQUER LE FILTRE SERVICE CÔTÉ CLIENT - if (selectedService && selectedService !== 'all') { - forfaitJour = forfaitJour.filter((emp: any) => - emp.ServiceId?.toString() === selectedService.toString() || - emp.serviceId?.toString() === selectedService.toString() - ); - console.log(`🔍 Filtré par service ${selectedService}: ${forfaitJour.length} collaborateurs`); - } - - console.log('✅ Forfaits jour actifs:', forfaitJour.length); - setCollaborateurs(forfaitJour); - - } catch (error: any) { - console.error('❌ Erreur chargement collaborateurs:', error); - toast.error('Erreur lors du chargement des collaborateurs: ' + error.message); - setCollaborateurs([]); - } finally { - setIsLoading(false); - } - }; - - loadCollaborateurs(); - }, [token, selectedService]); // 🆕 Recharger quand le service change - - // ✅ ÉTAPE 3 : Charger les stats globales - useEffect(() => { - if (!token || collaborateurs.length === 0) return; - - const loadStatsGlobales = async () => { - console.log('📊 Chargement stats globales...'); - - try { - let url = `/api/compte-rendu/stats?annee=${annee}&mois=${mois}`; - if (selectedService && selectedService !== 'all') { - url += `&service_id=${selectedService}`; - } - - const response = await fetch(url, { - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - if (response.ok) { - const data = await response.json(); - console.log('✅ Stats globales:', data); - setStatsGlobales(data); - } else { - console.warn('⚠️ Stats non disponibles, utilisation valeurs par défaut'); - setStatsGlobales({ - totalCollaborateurs: collaborateurs.length, - mensuelsValides: 0, - mensuelsEnAttente: collaborateurs.length - }); - } - } catch (error) { - console.error('❌ Erreur stats globales:', error); - setStatsGlobales({ - totalCollaborateurs: collaborateurs.length, - mensuelsValides: 0, - mensuelsEnAttente: collaborateurs.length - }); - } - }; - - loadStatsGlobales(); - }, [token, collaborateurs.length, annee, mois, selectedService]); - - // ✅ ÉTAPE 4 : Charger le compte-rendu quand on sélectionne un collaborateur - useEffect(() => { - if (!selectedCollab || !token) return; - - const loadCompteRendu = async () => { - setIsLoading(true); - console.log(`📥 Chargement compte-rendu pour collaborateur ${selectedCollab.id}`); - - try { - const response = await fetch( - `/api/compte-rendu-activites?user_id=${selectedCollab.id}&annee=${annee}&mois=${mois}`, - { - headers: { - 'Authorization': `Bearer ${token}` - } - } - ); - - if (!response.ok) { - throw new Error(`Erreur ${response.status}`); - } - - const data = await response.json(); - - if (data.success) { - setCompteRenduData(data.jours || []); - setMensuelData(data.mensuel); - console.log('✅ Compte-rendu chargé:', data.jours?.length, 'jours'); - } else { - toast.error(data.message || 'Erreur lors du chargement'); - } - } catch (error: any) { - console.error('❌ Erreur chargement compte-rendu:', error); - toast.error('Erreur lors du chargement du compte-rendu'); - setCompteRenduData([]); - setMensuelData(null); - } finally { - setIsLoading(false); - } - }; - - loadCompteRendu(); - }, [selectedCollab, annee, mois, token]); - - // Verrouiller/Déverrouiller un mois - const handleToggleLock = async (unlock = false) => { - if (!selectedCollab || !token) return; - - const action = unlock ? 'déverrouiller' : 'verrouiller'; - const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', - 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; - - if (!confirm(`Confirmer ${action} le mois de ${monthNames[mois - 1]} ${annee} pour ${selectedCollab.nom} ?`)) { - return; - } - - try { - const endpoint = unlock - ? '/api/compte-rendu/deverrouiller' - : '/api/compte-rendu/verrouiller'; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ - collaborateur_id: selectedCollab.id, - annee: annee, - mois: mois - }) - }); - - const data = await response.json(); - - if (data.success) { - toast.success(`Mois ${action === 'verrouiller' ? 'verrouillé' : 'déverrouillé'} avec succès`); - - // Recharger les données - const reloadResponse = await fetch( - `/api/compte-rendu-activites?user_id=${selectedCollab.id}&annee=${annee}&mois=${mois}`, - { headers: { 'Authorization': `Bearer ${token}` } } - ); - const reloadData = await reloadResponse.json(); - if (reloadData.success) { - setCompteRenduData(reloadData.jours || []); - setMensuelData(reloadData.mensuel); - } - - // Recharger les stats - let statsUrl = `/api/compte-rendu/stats?annee=${annee}&mois=${mois}`; - if (selectedService && selectedService !== 'all') { - statsUrl += `&service_id=${selectedService}`; - } - const statsResponse = await fetch(statsUrl, { + const res = await fetch('/api/collaborateurs/details', { headers: { 'Authorization': `Bearer ${token}` } }); - if (statsResponse.ok) { - const statsData = await statsResponse.json(); - setStatsGlobales(statsData); - } + if (!res.ok) throw new Error(`Erreur ${res.status}`); + const data = await res.json(); + const forfaitJour = data.filter((emp: any) => { + const contrat = (emp.typeContrat || emp.TypeContrat || '').toLowerCase(); + const isActif = emp.Actif === 1 || emp.Actif === true || emp.Actif === null; + return ['forfait_jour', 'forfaitjour', 'forfait jour', 'forfait-jour'].includes(contrat) && isActif; + }).map((e: any) => ({ ...e, nomComplet: `${e.prenom} ${e.nom}`.trim() })) + .sort((a: any, b: any) => a.nomComplet.localeCompare(b.nomComplet)); + setAllCollaborateurs(forfaitJour); + setCollaborateurs(forfaitJour); + } catch (err: any) { + toast.error('Erreur chargement collaborateurs: ' + err.message); + } finally { + setIsLoading(false); + } + }; + load(); + }, [token]); + + // ── Listes uniques pour filtres + const societesUniques = [...new Set(allCollaborateurs.map(e => e.societe).filter(Boolean))].sort(); + const campusUniques = [...new Set(allCollaborateurs.map(e => e.campus).filter(Boolean))].sort(); + const servicesUniques = [...new Set(allCollaborateurs.map(e => e.service).filter(Boolean))].sort(); + + // ── Filtrage + useEffect(() => { + let f = [...allCollaborateurs]; + if (searchTerm) f = f.filter(e => + e.nomComplet?.toLowerCase().includes(searchTerm.toLowerCase()) || + e.email?.toLowerCase().includes(searchTerm.toLowerCase()) || + e.service?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + if (filterSociete !== 'all') f = f.filter(e => e.societe === filterSociete); + if (filterCampus !== 'all') f = f.filter(e => e.campus === filterCampus); + if (filterService !== 'all') f = f.filter(e => e.service === filterService); + setCollaborateurs(f); + if (selectedCollab && !f.find(c => c.id === selectedCollab.id)) setSelectedCollab(null); + }, [allCollaborateurs, searchTerm, filterSociete, filterCampus, filterService]); + + // ── Stats globales + useEffect(() => { + if (!token || collaborateurs.length === 0) return; + fetch(`/api/compte-rendu/stats?annee=${annee}&mois=${mois}`, { + headers: { 'Authorization': `Bearer ${token}` } + }).then(r => r.ok ? r.json() : null) + .then(data => setStatsGlobales(data || { + totalCollaborateurs: collaborateurs.length, + mensuelsValides: 0, + mensuelsEnAttente: collaborateurs.length + })); + }, [token, collaborateurs.length, annee, mois]); + + // ── Chargement compte-rendu + const loadCompteRendu = async () => { + if (!selectedCollab || !token) return; + setIsLoading(true); + try { + // ✅ CORRECTION : paramètre "userid" (sans underscore) + const res = await fetch( + `/api/compte-rendu-activites?userid=${selectedCollab.id}&annee=${annee}&mois=${mois}`, + { headers: { 'Authorization': `Bearer ${token}` } } + ); + if (!res.ok) throw new Error(`Erreur ${res.status}`); + const data = await res.json(); + if (data.success) { + // ✅ NOUVEAU : on génère TOUS les jours du mois, y compris non saisis + const tousLesJours = genererJoursDuMois(annee, mois, data.jours || []); + setCompteRenduData(tousLesJours); + setMensuelData(data.mensuel); + } + } catch (err: any) { + console.error(err); + setCompteRenduData(genererJoursDuMois(annee, mois, [])); + setMensuelData(null); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { loadCompteRendu(); }, [selectedCollab, annee, mois, token]); + + // ── Cumul annuel + useEffect(() => { + if (!selectedCollab || !token) return; + fetch(`/api/compte-rendu/cumul-annuel?userid=${selectedCollab.id}&annee=${annee}`, { + headers: { 'Authorization': `Bearer ${token}` } + }).then(r => r.ok ? r.json() : null) + .then(data => setCumulAnnuel(data?.success ? data.cumul : null)); + }, [selectedCollab, annee, token]); + + // ── Verrouiller / Déverrouiller + const handleToggleLock = async (unlock = false) => { + if (!selectedCollab || !token) return; + const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; + if (!confirm(`Confirmer ${unlock ? 'déverrouiller' : 'verrouiller'} ${monthNames[mois - 1]} ${annee} pour ${selectedCollab.nomComplet} ?`)) return; + try { + const res = await fetch(unlock ? '/api/compte-rendu/deverrouiller' : '/api/compte-rendu/verrouiller', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + // ✅ CORRECTION : collaborateur_id → collaborateurid + body: JSON.stringify({ collaborateurid: selectedCollab.id, annee, mois }) + }); + const data = await res.json(); + if (data.success) { + toast.success(`Mois ${unlock ? 'déverrouillé' : 'verrouillé'} avec succès`); + await loadCompteRendu(); } else { toast.error(data.message || 'Erreur'); } - } catch (error) { - console.error(`Erreur ${action}:`, error); - toast.error('Erreur serveur'); - } + } catch { toast.error('Erreur serveur'); } }; - // Export PDF + // ── Export PDF const handleExportPDF = async () => { - if (!selectedCollab || !token) return; - + if (!selectedCollab || !token) { toast.error('Aucun collaborateur sélectionné'); return; } + const loadingToast = toast.loading('Génération du PDF...'); try { - const response = await fetch( + const res = await fetch( `/api/export-compte-rendu-pdf?user_id=${selectedCollab.id}&annee=${annee}&mois=${mois}`, - { - headers: { - 'Authorization': `Bearer ${token}` - } - } + { headers: { 'Authorization': `Bearer ${token}` } } ); - - if (!response.ok) { - throw new Error('Erreur lors de l\'export PDF'); - } - - const contentType = response.headers.get('content-type'); - if (contentType && contentType.includes('application/json')) { - const data = await response.json(); - console.log('📄 Données PDF:', data); - toast.info(data.message || 'Export PDF en cours de développement'); - - const dataWindow = window.open('', '_blank'); - if (dataWindow) { - dataWindow.document.write(` - - - Compte-rendu - ${selectedCollab.prenom} ${selectedCollab.nom} - - - -

Compte-rendu d'activités

-
-

Collaborateur: ${data.data?.collaborateur?.prenom} ${data.data?.collaborateur?.nom}

-

Email: ${data.data?.collaborateur?.email}

-

Service: ${data.data?.collaborateur?.service || 'Non défini'}

-

Période: ${data.data?.periode}

-
-

Résumé

-
-

Jours travaillés: ${data.data?.mensuel?.NbJoursTravailles || 0}

-

Non-respect repos quotidien: ${data.data?.mensuel?.NbJoursNonRespectsReposQuotidien || 0}

-

Non-respect repos hebdo: ${data.data?.mensuel?.NbJoursNonRespectsReposHebdo || 0}

-

Statut: ${data.data?.mensuel?.Verrouille ? '🔒 Verrouillé' : '🔓 Ouvert'}

-
-

- Export généré le ${data.data?.dateExport}
- Note: La génération PDF sera implémentée prochainement -

- - - `); - } - } else { - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `compte-rendu-${selectedCollab.nom}-${annee}-${String(mois).padStart(2, '0')}.pdf`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - window.URL.revokeObjectURL(url); - toast.success('PDF téléchargé avec succès'); - } - } catch (error: any) { - console.error('❌ Erreur export PDF:', error); - toast.error('Erreur lors de l\'export PDF: ' + error.message); + if (!res.ok) throw new Error(`Erreur ${res.status}`); + const blob = await res.blob(); + if (blob.size === 0) throw new Error('PDF vide'); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `compte-rendu-${selectedCollab.nom}-${annee}-${String(mois).padStart(2, '0')}.pdf`; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + window.URL.revokeObjectURL(url); + toast.dismiss(loadingToast); + toast.success('PDF téléchargé'); + } catch (err: any) { + toast.dismiss(loadingToast); + toast.error(err.message || 'Erreur export PDF'); } }; - // Navigation mois - const navigateMonth = (direction: string) => { - setCurrentDate(prev => { - const newDate = new Date(prev); - if (direction === 'prev') { - newDate.setMonth(prev.getMonth() - 1); - } else { - newDate.setMonth(prev.getMonth() + 1); - } - return newDate; - }); - }; + const navigateMonth = (dir: string) => setCurrentDate(prev => { + const d = new Date(prev); + d.setMonth(prev.getMonth() + (dir === 'prev' ? -1 : 1)); + return d; + }); - // Ouvrir le détail d'un jour - const handleViewDetail = (jourData: any) => { - setSelectedJour(jourData); - setShowDetailModal(true); - }; + const isVerrouille = () => mensuelData?.Verrouille === true || mensuelData?.Verrouille === 1; + const hasActiveFilters = filterSociete !== 'all' || filterCampus !== 'all' || filterService !== 'all'; + const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; + const nbNonSaisis = compteRenduData.filter(j => !j.DateSaisie).length; - // 🆕 Gérer le changement de service - const handleServiceChange = (serviceId: string) => { - setSelectedService(serviceId); - setSelectedCollab(null); // Réinitialiser le collaborateur sélectionné - }; - - // Filtrer les collaborateurs par recherche - const filteredCollaborateurs = collaborateurs.filter(collab => - collab.nom?.toLowerCase().includes(searchTerm.toLowerCase()) || - collab.prenom?.toLowerCase().includes(searchTerm.toLowerCase()) || - collab.email?.toLowerCase().includes(searchTerm.toLowerCase()) - ); - - const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', - 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; - - const formatDate = (dateStr: string) => { - if (!dateStr) return ''; - return new Date(dateStr).toLocaleDateString('fr-FR'); - }; - - const isVerrouille = () => { - return mensuelData?.Verrouille === true || mensuelData?.Verrouille === 1; - }; - - // ✅ Affichage du loader pendant le chargement initial - if (isLoading && collaborateurs.length === 0) { - return ( -
-
- -

Chargement des collaborateurs...

-
+ if (isLoading && allCollaborateurs.length === 0) return ( +
+
+ +

Chargement...

- ); - } +
+ ); return (
{/* Header */}
-
-
- -
-

- Gestion des Comptes-Rendus d'Activités -

-

- Suivi et validation des forfaits jour -

-
+
+ +
+

+ Gestion des Comptes-Rendus d'Activités +

+

+ Suivi et validation des forfaits jour · {allCollaborateurs.length} collaborateurs +

- {/* 🆕 FILTRE PAR SERVICE */} - - -
- -
- - + + {/* Filtres */} + + +
+
+ + setSearchTerm(e.target.value)} className="pl-10" />
+ + +
+ {hasActiveFilters && ( +
+ Filtres actifs : + {filterSociete !== 'all' && {filterSociete}} + {filterCampus !== 'all' && {filterCampus}} + {filterService !== 'all' && {filterService}} + +
+ )}
{/* Stats globales */} {statsGlobales && (
- - -
-
- -
-
-

- Forfaits jour - {selectedService !== 'all' && ( - (filtré) - )} -

-

- {statsGlobales.totalCollaborateurs} -

-
-
-
-
- - - -
-
- -
-
-

Mois validés

-

- {statsGlobales.mensuelsValides} -

-
-
-
-
- - - -
-
- -
-
-

En attente

-

- {statsGlobales.mensuelsEnAttente} -

-
-
-
-
+ +
+
+

Forfaits jour

{collaborateurs.length}

+
+
+ +
+
+

Mois validés

{statsGlobales.mensuelsValides}

+
+
+ +
+
+

En attente

{statsGlobales.mensuelsEnAttente}

+
+
)}
- {/* Liste des collaborateurs */} + {/* Liste collaborateurs */}
- +

Collaborateurs ({collaborateurs.length}) - {selectedService !== 'all' && ( - - Filtré - - )} + {hasActiveFilters && Filtré}

- - {/* Recherche */} - -
- - setSearchTerm(e.target.value)} - className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" - /> -
-
- - {/* Liste */}
- {filteredCollaborateurs.length === 0 ? ( -
- {searchTerm ? 'Aucun résultat' : 'Aucun collaborateur en forfait jour'} -
- ) : ( - filteredCollaborateurs.map(collab => ( -
setSelectedCollab(collab)} - className={` - p-4 border-b cursor-pointer transition-all - hover:bg-gray-50 - ${selectedCollab?.id === collab.id ? 'bg-blue-50 border-l-4 border-blue-500' : ''} - `} - > -
-
-

- {collab.prenom} {collab.nom} -

-

- {collab.email} -

-

- {collab.service || 'Service non défini'} -

-
-
+ {collaborateurs.length === 0 ? ( +

+ {searchTerm || hasActiveFilters ? 'Aucun résultat' : 'Aucun collaborateur en forfait jour'} +

+ ) : collaborateurs.map(collab => ( +
setSelectedCollab(collab)} + className={`p-4 border-b cursor-pointer hover:bg-gray-50 transition-all + ${selectedCollab?.id === collab.id ? 'bg-blue-50 border-l-4 border-blue-500' : ''}`}> +

{collab.nomComplet}

+
+ {collab.societe && {collab.societe}} + {collab.campus && {collab.campus}}
- )) - )} +

{collab.service || 'Service non défini'}

+
+ ))}
- {/* Détails du compte-rendu */} + {/* Détails */}
{!selectedCollab ? ( -

- Sélectionnez un collaborateur pour voir ses comptes-rendus -

+

Sélectionnez un collaborateur pour voir ses comptes-rendus

) : (
- {/* En-tête collaborateur */} + {/* En-tête + navigation */}
-

- {selectedCollab.prenom} {selectedCollab.nom} -

-

- {selectedCollab.email} -

-
- -
- {isVerrouille() ? ( - - - Verrouillé - - ) : ( - - - Ouvert - - )} +

{selectedCollab.nomComplet}

+
+ {selectedCollab.societe && {selectedCollab.societe}} + {selectedCollab.campus && {selectedCollab.campus}} + {selectedCollab.service && {selectedCollab.service}} +
+ {isVerrouille() ? ( + + Verrouillé + + ) : ( + + Ouvert + + )}
{/* Navigation mois */}
- - -

- {monthNames[currentDate.getMonth()]} {currentDate.getFullYear()} -

- -
@@ -672,63 +553,82 @@ const CompteRenduRH = () => { {/* Actions */}
{isVerrouille() ? ( - ) : ( - )} - -
+ {/* Cumul annuel */} + {cumulAnnuel && ( + + +
+
+
+

Cumul Annuel {annee}

+

Synthèse des activités sur l'année

+
+
+
+
+

Total jours travaillés

+

{cumulAnnuel.totalJoursTravailles || 0}

+

jours

+
+
+

Mois validés

+

{cumulAnnuel.moisValides || 0}

+

/ 12 mois

+
+
+

Repos quotidien non respect.

+

{cumulAnnuel.totalReposQuotidien || 0}

+
+
+

Repos hebdo non respect.

+

{cumulAnnuel.totalReposHebdo || 0}

+
+
+
+
+ Progression annuelle + {Math.round(((cumulAnnuel.moisValides || 0) / 12) * 100)}% +
+
+
+
+
+ + + )} + {/* Stats du mois */} {mensuelData && ( -

- Statistiques du mois -

- +

Statistiques du mois

-

- {mensuelData.NbJoursTravailles || 0} -

+

{mensuelData.NbJoursTravailles || 0}

Jours travaillés

-
-

- {mensuelData.NbJoursNonRespectsReposQuotidien || 0} -

+

{mensuelData.NbJoursNonRespectsReposQuotidien || 0}

Non-respect repos quotidien

-
-

- {mensuelData.NbJoursNonRespectsReposHebdo || 0} -

+

{mensuelData.NbJoursNonRespectsReposHebdo || 0}

Non-respect repos hebdo

@@ -736,75 +636,57 @@ const CompteRenduRH = () => {
)} - {/* Liste des jours */} + {/* Tableau éditable */} - -

- Jours saisis ({compteRenduData.length}) -

-
- -
- {compteRenduData.length === 0 ? ( -
- -

Aucune saisie pour ce mois

+ +
+

+ + Édition des saisies — accès RH +

+ {/* ✅ Badge jours non saisis */} + {nbNonSaisis > 0 && ( + + {nbNonSaisis} jour{nbNonSaisis > 1 ? 's' : ''} non saisi{nbNonSaisis > 1 ? 's' : ''} + + )} +
+

+ Les lignes en fond orange clair sont des jours non saisis par le collaborateur. + Les lignes en fond jaune ont été modifiées. +

+ {isLoading ? ( +
+
) : ( - compteRenduData.map((jour, index) => ( -
-
-
-
-

- {formatDate(jour.JourDate)} -

- - {jour.JourTravaille ? ( - - Travaillé - - ) : ( - - Non travaillé - - )} - - {!jour.ReposQuotidienRespect && ( - - ⚠️ Repos quotidien - - )} - - {!jour.ReposHebdomadaireRespect && ( - - ⚠️ Repos hebdo - - )} -
- - {jour.CommentaireRepos && ( -

- 💬 {jour.CommentaireRepos} -

- )} -
- - -
-
- )) +
+ + + + + + + + + + + + + {compteRenduData.map((jour: any) => ( + + ))} + +
DateTravailléRepos quotidienRepos hebdoCommentaireAction
+
)} -
+
)} @@ -816,64 +698,43 @@ const CompteRenduRH = () => { {showDetailModal && selectedJour && (
- +
-

- Détail du {formatDate(selectedJour.JourDate)} -

-
-

Statut

-

- {selectedJour.JourTravaille ? '✅ Jour travaillé' : '❌ Jour non travaillé'} -

+

{selectedJour.JourTravaille ? 'Jour travaillé' : 'Jour non travaillé'}

-

Repos quotidien respecté

-

- {selectedJour.ReposQuotidienRespect ? '✅ Oui' : '⚠️ Non'} -

+

{selectedJour.ReposQuotidienRespect ? 'Oui' : 'Non'}

-

Repos hebdomadaire respecté

-

- {selectedJour.ReposHebdomadaireRespect ? '✅ Oui' : '⚠️ Non'} -

+

{selectedJour.ReposHebdomadaireRespect ? 'Oui' : 'Non'}

- {selectedJour.CommentaireRepos && (

Commentaire

-

- {selectedJour.CommentaireRepos} -

+

{selectedJour.CommentaireRepos}

+
+ )} + {selectedJour.DateSaisie && ( +
+

Date de saisie

+

{new Date(selectedJour.DateSaisie).toLocaleDateString('fr-FR')}

)} - -
-

Date de saisie

-

- {selectedJour.DateSaisie ? formatDate(selectedJour.DateSaisie) : 'Non disponible'} -

-
- {selectedJour.DateModification && (

Dernière modification

-

- {formatDate(selectedJour.DateModification)} -

+

{new Date(selectedJour.DateModification).toLocaleDateString('fr-FR')}

)}
@@ -884,4 +745,4 @@ const CompteRenduRH = () => { ); }; -export default CompteRenduRH; +export default CompteRenduRH; \ No newline at end of file diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 3b03c12..93432ea 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,29 +1,28 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { - Calendar, Users, Clock, CheckCircle, XCircle, AlertCircle, - Plus, LogOut, FileSpreadsheet, RefreshCw, Edit, ClipboardList, FileText + Calendar, Users, Clock, CheckCircle, XCircle, AlertCircle, AlertTriangle, + Plus, LogOut, FileSpreadsheet, RefreshCw, ClipboardList, + FileText, ChevronLeft, ChevronRight } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { useSSE } from "@/hooks/useSSE"; import { PublicClientApplication } from "@azure/msal-browser"; -// Configuration MSAL const msalConfig = { auth: { clientId: "4bb4cc24-bac3-427c-b02c-5d14fc67b561", authority: "https://login.microsoftonline.com/9840a2a0-6ae1-4688-b03d-d2ec291be0f9", - redirectUri: "https://mygta-rh.ensup-adm.net", - }, - cache: { - cacheLocation: "sessionStorage", - storeAuthStateInCookie: false, + redirectUri: "https://mygta-rh-dev.ensup-adm.net", }, + cache: { cacheLocation: "sessionStorage", storeAuthStateInCookie: false }, }; +// ─── Interfaces ─────────────────────────────────────────────────────────────── + interface Stats { enAttente: number; valideeCeMois: number; @@ -35,346 +34,384 @@ interface Demande { nomEmploye: string; typesConge: string; DateDebut: string; + DateFin?: string; Statut: string; + Campus?: string; + service?: string; + NombreJours?: number; } +interface Referentiel { + id: number; + nom: string; +} + +interface Collaborateur { + id: number; + nom: string; + prenom: string; + service: string; + serviceId: number | null; + campus: string; + campusId: number | null; + societe: string; + societeId: number | null; + dateEntree: string | null; + nPlus1Id: number | null; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const MONTH_NAMES = ["Janvier", "Février", "Mars", "Avril", "Mai", "Juin", + "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre"]; +const DAY_NAMES_SHORT = ["L", "M", "M", "J", "V", "S", "D"]; + +// Dans Dashboard.tsx + +const TYPE_COLORS: Record = { + cp: { bg: "#dcfce7", text: "#15803d", border: "#86efac" }, + rtt: { bg: "#dbeafe", text: "#1d4ed8", border: "#93c5fd" }, + formation: { bg: "#ede9fe", text: "#6d28d9", border: "#c4b5fd" }, + maladie: { bg: "#fee2e2", text: "#b91c1c", border: "#fca5a5" }, + recup: { bg: "#ffedd5", text: "#c2410c", border: "#fdba74" }, + jpo: { bg: "#fae8ff", text: "#86198f", border: "#f0abfc" }, + teletravail: { bg: "#e0f2fe", text: "#0369a1", border: "#7dd3fc" }, // ← AJOUT + autre: { bg: "#f1f5f9", text: "#475569", border: "#cbd5e1" }, +}; + +const TYPE_LABELS: Record = { + cp: "Congé payé", rtt: "RTT", formation: "Formation", + maladie: "Maladie", recup: "Récupération", jpo: "JPO / SF", + teletravail: "Télétravail", // ← AJOUT + autre: "Autre", +}; + +const TYPE_SHORT: Record = { + cp: "CP", rtt: "RTT", formation: "FOR", maladie: "MAL", + recup: "REC", jpo: "JPO", + teletravail: "TT", // ← AJOUT + autre: "ABS", +}; + +const getTypeKey = (type: string): string => { + const t = (type || "").toLowerCase(); + if (t.includes("congé payé") || t.includes("cp")) return "cp"; + if (t.includes("rtt")) return "rtt"; + if (t.includes("formation")) return "formation"; + if (t.includes("maladie") || t.includes("arrêt")) return "maladie"; + if (t.includes("récupération") || t.includes("recup")) return "recup"; + if (t.includes("jpo") || t.includes("sf") || t.includes("salon")) return "jpo"; + if (t.includes("télétravail") || t.includes("teletravail") || t.includes("remote")) return "teletravail"; // ← AJOUT + return "autre"; +}; + +const toDateStr = (d: Date): string => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + +const diffDays = (a: string, b: string): number => { + const parse = (s: string) => { + const [y, m, d] = s.split("T")[0].split("-").map(Number); + return new Date(y, m - 1, d); + }; + return Math.round((parse(b).getTime() - parse(a).getTime()) / 86400000) + 1; +}; + +// ────────────────────────────────────────────────────────────────────────────── const Dashboard = () => { const navigate = useNavigate(); + + // ── States ──────────────────────────────────────────────────────────────── const [stats, setStats] = useState({ enAttente: 0, valideeCeMois: 0, nombreEquipes: 0 }); const [recentRequests, setRecentRequests] = useState([]); const [user, setUser] = useState(null); - const [token, setToken] = useState(''); + const [token, setToken] = useState(""); const [isLoading, setIsLoading] = useState(true); - // ✅ Chargement initial de l'utilisateur - useEffect(() => { - const userData = localStorage.getItem('user'); - const userToken = localStorage.getItem('token'); + // Calendrier Gantt + const [calMonth, setCalMonth] = useState(new Date()); + const [calLeaves, setCalLeaves] = useState([]); + const [isCalLoading, setIsCalLoading] = useState(false); + // Filtres + const [filterCampusId, setFilterCampusId] = useState(""); + const [filterSocieteId, setFilterSocieteId] = useState(""); + const [filterServiceId, setFilterServiceId] = useState(""); + + // Référentiels + const [campusList, setCampusList] = useState([]); + const [societeList, setSocieteList] = useState([]); + const [serviceList, setServiceList] = useState([]); + const [allCollabs, setAllCollabs] = useState([]); + + // ── Init user ────────────────────────────────────────────────────────────── + useEffect(() => { + const userData = localStorage.getItem("user"); + const userToken = localStorage.getItem("token"); if (!userData || !userToken) { toast.error("Session expirée, reconnexion nécessaire"); - navigate('/'); - return; + navigate("/"); return; } - try { - const parsedUser = JSON.parse(userData); - setUser(parsedUser); + setUser(JSON.parse(userData)); setToken(userToken); - } catch (error) { - console.error('Erreur parsing user:', error); - toast.error("Erreur lors de la récupération des données utilisateur"); - navigate('/'); + } catch { + toast.error("Erreur récupération utilisateur"); + navigate("/"); } }, [navigate]); - // ✅ Fonction de chargement des données - const chargerDonnees = async () => { - if (!token) { - console.log('⏳ Pas de token, attente...'); - return; - } - - console.log('📥 Chargement des données du dashboard...'); - setIsLoading(true); - + // ── Chargement référentiels + collaborateurs ─────────────────────────────── + const chargerReferentiels = async () => { + if (!token) return; try { - // Charger les stats - const respStats = await fetch('/api/stats', { - headers: { 'Authorization': `Bearer ${token}` } - }); - - if (!respStats.ok) { - throw new Error(`Erreur stats: ${respStats.status}`); + const [rCampus, rSocietes, rServices, rCollabs] = await Promise.all([ + fetch("/api/campus", { headers: { Authorization: `Bearer ${token}` } }), + fetch("/api/societes", { headers: { Authorization: `Bearer ${token}` } }), + fetch("/api/services", { headers: { Authorization: `Bearer ${token}` } }), + fetch("/api/collaborateurs/details", { headers: { Authorization: `Bearer ${token}` } }), + ]); + if (rCampus.ok) setCampusList(await rCampus.json()); + if (rSocietes.ok) setSocieteList(await rSocietes.json()); + if (rServices.ok) setServiceList(await rServices.json()); + if (rCollabs.ok) { + const data = await rCollabs.json(); + setAllCollabs( + data.map((c: any) => ({ + id: c.id, + nom: c.nom || "", + prenom: c.prenom || "", + service: c.service || "", + serviceId: c.serviceId ?? null, + campus: c.campus || "", + campusId: c.campusId ?? null, + societe: c.societe || "", + societeId: c.societeId ?? null, + dateEntree: c.dateEntree ?? null, + nPlus1Id: c.nPlus1Id ?? null, + })) + ); } + } catch (e) { + console.error("Erreur chargement référentiels:", e); + } + }; - const dataStats = await respStats.json(); - console.log('📊 Stats reçues:', dataStats); - setStats(dataStats); - - // Charger les demandes récentes - const respDemandes = await fetch('/api/demandes?limit=5', { - headers: { 'Authorization': `Bearer ${token}` } - }); - - if (!respDemandes.ok) { - throw new Error(`Erreur demandes: ${respDemandes.status}`); - } - - const dataDemandes = await respDemandes.json(); - console.log('📋 Demandes reçues:', dataDemandes.length); - setRecentRequests(dataDemandes); - - } catch (error: any) { - console.error("❌ Erreur chargement données:", error); - toast.error("Erreur lors du chargement des données: " + error.message); + // ── Chargement stats + demandes récentes ────────────────────────────────── + const chargerDonnees = async () => { + if (!token) return; + setIsLoading(true); + try { + const [rStats, rDemandes] = await Promise.all([ + fetch("/api/stats", { headers: { Authorization: `Bearer ${token}` } }), + fetch("/api/demandes?limit=5", { headers: { Authorization: `Bearer ${token}` } }), + ]); + if (rStats.ok) setStats(await rStats.json()); + if (rDemandes.ok) setRecentRequests(await rDemandes.json()); + } catch (e: any) { + toast.error("Erreur chargement: " + e.message); } finally { setIsLoading(false); } }; - // ✅ Charger les données quand le token est disponible + // ── Chargement congés du mois ───────────────────────────────────────────── + const chargerConges = async () => { + if (!token) return; + setIsCalLoading(true); + try { + const y = calMonth.getFullYear(); + const m = calMonth.getMonth() + 1; + // On charge tous les congés validés du mois sans filtre serveur. + // Le filtrage campus / société / service se fait côté client dans buildGanttRows. + const params = new URLSearchParams({ + year: String(y), + month: String(m), + limit: "500", + }); + const resp = await fetch(`/api/demandes?${params.toString()}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (resp.ok) { + const data = await resp.json(); + const allLeaves = Array.isArray(data) ? data : data.demandes || []; + // Keep "Validée" and "En attente", exclude "Refusée" and "Annulée" + setCalLeaves(allLeaves.filter(l => l.Statut === 'Validée' || l.Statut === 'En attente')); + } + } catch (e) { + console.error("Erreur calendrier:", e); + } finally { + setIsCalLoading(false); + } + }; + useEffect(() => { if (token) { chargerDonnees(); + chargerReferentiels(); + chargerConges(); } }, [token]); - // 🔔 CONNEXION SSE + useEffect(() => { + if (token) chargerConges(); + }, [calMonth]); + + // ── SSE ─────────────────────────────────────────────────────────────────── useSSE((event) => { - console.log('🔔 Événement SSE reçu dans Dashboard:', event.type); - + const refresh = () => { chargerDonnees(); chargerConges(); }; switch (event.type) { - case 'connected': - console.log('✅ Dashboard connecté au serveur temps réel'); - toast.success("Connexion temps réel établie", { duration: 2000 }); - break; - - case 'demande-created': - console.log('🆕 Nouvelle demande créée'); - toast.info("Nouvelle demande de congé", { duration: 3000 }); - if (token) chargerDonnees(); - break; - - case 'demande-updated': - console.log('🔄 Demande mise à jour'); - if (token) chargerDonnees(); - break; - - case 'demande-validated': - console.log('✅ Demande validée/refusée'); - toast.info("Une demande a été traitée", { duration: 3000 }); - if (token) chargerDonnees(); - break; - - // 🆕 GESTION DES ANNULATIONS - case 'demande-cancelled': - console.log('🚫 Demande annulée'); - toast.warning("Une demande a été annulée", { - description: "La liste a été mise à jour", - duration: 4000 - }); - if (token) chargerDonnees(); - break; - - case 'demande-deleted': - console.log('🗑️ Demande supprimée'); - if (token) chargerDonnees(); - break; - - case 'demande-list-updated': - console.log('📋 Liste des demandes mise à jour'); - if (token) chargerDonnees(); - break; - - case 'heartbeat': - // Heartbeat pour garder la connexion - break; - - default: - console.log('❓ Type d\'événement inconnu:', event.type); + case "connected": toast.success("Connexion temps réel établie", { duration: 2000 }); break; + case "demande-created": toast.info("Nouvelle demande de congé"); if (token) refresh(); break; + case "demande-updated": if (token) refresh(); break; + case "demande-validated": toast.info("Une demande a été traitée"); if (token) refresh(); break; + case "demande-cancelled": toast.warning("Une demande a été annulée"); if (token) refresh(); break; + case "demande-deleted": + case "demande-list-updated": if (token) refresh(); break; } }); + // ── Logout ──────────────────────────────────────────────────────────────── const handleLogout = async () => { try { const msalInstance = new PublicClientApplication(msalConfig); await msalInstance.initialize(); - const accounts = msalInstance.getAllAccounts(); - if (accounts.length > 0) { - // Déconnexion popup au lieu de redirect await msalInstance.logoutPopup({ account: accounts[0], - mainWindowRedirectUri: "https://mygta-rh.ensup-adm.net" + mainWindowRedirectUri: "https://mygta-rh-dev.ensup-adm.net", }); } - - // Nettoyer tout le stockage - localStorage.removeItem('token'); - localStorage.removeItem('user'); - sessionStorage.clear(); - - toast.success("Déconnexion réussie"); - navigate("/"); - } catch (error) { - console.error("Erreur déconnexion:", error); - // Forcer la déconnexion locale même en cas d'erreur - localStorage.removeItem('token'); - localStorage.removeItem('user'); - sessionStorage.clear(); - toast.success("Déconnexion réussie"); - navigate("/"); - } + } catch { } + localStorage.removeItem("token"); + localStorage.removeItem("user"); + sessionStorage.clear(); + toast.success("Déconnexion réussie"); + navigate("/"); }; - // ✅ FONCTION MISE À JOUR AVEC LE STATUT "ANNULÉE" + // ── Helpers UI ──────────────────────────────────────────────────────────── const getStatusBadge = (status: string) => { - switch (status) { - case "En attente": - return - - En attente - ; - case "Validée": - return - - Validée - ; - case "Refusée": - return - - Refusée - ; - case "Annulée": - return - - Annulée - ; - default: - return - {status} - ; - } + const map: Record = { + "En attente": { icon: Clock, cls: "bg-warning/10 text-warning border-warning/20", label: "En attente" }, + "Validée": { icon: CheckCircle, cls: "bg-success/10 text-success border-success/20", label: "Validée" }, + "Refusée": { icon: XCircle, cls: "bg-destructive/10 text-destructive border-destructive/20", label: "Refusée" }, + "Annulée": { icon: AlertCircle, cls: "bg-gray-100 text-gray-700 border-gray-300", label: "Annulée" }, + }; + const s = map[status] || { icon: AlertCircle, cls: "bg-gray-100 text-gray-700", label: status }; + return ( + + {s.label} + + ); }; - const formatDate = (dateString: string) => { - if (!dateString) return ''; - try { - return new Date(dateString).toLocaleDateString('fr-FR', { - day: '2-digit', - month: '2-digit', - year: 'numeric' + const formatDate = (ds: string) => { + if (!ds) return ""; + try { return new Date(ds).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" }); } + catch { return ds; } + }; + + // ── Gantt helpers ───────────────────────────────────────────────────────── + const getDaysInMonth = (y: number, m: number) => new Date(y, m + 1, 0).getDate(); + + const buildGanttRows = () => { + const y = calMonth.getFullYear(), mo = calMonth.getMonth(); + const daysCount = getDaysInMonth(y, mo); + const mStart = `${y}-${String(mo + 1).padStart(2, "0")}-01`; + const mEnd = `${y}-${String(mo + 1).padStart(2, "0")}-${String(daysCount).padStart(2, "0")}`; + + // Congés du mois (tous, sans filtre serveur) + const leavesThisMonth = calLeaves.filter(l => { + const s = (l.DateDebut || "").split("T")[0]; + const e = (l.DateFin || l.DateDebut || "").split("T")[0]; + return s <= mEnd && e >= mStart; + }); + + // Index congés par nom complet + const leavesByName: Record = {}; + leavesThisMonth.forEach(l => { + if (!leavesByName[l.nomEmploye]) leavesByName[l.nomEmploye] = []; + leavesByName[l.nomEmploye].push(l); + }); + + // Filtrage client-side sur les trois dimensions + let baseCollabs = allCollabs; + if (filterCampusId) { + baseCollabs = baseCollabs.filter(c => String(c.campusId) === filterCampusId); + } + if (filterSocieteId) { + baseCollabs = baseCollabs.filter(c => String(c.societeId) === filterSocieteId); + } + if (filterServiceId) { + baseCollabs = baseCollabs.filter(c => String(c.serviceId) === filterServiceId); + } + + // Collaborateurs dans les congés mais absents de la liste filtrée (on les exclut volontairement + // si un filtre est actif, pour rester cohérent avec la sélection) + const nameSet = new Set(baseCollabs.map(c => `${c.prenom} ${c.nom}`)); + const extraRows: Array<{ fullName: string; service: string; leaves: Demande[] }> = []; + if (!filterCampusId && !filterSocieteId && !filterServiceId) { + Object.keys(leavesByName).forEach(name => { + if (!nameSet.has(name)) { + extraRows.push({ + fullName: name, + service: leavesByName[name][0]?.service || "", + leaves: leavesByName[name], + }); + } }); - } catch (error) { - console.error('Erreur formatage date:', error); - return dateString; } + + const allRows = [ + ...baseCollabs.map(c => ({ + fullName: `${c.prenom} ${c.nom}`, + service: c.service, + leaves: leavesByName[`${c.prenom} ${c.nom}`] || [], + })), + ...extraRows, + ].sort((a, b) => a.fullName.localeCompare(b.fullName, "fr")); + + return { allRows, leavesThisMonth, daysCount }; }; + const getLeaveForDay = (leaves: Demande[], ds: string): Demande | undefined => + leaves.find(l => { + const s = (l.DateDebut || "").split("T")[0]; + const e = (l.DateFin || l.DateDebut || "").split("T")[0]; + return ds >= s && ds <= e; + }); + + const todayStr = toDateStr(new Date()); + const { allRows, leavesThisMonth, daysCount } = buildGanttRows(); + const days = Array.from({ length: daysCount }, (_, i) => + new Date(calMonth.getFullYear(), calMonth.getMonth(), i + 1) + ); + const usedTypes = [...new Set(leavesThisMonth.map(l => getTypeKey(l.typesConge)))]; + + // ── Profils à compléter (nouveaux arrivants synchronisés depuis Entra) ───── + const profilsIncompletsCount = allCollabs.filter( + c => !c.societeId || !c.campusId || !c.serviceId || !c.dateEntree || !c.nPlus1Id + ).length; + const peutGererCollaborateurs = user?.role === "Admin" || user?.role === "RH"; + + // ── Cartes stats & actions ──────────────────────────────────────────────── const statsCards = [ - { - title: "Demandes en attente", - value: stats.enAttente, - icon: Clock, - color: "text-warning", - bg: "bg-warning/10", - action: () => navigate("/validation") - }, - { - title: "Validées ce mois", - value: stats.valideeCeMois, - icon: CheckCircle, - color: "text-success", - bg: "bg-success/10", - action: () => navigate("/validation") - }, - { - title: "Total équipes", - value: stats.nombreEquipes, - icon: Users, - color: "text-primary", - bg: "bg-primary/10", - action: () => navigate("/teams") - }, + { title: "Demandes en attente", value: stats.enAttente, icon: Clock, color: "text-warning", bg: "bg-warning/10", action: () => navigate("/validation") }, + { title: "Total équipes", value: stats.nombreEquipes, icon: Users, color: "text-primary", bg: "bg-primary/10", action: () => navigate("/teams") }, ]; const actionCards = [ - { - title: "Saisie manuelle", - description: "Enregistrer une demande", - icon: Plus, - color: "bg-[#7e5aa2]", - action: () => navigate("/saisie-manuelle") - }, - { - title: "Validation", - description: "Valider les demandes", - icon: CheckCircle, - color: "bg-[#7e5aa2]", // Mauve GTARH comme les autres - action: () => navigate("/validation") - }, - - { - title: "Récupération", - description: "Gérer les samedis travaillés", - icon: Clock, - color: "bg-purple-600", - action: () => navigate("/gestion-recuperation") - }, - { - title: "Export paie", - description: "Générer un rapport", - icon: FileSpreadsheet, - color: "bg-blue-600", - action: () => navigate("/export-paie") - }, - { - title: "Compteurs", - description: "Gérer les soldes", - icon: RefreshCw, - color: "bg-purple-600", - action: () => navigate("/compteurs") - }, - { - title: "Équipes", - description: "Voir toutes les équipes", - icon: Users, - color: "bg-indigo-600", - action: () => navigate("/teams") - }, - { - title: "Historique", - description: "Consulter l'historique", - icon: ClipboardList, - color: "bg-orange-600", - action: () => navigate("/historique") - }, - { - title: "Comptes-Rendus RH", - description: "Gérer les forfaits jour", - icon: FileText, - color: "bg-cyan-600", - action: () => navigate("/compte-rendu-rh") - } + { title: "Saisie manuelle", description: "Enregistrer une demande", icon: Plus, color: "bg-[#7e5aa2]", action: () => navigate("/saisie-manuelle") }, + { title: "Validation", description: "Valider les demandes", icon: CheckCircle, color: "bg-[#7e5aa2]", action: () => navigate("/validation") }, + { title: "Récupération", description: "Gérer les Week-end travaillés", icon: Clock, color: "bg-purple-600", action: () => navigate("/gestion-recuperation") }, + { title: "Export paie", description: "Générer un rapport", icon: FileSpreadsheet, color: "bg-blue-600", action: () => navigate("/export-paie") }, + { title: "Compteurs", description: "Gérer les soldes", icon: RefreshCw, color: "bg-purple-600", action: () => navigate("/compteurs") }, + { title: "Équipes", description: "Voir toutes les équipes", icon: Users, color: "bg-indigo-600", action: () => navigate("/teams") }, + { title: "Historique", description: "Consulter l'historique", icon: ClipboardList, color: "bg-orange-600", action: () => navigate("/historique") }, + { title: "Comptes-Rendus RH", description: "Gestion des CRA", icon: FileText, color: "bg-cyan-600", action: () => navigate("/compte-rendu-rh") }, ]; - const handleDeleteRequest = async (id: number) => { - if (!confirm("Voulez-vous vraiment supprimer cette demande ?")) return; - - if (!token) { - toast.error("Utilisateur non authentifié"); - return; - } - - try { - console.log('🗑️ Suppression demande:', id); - - const resp = await fetch(`/api/demandes/${id}`, { - method: "DELETE", - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }); - - const data = await resp.json(); - - if (resp.ok) { - toast.success("Demande supprimée avec succès"); - // Le SSE rechargera automatiquement, mais on peut forcer un rechargement immédiat - await chargerDonnees(); - } else { - toast.error(data.error || "Erreur lors de la suppression"); - } - } catch (error: any) { - console.error("❌ Erreur suppression:", error); - toast.error("Erreur lors de la suppression: " + error.message); - } - }; - - // ✅ Affichage du loader pendant le chargement initial if (isLoading && !user) { return (
@@ -386,8 +423,11 @@ const Dashboard = () => { ); } + // ── Render ──────────────────────────────────────────────────────────────── return (
+ + {/* Header */}
@@ -397,164 +437,370 @@ const Dashboard = () => {

GTARH

- {user && ( -

- {user.prenom} {user.nom} - {user.role} -

- )} + {user &&

{user.prenom} {user.nom} - {user.role}

}
-
-
- {/* Stats Cards */} -
- {statsCards.map((stat, index) => ( - - -
-
-

{stat.title}

-

{stat.value}

-
-
- -
+
+ + {/* Alerte profils à compléter */} + {peutGererCollaborateurs && profilsIncompletsCount > 0 && ( + navigate("/teams")} + > + + +

+ {profilsIncompletsCount} profil{profilsIncompletsCount > 1 ? "s" : ""} à compléter (société, campus, service, date d'entrée ou validateur manquant) — cliquez pour ouvrir la gestion des collaborateurs. +

+
+
+ )} + + {/* Stats */} +
+ {statsCards.map((stat, i) => ( + + +
+

{stat.title}

+

{stat.value}

+
+
+
))}
- {/* Action Cards */} -
- {actionCards.map((action, index) => ( - - -
-
- -
-
-

{action.title}

-

{action.description}

-
+ {/* Actions */} +
+ {actionCards.map((action, i) => ( + + +
+ +
+
+

{action.title}

+

{action.description}

))}
- {/* Recent Requests */} - - -
-
- Demandes récentes - Les dernières demandes soumises + {/* Demandes récentes | Calendrier Gantt */} +
+ {/* Demandes récentes */} + + +
+
+ Demandes récentes + Les dernières demandes soumises +
+
+ + +
-
- - -
-
- - - {isLoading && recentRequests.length === 0 ? ( -
- -

Chargement des demandes...

-
- ) : recentRequests.length > 0 ? ( -
- {recentRequests.map((request) => ( -
navigate(`/modification-demande/${request.Id}`)} - > -
-
-

{request.nomEmploye}

- {request.typesConge} + + + {isLoading && recentRequests.length === 0 ? ( +
+ +

Chargement...

+
+ ) : recentRequests.length > 0 ? ( +
+ {recentRequests.map((req) => { + const k = getTypeKey(req.typesConge); + const c = TYPE_COLORS[k]; + const d = req.NombreJours ?? diffDays(req.DateDebut, req.DateFin || req.DateDebut); + return ( +
+
+
+

{req.nomEmploye}

+
+ + {TYPE_SHORT[k]} · {req.typesConge} + +
+

+ {formatDate(req.DateDebut)} + {req.DateFin && req.DateFin !== req.DateDebut ? ` → ${formatDate(req.DateFin)}` : ""} + {" · "}{Number(d) % 1 === 0 ? d : Number(d).toFixed(1)}j +

+
+
{getStatusBadge(req.Statut)}
+
-

- À partir du {formatDate(request.DateDebut)} -

-
-
- {getStatusBadge(request.Statut)} + ); + })} +
+ ) : ( +
+ +

Aucune demande récente

+
+ )} +
+ - {/* Bouton Edit */} - - - {/* Bouton Supprimer */} - -
+ {/* Calendrier Gantt */} + + +
+
+
+
+
+ + {MONTH_NAMES[calMonth.getMonth()]} {calMonth.getFullYear()} + + + {allRows.length} collaborateur{allRows.length > 1 ? "s" : ""} + +
+
+
+ + + + +
+
+
+ + {/* Filtres */} +
+ + + +
+ + + {isCalLoading ? ( +
+ +

Chargement du calendrier...

+
+ ) : ( +
+ + + + {days.map((_, i) => )} + + + {/* En-tête jours */} + + + + {days.map((date) => { + const ds = toDateStr(date); + const dw = date.getDay(); + const isWe = dw === 0 || dw === 6; + const isToday = ds === todayStr; + const dayIdx = (dw + 6) % 7; + return ( + + ); + })} + + + + {/* Corps : une ligne par collaborateur */} + + {allRows.length === 0 ? ( + + + + ) : ( + allRows.map((row) => ( + + + {/* Colonne nom sticky */} + + + {/* Cellules jours */} + {days.map((date) => { + const ds = toDateStr(date); + const dw = date.getDay(); + const isWe = dw === 0 || dw === 6; + const isToday = ds === todayStr; + const leave = getLeaveForDay(row.leaves, ds); + + const cellBg = isToday + ? "rgba(126,90,162,0.05)" + : isWe + ? "var(--muted)" + : undefined; + + if (!leave) { + return ( + + ); + })} + + )) + )} + +
+ Collaborateur + +
{DAY_NAMES_SHORT[dayIdx]}
+
+ {date.getDate()} +
+
+ + Aucun collaborateur trouvé +
+

{row.fullName}

+

{row.service}

+
+ ); + } + + const k = getTypeKey(leave.typesConge); + const c = TYPE_COLORS[k]; + const startDs = (leave.DateDebut || "").split("T")[0]; + const endDs = (leave.DateFin || leave.DateDebut || "").split("T")[0]; + const isStart = startDs === ds; + const isEnd = endDs === ds; + const isSingle = isStart && isEnd; + const totalDays = leave.NombreJours ?? diffDays(leave.DateDebut, leave.DateFin || leave.DateDebut); + + const barStyle: React.CSSProperties = { + height: 20, + backgroundColor: c.bg, + color: c.text, + fontSize: 9, + fontWeight: 600, + display: "flex", + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + whiteSpace: "nowrap", + borderLeft: isStart ? `2.5px solid ${c.border}` : "none", + borderRadius: isSingle ? 3 : isStart ? "3px 0 0 3px" : isEnd ? "0 3px 3px 0" : 0, + marginLeft: isStart ? 1 : 0, + marginRight: isEnd ? 1 : 0, + }; + + return ( + +
+ {(isStart || isSingle) ? `${TYPE_SHORT[k]} ${Number(totalDays) % 1 === 0 ? totalDays : Number(totalDays).toFixed(1)}j` : ""} +
+
+
+ )} + + {/* Légende */} + {usedTypes.length > 0 && ( +
+ {usedTypes.map(k => { + const c = TYPE_COLORS[k]; + return ( +
+
+ {TYPE_LABELS[k]} +
+ ); + })} +
+ )} + + + +
); diff --git a/src/pages/ExportPaie.tsx b/src/pages/ExportPaie.tsx index 794085a..750b329 100644 --- a/src/pages/ExportPaie.tsx +++ b/src/pages/ExportPaie.tsx @@ -1,23 +1,28 @@ -import { useState } from "react"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; -import { ArrowLeft, Download, FileSpreadsheet } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ArrowLeft, Download, FileSpreadsheet, Sunrise, Sunset, Sun, Calendar, ClipboardList } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "sonner"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import ExcelJS from 'exceljs'; interface CongeDetail { nb: number; dates: string; + periodes?: string; } interface AutreConge { type: string; nb: number; dates: string; + periodes?: string; } interface DataPaie { @@ -28,13 +33,34 @@ interface DataPaie { cp: CongeDetail; aap: CongeDetail; am: CongeDetail; + jposf: CongeDetail; + recup: CongeDetail; autres: AutreConge[]; } const ExportPaie = () => { const navigate = useNavigate(); + + // États pour le mode Mois/Année const [mois, setMois] = useState(new Date().getMonth() + 1); const [annee, setAnnee] = useState(new Date().getFullYear()); + + // ── NOUVEAU : filtre optionnel par date de création dans l'onglet "Par mois" ── + const [filtrerParCreation, setFiltrerParCreation] = useState(false); + const [dateCreationDebut, setDateCreationDebut] = useState(""); + const [dateCreationFin, setDateCreationFin] = useState(""); + + // États pour le mode Plage de dates (période du congé) + const [dateDebut, setDateDebut] = useState(""); + const [dateFin, setDateFin] = useState(""); + + // États pour le mode Plage de saisie (date de création de la demande) + const [dateSaisieDebut, setDateSaisieDebut] = useState(""); + const [dateSaisieFin, setDateSaisieFin] = useState(""); + + // État pour le mode sélectionné + const [modeExport, setModeExport] = useState<"mois" | "plage" | "saisie">("mois"); + const [dataPaie, setDataPaie] = useState([]); const [loading, setLoading] = useState(false); @@ -47,20 +73,113 @@ const ExportPaie = () => { { value: 11, label: 'Novembre' }, { value: 12, label: 'Décembre' } ]; + const formatDateAvecPeriode = (date: string, periode?: string) => { + if (!periode || periode === 'Journée' || periode === 'Journée entière') { + return date; + } + const periodeAbregee = periode === 'Matin' ? 'M' : periode === 'Après-midi' ? 'AM' : ''; + return periodeAbregee ? `${date} (${periodeAbregee})` : date; + }; + + const getPeriodeIcon = (periode?: string) => { + if (periode === 'Matin') { + return ; + } else if (periode === 'Après-midi') { + return ; + } else if (periode === 'Journée' || periode === 'Journée entière') { + return ; + } + return null; + }; + + const getPeriodeLabel = (periode?: string) => { + if (periode === 'Matin') return 'Matin'; + if (periode === 'Après-midi') return 'Après-midi'; + if (periode === 'Journée' || periode === 'Journée entière') return 'Journée'; + return 'Journée'; + }; + + // Fonction pour générer le rapport selon le mode sélectionné const genererRapport = async () => { + // ── Validation spécifique au mode "mois" avec filtre création actif ── + if (modeExport === "mois" && filtrerParCreation) { + if (!dateCreationDebut || !dateCreationFin) { + toast.error("Veuillez sélectionner une date de début et une date de fin de création"); + return; + } + if (new Date(dateCreationDebut) > new Date(dateCreationFin)) { + toast.error("La date de début de création doit être antérieure à la date de fin"); + return; + } + } + + if (modeExport === "plage") { + if (!dateDebut || !dateFin) { + toast.error("Veuillez sélectionner une date de début et une date de fin"); + return; + } + if (new Date(dateDebut) > new Date(dateFin)) { + toast.error("La date de début doit être antérieure à la date de fin"); + return; + } + } + + if (modeExport === "saisie") { + if (!dateSaisieDebut || !dateSaisieFin) { + toast.error("Veuillez sélectionner une date de début et une date de fin de saisie"); + return; + } + if (new Date(dateSaisieDebut) > new Date(dateSaisieFin)) { + toast.error("La date de début doit être antérieure à la date de fin"); + return; + } + } + setLoading(true); try { const token = localStorage.getItem('token'); - const response = await fetch( - `/api/export/paie?mois=${mois}&annee=${annee}`, - { headers: { 'Authorization': `Bearer ${token}` } } - ); + let url = ''; + + if (modeExport === "mois") { + // ── Construction de l'URL avec paramètres optionnels de création ── + url = `/api/export/paie?mois=${mois}&annee=${annee}`; + if (filtrerParCreation && dateCreationDebut && dateCreationFin) { + url += `&dateSaisieDebut=${dateCreationDebut}&dateSaisieFin=${dateCreationFin}`; + } + } else if (modeExport === "plage") { + url = `/api/export/paie-plage?dateDebut=${dateDebut}&dateFin=${dateFin}`; + } else { + url = `/api/export/paie-saisie?dateSaisieDebut=${dateSaisieDebut}&dateSaisieFin=${dateSaisieFin}`; + } + + const response = await fetch(url, { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) { + throw new Error(`Erreur serveur: ${response.status}`); + } + const data = await response.json(); - console.log('📊 Données reçues:', data); - setDataPaie(data); - toast.success(`Rapport généré avec succès (${data.length} employé${data.length > 1 ? 's' : ''})`); + + if (modeExport === "mois") { + const suffix = filtrerParCreation && dateCreationDebut && dateCreationFin + ? `, saisies du ${new Date(dateCreationDebut).toLocaleDateString('fr-FR')} au ${new Date(dateCreationFin).toLocaleDateString('fr-FR')}` + : ''; + toast.success(`Rapport généré avec succès (${data.length} employé${data.length > 1 ? 's' : ''}${suffix})`); + } else if (modeExport === "plage") { + const debut = new Date(dateDebut); + const fin = new Date(dateFin); + const nbJours = Math.ceil((fin.getTime() - debut.getTime()) / (1000 * 60 * 60 * 24)) + 1; + toast.success(`Rapport généré avec succès (${data.length} employé${data.length > 1 ? 's' : ''}, ${nbJours} jours)`); + } else { + const debut = new Date(dateSaisieDebut); + const fin = new Date(dateSaisieFin); + const nbJours = Math.ceil((fin.getTime() - debut.getTime()) / (1000 * 60 * 60 * 24)) + 1; + toast.success(`Rapport généré avec succès (${data.length} employé${data.length > 1 ? 's' : ''}, demandes saisies sur ${nbJours} jours)`); + } } catch (error) { console.error('❌ Erreur:', error); toast.error("Erreur lors de la génération du rapport"); @@ -69,30 +188,56 @@ const ExportPaie = () => { } }; + // Titre du rapport selon le mode + const getTitreRapport = () => { + if (modeExport === "mois") { + const base = `Rapport des congés - ${moisOptions.find(m => m.value === mois)?.label} ${annee}`; + if (filtrerParCreation && dateCreationDebut && dateCreationFin) { + return `${base} (saisies du ${new Date(dateCreationDebut).toLocaleDateString('fr-FR')} au ${new Date(dateCreationFin).toLocaleDateString('fr-FR')})`; + } + return base; + } else if (modeExport === "plage") { + return `Rapport des congés - Du ${new Date(dateDebut).toLocaleDateString('fr-FR')} au ${new Date(dateFin).toLocaleDateString('fr-FR')}`; + } else { + return `Rapport des congés - Demandes saisies du ${new Date(dateSaisieDebut).toLocaleDateString('fr-FR')} au ${new Date(dateSaisieFin).toLocaleDateString('fr-FR')}`; + } + }; + const exporterExcel = async () => { if (dataPaie.length === 0) return toast.error("Aucune donnée à exporter"); const moisLabel = moisOptions.find(m => m.value === mois)?.label || 'Inconnu'; + let nomFichier = ''; + if (modeExport === "mois") { + nomFichier = filtrerParCreation && dateCreationDebut && dateCreationFin + ? `conges_paie_${moisLabel}_${annee}_saisies_${dateCreationDebut}_${dateCreationFin}.xlsx` + : `conges_paie_${moisLabel}_${annee}.xlsx`; + } else if (modeExport === "plage") { + nomFichier = `conges_paie_${dateDebut}_${dateFin}.xlsx`; + } else { + nomFichier = `conges_saisie_${dateSaisieDebut}_${dateSaisieFin}.xlsx`; + } + try { const workbook = new ExcelJS.Workbook(); - const worksheet = workbook.addWorksheet(`Congés ${moisLabel}`); + const sheetTitle = modeExport === "saisie" ? `Saisies ${dateSaisieDebut}` : `Congés ${moisLabel}`; + const worksheet = workbook.addWorksheet(sheetTitle); - // ✅ MODIFICATION : Colonnes sans Email worksheet.columns = [ { header: 'Collaborateur', key: 'collaborateur', width: 20 }, { header: 'Service', key: 'service', width: 20 }, { header: 'RTT Nb', key: 'rtt_nb', width: 10 }, - { header: 'RTT Dates', key: 'rtt_dates', width: 25 }, + { header: 'RTT Dates', key: 'rtt_dates', width: 35 }, { header: 'CP Nb', key: 'cp_nb', width: 10 }, - { header: 'CP Dates', key: 'cp_dates', width: 25 }, + { header: 'CP Dates', key: 'cp_dates', width: 35 }, { header: 'AAP Nb', key: 'aap_nb', width: 10 }, - { header: 'AAP Dates', key: 'aap_dates', width: 25 }, + { header: 'AAP Dates', key: 'aap_dates', width: 35 }, { header: 'AM Nb', key: 'am_nb', width: 10 }, - { header: 'AM Dates', key: 'am_dates', width: 25 }, + { header: 'AM Dates', key: 'am_dates', width: 35 }, { header: 'Autres Type', key: 'autres_type', width: 20 }, { header: 'Autres Nb', key: 'autres_nb', width: 10 }, - { header: 'Autres Dates', key: 'autres_dates', width: 30 } + { header: 'Autres Dates', key: 'autres_dates', width: 40 } ]; // Style de l'en-tête @@ -105,7 +250,6 @@ const ExportPaie = () => { fgColor: { argb: 'FF7e5aa2' } }; - // Bordures ÉPAISSES pour l'en-tête worksheet.getRow(1).eachCell((cell) => { cell.border = { top: { style: 'medium', color: { argb: 'FF000000' } }, @@ -117,24 +261,26 @@ const ExportPaie = () => { let currentRow = 2; - // Ajouter les données avec fusion de cellules - dataPaie.forEach((row, employeIndex) => { + dataPaie.forEach((row) => { const startRow = currentRow; - // ✅ MODIFICATION : Extraire uniquement le nom (dernier mot) const nomComplet = row.employe.split(' '); - const nomSeul = nomComplet[nomComplet.length - 1]; // Prend le dernier mot (le nom) + const nomSeul = nomComplet[nomComplet.length - 1]; - // Calculer le nombre maximum de lignes nécessaires pour cet employé const rttDatesArray = row.rtt?.dates ? row.rtt.dates.split(' ; ') : []; + const rttPeriodesArray = row.rtt?.periodes ? row.rtt.periodes.split(' ; ') : []; const cpDatesArray = row.cp?.dates ? row.cp.dates.split(' ; ') : []; + const cpPeriodesArray = row.cp?.periodes ? row.cp.periodes.split(' ; ') : []; const aapDatesArray = row.aap?.dates ? row.aap.dates.split(' ; ') : []; + const aapPeriodesArray = row.aap?.periodes ? row.aap.periodes.split(' ; ') : []; const amDatesArray = row.am?.dates ? row.am.dates.split(' ; ') : []; + const amPeriodesArray = row.am?.periodes ? row.am.periodes.split(' ; ') : []; - // Gérer les "Autres" let autresDatesArray: string[][] = []; + let autresPeriodesArray: string[][] = []; if (row.autres && row.autres.length > 0) { autresDatesArray = row.autres.map(a => a.dates.split(' ; ')); + autresPeriodesArray = row.autres.map(a => a.periodes ? a.periodes.split(' ; ') : []); } const maxAutresDates = Math.max(...autresDatesArray.map(arr => arr.length), 1); @@ -146,20 +292,17 @@ const ExportPaie = () => { maxAutresDates ); - // Ajouter les lignes nécessaires for (let i = 0; i < maxRows; i++) { const rowData: any = {}; - // Première ligne : inclure nom et service (sans email) if (i === 0) { - rowData.collaborateur = nomSeul; // ✅ Uniquement le nom + rowData.collaborateur = nomSeul; rowData.service = row.service; rowData.rtt_nb = row.rtt?.nb || 0; rowData.cp_nb = row.cp?.nb || 0; rowData.aap_nb = row.aap?.nb || 0; rowData.am_nb = row.am?.nb || 0; - // Autres (première ligne) if (row.autres && row.autres.length > 0) { rowData.autres_type = row.autres.map(a => a.type).join('\n'); rowData.autres_nb = row.autres.map(a => a.nb).join('\n'); @@ -175,23 +318,18 @@ const ExportPaie = () => { rowData.autres_nb = ''; } - // Dates RTT - rowData.rtt_dates = rttDatesArray[i] || ''; + rowData.rtt_dates = rttDatesArray[i] ? formatDateAvecPeriode(rttDatesArray[i], rttPeriodesArray[i] || 'Journée') : ''; + rowData.cp_dates = cpDatesArray[i] ? formatDateAvecPeriode(cpDatesArray[i], cpPeriodesArray[i] || 'Journée') : ''; + rowData.aap_dates = aapDatesArray[i] ? formatDateAvecPeriode(aapDatesArray[i], aapPeriodesArray[i] || 'Journée') : ''; + rowData.am_dates = amDatesArray[i] ? formatDateAvecPeriode(amDatesArray[i], amPeriodesArray[i] || 'Journée') : ''; - // Dates CP - rowData.cp_dates = cpDatesArray[i] || ''; - - // Dates AAP - rowData.aap_dates = aapDatesArray[i] || ''; - - // Dates AM - rowData.am_dates = amDatesArray[i] || ''; - - // Dates Autres if (row.autres && row.autres.length > 0) { - const allAutresDates = row.autres.map(a => { + const allAutresDates = row.autres.map((a, autreIdx) => { const dates = a.dates.split(' ; '); - return dates[i] || ''; + const periodes = autresPeriodesArray[autreIdx] || []; + const date = dates[i] || ''; + const periode = periodes[i] || 'Journée'; + return formatDateAvecPeriode(date, periode); }).filter(d => d !== '').join(' | '); rowData.autres_dates = allAutresDates; } else { @@ -204,44 +342,34 @@ const ExportPaie = () => { const endRow = currentRow - 1; - // ✅ MODIFICATION : Fusionner les cellules (ajusté pour la nouvelle structure) if (maxRows > 1) { - worksheet.mergeCells(`A${startRow}:A${endRow}`); // Collaborateur - worksheet.mergeCells(`B${startRow}:B${endRow}`); // Service - worksheet.mergeCells(`C${startRow}:C${endRow}`); // RTT Nb - worksheet.mergeCells(`E${startRow}:E${endRow}`); // CP Nb - worksheet.mergeCells(`G${startRow}:G${endRow}`); // AAP Nb - worksheet.mergeCells(`I${startRow}:I${endRow}`); // AM Nb - worksheet.mergeCells(`K${startRow}:K${endRow}`); // Autres Type - worksheet.mergeCells(`L${startRow}:L${endRow}`); // Autres Nb + worksheet.mergeCells(`A${startRow}:A${endRow}`); + worksheet.mergeCells(`B${startRow}:B${endRow}`); + worksheet.mergeCells(`C${startRow}:C${endRow}`); + worksheet.mergeCells(`E${startRow}:E${endRow}`); + worksheet.mergeCells(`G${startRow}:G${endRow}`); + worksheet.mergeCells(`I${startRow}:I${endRow}`); + worksheet.mergeCells(`K${startRow}:K${endRow}`); + worksheet.mergeCells(`L${startRow}:L${endRow}`); } - // Appliquer les styles pour ce groupe de lignes for (let r = startRow; r <= endRow; r++) { const currentRowObj = worksheet.getRow(r); currentRowObj.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; - - // Hauteur de ligne currentRowObj.height = 25; - // ✅ MODIFICATION : Couleurs ajustées (colonnes décalées) - currentRowObj.getCell(3).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE9D5FF' } }; // RTT Nb - currentRowObj.getCell(4).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE9D5FF' } }; // RTT Dates + currentRowObj.getCell(3).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE9D5FF' } }; + currentRowObj.getCell(4).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE9D5FF' } }; + currentRowObj.getCell(5).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFDBEAFE' } }; + currentRowObj.getCell(6).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFDBEAFE' } }; + currentRowObj.getCell(7).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD1FAE5' } }; + currentRowObj.getCell(8).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD1FAE5' } }; + currentRowObj.getCell(9).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFEE2E2' } }; + currentRowObj.getCell(10).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFEE2E2' } }; + currentRowObj.getCell(11).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFEF3C7' } }; + currentRowObj.getCell(12).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFEF3C7' } }; + currentRowObj.getCell(13).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFEF3C7' } }; - currentRowObj.getCell(5).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFDBEAFE' } }; // CP Nb - currentRowObj.getCell(6).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFDBEAFE' } }; // CP Dates - - currentRowObj.getCell(7).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD1FAE5' } }; // AAP Nb - currentRowObj.getCell(8).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD1FAE5' } }; // AAP Dates - - currentRowObj.getCell(9).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFEE2E2' } }; // AM Nb - currentRowObj.getCell(10).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFEE2E2' } }; // AM Dates - - currentRowObj.getCell(11).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFEF3C7' } }; // Autres Type - currentRowObj.getCell(12).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFEF3C7' } }; // Autres Nb - currentRowObj.getCell(13).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFEF3C7' } }; // Autres Dates - - // Bordures ÉPAISSES pour toutes les cellules currentRowObj.eachCell((cell) => { cell.border = { top: { style: 'medium', color: { argb: 'FF000000' } }, @@ -253,7 +381,6 @@ const ExportPaie = () => { } }); - // Générer le fichier Excel const buffer = await workbook.xlsx.writeBuffer(); const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' @@ -261,64 +388,15 @@ const ExportPaie = () => { const link = document.createElement('a'); link.href = URL.createObjectURL(blob); - link.download = `conges_paie_${moisLabel}_${annee}.xlsx`; + link.download = nomFichier; link.click(); - toast.success("Export Excel réussi avec fusion de cellules !"); + toast.success("Export Excel réussi !"); } catch (error) { console.error('Erreur export Excel:', error); toast.error("Erreur lors de l'export Excel"); } }; - const exporterCSV = () => { - if (dataPaie.length === 0) return toast.error("Aucune donnée à exporter"); - - const headers = [ - 'Employé', 'Email', 'Service', - 'RTT Nb', 'RTT Dates', - 'CP Nb', 'CP Dates', - 'AAP Nb', 'AAP Dates', - 'AM Nb', 'AM Dates', - 'Autres Type', 'Autres Nb', 'Autres Dates' - ]; - - const rows = dataPaie.map(row => { - const baseData = [ - row.employe, - row.email, - row.service, - row.rtt?.nb || 0, - row.rtt?.dates || '', - row.cp?.nb || 0, - row.cp?.dates || '', - row.aap?.nb || 0, - row.aap?.dates || '', - row.am?.nb || 0, - row.am?.dates || '' - ]; - - if (row.autres && row.autres.length > 0) { - const autresTypes = row.autres.map(a => a.type).join(' | '); - const autresNb = row.autres.map(a => a.nb).join(' | '); - const autresDates = row.autres.map(a => a.dates).join(' | '); - return [...baseData, autresTypes, autresNb, autresDates]; - } else { - return [...baseData, '', '', '']; - } - }); - - const csvContent = [ - headers.join(';'), - ...rows.map(row => row.join(';')) - ].join('\n'); - - const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' }); - const link = document.createElement('a'); - link.href = URL.createObjectURL(blob); - link.download = `conges_paie_${moisOptions.find(m => m.value === mois)?.label}_${annee}.csv`; - link.click(); - toast.success("Export CSV réussi"); - }; return (
@@ -349,46 +427,217 @@ const ExportPaie = () => { Sélectionnez la période pour générer le rapport -
-
- - -
-
- - -
- {/* Bouton Générer le rapport toujours affiché avec fond mauve */} -
- -
-
+ { setModeExport(v as "mois" | "plage" | "saisie"); setDataPaie([]); }} className="mb-6"> + + Par mois + + + Par plage de dates + + + + Par plage de saisie + + + + {/* ── Mode Mois/Année ── */} + +
+
+ + +
+
+ + +
+
+ +
+
+ + {/* ── Filtre optionnel par date de création ── */} +
+
+ { + setFiltrerParCreation(checked === true); + if (!checked) { + setDateCreationDebut(""); + setDateCreationFin(""); + } + }} + /> + +
+ + {filtrerParCreation && ( +
+
+ + setDateCreationDebut(e.target.value)} + className="transition-smooth" + /> +
+
+ + setDateCreationFin(e.target.value)} + className="transition-smooth" + /> +
+ + {dateCreationDebut && dateCreationFin && ( +
+

+ Filtre actif : Congés du mois de {moisOptions.find(m => m.value === mois)?.label} {annee}, + dont la demande a été créée entre le {new Date(dateCreationDebut).toLocaleDateString('fr-FR')} et le {new Date(dateCreationFin).toLocaleDateString('fr-FR')}. +

+
+ )} +
+ )} +
+
+ + {/* Mode Plage de dates (période du congé) */} + +
+
+ + setDateDebut(e.target.value)} + className="transition-smooth" + /> +
+
+ + setDateFin(e.target.value)} + className="transition-smooth" + /> +
+
+ +
+
+ {dateDebut && dateFin && ( +
+

+ Période du congé : Du {new Date(dateDebut).toLocaleDateString('fr-FR')} au {new Date(dateFin).toLocaleDateString('fr-FR')} + {(() => { + const debut = new Date(dateDebut); + const fin = new Date(dateFin); + const nbJours = Math.ceil((fin.getTime() - debut.getTime()) / (1000 * 60 * 60 * 24)) + 1; + return ` (${nbJours} jour${nbJours > 1 ? 's' : ''})`; + })()} +

+
+ )} +
+ + {/* Mode Plage de saisie (date de création de la demande) */} + +
+
+ + setDateSaisieDebut(e.target.value)} + className="transition-smooth" + /> +
+
+ + setDateSaisieFin(e.target.value)} + className="transition-smooth" + /> +
+
+ +
+
+ {dateSaisieDebut && dateSaisieFin && ( +
+

+ Demandes saisies entre le : {new Date(dateSaisieDebut).toLocaleDateString('fr-FR')} et le {new Date(dateSaisieFin).toLocaleDateString('fr-FR')} + {(() => { + const debut = new Date(dateSaisieDebut); + const fin = new Date(dateSaisieFin); + const nbJours = Math.ceil((fin.getTime() - debut.getTime()) / (1000 * 60 * 60 * 24)) + 1; + return ` (${nbJours} jour${nbJours > 1 ? 's' : ''} de saisie)`; + })()} +

+

+ Remonte toutes les demandes créées dans cette fenêtre, quelle que soit la période du congé. +

+
+ )} +
+
- {/* Bouton Export Excel toujours affiché, grisé s’il n’y a pas de données */}
- {/* Tableau des données uniquement si data présente */} {dataPaie.length > 0 ? ( - Rapport des congés - {moisOptions.find(m => m.value === mois)?.label} {annee} + {getTitreRapport()} - AAP = Absence autorisée payée (ex : enfant malade, récup de JPOFS, etc) • AM = Arrêt maladie + AAP = Absence autorisée payée (ex : enfant malade, récup de JPOSF, etc) • AM = Arrêt maladie + {modeExport === "saisie" && ( + • Filtré par date de saisie de la demande + )} + {modeExport === "mois" && filtrerParCreation && ( + • Filtré par date de création de la demande + )} +
+
+ + Journée + + + Matin + + + Après-midi + +
@@ -415,133 +681,122 @@ const ExportPaie = () => { - - - - - - - + + + + + + + - {dataPaie.map((row, index) => ( - - - - - - - - + + + - - ))} + + ) : -} + + + + + + + ); + })}
- Collaborateur - - Service - - RTT
- (Nb / Dates) -
- CP
- (Nb / Dates) -
- AAP
- (Nb / Dates) -
- AM
- (Nb / Dates) -
- Autres
- (Type / Nb / Dates) -
CollaborateurServiceRTT
(Nb / Dates)
CP
(Nb / Dates)
AAP
(Nb / Dates)
AM
(Nb / Dates)
Autres
(Type / Nb / Dates)
- {row.employe} - - {row.service} - - {row.rtt && row.rtt.nb > 0 ? ( -
-
{row.rtt.nb}
-
- {row.rtt.dates.split(' ; ').map((date, idx) => ( -
- {date} -
- ))} -
-
- ) : ( - - - )} -
- {row.cp && row.cp.nb > 0 ? ( -
-
{row.cp.nb}
-
- {row.cp.dates.split(' ; ').map((date, idx) => ( -
- {date} -
- ))} -
-
- ) : ( - - - )} -
- {row.aap && row.aap.nb > 0 ? ( -
-
{row.aap.nb}
-
- {row.aap.dates.split(' ; ').map((date, idx) => ( -
- {date} -
- ))} -
-
- ) : ( - - - )} -
- {row.am && row.am.nb > 0 ? ( -
-
{row.am.nb}
-
- {row.am.dates.split(' ; ').map((date, idx) => ( -
- {date} -
- ))} -
-
- ) : ( - - - )} -
- {row.autres && row.autres.length > 0 ? ( -
- {row.autres.map((autre, idx) => ( -
-
{autre.type}
-
{autre.nb}
-
- {autre.dates.split(' ; ').map((date, dateIdx) => ( -
- {date} -
- ))} -
+ {dataPaie.map((row, index) => { + const rttDates = row.rtt?.dates ? row.rtt.dates.split(' ; ') : []; + const rttPeriodes = row.rtt?.periodes ? row.rtt.periodes.split(' ; ') : []; + const cpDates = row.cp?.dates ? row.cp.dates.split(' ; ') : []; + const cpPeriodes = row.cp?.periodes ? row.cp.periodes.split(' ; ') : []; + const aapDates = row.aap?.dates ? row.aap.dates.split(' ; ') : []; + const aapPeriodes = row.aap?.periodes ? row.aap.periodes.split(' ; ') : []; + const amDates = row.am?.dates ? row.am.dates.split(' ; ') : []; + const amPeriodes = row.am?.periodes ? row.am.periodes.split(' ; ') : []; + + return ( +
{row.employe}{row.service} + {row.rtt && row.rtt.nb > 0 ? ( +
+
{row.rtt.nb}
+
+ {rttDates.map((date, idx) => ( +
+ {getPeriodeIcon(rttPeriodes[idx])} + {date} + ({getPeriodeLabel(rttPeriodes[idx])}) +
+ ))}
- ))} -
- ) : ( - - - )} -
+ {row.cp && row.cp.nb > 0 ? ( +
+
{row.cp.nb}
+
+ {cpDates.map((date, idx) => ( +
+ {getPeriodeIcon(cpPeriodes[idx])} + {date} + ({getPeriodeLabel(cpPeriodes[idx])}) +
+ ))} +
+
+ ) : -} +
+ {row.aap && row.aap.nb > 0 ? ( +
+
{row.aap.nb}
+
+ {aapDates.map((date, idx) => ( +
+ {getPeriodeIcon(aapPeriodes[idx])} + {date} + ({getPeriodeLabel(aapPeriodes[idx])}) +
+ ))} +
+
+ ) : -} +
+ {row.am && row.am.nb > 0 ? ( +
+
{row.am.nb}
+
+ {amDates.map((date, idx) => ( +
+ {getPeriodeIcon(amPeriodes[idx])} + {date} + ({getPeriodeLabel(amPeriodes[idx])}) +
+ ))} +
+
+ ) : -} +
+ {row.autres && row.autres.length > 0 ? ( +
+ {row.autres.map((autre, idx) => { + const autresDates = autre.dates.split(' ; '); + const autresPeriodes = autre.periodes ? autre.periodes.split(' ; ') : []; + return ( +
+
{autre.type}
+
{autre.nb}
+
+ {autresDates.map((date, dateIdx) => ( +
+ {getPeriodeIcon(autresPeriodes[dateIdx])} + {date} + ({getPeriodeLabel(autresPeriodes[dateIdx])}) +
+ ))} +
+
+ ); + })} +
+ ) : -} +
@@ -552,7 +807,11 @@ const ExportPaie = () => { -

Sélectionnez une période et cliquez sur "Générer le rapport"

+

+ {modeExport === "mois" && "Sélectionnez un mois et cliquez sur \"Générer le rapport\""} + {modeExport === "plage" && "Sélectionnez une plage de dates de congé et cliquez sur \"Générer le rapport\""} + {modeExport === "saisie" && "Sélectionnez une plage de dates de saisie et cliquez sur \"Générer le rapport\""} +

)} diff --git a/src/pages/GestionCompteurs.tsx b/src/pages/GestionCompteurs.tsx index d8a7614..e9e138c 100644 --- a/src/pages/GestionCompteurs.tsx +++ b/src/pages/GestionCompteurs.tsx @@ -1,15 +1,23 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; -import { ArrowLeft, RefreshCw, Search, AlertTriangle, Settings, Edit } from "lucide-react"; +import { ArrowLeft, RefreshCw, Search, Edit, BarChart2, TrendingUp, TrendingDown, Minus, X, Calendar, CheckCircle, AlertCircle } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "sonner"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, + ResponsiveContainer, ReferenceLine, Area, AreaChart, Legend +} from "recharts"; + +// ===================================================== +// INTERFACES +// ===================================================== interface Compteur { id: number; @@ -19,67 +27,606 @@ interface Compteur { email: string; service: string; campus?: string; + societe?: string; typeConge: string; annee: number; total: number; solde: number; soldeReporte: number; + soldeAnticipe?: number; consomme: number; typeContrat?: string; role?: string; estApprenti?: boolean; } +interface SnapshotEntry { + DateSnapshot: string; + Solde: number; + Total: number; + SoldeReporte: number; + IsAnticipe: boolean; + typeConge: string; + Annee: number; +} + +interface SoldesALaDate { + date: string; + dateSnapshot: string; + compteurs: Array<{ + typeConge: string; + typeCongeId: number; + annee: number; + solde: number; + total: number; + soldeAnticipe: number; + isAnticipe: boolean; + }>; +} + +interface Previsionnel { + mode: string; + isAnticipe: boolean; + isAnticipeRTT: boolean; + apresBasculement: boolean; + anneeN1: number; + anneeN: number; + anneeRTT: number; + cpN1Reporte: number; + cpNSolde: number; + cpNSoldeALaDate: number; + cpNAnticipeDispo: number; + acquisCPADateDebut: number; + totalCPDisponible: number; + rttDispo: number; + rttNAcquisAujourdHui?: number; + rttAnticipeDispo: number; + joursNecessairesCP: number; + joursNecessairesRTT: number; + suffisant: boolean; + suffisantCP: boolean; + suffisantRTT: boolean; + deficitCP: number; + deficitRTT: number; + message: string; +} + +// ===================================================== +// TOOLTIP CUSTOM POUR LE GRAPHIQUE +// ===================================================== + +const CustomTooltip = ({ active, payload, label }: any) => { + if (active && payload && payload.length) { + const data = payload[0]?.payload; + return ( +
+

+ {new Date(label).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })} +

+ {payload.map((entry: any, i: number) => ( +

+ {entry.name} : {entry.value}j +

+ ))} + {data?.IsAnticipe && ( +

⚠️ Mode prévisionnel

+ )} +
+ ); + } + return null; +}; + +// ===================================================== +// DIALOG HISTORIQUE SNAPSHOT +// ===================================================== + +interface HistoriqueDialogProps { + compteur: Compteur | null; + onClose: () => void; +} + +const HistoriqueSnapshotDialog = ({ compteur, onClose }: HistoriqueDialogProps) => { + const [snapshots, setSnapshots] = useState([]); + const [loading, setLoading] = useState(false); + + const getAuthHeaders = () => { + const token = localStorage.getItem('token'); + return token ? { Authorization: `Bearer ${token}` } : {}; + }; + + useEffect(() => { + if (!compteur) return; + chargerSnapshots(); + }, [compteur]); + + const chargerSnapshots = async () => { + if (!compteur) return; + setLoading(true); + try { + const params = new URLSearchParams(); + if (compteur.annee !== undefined && compteur.annee !== null) { + params.append('annee', compteur.annee.toString()); + } + if (compteur.typeCongeId !== undefined && compteur.typeCongeId !== null) { + params.append('typeCongeId', compteur.typeCongeId.toString()); + } else if (compteur.typeConge) { + params.append('typeConge', compteur.typeConge); + } + + const response = await fetch( + `/api/snapshot/historique/${compteur.collaborateurId}?${params}`, + { headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' } } + ); + + const text = await response.text(); + if (!text || text.trim() === '') { setSnapshots([]); return; } + + let data; + try { data = JSON.parse(text); } catch { + console.error('Réponse non-JSON:', text.substring(0, 200)); + setSnapshots([]); return; + } + + if (!response.ok) throw new Error(data?.error || data?.message || `Erreur ${response.status}`); + setSnapshots(Array.isArray(data) ? data : []); + } catch (error: any) { + console.error('Erreur snapshot:', error); + toast.error("Erreur chargement historique : " + error.message); + setSnapshots([]); + } finally { + setLoading(false); + } + }; + + if (!compteur) return null; + + const chartData = snapshots.map(s => ({ + date: s.DateSnapshot, + dateLabel: new Date(s.DateSnapshot).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }), + Solde: parseFloat(s.Solde as any), + Total: parseFloat(s.Total as any), + SoldeAnticipe: parseFloat((s.SoldeAnticipe as any) || 0), + IsAnticipe: s.IsAnticipe, + })); + + const premier = snapshots[0]; + const dernier = snapshots[snapshots.length - 1]; + const delta = premier && dernier ? parseFloat(dernier.Solde as any) - parseFloat(premier.Solde as any) : 0; + const minSolde = snapshots.length ? Math.min(...snapshots.map(s => parseFloat(s.Solde as any))) : 0; + const maxSolde = snapshots.length ? Math.max(...snapshots.map(s => parseFloat(s.Solde as any))) : 0; + const maxSoldeAnticipe = dernier + ? parseFloat((dernier as any).SoldeAnticipeActuel || (dernier as any).SoldeAnticipe || 0) : 0; + + const colorMap: Record = { + 'Congé payé': '#7C3AED', 'RTT': '#2563EB', 'Récupération': '#16A34A', + }; + const couleur = colorMap[compteur.typeConge] || '#6B7280'; + + return ( + + + + +
+ +
+ Historique — {compteur.employe} +
+ + {compteur.typeConge} · Année {compteur.annee} · Snapshots quotidiens + +
+ + {loading ? ( +
+ + Chargement... +
+ ) : snapshots.length === 0 ? ( +
+ +

Aucun snapshot disponible

+

+ Les snapshots sont capturés chaque soir à 23h55.
+ Le premier apparaîtra demain. +

+
+ ) : ( +
+
+
+

Solde actuel

+

+ {dernier ? parseFloat(dernier.Solde as any).toFixed(1) : '—'}j +

+
+
+

Évolution

+

0 ? 'text-green-600' : 'text-gray-500'}`}> + {delta < 0 ? : delta > 0 ? : } + {delta > 0 ? '+' : ''}{delta.toFixed(1)}j +

+
+
+

Min / Max

+

{minSolde.toFixed(1)}j / {maxSolde.toFixed(1)}j

+
+
0 ? 'bg-orange-50' : 'bg-muted/30'}`}> +

Solde prévisionnel

+

0 ? 'text-orange-600' : 'text-gray-400'}`}> + {maxSoldeAnticipe > 0 ? `${maxSoldeAnticipe.toFixed(1)}j` : '—'} +

+
+
+ +
+

Évolution du solde

+
+ + + + + + + + + + + + + + + + + + + `${v}j`} /> + } /> + + + + + {maxSoldeAnticipe > 0 && ( + + )} + + +
+
+ +
+

+ Derniers mouvements + (jours où le solde a changé) +

+
+ + + + + + + + + + + {snapshots.slice().reverse() + .reduce((acc: Array<{ snap: SnapshotEntry; variation: number }>, snap, idx, arr) => { + const prev = arr[idx + 1]; + const variation = prev ? parseFloat(snap.Solde as any) - parseFloat(prev.Solde as any) : 0; + if (variation !== 0 || idx === 0) acc.push({ snap, variation }); + return acc; + }, []) + .slice(0, 10) + .map(({ snap, variation }, idx) => ( + + + + + + + ))} + +
DateSoldeVariationPrévisionnel
+ {new Date(snap.DateSnapshot).toLocaleDateString('fr-FR', { + weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' + })} + + {parseFloat(snap.Solde as any).toFixed(1)}j + + {variation !== 0 ? ( + + {variation > 0 ? '+' : ''}{variation.toFixed(1)}j + + ) : } + + {snap.IsAnticipe ? ⚠️ : } +
+
+
+ +

+ {snapshots.length} snapshot{snapshots.length > 1 ? 's' : ''} · du{' '} + {premier && new Date(premier.DateSnapshot).toLocaleDateString('fr-FR')} + {' '}au{' '} + {dernier && new Date(dernier.DateSnapshot).toLocaleDateString('fr-FR')} +

+
+ )} + + + + +
+
+ ); +}; + +// ===================================================== +// PANNEAU PRÉVISIONNEL +// ===================================================== + +interface PrevisonnnelPanelProps { + collaborateurId: number; + dateDebut: string; + loadingPrevisionnel: boolean; + previsionnel: Previsionnel | null; +} + +const PrevisonnnelPanel = ({ collaborateurId, dateDebut, loadingPrevisionnel, previsionnel }: PrevisonnnelPanelProps) => { + if (!dateDebut) return null; + + if (loadingPrevisionnel) { + return ( +
+
+ + + Calcul des soldes prévisionnels au {new Date(dateDebut).toLocaleDateString('fr-FR')}... + +
+
+ ); + } + + if (!previsionnel) return null; + + const estOk = previsionnel.suffisantCP && previsionnel.suffisantRTT; + const showCP = previsionnel.totalCPDisponible > 0 || previsionnel.cpNSolde > 0; + const showRTT = previsionnel.rttDispo > 0 || previsionnel.rttNAcquisAujourdHui > 0; + + return ( +
+
+ {estOk + ? + : + } +
+

+ 📊 Soldes prévisionnels au {new Date(dateDebut).toLocaleDateString('fr-FR', { + day: 'numeric', month: 'long', year: 'numeric' + })} +

+

+ Mode : {previsionnel.mode} + {previsionnel.isAnticipe && ' • ⚠️ Congé N+1 (exercice suivant)'} + {previsionnel.isAnticipeRTT && ' • ⚠️ RTT prévisionnel'} +

+
+
+ +
+ {showCP && ( +
+
+

Congés Payés

+ + {previsionnel.anneeN} + +
+
+ {previsionnel.apresBasculement ? ( + <> + {previsionnel.cpN1Reporte > 0 && ( +
+ CP {previsionnel.anneeN1} reporté : + + {previsionnel.cpN1Reporte.toFixed(1)}j + +
+ )} +
+ CP {previsionnel.anneeN} acquis depuis juin : + + {previsionnel.cpNSolde.toFixed(1)}j + +
+ {previsionnel.cpNAnticipeDispo > 0 && ( +
+ CP {previsionnel.anneeN} à acquérir d'ici là : + + {previsionnel.cpNAnticipeDispo.toFixed(1)}j + +
+ )} + + ) : ( + <> + {previsionnel.cpN1Reporte > 0 && ( +
+ CP N-1 ({previsionnel.anneeN1}) : + + {previsionnel.cpN1Reporte.toFixed(1)}j + +
+ )} +
+ CP N acquis à ce jour : + + {previsionnel.cpNSolde.toFixed(1)}j + +
+ {previsionnel.cpNAnticipeDispo > 0 && ( +
+ CP N à acquérir d'ici là : + + {previsionnel.cpNAnticipeDispo.toFixed(1)}j + +
+ )} + + )} +
+ Total disponible à la date : + + {previsionnel.totalCPDisponible.toFixed(1)}j + +
+
+
+ )} + + {showRTT && ( +
+
+

RTT

+ + {previsionnel.anneeRTT} + +
+
+
+ RTT acquis à ce jour : + + {(previsionnel.rttNAcquisAujourdHui ?? 0).toFixed(1)}j + +
+ {previsionnel.rttAnticipeDispo > 0 && ( +
+ RTT à acquérir d'ici là : + {previsionnel.rttAnticipeDispo.toFixed(1)}j +
+ )} +
+ Total disponible à la date : + + {previsionnel.rttDispo.toFixed(1)}j + +
+
+
+ )} +
+ + {(previsionnel.isAnticipe || previsionnel.isAnticipeRTT) && ( +
+

+ ⚠️ Mode prévisionnel. +

+
+ )} +
+ ); +}; + +// ===================================================== +// COMPOSANT PRINCIPAL +// ===================================================== + const GestionCompteurs = () => { const navigate = useNavigate(); const [compteurs, setCompteurs] = useState([]); const [recherche, setRecherche] = useState(""); const [filtreCampus, setFiltreCampus] = useState("all"); const [filtreService, setFiltreService] = useState("all"); + const [filtreSociete, setFiltreSociete] = useState("all"); const [filtreType, setFiltreType] = useState("all"); const [filtreAnnee, setFiltreAnnee] = useState("all"); const [selectedCompteur, setSelectedCompteur] = useState(null); + const [loading, setLoading] = useState(false); - const [showInitDialog, setShowInitDialog] = useState(false); const [showEditDialog, setShowEditDialog] = useState(false); - const currentYear = new Date().getFullYear(); - const [initForm, setInitForm] = useState({ - anneeActuelle: currentYear, - cpActuel: 25, - rttActuel: 10, - anneePrecedente: currentYear - 1, - cpPrecedent: 25, - rttPrecedent: 10 - }); + const [showSoldesDateDialog, setShowSoldesDateDialog] = useState(false); + const [selectedCollaborateurId, setSelectedCollaborateurId] = useState(null); + const [selectedEmployeName, setSelectedEmployeName] = useState(""); + const [dateConsultation, setDateConsultation] = useState(new Date().toISOString().split('T')[0]); - const [editForm, setEditForm] = useState({ - total: 0, - solde: 0 - }); + const [previsionnel, setPrevisionnel] = useState(null); + const [loadingPrevisionnel, setLoadingPrevisionnel] = useState(false); + + // ✅ CORRECTION : currentYear + 1 = 2027 = année en cours après bascule + const currentYear = new Date().getFullYear(); + const anneeEnCours = currentYear + 1; // 2027 + const anneeN1 = currentYear; // 2026 + + const [editForm, setEditForm] = useState({ total: 0, solde: 0 }); + + useEffect(() => { chargerCompteurs(); }, []); useEffect(() => { - chargerCompteurs(); - }, []); + if (showSoldesDateDialog && selectedCollaborateurId && dateConsultation) { + chargerPrevisionnel(); + } else { + setPrevisionnel(null); + } + }, [showSoldesDateDialog, selectedCollaborateurId, dateConsultation]); const getAuthHeaders = () => { const token = localStorage.getItem('token'); return token ? { Authorization: `Bearer ${token}` } : {}; }; + const chargerPrevisionnel = useCallback(async () => { + if (!selectedCollaborateurId || !dateConsultation) return; + + setLoadingPrevisionnel(true); + try { + const params = new URLSearchParams({ + userid: selectedCollaborateurId.toString(), + dateDebut: dateConsultation, + joursCP: '0', + joursRTT: '0' + }); + const response = await fetch(`/api/soldes-previsionnel?${params}`, { + headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' } + }); + if (!response.ok) throw new Error(`Erreur ${response.status}`); + const data = await response.json(); + if (data.success && data.previsionnel) { + setPrevisionnel(data.previsionnel); + } else { + setPrevisionnel(null); + } + } catch (error) { + console.error('Erreur prévisionnel:', error); + setPrevisionnel(null); + } finally { + setLoadingPrevisionnel(false); + } + }, [selectedCollaborateurId, dateConsultation]); + const chargerCompteurs = async () => { setLoading(true); try { - const response = await fetch(`/api/getAllDetailedCounters?ts=${Date.now()}`); - + const response = await fetch(`/api/getAllDetailedCounters?ts=${Date.now()}`, { + headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' } + }); if (!response.ok) { - throw new Error('Erreur lors du chargement'); + const errorText = await response.text(); + throw new Error(`Erreur ${response.status}: ${errorText}`); } - const data = await response.json(); setCompteurs(data); } catch (error: any) { - console.error('Erreur:', error); toast.error(error.message || "Erreur lors du chargement des compteurs"); } finally { setLoading(false); @@ -88,16 +635,28 @@ const GestionCompteurs = () => { const ouvrirEditionCompteur = (compteur: Compteur) => { setSelectedCompteur(compteur); - setEditForm({ - total: compteur.total, - solde: compteur.solde - }); + setEditForm({ total: compteur.total, solde: compteur.solde }); setShowEditDialog(true); }; + const ouvrirConsultationSoldes = (compteur: Compteur) => { + setSelectedCollaborateurId(compteur.collaborateurId); + setSelectedEmployeName(compteur.employe); + setDateConsultation(new Date().toISOString().split('T')[0]); + setPrevisionnel(null); + setShowSoldesDateDialog(true); + }; + + const fermerConsultationSoldes = () => { + setShowSoldesDateDialog(false); + setPrevisionnel(null); + setSelectedCollaborateurId(null); + }; + const determinerCodeType = (typeConge: string) => { if (!typeConge) return 'CP'; const t = typeConge.toLowerCase(); + if (t.includes('récup') || t.includes('recup')) return 'Récupération'; if (t.includes('rt') || t.includes('rtt')) return 'RTT'; if (t.includes('cong') || t.includes('cp')) return 'CP'; return 'CP'; @@ -105,12 +664,10 @@ const GestionCompteurs = () => { const modifierCompteur = async () => { if (!selectedCompteur) return; - if (editForm.solde > editForm.total) { toast.error("Le solde ne peut pas être supérieur au total"); return; } - setLoading(true); try { const payload = { @@ -121,111 +678,43 @@ const GestionCompteurs = () => { nouveauSolde: parseFloat((editForm.solde || 0).toString()), source: 'RH_UI' }; - - console.log('✏️ Envoi modification compteur V2:', payload); - const response = await fetch('/api/v2/compteurs/update', { method: 'PUT', - headers: { - ...getAuthHeaders(), - 'Content-Type': 'application/json' - }, + headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); - const data = await response.json().catch(() => ({})); - - if (!response.ok) { - throw new Error(data.message || 'Erreur lors de la mise à jour V2'); - } - - toast.success("Compteur modifié (V2)"); + if (!response.ok) throw new Error(data.message || 'Erreur lors de la mise à jour V2'); + toast.success("Compteur modifié"); setShowEditDialog(false); setSelectedCompteur(null); await chargerCompteurs(); } catch (error: any) { - console.error('❌ Erreur modifierCompteur V2:', error); - toast.error(error.message || "Erreur lors de la modification du compteur (V2)"); - } finally { - setLoading(false); - } - }; - - const initialiserTous = async () => { - setLoading(true); - try { - console.log('🔄 Initialisation manuelle V2 pour tous:', initForm); - - const response = await fetch('/api/v2/compteurs/initialiser-tous-manuel', { - method: 'POST', - headers: { - ...getAuthHeaders(), - 'Content-Type': 'application/json' - }, - body: JSON.stringify(initForm) - }); - - const data = await response.json().catch(() => ({})); - - if (!response.ok) { - throw new Error(data.message || 'Erreur initialisation V2'); - } - - const apprentisMessage = data.apprentisTraites > 0 - ? ` (${data.apprentisTraites} apprentis : CP uniquement)` - : ''; - - toast.success(`Compteurs initialisés (V2) pour ${data.totalEmployes || 'tous'} employés${apprentisMessage}`, { - description: `${data.compteursCrees || 0} créés, ${data.compteursMAJ || 0} mis à jour`, - duration: 5000 - }); - - setShowInitDialog(false); - await chargerCompteurs(); - } catch (error: any) { - console.error('❌ Erreur initialisation V2:', error); - toast.error(error.message || "Erreur lors de l'initialisation (V2)"); + toast.error(error.message || "Erreur lors de la modification du compteur"); } finally { setLoading(false); } }; const compteursFiltres = compteurs.filter(c => { - const matchRecherche = (c.employe?.toLowerCase() || '').includes(recherche.toLowerCase()) || + const matchRecherche = + (c.employe?.toLowerCase() || '').includes(recherche.toLowerCase()) || (c.email?.toLowerCase() || '').includes(recherche.toLowerCase()); const matchCampus = filtreCampus === 'all' || (c.campus || 'Sans campus') === filtreCampus; const matchService = filtreService === 'all' || c.service === filtreService; + const matchSociete = filtreSociete === 'all' || (c.societe || 'Sans société') === filtreSociete; const matchType = filtreType === 'all' || c.typeConge === filtreType; const matchAnnee = filtreAnnee === 'all' || c.annee?.toString() === filtreAnnee; - return matchRecherche && matchCampus && matchService && matchType && matchAnnee; + return matchRecherche && matchCampus && matchService && matchSociete && matchType && matchAnnee; }).sort((a, b) => { if (a.annee !== b.annee) return b.annee - a.annee; if (a.employe !== b.employe) return (a.employe || '').localeCompare(b.employe || ''); return (a.typeConge || '').localeCompare(b.typeConge || ''); }); - const getStatutBadge = (compteur: Compteur) => { - const pourcentage = compteur.total > 0 ? (compteur.solde / compteur.total) * 100 : 0; - - if (compteur.solde <= 0) { - return Épuisé; - } else if (pourcentage <= 20) { - return - Critique - ; - } else if (pourcentage <= 50) { - return - Attention - ; - } else { - return - Disponible - ; - } - }; - const campus = [...new Set(compteurs.map(c => c.campus || 'Sans campus'))].sort(); const services = [...new Set(compteurs.map(c => c.service))].sort(); + const societes = [...new Set(compteurs.map(c => c.societe || 'Sans société'))].sort(); const typesConge = [...new Set(compteurs.map(c => c.typeConge))].sort(); const annees = [...new Set(compteurs.map(c => c.annee))].sort((a, b) => b - a); @@ -233,190 +722,68 @@ const GestionCompteurs = () => {
-
-
- -
-
- -
-

Gestion des compteurs

+
+ +
+
+
+

Gestion des compteurs

- - - - - - - - Initialisation manuelle des compteurs - - Définir les valeurs pour l'année en cours et l'année précédente (demi-journées acceptées : ex. 25.5) - - - -
-
- -
-

Information

-

- Les apprentis recevront uniquement des Congés Payés. - Les RTT ne seront pas créés pour eux. -

-
-
- -
-

Année {initForm.anneeActuelle}

-
-
- - setInitForm({ ...initForm, cpActuel: parseFloat(e.target.value) || 0 })} - min={0} - /> -

Pour tous les collaborateurs

-
-
- - setInitForm({ ...initForm, rttActuel: parseFloat(e.target.value) || 0 })} - min={0} - /> -

Sauf apprentis

-
-
-
- -
-

Année {initForm.anneePrecedente} (N-1)

-
-
- - setInitForm({ ...initForm, cpPrecedent: parseFloat(e.target.value) || 0 })} - min={0} - /> -
-
- - setInitForm({ ...initForm, rttPrecedent: parseFloat(e.target.value) || 0 })} - min={0} - /> -
-
-
- -
- -
-

Attention

-

- Cette opération va initialiser ou mettre à jour les compteurs pour TOUS les collaborateurs actifs. -

-
-
-
- - - - - -
-
+ + {/* FILTRES */} -
- {/* RECHERCHE */} +
- setRecherche(e.target.value)} - className="pl-10" - /> + setRecherche(e.target.value)} className="pl-10" />
- - {/* FILTRE CAMPUS */} + - - {/* FILTRE SERVICE */} - - {/* FILTRE TYPE */} - - {/* FILTRE ANNÉE */} setDateConsultation(e.target.value)} + /> +

+ Sélectionnez une date passée pour voir les soldes du snapshot, ou une date future pour le prévisionnel d'acquisition +

+
+ + {selectedCollaborateurId && dateConsultation && ( + + )} + + {selectedCollaborateurId && dateConsultation && + dateConsultation <= new Date().toISOString().split('T')[0] && ( + + ) + } +
+ + + + + + + + {/* DIALOG MODIFICATION */} - Modifier le compteur (V2) + Modifier le compteur {selectedCompteur && `${selectedCompteur.employe} - ${selectedCompteur.typeConge} ${selectedCompteur.annee}`} -
- setEditForm({ ...editForm, total: parseFloat(e.target.value) || 0 })} - min={0} - /> + min={0} />

Accepte les demi-journées (ex: 25.5)

-
- setEditForm({ ...editForm, solde: parseFloat(e.target.value) || 0 })} - min={0} - max={editForm.total} - /> -

- Le solde ne peut pas dépasser le total -

+ min={0} max={editForm.total} /> +

Le solde ne peut pas dépasser le total

- {selectedCompteur && (

Informations actuelles

@@ -587,16 +1000,10 @@ const GestionCompteurs = () => {
)}
- - - + @@ -606,4 +1013,93 @@ const GestionCompteurs = () => { ); }; -export default GestionCompteurs; +// ===================================================== +// SNAPSHOT À UNE DATE +// ===================================================== + +interface SnapshotDateDisplayProps { + collaborateurId: number; + date: string; +} + +const SnapshotDateDisplay = ({ collaborateurId, date }: SnapshotDateDisplayProps) => { + const [soldes, setSoldes] = useState(null); + const [loading, setLoading] = useState(false); + + const getAuthHeaders = () => { + const token = localStorage.getItem('token'); + return token ? { Authorization: `Bearer ${token}` } : {}; + }; + + useEffect(() => { + if (collaborateurId && date) charger(); + }, [collaborateurId, date]); + + const charger = async () => { + setLoading(true); + try { + const response = await fetch( + `/api/snapshot/soldes-a-date/${collaborateurId}?date=${date}`, + { headers: getAuthHeaders() } + ); + if (!response.ok) throw new Error(`Erreur ${response.status}`); + const data = await response.json(); + setSoldes(data); + } catch (error: any) { + console.error('Erreur snapshot à la date:', error); + setSoldes(null); + } finally { + setLoading(false); + } + }; + + if (loading) return ( +
+
+ + Chargement du snapshot... +
+
+ ); + + if (!soldes || soldes.compteurs.length === 0) return ( +
+

+ + Aucun snapshot enregistré pour cette date (les snapshots sont créés chaque soir à 23h55) +

+
+ ); + + const colorMap: Record = { + 'Congé payé': '#7C3AED', 'CP': '#7C3AED', 'RTT': '#2563EB', 'Récupération': '#16A34A', + }; + + return ( +
+
+

+ 📸 Snapshot enregistré le {new Date(soldes.dateSnapshot).toLocaleDateString('fr-FR')} +

+
+
+ {soldes.compteurs.map((c, idx) => { + const couleur = colorMap[c.typeConge] || '#6B7280'; + return ( +
+
+
+

{c.typeConge}

+
+

{c.solde.toFixed(1)}j

+

sur {c.total.toFixed(1)}j

+ {c.isAnticipe &&

⚠️ Prévisionnel

} +
+ ); + })} +
+
+ ); +}; + +export default GestionCompteurs; \ No newline at end of file diff --git a/src/pages/GestionRecuperation.tsx b/src/pages/GestionRecuperation.tsx index 2a9d2dc..52f5bda 100644 --- a/src/pages/GestionRecuperation.tsx +++ b/src/pages/GestionRecuperation.tsx @@ -1,31 +1,52 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Badge } from "@/components/ui/badge"; -import { ArrowLeft, Plus, Minus, Search, Clock } from "lucide-react"; +import { ArrowLeft, Plus, Search, Clock, CalendarDays, X } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; - +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; interface Employe { id: number; + nom: string; + prenom: string; nomComplet: string; email: string; + societe: string; + societeId: number | null; + campus: string; + campusId: number | null; service: string; - serviceId?: number; - campus?: string; - campusId?: number; - role: string; + serviceId: number | null; + poste: string; + typeContrat: string; Actif: number; } -interface CompteurRecup { +interface Societe { + id: number; + nom: string; +} + +interface Campus { + id: number; + nom: string; +} + +interface Service { + id: number; + nom: string; +} + +interface Compteur { id: number; collaborateurId: number; employe: string; @@ -35,296 +56,302 @@ interface CompteurRecup { total: number; solde: number; consomme: number; + typeConge: string; +} + +interface JPOSF { + Id: number; + CollaborateurADId: number; + employe: string; + email: string; + service: string; + totalEvenements: number; + solde: number; } const GestionRecuperation = () => { const navigate = useNavigate(); + const [employes, setEmployes] = useState([]); - const [compteursRecup, setCompteursRecup] = useState([]); - const [selectedEmployeId, setSelectedEmployeId] = useState(""); - const [nombreJours, setNombreJours] = useState("1"); + const [societes, setSocietes] = useState([]); + const [campusList, setCampusList] = useState([]); + const [services, setServices] = useState([]); + const [jposfListe, setJposfListe] = useState([]); + const [compteursRecup, setCompteursRecup] = useState([]); + const [employesSelectionnes, setEmployesSelectionnes] = useState([]); + const [employeIdsSelectionnes, setEmployeIdsSelectionnes] = useState([]); const [commentaire, setCommentaire] = useState(""); const [loading, setLoading] = useState(false); - const [recherche, setRecherche] = useState(""); - const [showAddDialog, setShowAddDialog] = useState(false); - const [showRemoveDialog, setShowRemoveDialog] = useState(false); - const [selectedCompteur, setSelectedCompteur] = useState(null); const [selectedDate, setSelectedDate] = useState(""); const [periodeJournee, setPeriodeJournee] = useState(""); - // ⭐ FILTRES POUR LA POP-UP - const [selectedCampus, setSelectedCampus] = useState(""); - const [selectedService, setSelectedService] = useState(""); + // Filtres globaux (haut de page) + const [searchQuery, setSearchQuery] = useState(""); + const [filterSociete, setFilterSociete] = useState("all"); + const [filterCampus, setFilterCampus] = useState("all"); + const [filterService, setFilterService] = useState("all"); + const [filterContrat, setFilterContrat] = useState("all"); + + // Filtres du formulaire de saisie + const [formFilterSociete, setFormFilterSociete] = useState("all"); + const [formFilterCampus, setFormFilterCampus] = useState("all"); + const [formFilterService, setFormFilterService] = useState("all"); + const [formSearchQuery, setFormSearchQuery] = useState(""); + + const [dataLoading, setDataLoading] = useState(true); + const [showSaisieDialog, setShowSaisieDialog] = useState(false); const currentYear = new Date().getFullYear(); - // ⭐ FONCTION HELPER POUR FORMATER LES NOMBRES const formatNumber = (value: any): string => { - const num = parseFloat(value?.toString() || '0'); - return isNaN(num) ? '0.0' : num.toFixed(1); + const num = parseFloat(value?.toString() || "0"); + return isNaN(num) ? "0.0" : num.toFixed(1); }; - // ⭐ FONCTION HELPER POUR PARSER LES NOMBRES const parseNumber = (value: any): number => { - const num = parseFloat(value?.toString() || '0'); + const num = parseFloat(value?.toString() || "0"); return isNaN(num) ? 0 : num; }; useEffect(() => { - chargerEmployes(); - chargerCompteursRecup(); + chargerDonnees(); }, []); - const chargerEmployes = async () => { + const chargerDonnees = async () => { + setDataLoading(true); try { const token = localStorage.getItem('token'); - const response = await fetch('/api/employes', { - headers: { 'Authorization': `Bearer ${token}` } - }); - const data = await response.json(); + const headers = { 'Authorization': `Bearer ${token}` }; - const employesAvecCampus = await Promise.all( - data - .filter((e: any) => e.Actif === 1 || e.Actif === null) - .map(async (e: any) => { - if (!e.campus && e.serviceId) { - try { - const serviceResp = await fetch( - `/api/services/${e.serviceId}`, - { headers: { 'Authorization': `Bearer ${token}` } } - ); - const serviceData = await serviceResp.json(); - return { - ...e, - campus: serviceData.campus || 'Sans campus' - }; - } catch { - return { ...e, campus: 'Sans campus' }; - } - } - return { ...e, campus: e.campus || 'Sans campus' }; + // Charger les collaborateurs avec toutes leurs infos + const collabResponse = await fetch('/api/collaborateurs/details', { headers }); + if (collabResponse.ok) { + const collabData = await collabResponse.json(); + setEmployes(collabData.map((e: any) => ({ + ...e, + nomComplet: `${e.prenom} ${e.nom}`.trim() + }))); + } + + // Charger les sociétés + const societesResponse = await fetch('/api/societes', { headers }); + if (societesResponse.ok) { + const societesData = await societesResponse.json(); + setSocietes(societesData); + } + + // Charger les campus + const campusResponse = await fetch('/api/campus', { headers }); + if (campusResponse.ok) { + const campusData = await campusResponse.json(); + setCampusList(campusData); + } + + // Charger les services + const servicesResponse = await fetch('/api/services', { headers }); + if (servicesResponse.ok) { + const servicesData = await servicesResponse.json(); + setServices(servicesData); + } + + // Charger JPOSF + const jposfResponse = await fetch('/api/jposf', { headers }); + if (jposfResponse.ok) { + const jposfData = await jposfResponse.json(); + setJposfListe(jposfData); + } + + // Charger compteurs + const compteursResponse = await fetch('/api/compteurs', { headers }); + if (compteursResponse.ok) { + const compteursData = await compteursResponse.json(); + const recup = compteursData + .filter((c: any) => { + const typeConge = c.typeConge || c.TypeConge; + const annee = c.annee || c.Annee || 0; + return typeConge === "Récupération" && annee === currentYear; }) - ); + .map((c: any) => ({ + ...c, + total: parseNumber(c.total || c.Total), + solde: parseNumber(c.solde || c.Solde), + consomme: parseNumber(c.consomme || c.Consomme) || (parseNumber(c.total || c.Total) - parseNumber(c.solde || c.Solde)) + })); + setCompteursRecup(recup); + } - setEmployes(employesAvecCampus); } catch (error) { - toast.error("Erreur lors du chargement des collaborateurs"); + console.error('Erreur:', error); + toast.error("Erreur lors du chargement des données"); + } finally { + setDataLoading(false); } }; - const chargerCompteursRecup = async () => { - try { - const token = localStorage.getItem('token'); - const response = await fetch('/api/compteurs', { - headers: { 'Authorization': `Bearer ${token}` } - }); - const data = await response.json(); + const handleEmployeChange = (value: string) => { + const employeId = parseInt(value); + const employe = employes.find(e => e.id === employeId); + if (!employe) return; - const compteursRecupFiltres = data - .filter((c: any) => c.typeConge === 'Récupération' && c.annee === currentYear) - .map((c: any) => ({ - ...c, - total: parseNumber(c.total), - solde: parseNumber(c.solde), - consomme: parseNumber(c.consomme) - })); - - setCompteursRecup(compteursRecupFiltres); - } catch (error) { - toast.error("Erreur lors du chargement des compteurs"); + if (employeIdsSelectionnes.includes(employeId)) { + setEmployesSelectionnes(prev => prev.filter(e => e.id !== employeId)); + setEmployeIdsSelectionnes(prev => prev.filter(id => id !== employeId)); + } else { + setEmployesSelectionnes(prev => [...prev, employe]); + setEmployeIdsSelectionnes(prev => [...prev, employeId]); } }; - const ajouterRecuperation = async () => { - if (!selectedEmployeId || !selectedDate || !periodeJournee) { + const deselectEmploye = (employeId: number) => { + setEmployesSelectionnes(prev => prev.filter(e => e.id !== employeId)); + setEmployeIdsSelectionnes(prev => prev.filter(id => id !== employeId)); + }; + + const saisirJPOSF = async () => { + if (employesSelectionnes.length === 0 || !selectedDate || !periodeJournee) { toast.error("Veuillez remplir tous les champs obligatoires"); return; } - const dateObj = new Date(selectedDate + 'T00:00:00'); + const dateObj = new Date(selectedDate + "T00:00:00"); const dayOfWeek = dateObj.getDay(); if (dayOfWeek !== 6 && dayOfWeek !== 0) { - const dayNames = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi']; + const dayNames = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"]; const confirmer = window.confirm( - `⚠️ ATTENTION\n\n` + - `Le jour sélectionné est un ${dayNames[dayOfWeek]}, pas un samedi/dimanche.\n\n` + - `Les récupérations sont normalement réservées aux week-ends travaillés.\n\n` + - `Êtes-vous sûr de vouloir continuer ?` + `⚠️ ATTENTION : Le jour sélectionné est un ${dayNames[dayOfWeek]}, pas un samedi/dimanche.\n\nLes JPOSF sont normalement réservés aux week-ends travaillés.\n\nÊtes-vous sûr de vouloir continuer pour ${employesSelectionnes.length} collaborateur${employesSelectionnes.length > 1 ? 's' : ''} ?` ); - - if (!confirmer) { - return; - } + if (!confirmer) return; } setLoading(true); + let successes = 0; + let errors = 0; + const token = localStorage.getItem('token'); + try { - const token = localStorage.getItem('token'); + for (const employe of employesSelectionnes) { + try { + console.log(`📤 Envoi JPOSF pour ${employe.nomComplet}...`); - console.log('📤 Envoi récupération:', { - collaborateur_id: parseInt(selectedEmployeId), - date: selectedDate, - periode_journee: periodeJournee, - commentaire: commentaire.trim() || undefined - }); - - const response = await fetch('/api/compteurs/saisir-recup-collaborateur', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - collaborateur_id: parseInt(selectedEmployeId), - date: selectedDate, - periode_journee: periodeJournee, - commentaire: commentaire.trim() || undefined - }) - }); - - if (!response.ok) { - if (response.status === 403) { - toast.error("Accès non autorisé", { - description: "Seuls les administrateurs et RH peuvent gérer les récupérations" + const response = await fetch('/api/compteurs/saisir-jposf', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + collaborateur_id: employe.id, + date: selectedDate, + periode_journee: periodeJournee, + commentaire: commentaire.trim() || undefined + }) }); - return; + + const data = await response.json(); + console.log(`📥 Réponse pour ${employe.nomComplet}:`, data); + + if (response.ok && data.success) { + successes++; + console.log(`✅ Succès pour ${employe.nomComplet}`); + } else { + console.error(`❌ Erreur pour ${employe.nomComplet}:`, data); + errors++; + } + + } catch (error: any) { + console.error(`❌ Exception pour ${employe.nomComplet}:`, error); + errors++; } - - const errorData = await response.json().catch(() => ({ message: 'Erreur serveur' })); - throw new Error(errorData.message || `Erreur HTTP ${response.status}`); } - const data = await response.json(); - - if (data.success) { - toast.success(`✅ Récupération enregistrée pour ${data.collaborateur}`, { - description: `${data.date} (${data.periode}) : +${data.jours_ajoutes}j` + if (successes > 0 && errors === 0) { + toast.success(`✅ ${successes} JPOSF enregistrés avec succès`, { + description: `Date: ${new Date(selectedDate).toLocaleDateString('fr-FR')} - ${periodeJournee}` }); - setShowAddDialog(false); - resetForm(); - await chargerCompteursRecup(); + } else if (successes > 0 && errors > 0) { + toast.warning(`⚠️ ${successes} réussis, ${errors} échecs`); } else { - toast.error(data.message || 'Erreur lors de l\'enregistrement', { - description: data.details || "Vérifiez les données saisies", - duration: 5000 - }); - } - } catch (error: any) { - console.error('❌ Erreur ajout récupération:', error); - toast.error("Erreur lors de l'ajout", { - description: error.message || "Une erreur est survenue" - }); - } finally { - setLoading(false); - } - }; - - const retirerRecuperation = async () => { - if (!selectedCompteur || !nombreJours || parseFloat(nombreJours) <= 0) { - toast.error("Veuillez remplir tous les champs obligatoires"); - return; - } - - if (parseFloat(nombreJours) > selectedCompteur.solde) { - toast.error("Le nombre de jours à retirer ne peut pas dépasser le solde disponible"); - return; - } - - setLoading(true); - try { - const token = localStorage.getItem('token'); - const response = await fetch('/api/compteurs/retirer-recup', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - collaborateurId: selectedCompteur.collaborateurId, - nombreJours: parseFloat(nombreJours), - commentaire: commentaire.trim() || undefined - }) - }); - - if (response.status === 403) { - toast.error("Accès non autorisé", { - description: "Seuls les administrateurs et RH peuvent gérer les récupérations" - }); - setLoading(false); - return; + toast.error("❌ Échec de l'enregistrement"); } - const data = await response.json(); - - if (data.success) { - toast.success(`${nombreJours}j de récupération retirés pour ${data.collaborateur}`, { - description: commentaire ? `Motif: ${commentaire}` : undefined - }); - setShowRemoveDialog(false); + if (successes > 0) { + setShowSaisieDialog(false); resetForm(); - chargerCompteursRecup(); - } else { - throw new Error(data.message || 'Erreur serveur'); + await chargerDonnees(); } + } catch (error: any) { - toast.error(error.message || "Erreur lors du retrait"); + console.error("Erreur globale:", error); + toast.error("Erreur lors de l'enregistrement"); } finally { setLoading(false); } }; const resetForm = () => { - setSelectedEmployeId(""); + setEmployesSelectionnes([]); + setEmployeIdsSelectionnes([]); setSelectedDate(""); setPeriodeJournee(""); setCommentaire(""); - setSelectedCompteur(null); - setSelectedCampus(""); - setSelectedService(""); - setNombreJours("1"); + setFormFilterSociete("all"); + setFormFilterCampus("all"); + setFormFilterService("all"); + setFormSearchQuery(""); }; - const ouvrirDialogAjout = () => { - resetForm(); - setShowAddDialog(true); - }; + // ============ FILTRAGE GLOBAL (haut de page) ============ + const employesFiltresGlobal = employes.filter(e => { + const matchSearch = !searchQuery || + e.nomComplet.toLowerCase().includes(searchQuery.toLowerCase()) || + e.email?.toLowerCase().includes(searchQuery.toLowerCase()) || + e.service?.toLowerCase().includes(searchQuery.toLowerCase()); - const ouvrirDialogRetrait = (compteur: CompteurRecup) => { - resetForm(); - setSelectedCompteur(compteur); - setShowRemoveDialog(true); - }; + const matchSociete = filterSociete === "all" || e.societe === filterSociete; + const matchCampus = filterCampus === "all" || e.campus === filterCampus; + const matchService = filterService === "all" || e.service === filterService; + const matchContrat = filterContrat === "all" || e.typeContrat === filterContrat; - const employesFiltres = employes.filter(e => - e.nomComplet.toLowerCase().includes(recherche.toLowerCase()) || - e.email.toLowerCase().includes(recherche.toLowerCase()) - ); + return matchSearch && matchSociete && matchCampus && matchService && matchContrat; + }); - const compteursFiltres = compteursRecup.filter(c => - c.employe.toLowerCase().includes(recherche.toLowerCase()) || - c.email.toLowerCase().includes(recherche.toLowerCase()) - ); + // ============ FILTRAGE FORMULAIRE (dialog) ============ + const employesFiltresForm = employes.filter(e => { + const matchSearch = !formSearchQuery || + e.nomComplet.toLowerCase().includes(formSearchQuery.toLowerCase()) || + e.email?.toLowerCase().includes(formSearchQuery.toLowerCase()); - const campusDisponibles = Array.from(new Set(employes.map(e => e.campus || 'Sans campus'))) - .sort(); + const matchSociete = formFilterSociete === "all" || e.societe === formFilterSociete; + const matchCampus = formFilterCampus === "all" || e.campus === formFilterCampus; + const matchService = formFilterService === "all" || e.service === formFilterService; - const servicesDisponibles = selectedCampus - ? Array.from(new Set( - employes - .filter(e => (e.campus || 'Sans campus') === selectedCampus) - .map(e => e.service || 'Sans service') - )).sort() - : Array.from(new Set(employes.map(e => e.service || 'Sans service'))).sort(); - - const employesFiltre = employes.filter(e => { - if (selectedCampus && (e.campus || 'Sans campus') !== selectedCampus) return false; - if (selectedService && (e.service || 'Sans service') !== selectedService) return false; - return true; + return matchSearch && matchSociete && matchCampus && matchService; }).sort((a, b) => a.nomComplet.localeCompare(b.nomComplet)); - const totalJoursDisponibles = compteursRecup.reduce((sum, c) => sum + parseNumber(c.solde), 0); - const totalJoursConsommes = compteursRecup.reduce((sum, c) => sum + parseNumber(c.consomme), 0); + // Listes uniques pour les filtres globaux + const societesUniques = [...new Set(employes.map(e => e.societe).filter(Boolean))].sort(); + const campusUniques = [...new Set(employes.map(e => e.campus).filter(Boolean))].sort(); + const servicesUniques = [...new Set(employes.map(e => e.service).filter(Boolean))].sort(); + + const jposfFiltres = jposfListe.filter(j => + employesFiltresGlobal.some(e => `${e.prenom} ${e.nom}`.toLowerCase().includes(j.employe.toLowerCase())) + ); + + const recupFiltres = compteursRecup.filter(c => + employesFiltresGlobal.some(e => `${e.prenom} ${e.nom}`.toLowerCase().includes(c.employe.toLowerCase())) + ); + + if (dataLoading) { + return ( +
+
+
+

Chargement des données...

+
+
+ ); + } return (
@@ -332,290 +359,415 @@ const GestionRecuperation = () => {
-
- +
-

Gestion des récupérations

-

Samedis travaillés

+

JPOSF & Récupérations

+

+ Gestion des samedis travaillés · {employes.length} collaborateurs +

- -
- -
+
- {/* Statistiques */} -
- - -
-
-

Compteurs actifs

-

{compteursRecup.length}

-
-
- -
-
-
-
- - - -
-
-

Total jours disponibles

-

- {formatNumber(totalJoursDisponibles)} -

-
-
- -
-
-
-
- - - -
-
-

Total jours consommés

-

- {formatNumber(totalJoursConsommes)} -

-
-
- -
-
-
-
-
- - {/* Recherche */} + {/* FILTRES GLOBAUX */} -
- - setRecherche(e.target.value)} - className="pl-10" - /> +
+ {/* Recherche collaborateur */} +
+ + setSearchQuery(e.target.value)} + className="pl-10 transition-smooth" + /> +
+ + {/* Filtre Société */} + + + {/* Filtre Campus */} + + + {/* Filtre Service */} + + + {/* Filtre Type de contrat */} +
- {/* Liste des compteurs */} - - - Compteurs de récupération {currentYear} - - {compteursFiltres.length} compteur{compteursFiltres.length > 1 ? 's' : ''} affiché{compteursFiltres.length > 1 ? 's' : ''} - - - - {compteursFiltres.length > 0 ? ( -
- - - - Collaborateur - Service - Total accumulé - Consommé - Solde disponible - Actions - - - - {compteursFiltres.map((compteur) => ( - - -
-

{compteur.employe}

-

{compteur.email}

-
-
- {compteur.service} - - {formatNumber(compteur.total)}j - - - {formatNumber(compteur.consomme)}j - - - - {formatNumber(compteur.solde)}j - - - -
- - -
-
-
- ))} -
-
-
- ) : ( -
- -

Aucun compteur de récupération

-

- Ajoutez des jours de récupération pour créer un compteur -

-
- )} -
-
+ {/* Onglets */} + + + + + JPOSF saisis ({jposfFiltres.length}) + + + + Compteurs Récupération ({recupFiltres.length}) + + + + + + + JPOSF {currentYear} + + Journées saisies par le service RH (apparaissent dans le calendrier en violet) + + + + {jposfFiltres.length > 0 ? ( +
+ + + + Collaborateur + Service + Total événements + Jours accumulés + + + + {jposfFiltres.map((jposf) => ( + + +

{jposf.employe}

+
+ {jposf.service} + + {jposf.totalEvenements} + + + + {formatNumber(jposf.solde)}j + + +
+ ))} +
+
+
+ ) : ( +
+ +

Aucune JPOSF enregistré

+

+ Cliquez sur "Saisir une JPOSF" pour commencer +

+
+ )} +
+
+
+ + + + + Compteurs Récupération {currentYear} + + Soldes de récupération disponibles (incrémentés par les JPOSF, décrémentés par les demandes validées) + + + + {recupFiltres.length > 0 ? ( +
+ + + + Collaborateur + Service + Acquis + Consommé + Solde + + + + {recupFiltres.map((compteur) => ( + + +

{compteur.employe}

+
+ {compteur.service} + + {formatNumber(compteur.total)}j + + + {formatNumber(compteur.consomme)}j + + + + {formatNumber(compteur.solde)}j + + +
+ ))} +
+
+
+ ) : ( +
+ +

Aucun compteur de récupération

+

+ Les compteurs se créent automatiquement lors de la saisie de JPOSF +

+
+ )} +
+
+
+
- {/* Dialog Ajout */} - - + {/* Dialog Saisie JPOSF */} + + - Ajouter des jours de récupération + Saisir une JPOSF - Enregistrer des samedis travaillés + Enregistrer une Journée Portes Ouvertes
- {/* FILTRE CAMPUS */} -
- - + {/* FILTRES DU FORMULAIRE */} +
+

Filtres de recherche

+ + {/* Filtre Société */} +
+ + +
+ + {/* Filtre Campus */} +
+ + +
+ + {/* Filtre Service */} +
+ + +
+ + {/* Recherche */} +
+ +
+ + setFormSearchQuery(e.target.value)} + className="pl-10" + /> +
+
- {/* FILTRE SERVICE */} + {/* SÉLECTION MULTIPLE D'EMPLOYÉS AVEC BADGES */}
- - -
+ - {/* SÉLECTION EMPLOYÉ */} -
- - - + 0 + ? `${employesSelectionnes.length} collaborateurs sélectionnés` + : "Sélectionner des collaborateurs..." + } /> - {employesFiltre.length === 0 ? ( + {employesFiltresForm.length === 0 ? (
- Aucun collaborateur trouvé + Aucun collaborateur trouvé
+ Ajustez les filtres ci-dessus
) : ( - employesFiltre.map(employe => ( + employesFiltresForm.map((employe) => ( - {employe.nomComplet} - - ({employe.service}) - +
+
+ {employeIdsSelectionnes.includes(employe.id) && ( + + )} + {employe.nomComplet} +
+
+ {employe.societe} + · + {employe.campus} + · + {employe.service} +
+
)) )}

- {employesFiltre.length} collaborateur{employesFiltre.length > 1 ? 's' : ''} disponible{employesFiltre.length > 1 ? 's' : ''} + {employesSelectionnes.length > 0 + ? `${employesSelectionnes.length} collaborateurs sélectionnés - Une demande sera créée pour chacun` + : `${employesFiltresForm.length} collaborateur${employesFiltresForm.length > 1 ? 's' : ''} disponible${employesFiltresForm.length > 1 ? 's' : ''}` + }

+ {/* ALERTE MULTI-SÉLECTION */} + {employesSelectionnes.length > 1 && ( + + + Sélection multiple : Une JPOSF identique sera enregistré pour chaque collaborateur ({employesSelectionnes.length} JPOSF au total). + + + )} + {/* DATE */}
- + setSelectedDate(e.target.value)} /> -

- ℹ️ Sélectionnez un samedi. La validation se fera lors de l'enregistrement. -

+

Privilégiez un samedi ou dimanche

{selectedDate && (() => { - const date = new Date(selectedDate + 'T00:00:00'); + const date = new Date(selectedDate + "T00:00:00"); const dayOfWeek = date.getDay(); - const dayNames = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi']; + const dayNames = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"]; return ( -

- {dayOfWeek === 6 ? '✅' : '⚠️'} Jour sélectionné : {dayNames[dayOfWeek]} +

+ {dayOfWeek === 6 || dayOfWeek === 0 ? '✓' : '⚠️'} Jour sélectionné : {dayNames[dayOfWeek]}

); })()} @@ -623,7 +775,7 @@ const GestionRecuperation = () => { {/* PÉRIODE */}
- + {periodeJournee && (

- {periodeJournee === 'Journée entière' ? '1 jour' : '0.5 jour'} sera ajouté + ➡️ {periodeJournee === "Journée entière" ? "1 jour" : "0.5 jour"} sera ajouté au compteur Récupération{employesSelectionnes.length > 1 ? ' de chaque collaborateur' : ''}

)}
+ {/* COMMENTAIRE */}