console.log('🚀 1. Démarrage du script...'); const express = require('express'); const cors = require('cors'); const mysql = require('mysql2/promise'); const jwt = require('jsonwebtoken'); const PDFDocument = require('pdfkit'); const { ConfidentialClientApplication } = require('@azure/msal-node'); console.log('✅ 2. Modules de base chargés'); require('dotenv').config(); console.log('✅ 3. Dotenv chargé'); // HANDLERS D'ERREURS process.on('uncaughtException', (error) => { console.error('\n❌❌❌ ERREUR NON CAPTURÉE ❌❌❌'); console.error(error); console.error(error.stack); }); process.on('unhandledRejection', (reason, promise) => { console.error('\n❌❌❌ PROMESSE REJETÉE ❌❌❌'); console.error('Raison:', reason); console.error('Promise:', promise); }); process.on('exit', (code) => { console.log(`\n⚠️⚠️⚠️ PROCESSUS EN COURS DE TERMINAISON - CODE: ${code} ⚠️⚠️⚠️\n`); }); console.log('✅ 4. Handlers d\'erreurs installés'); // ⭐ IMPORTS WEBHOOKS // ⭐ IMPORTS WEBHOOKS try { console.log('🔄 5. Chargement WebhookManager...'); const WebhookManager = require('./webhook-utils.js'); console.log(' Type de WebhookManager:', typeof WebhookManager); console.log('🔄 6. Chargement webhook-config...'); const { WEBHOOKS, EVENTS } = require('./webhook-config'); console.log('✅ 7. Webhooks chargés avec succès'); const app = express(); console.log('✅ 8. Express initialisé'); const PORT = process.env.PORT || 3001; console.log(`✅ 9. Port configuré: ${PORT}`); // ⭐ INITIALISER LE WEBHOOK MANAGER console.log('🔄 10. Initialisation WebhookManager...'); const webhookManager = new WebhookManager(WEBHOOKS.SECRET_KEY); console.log('✅ 11. WebhookManager créé'); // Middleware app.use(cors()); app.use(express.json()); console.log('✅ 12. Middlewares installés'); // Configuration MySQL 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 }; 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.error(' User:', dbConfig.user); console.error(' Database:', dbConfig.database); }); console.log('✅ 15. Pool MySQL créé'); // Configuration Microsoft O365 const msalConfig = { auth: { clientId: process.env.AZURE_CLIENT_ID, authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`, clientSecret: process.env.AZURE_CLIENT_SECRET } }; const cca = new ConfidentialClientApplication(msalConfig); async function getGraphToken() { try { console.log('🔑 Demande token Graph API...'); const params = new URLSearchParams({ grant_type: 'client_credentials', client_id: process.env.AZURE_CLIENT_ID, client_secret: process.env.AZURE_CLIENT_SECRET, scope: 'https://graph.microsoft.com/.default' }); const response = await fetch( `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/oauth2/v2.0/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() } ); console.log('📊 Réponse OAuth - Status:', response.status); if (!response.ok) { const errorText = await response.text(); console.error('❌ Erreur OAuth:', errorText); throw new Error(`OAuth Error: ${response.status}`); } const data = await response.json(); if (data.access_token) { console.log('✅ Token obtenu'); return data.access_token; } else { console.error('❌ Pas de token dans la réponse:', data); return null; } } catch (error) { console.error('❌ Erreur getGraphToken:', error.message); return null; } } async function sendMailGraph(accessToken, fromEmail, toEmail, subject, bodyHtml) { try { console.log('📤 Envoi email via Graph API...'); console.log(' From:', fromEmail); console.log(' To:', toEmail); console.log(' Subject:', subject); const response = await fetch( `https://graph.microsoft.com/v1.0/users/${fromEmail}/sendMail`, { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ message: { subject, body: { contentType: 'HTML', content: bodyHtml }, toRecipients: [{ emailAddress: { address: toEmail } }] }, saveToSentItems: false }) } ); console.log('📊 Réponse Graph API - Status:', response.status); if (!response.ok) { const errorText = await response.text(); console.error('❌ Erreur Graph API:', errorText); throw new Error(`Graph API Error: ${response.status} - ${errorText}`); } console.log('✅ Email envoyé avec succès'); return true; } catch (error) { console.error('❌ Erreur sendMailGraph:', error.message); return false; } } // Middleware d'authentification const authenticateToken = (req, res, next) => { const token = req.headers['authorization']?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'Token requis' }); jwt.verify(token, process.env.JWT_SECRET, (err, user) => { if (err) return res.status(403).json({ error: 'Token invalide' }); req.user = user; next(); }); }; // ================================================ // 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; let userId = userid; // ✅ Si token fourni, extraire l'ID utilisateur if (token && !userId) { try { const decoded = jwt.verify(token, process.env.JWT_SECRET); userId = decoded.id; console.log('🔓 Token JWT décodé, userId:', userId); } catch (error) { console.error('❌ Token invalide:', error.message); return res.status(401).json({ error: 'Token invalide' }); } } if (!userId) { console.error('❌ Ni userid ni token fourni'); return res.status(400).json({ error: 'userid ou token requis' }); } console.log('🔗 Nouvelle connexion SSE (RH):', userId); // Headers SSE critiques res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); // Flush pour établir la connexion res.flushHeaders(); const sendEvent = (data) => { try { res.write(`data: ${JSON.stringify(data)}\n\n`); } catch (error) { console.error('Erreur envoi SSE:', error); } }; const client = { id: userId, send: sendEvent }; sseClients.add(client); console.log(`📊 Clients SSE connectés: ${sseClients.size}`); // Envoyer un heartbeat initial sendEvent({ type: 'ping', message: 'Connexion établie', timestamp: new Date().toISOString() }); // Heartbeat toutes les 30 secondes const heartbeat = setInterval(() => { try { sendEvent({ type: 'ping', timestamp: new Date().toISOString() }); } catch (error) { clearInterval(heartbeat); } }, 30000); // Gérer la déconnexion req.on('close', () => { console.log('🔌 Déconnexion SSE (RH):', userId); clearInterval(heartbeat); sseClients.delete(client); console.log(`📊 Clients SSE connectés: ${sseClients.size}`); }); }); // 📢 FONCTION POUR NOTIFIER LES CLIENTS const notifyClients = (event, userId = null) => { console.log(`📢 Notification SSE: ${event.type}${userId ? ` pour ${userId}` : ''}`); sseClients.forEach(client => { if (userId && client.id !== userId) { return; } try { client.send(event); } catch (error) { console.error('❌ Erreur envoi event:', error); } }); }; // ================================================ // ROUTE WEBHOOK RECEIVER // ================================================ app.post('/webhook/receive', async (req, res) => { try { const signature = req.headers['x-webhook-signature']; const payload = req.body; console.log('📥 Webhook reçu du serveur RH:', payload.event); if (!webhookManager.verifySignature(payload, signature)) { console.error('❌ Signature webhook invalide'); return res.status(401).json({ error: 'Signature invalide' }); } const { event, data } = payload; console.log('✅ Signature valide ! Traitement...'); console.log(' Event:', event); console.log(' Data:', data); switch (event) { case EVENTS.DEMANDE_VALIDATED: console.log(`\n✅ === WEBHOOK DEMANDE_VALIDATED REÇU ===`); console.log(` Demande: ${data.demandeId}`); console.log(` Statut: ${data.statut}`); 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); console.log(` ✅ SSE envoyé au collaborateur ${data.collaborateurId}`); break; // 🆕 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, statut: 'Annulée', collaborateurId: data.collaborateurId, annulateurRole: data.annulateurRole, commentaire: data.commentaire, timestamp: new Date().toISOString() }); // Notifier spécifiquement le collaborateur concerné notifyClients({ type: 'demande-updated', demandeId: data.demandeId, nouveauStatut: 'Annulée', timestamp: new Date().toISOString() }, data.collaborateurId); console.log(` ✅ SSE envoyé - Demande ${data.demandeId} marquée comme annulée`); break; case EVENTS.COMPTEUR_UPDATED: console.log(`\n🔄 === WEBHOOK COMPTEUR_UPDATED REÇU ===`); console.log(` Collaborateur: ${data.collaborateurId}`); notifyClients({ type: 'compteur-updated', collaborateurId: data.collaborateurId, typeConge: data.typeConge, typeUpdate: data.typeUpdate, jours: data.jours, timestamp: new Date().toISOString() }, data.collaborateurId); console.log(` ✅ SSE envoyé`); break; case EVENTS.DEMANDE_UPDATED: console.log(`✏️ Demande ${data.demandeId} modifiée via RH`); notifyClients({ type: 'demande-updated-rh', demandeId: data.demandeId, timestamp: new Date().toISOString() }, data.collaborateurId); break; case EVENTS.DEMANDE_DELETED: console.log(`🗑️ Demande ${data.demandeId} supprimée via RH`); notifyClients({ type: 'demande-deleted-rh', demandeId: data.demandeId, timestamp: new Date().toISOString() }, data.collaborateurId); break; default: console.warn(`⚠️ Type d'événement webhook inconnu: ${event}`); } res.json({ success: true, message: 'Webhook traité' }); } catch (error) { console.error('❌ Erreur traitement webhook:', error); res.status(500).json({ error: error.message }); } }); // ================================================ // ROUTES D'AUTHENTIFICATION // ================================================ app.get('/users-dev', async (req, res) => { try { // On récupère juste l'essentiel pour le sélecteur const [users] = await pool.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); } 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'); const { accessToken } = req.body; if (!accessToken) { console.error('❌ No access token provided'); return res.status(400).json({ error: 'Token d\'accès manquant' }); } console.log('👤 Validating access token...'); let userInfo; try { const graphResponse = await fetch('https://graph.microsoft.com/v1.0/me', { headers: { 'Authorization': `Bearer ${accessToken}` } }); if (!graphResponse.ok) { throw new Error('Token invalide'); } userInfo = await graphResponse.json(); console.log('✅ Token validated, user:', userInfo.mail || userInfo.userPrincipalName); } catch (graphError) { console.error('❌ Graph API Error:', graphError); return res.status(401).json({ error: 'Token d\'accès invalide', details: graphError.message }); } 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] ); if (users.length === 0) { console.warn('⚠️ User not found or inactive:', userEmail); return res.status(404).json({ error: 'Utilisateur non trouvé ou compte désactivé', details: `Aucun utilisateur actif avec l'email ${userEmail}` }); } const user = users[0]; console.log('👤 User found:', user.email, 'Role:', user.role); if (!['RH', 'Admin', 'Validateur'].includes(user.role)) { console.warn('⚠️ Unauthorized role:', user.role); return res.status(403).json({ error: 'Accès non autorisé', details: `Le rôle '${user.role}' n'a pas accès à cette application` }); } const token = jwt.sign( { id: user.id, email: user.email, role: user.role }, process.env.JWT_SECRET, { expiresIn: '8h' } ); console.log('✅ Login successful for:', user.email); res.json({ token, user: { id: user.id, nom: user.nom, prenom: user.prenom, role: user.role } }); } catch (error) { console.error('❌ Unexpected error in /api/auth/login:', error); res.status(500).json({ error: 'Erreur serveur inattendue', details: error.message }); } }); // ================================================ // ROUTES DES DEMANDES DE CONGÉS // ================================================ app.get('/demandes', authenticateToken, async (req, res) => { 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 = []; if (statut) { query += ' AND dc.Statut = ?'; params.push(statut); } if (dateDebut) { query += ' AND dc.DateDebut >= ?'; params.push(dateDebut); } if (dateFin) { query += ' AND dc.DateFin <= ?'; params.push(dateFin); } if (service) { query += ' AND s.Id = ?'; params.push(service); } query += ' GROUP BY dc.Id ORDER BY dc.DateDemande DESC'; const [demandes] = await pool.query(query, params); res.json(demandes); } catch (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]); 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]); res.json({ ...demandes[0], typesConge: types }); } 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(); } }); app.put('/demandes/:id', authenticateToken, async (req, res) => { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const { id } = req.params; const { dateDebut, dateFin, typesConge, commentaire } = req.body; const [demande] = await conn.query( 'SELECT Statut, CollaborateurADId FROM DemandeConge WHERE Id = ?', [id] ); if (demande.length === 0) { await conn.rollback(); return res.status(404).json({ error: 'Demande non trouvée' }); } const ancienStatut = demande[0].Statut; const collaborateurId = demande[0].CollaborateurADId; const rolesAutorises = ['Admin', 'RH', 'Validateur']; const estAuteur = collaborateurId === req.user.id; const estRoleAutorise = rolesAutorises.includes(req.user.role); if (!estAuteur && !estRoleAutorise) { await conn.rollback(); return res.status(403).json({ error: 'Accès non autorisé', details: 'Seuls les Admin, RH, Validateurs ou l\'auteur peuvent modifier cette demande' }); } 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] ); 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] ); } } const totalJours = typesConge.reduce((sum, type) => sum + parseFloat(type.nombreJours), 0); const nouveauStatut = (ancienStatut === 'Validée' || ancienStatut === 'Refusée') ? 'En attente' : ancienStatut; 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] ); await conn.query('DELETE FROM DemandeCongeType WHERE DemandeCongeId = ?', [id]); for (const type of typesConge) { await conn.query( 'INSERT INTO DemandeCongeType (DemandeCongeId, TypeCongeId, NombreJours) VALUES (?, ?, ?)', [id, type.typeId, type.nombreJours] ); } 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}`; await conn.query( `INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId) VALUES (?, ?, ?, ?)`, [req.user.id, 'Modification demande', actionDetails, id] ); await conn.commit(); notifyClients({ type: 'demande-updated', demandeId: parseInt(id), action: 'modification', nouveauStatut: nouveauStatut, ancienStatut: ancienStatut, timestamp: new Date().toISOString() }, collaborateurId); notifyClients({ type: 'demande-list-updated', action: 'modification', demandeId: parseInt(id), timestamp: new Date().toISOString() }); try { await webhookManager.sendWebhook( WEBHOOKS.COLLABORATEURS_URL, EVENTS.DEMANDE_UPDATED, { demandeId: parseInt(id), collaborateurId: collaborateurId, nouveauStatut: nouveauStatut, ancienStatut: ancienStatut } ); if (nouveauStatut !== ancienStatut) { 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 }); console.log(`✅ Demande ${id} modifiée par ${req.user.role} - Ancien: ${ancienStatut}, Nouveau: ${nouveauStatut}`); } catch (error) { await conn.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(); try { await conn.beginTransaction(); const { id } = req.params; const [demande] = await conn.query( 'SELECT Statut, CollaborateurADId FROM DemandeConge WHERE Id = ?', [id] ); if (demande.length === 0) { await conn.rollback(); return res.status(404).json({ error: 'Demande non trouvée' }); } const collaborateurId = demande[0].CollaborateurADId; const rolesAutorises = ['Admin', 'RH', 'Validateur']; const estAuteur = collaborateurId === req.user.id; const estRoleAutorise = rolesAutorises.includes(req.user.role); if (!estAuteur && !estRoleAutorise) { await conn.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 les types de congé await conn.query('DELETE FROM DemandeCongeType WHERE DemandeCongeId = ?', [id]); // Supprimer la demande await conn.query('DELETE FROM DemandeConge WHERE Id = ?', [id]); await conn.commit(); notifyClients({ type: 'demande-deleted', demandeId: parseInt(id), collaborateurId: collaborateurId, timestamp: new Date().toISOString() }); try { await webhookManager.sendWebhook( WEBHOOKS.COLLABORATEURS_URL, EVENTS.DEMANDE_DELETED, { demandeId: parseInt(id), collaborateurId: collaborateurId } ); } catch (webhookError) { console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message); } console.log(`✅ Demande ${id} supprimée par ${req.user.role}`); res.json({ message: 'Demande supprimée avec succès' }); } catch (error) { await conn.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 app.put('/demandes/:id/valider', authenticateToken, async (req, res) => { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const { id } = req.params; const { statut, commentaire } = req.body; console.log('\n🔍 === DÉBUT VALIDATION DEMANDE ==='); const [demandeInfo] = await conn.query( `SELECT dc.Id, dc.CollaborateurADId, dc.DateDebut, dc.DateFin, dc.NombreJours, ca.prenom, ca.nom, ca.email as collaborateur_email, ca.Actif, GROUP_CONCAT(tc.Nom SEPARATOR ', ') 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] ); if (demandeInfo.length === 0) { await conn.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] ); if (statut === 'Validée') { const [types] = await conn.query( 'SELECT TypeCongeId, NombreJours FROM DemandeCongeType WHERE DemandeCongeId = ?', [id] ); 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 conn.query( `INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId) VALUES (?, ?, ?, ?)`, [req.user.id, statut === 'Validée' ? 'Validation congé' : 'Refus congé', commentaire, id] ); await conn.commit(); // ⭐ ENVOI EMAIL const accessToken = await getGraphToken(); if (accessToken && demande.collaborateur_email) { const fromEmail = 'noreply@ensup.eu'; const collaborateurNom = `${demande.prenom} ${demande.nom}`; const validateurNom = req.user.prenom && req.user.nom ? `${req.user.prenom} ${req.user.nom}` : 'l\'équipe RH'; const dateDebut = new Date(demande.DateDebut).toLocaleDateString('fr-FR'); const dateFin = new Date(demande.DateFin).toLocaleDateString('fr-FR'); const datesPeriode = dateDebut === dateFin ? dateDebut : `du ${dateDebut} au ${dateFin}`; const subject = statut === 'Validée' ? '✅ Votre demande de congé a été approuvée' : '❌ Votre demande de congé a été refusée'; const body = statut === 'Validé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}

` : ''}
` : `

❌ 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}

` : ''}
`; await sendMailGraph(accessToken, fromEmail, demande.collaborateur_email, subject, body); } notifyClients({ type: 'demande-validated', demandeId: parseInt(id), statut: statut, collaborateurId: collaborateurId, timestamp: new Date().toISOString() }, collaborateurId); notifyClients({ type: 'demande-list-updated', action: 'validation', demandeId: parseInt(id), timestamp: new Date().toISOString() }); try { await webhookManager.sendWebhook( WEBHOOKS.COLLABORATEURS_URL, EVENTS.DEMANDE_VALIDATED, { demandeId: parseInt(id), statut: statut, collaborateurId: collaborateurId, validateurId: req.user.id, commentaire: commentaire } ); await webhookManager.sendWebhook( WEBHOOKS.COLLABORATEURS_URL, EVENTS.COMPTEUR_UPDATED, { collaborateurId: collaborateurId } ); } catch (webhookError) { console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message); } res.json({ message: 'Demande mise à jour' }); } catch (error) { await conn.rollback(); console.error('\n❌ ERREUR VALIDATION:', error); res.status(500).json({ error: error.message }); } finally { conn.release(); } }); // ================================================ // ROUTES EMPLOYÉS // ================================================ app.get('/employes', authenticateToken, async (req, res) => { try { const includeInactifs = req.query.include_inactifs === 'true'; const serviceId = req.query.service_id; 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, cc1.Solde AS soldeCP, 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 params = []; if (!includeInactifs) { query += ' AND (ca.Actif = 1 OR ca.Actif IS NULL)'; } // 🆕 Ajouter le filtre service si fourni if (serviceId && serviceId !== 'all') { query += ' AND ca.ServiceId = ?'; params.push(serviceId); } query += ' ORDER BY c.Nom, s.Nom, ca.nom, ca.prenom'; const [employes] = await pool.query(query, params); res.json(employes); } catch (error) { res.status(500).json({ error: error.message }); } }); // ✅ NOUVELLE ROUTE: Désactiver un collaborateur app.post('/employes/desactiver', authenticateToken, async (req, res) => { try { if (!['Admin', 'RH'].includes(req.user.role)) { return res.status(403).json({ error: 'Accès non autorisé' }); } const { collaborateur_id, date_sortie } = req.body; if (!collaborateur_id) { return res.json({ success: false, message: 'ID collaborateur manquant' }); } 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] ); 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] ); res.json({ success: true, message: 'Collaborateur désactivé', nom: `${collab[0].prenom} ${collab[0].nom}`, date_sortie: dateSortie }); } catch (error) { console.error('Erreur desactiverCollaborateur:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); // ✅ NOUVELLE ROUTE: Réactiver un collaborateur app.post('/employes/reactiver', authenticateToken, async (req, res) => { try { if (!['Admin', 'RH'].includes(req.user.role)) { return res.status(403).json({ error: 'Accès non autorisé' }); } const { collaborateur_id } = req.body; if (!collaborateur_id) { return res.json({ success: false, message: 'ID collaborateur manquant' }); } const [collab] = await pool.query( 'SELECT prenom, nom, email FROM CollaborateurAD WHERE id = ?', [collaborateur_id] ); 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] ); res.json({ success: true, message: 'Collaborateur réactivé', nom: `${collab[0].prenom} ${collab[0].nom}` }); } catch (error) { console.error('Erreur reactiverCollaborateur:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); app.get('/types-conge', authenticateToken, async (req, res) => { try { const [types] = await pool.query( 'SELECT Id as id, Nom as nom, CouleurHex as couleur FROM TypeConge WHERE Actif = 1 ORDER BY Nom' ); res.json(types); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/export/paie', authenticateToken, async (req, res) => { try { const { mois, annee } = 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}`); // 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, ca.email, s.Nom as service, tc.Nom as typeConge, dct.NombreJours, 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 ( (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`); 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] ); if (employe.length === 0) { return res.status(404).json({ error: 'Employé non trouvé' }); } const collab = employe[0]; // Déterminer si c'est un apprenti const estApprenti = collab.TypeContrat === 'Apprentissage' || collab.role === 'Apprenti' || collab.role?.toLowerCase().includes('apprenti'); res.json({ ...collab, estApprenti, droitRTT: !estApprenti }); } catch (error) { console.error('Erreur récupération info employé:', error); res.status(500).json({ error: error.message }); } }); // ✅ Modifier la route GET /api/compteurs pour inclure les infos sur les apprentis app.get('/compteurs', authenticateToken, async (req, res) => { try { 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 `; if (!includeInactifs) { query += ' WHERE (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); // Ajouter l'indicateur apprenti à chaque compteur const compteursAvecStatut = compteurs.map(c => ({ ...c, estApprenti: c.TypeContrat === 'Apprentissage' || c.role === 'Apprenti' || c.role?.toLowerCase().includes('apprenti') })); res.json(compteursAvecStatut); } catch (error) { res.status(500).json({ error: error.message }); } }); app.put('/compteurs/:id', authenticateToken, async (req, res) => { const conn = await pool.getConnection(); try { await conn.beginTransaction(); if (!['Admin', 'RH', 'Validateur'].includes(req.user.role)) { await conn.rollback(); conn.release(); 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]); if (existing.length === 0) { await conn.rollback(); conn.release(); return res.status(404).json({ error: 'Compteur non trouvé' }); } const collaborateurId = existing[0].CollaborateurADId; const typeCongeId = existing[0].TypeCongeId; 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 typeConge = typeInfo.length > 0 ? typeInfo[0].Nom : 'Inconnu'; // ⭐ MISE À JOUR AVEC TRANSACTION await conn.query( `UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = NOW() WHERE Id = ?`, [totalNum, soldeNum, id] ); await conn.commit(); // Notifier les clients SSE notifyClients({ type: 'compteur-updated', collaborateurId: collaborateurId, typeCongeId: typeCongeId, typeConge: typeConge, annee: annee, action: 'modification_rh', nouveauTotal: totalNum, nouveauSolde: soldeNum, timestamp: new Date().toISOString() }, collaborateurId); // ⭐ WEBHOOK AMÉLIORÉ AVEC TOUTES LES INFOS try { await webhookManager.sendWebhook( WEBHOOKS.COLLABORATEURS_URL, EVENTS.COMPTEUR_UPDATED, { collaborateurId: collaborateurId, typeCongeId: typeCongeId, typeConge: typeConge, typeUpdate: 'modification_manuelle_rh', annee: annee, nouveauTotal: totalNum, nouveauSolde: soldeNum, source: 'rh', timestamp: new Date().toISOString() } ); console.log(`✅ Webhook envoyé pour ${collaborateurId} (${typeConge} ${annee}: Total=${totalNum}j, Solde=${soldeNum}j)`); } catch (webhookError) { console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message); } res.json({ message: 'Compteur modifié avec succès', total: totalNum, solde: soldeNum }); } catch (error) { await conn.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(); 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 { collaborateurId, nombreJours, commentaire } = req.body; if (!collaborateurId || !nombreJours || nombreJours <= 0) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Données invalides' }); } 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] ); if (collaborateur.length === 0) { await conn.rollback(); conn.release(); return res.status(404).json({ success: false, message: 'Collaborateur non trouvé' }); } if (collaborateur[0].Actif === 0) { await conn.rollback(); conn.release(); return res.status(403).json({ success: false, message: 'Impossible d\'ajouter des jours à un compte désactivé' }); } const collab = collaborateur[0]; 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'] ); if (recupType.length === 0) { await conn.rollback(); conn.release(); return res.status(500).json({ success: false, message: 'Type de congé "Récupération" non trouvé' }); } 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] ); 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] ); console.log(`✅ Compteur Récupération mis à jour: +${nombreJours}j (nouveau solde: ${(parseFloat(compteurExisting[0].Solde) + nombreJours).toFixed(2)}j)`); } else { // 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] ); 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 conn.commit(); // Notifier les clients SSE notifyClients({ type: 'compteur-updated', collaborateurId: collaborateurId, typeConge: 'Récupération', action: 'ajout', nombreJours: nombreJours, 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 const accessToken = await getGraphToken(); if (accessToken && collab.email) { const fromEmail = 'noreply@ensup.eu'; const subject = '✅ Jours de récupération ajoutés'; const body = `

✅ Jours de récupération ajoutés

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

Des jours de récupération ont été ajoutés à votre compteur.

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

${commentaire ? `

Motif : ${commentaire}

` : ''}

Année : ${currentYear}

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

`; try { await sendMailGraph(accessToken, fromEmail, collab.email, subject, body); console.log('✅ Email de notification envoyé'); } catch (emailError) { console.error('❌ Erreur envoi email:', emailError); } } conn.release(); res.json({ success: true, message: 'Jours de récupération ajoutés avec succès', collaborateur: `${collab.prenom} ${collab.nom}`, nombreJours: nombreJours, annee: currentYear }); } catch (error) { await conn.rollback(); if (conn) conn.release(); console.error('❌ Erreur ajout récupération:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); // ================================================ // ROUTE POUR RETIRER DES JOURS DE RÉCUPÉRATION // ================================================ app.post('/compteurs/retirer-recup', 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 { collaborateurId, nombreJours, commentaire } = req.body; if (!collaborateurId || !nombreJours || nombreJours <= 0) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Données invalides' }); } 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] ); if (collaborateur.length === 0) { await conn.rollback(); conn.release(); return res.status(404).json({ success: false, message: 'Collaborateur non trouvé' }); } const collab = collaborateur[0]; 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'] ); if (recupType.length === 0) { await conn.rollback(); conn.release(); return res.status(500).json({ success: false, message: 'Type de congé "Récupération" non trouvé' }); } 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] ); if (compteur.length === 0 || compteur[0].Solde < nombreJours) { await conn.rollback(); conn.release(); return res.status(400).json({ success: false, message: 'Solde de récupération insuffisant', soldeActuel: compteur.length > 0 ? compteur[0].Solde : 0 }); } // 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] ); 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 conn.commit(); // Notifier les clients SSE notifyClients({ type: 'compteur-updated', collaborateurId: collaborateurId, typeConge: 'Récupération', action: 'retrait', nombreJours: nombreJours, 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', collaborateur: `${collab.prenom} ${collab.nom}`, nombreJours: nombreJours, annee: currentYear }); } catch (error) { await conn.rollback(); if (conn) conn.release(); console.error('❌ Erreur retrait récupération:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); app.get('/equipes', authenticateToken, async (req, res) => { try { const [equipes] = await pool.query(` SELECT s.Id, s.Nom as nomService, s.Nom as service, 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)), '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 ) ORDER BY s.Nom `); res.json(equipes); } catch (error) { console.error('❌ Erreur /api/equipes:', error); res.status(500).json({ error: error.message }); } }); app.get('/equipes/:id', authenticateToken, async (req, res) => { try { 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), '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]); if (equipes.length === 0) { return res.status(404).json({ error: 'Service non trouvé' }); } res.json(equipes[0]); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/equipes/:id/membres', authenticateToken, async (req, res) => { try { const [membres] = await pool.query(` SELECT ca.id, CONCAT(ca.prenom, ' ', ca.nom) as nom, ca.email, ca.role, ca.Actif, ca.DateSortie, COALESCE(cc.Solde, 0) as soldeCP, 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) ORDER BY ca.nom, ca.prenom `, [req.params.id]); res.json(membres); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/stats', authenticateToken, async (req, res) => { try { const [stats] = await pool.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(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) `); res.json(stats[0]); } catch (error) { res.status(500).json({ error: error.message }); } }); 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 params = []; if (dateDebut) { query += ' AND ha.DateAction >= ?'; params.push(dateDebut); } if (dateFin) { query += ' AND ha.DateAction <= ?'; params.push(dateFin); } if (action && action !== 'all') { query += ' AND ha.Action = ?'; params.push(action); } query += ' ORDER BY ha.DateAction DESC LIMIT 500'; const [historique] = await pool.query(query, params); res.json(historique); } catch (error) { res.status(500).json({ error: error.message }); } }); // ================================================ // ROUTES COMPTES-RENDUS D'ACTIVITÉS (RH) // ================================================ // 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 */ function calculerAcquisitionCP(dateReference = new Date(), dateEntree = null) { const d = new Date(dateReference); d.setHours(0, 0, 0, 0); const annee = d.getFullYear(); const mois = d.getMonth() + 1; // 1-12 // 1. Déterminer le début de l'exercice CP (01/06) let exerciceDebut; if (mois >= 6) { exerciceDebut = new Date(annee, 5, 1); // 01/06/N } else { exerciceDebut = new Date(annee - 1, 5, 1); // 01/06/N-1 } exerciceDebut.setHours(0, 0, 0, 0); // 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 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; } } // 3. Calculer avec la formule Excel const coeffCP = 25 / 12; // 2.0833 const acquisition = calculerAcquisitionFormuleExcel(dateDebutAcquis, d, coeffCP); // 4. Plafonner à 25 jours return Math.min(acquisition, 25); } /** * Calcule l'acquisition RTT avec la configuration variable */ async function calculerAcquisitionRTT(conn, 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] ); if (collabInfo.length === 0) { throw new Error(`Collaborateur ${collaborateurId} non trouvé`); } const typeContrat = collabInfo[0].TypeContrat || '37h'; const dateEntree = collabInfo[0].DateEntree; const isApprenti = collabInfo[0].role === 'Apprenti'; // 2. Apprentis : pas de RTT if (isApprenti) { return { acquisition: 0, moisTravailles: 0, config: { joursAnnuels: 0, acquisitionMensuelle: 0 }, typeContrat: typeContrat }; } // 3. Récupérer la configuration RTT const config = await getConfigurationRTT(conn, 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 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; } if (entree.getFullYear() > annee) { return { acquisition: 0, moisTravailles: 0, config: config, typeContrat: typeContrat }; } } } // 5. Calculer avec la formule Excel const acquisition = calculerAcquisitionFormuleExcel( dateDebutAcquis, d, config.acquisitionMensuelle ); // 6. Calculer les mois travaillés const moisTravailles = config.acquisitionMensuelle > 0 ? acquisition / config.acquisitionMensuelle : 0; // 7. Plafonner au maximum annuel const acquisitionFinale = Math.min(acquisition, config.joursAnnuels); return { acquisition: Math.round(acquisitionFinale * 100) / 100, moisTravailles: Math.round(moisTravailles * 100) / 100, config: config, typeContrat: typeContrat }; } function dateDifMonths(date1, date2) { const d1 = new Date(date1); const d2 = new Date(date2); // Vérifier que les dates sont valides if (isNaN(d1.getTime()) || isNaN(d2.getTime())) { console.error('❌ Date invalide dans dateDifMonths'); return 0; } let months = (d2.getFullYear() - d1.getFullYear()) * 12; months += d2.getMonth() - d1.getMonth(); // Si le jour de d2 < jour de d1, on n'a pas encore complété le mois if (d2.getDate() < d1.getDate()) { months--; } return Math.max(0, months); } /** * Formule Excel exacte pour calculer l'acquisition */ function calculerAcquisitionFormuleExcel(dateDebut, dateReference, coeffMensuel) { const b1 = new Date(dateDebut); const b2 = new Date(dateReference); b1.setHours(0, 0, 0, 0); b2.setHours(0, 0, 0, 0); // ✅ AJOUT : 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 if (isNaN(coeffMensuel) || coeffMensuel <= 0) { console.error('❌ Coefficient mensuel invalide:', coeffMensuel); return 0; } // Si date référence avant date début if (b2 < b1) return 0; // Si même mois et même année if (b1.getFullYear() === b2.getFullYear() && b1.getMonth() === b2.getMonth()) { const joursTotal = new Date(b2.getFullYear(), b2.getMonth() + 1, 0).getDate(); const joursAcquis = b2.getDate() - b1.getDate() + 1; return Math.round((joursAcquis / joursTotal) * coeffMensuel * 100) / 100; } // 1. Fraction du PREMIER mois const joursFinMoisB1 = new Date(b1.getFullYear(), b1.getMonth() + 1, 0).getDate(); const jourB1 = b1.getDate(); const fractionPremierMois = (joursFinMoisB1 - jourB1 + 1) / joursFinMoisB1; // 2. Mois COMPLETS entre const moisComplets = dateDifMonths(b1, b2) - 1; // 3. Fraction du DERNIER mois const joursFinMoisB2 = new Date(b2.getFullYear(), b2.getMonth() + 1, 0).getDate(); const jourB2 = b2.getDate(); const fractionDernierMois = jourB2 / joursFinMoisB2; // 4. Total const totalMois = fractionPremierMois + Math.max(0, moisComplets) + fractionDernierMois; const acquisition = totalMois * coeffMensuel; 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') { 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] ); if (config.length > 0) { return { joursAnnuels: parseFloat(config[0].JoursAnnuels), acquisitionMensuelle: parseFloat(config[0].AcquisitionMensuelle) }; } // Sinon, utiliser les règles par défaut return getConfigurationRTTDefaut(annee, typeContrat); } catch (error) { console.error('Erreur getConfigurationRTT:', error); return getConfigurationRTTDefaut(annee, typeContrat); } } /** * Configuration RTT par défaut selon les règles métier */ function getConfigurationRTTDefaut(annee, typeContrat = '37h') { // 37h : toujours 10 RTT/an if (typeContrat === '37h' || typeContrat === 'tempspartiel') { return { joursAnnuels: 10, acquisitionMensuelle: 10 / 12 // 0.8333 }; } // Forfait jour : dépend de l'année if (typeContrat === 'forfaitjour') { if (annee <= 2025) { // 2025 et avant : 10 RTT/an return { joursAnnuels: 10, acquisitionMensuelle: 10 / 12 // 0.8333 }; } else { // 2026 et après : 12 RTT/an return { joursAnnuels: 12, acquisitionMensuelle: 12 / 12 // 1.0 }; } } // Par défaut : 10 RTT/an return { joursAnnuels: 10, acquisitionMensuelle: 10 / 12 }; } // 📊 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 Fixée V2 + V1)'); // Récupérer tous les collaborateurs actifs const [collaborateurs] = await conn.query(` SELECT ca.id, ca.prenom, ca.nom, ca.email, ca.role, ca.TypeContrat, ca.DateEntree, s.Nom as service 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 resultats = []; const currentYear = new Date().getFullYear(); const previousYear = currentYear - 1; // Pré-charger les TypeConge Id 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`); for (const collab of collaborateurs) { // // ----------------------------------------------------- // 1️⃣ CONGÉS PAYÉS — ANNÉE N (V2 si existant, sinon V1) // ----------------------------------------------------- // let [cpN] = await conn.query(` SELECT Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collab.id, cpType.Id, currentYear]); let cpTotalN, cpSoldeN; if (cpN.length > 0) { // Utiliser données V2 cpTotalN = parseFloat(cpN[0].Total); cpSoldeN = parseFloat(cpN[0].Solde); } else { // Fallback ancien calcul 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.Id, currentYear]); cpSoldeN = Math.max(0, cpTotalN - consomme.total); } resultats.push({ collaborateurId: collab.id, employe: `${collab.prenom} ${collab.nom}`, email: collab.email, service: collab.service || 'Non assigné', typeConge: "Congé payé", annee: currentYear, total: parseFloat(cpTotalN.toFixed(2)), solde: parseFloat(cpSoldeN.toFixed(2)), consomme: parseFloat((cpTotalN - cpSoldeN).toFixed(2)), role: collab.role, typeContrat: collab.TypeContrat }); // // ----------------------------------------------------- // 2️⃣ CP N-1 (toujours basé sur CompteurConges) // ----------------------------------------------------- // let [cpN1] = await conn.query(` SELECT Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collab.id, cpType.Id, previousYear]); if (cpN1.length > 0 && cpN1[0].Solde > 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é', typeConge: "Congé payé", annee: previousYear, total, solde, consomme: total - solde, role: collab.role, typeContrat: collab.TypeContrat }); } // // ----------------------------------------------------- // 3️⃣ RTT — ANNÉE N (V2 si existant) // ----------------------------------------------------- // if (collab.role !== 'Apprenti') { let [rttN] = await conn.query(` SELECT Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collab.id, rttType.Id, currentYear]); let rttTotalN, rttSoldeN; if (rttN.length > 0) { rttTotalN = parseFloat(rttN[0].Total); rttSoldeN = parseFloat(rttN[0].Solde); } else { // Fallback ancien calcul 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') AND dc.Statut != 'Refusé' `, [collab.id, rttType.Id, currentYear]); rttTotalN = rtt.acquisition; rttSoldeN = Math.max(0, rttTotalN - consommeRTT.total); } resultats.push({ collaborateurId: collab.id, employe: `${collab.prenom} ${collab.nom}`, email: collab.email, service: collab.service || 'Non assigné', 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 }); } // // ----------------------------------------------------- // 4️⃣ RÉCUP — ANNÉE N // ----------------------------------------------------- // 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.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.Id, currentYear]); const recupTotal = parseFloat(accum.total); const recupCons = parseFloat(consomm.total); const recupSolde = Math.max(0, recupTotal - recupCons); if (recupTotal + recupCons > 0) { resultats.push({ collaborateurId: collab.id, employe: `${collab.prenom} ${collab.nom}`, email: collab.email, service: collab.service || 'Non assigné', typeConge: "Récupération", annee: currentYear, total: recupTotal, solde: recupSolde, consomme: recupCons, role: collab.role, typeContrat: collab.TypeContrat }); } } conn.release(); res.json(resultats); } catch (err) { conn.release(); console.error("❌ Erreur GET ALL:", err); res.status(500).json({ error: err.message }); } }); // ✅ ROUTE STATS MANQUANTE app.get('/api/stats', authenticateToken, async (req, res) => { try { console.log('📊 GET /api/stats appelé par', req.user.email); // Stats simples pour test const stats = { totalEmployes: 0, demandesEnAttente: 0, demandesValidees: 0, timestamp: new Date().toISOString() }; // TODO: Requêtes SQL réelles ici res.json(stats); } catch (error) { console.error('Erreur /api/stats:', error); res.status(500).json({ error: 'Erreur stats' }); } }); // ================================================ // DÉMARRAGE DU SERVEUR // ================================================ console.log('✅ 99. Toutes les routes définies, démarrage du serveur...'); const server = app.listen(PORT, () => { 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('🔗 WEBHOOKS configurés:'); console.log(` - Serveur Collaborateurs: ${WEBHOOKS.COLLABORATEURS_URL}`); console.log(` - Route webhook receiver: POST /api/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('================================================'); }); server.on('error', (error) => { console.error('\n❌ ERREUR SERVEUR:', error); if (error.code === 'EADDRINUSE') { console.error(`⚠️ Le port ${PORT} est déjà utilisé`); } }); setInterval(() => { }, 60000); } catch (error) { console.error('\n❌❌❌ ERREUR FATALE AU CHARGEMENT ❌❌❌'); console.error(error); console.error(error.stack); process.exit(1); }