const express = require('express'); const cors = require('cors'); const sql = require('mssql'); // ⭐ CHANGEMENT: mssql au lieu de mysql2 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 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 || 3000; 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 SQL SERVER const dbConfig = { server: '192.168.0.3', user: 'gta_app', password: 'GTA2025!Secure', database: 'GTA', port: 1433, options: { encrypt: true, trustServerCertificate: true, enableArithAbort: true, connectTimeout: 60000, requestTimeout: 60000 }, pool: { max: 10, min: 0, idleTimeoutMillis: 30000 } }; console.log('🔄 13. Test connexion SQL Server...'); let pool; // ⭐ FONCTION D'INITIALISATION DE LA BASE async function initializeDatabase() { try { pool = await sql.connect(dbConfig); console.log('✅ 14. Connexion SQL Server réussie'); console.log(' Server:', dbConfig.server); console.log(' User:', dbConfig.user); console.log(' Database:', dbConfig.database); } catch (err) { console.error('❌ 14. ERREUR CONNEXION SQL SERVER:', err.message); console.error(' Server:', dbConfig.server); console.error(' User:', dbConfig.user); console.error(' Database:', dbConfig.database); throw err; } } // Initialiser la connexion initializeDatabase().catch(err => { console.error('❌ Impossible de démarrer le serveur:', err); process.exit(1); }); console.log('✅ 15. Pool SQL Server configuré'); // ================================================ // SNAPSHOT QUOTIDIEN — Appelle la Stored Procedure // ================================================ async function executerSnapshotQuotidien() { try { console.log('[SNAPSHOT] Démarrage du snapshot quotidien...'); const result = await pool.request() .query('EXEC [dbo].[SP_SnapshotQuotidienCompteurs]'); const resume = result.recordset?.[0]; if (resume) { console.log(`[SNAPSHOT] ✅ Terminé — Date: ${resume.DateSnapshot}, Insérés: ${resume.NbInseres}`); } else { console.log('[SNAPSHOT] ✅ Terminé'); } } catch (error) { console.error('[SNAPSHOT] ❌ Erreur:', error.message); } } // Configuration Microsoft O365 const msalConfig = { 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; } } function agregerLignesParEmploye(demandes) { const employesMap = new Map(); demandes.forEach(row => { const key = row.collaborateurId; if (!employesMap.has(key)) { employesMap.set(key, { employe: row.employe, email: row.email, service: row.service || 'Non assigné', rtt: { nb: 0, dates: [], periodes: [] }, cp: { nb: 0, dates: [], periodes: [] }, aap: { nb: 0, dates: [], periodes: [] }, am: { nb: 0, dates: [], periodes: [] }, jposf: { nb: 0, dates: [], periodes: [] }, recup: { nb: 0, dates: [], periodes: [] }, autres: [] }); } const employe = employesMap.get(key); const dateDebutType = row.dateDebutType ? new Date(row.dateDebutType) : new Date(row.DateDebut); const dateFinType = row.dateFinType ? new Date(row.dateFinType) : new Date(row.DateFin); // Pour les demi-journées sans date propre au type, // la date de fin = date de début (une seule journée) const periodeJourneeRaw = row.PeriodeJournee || ''; const isDemiJournee = periodeJourneeRaw === 'Matin' || periodeJourneeRaw === 'Après-midi'; const dateFinEffective = (isDemiJournee && !row.dateFinType) ? dateDebutType : dateFinType; const formatDate = (date) => String(date.getDate()).padStart(2, '0') + '/' + String(date.getMonth() + 1).padStart(2, '0'); const dateDebutStr = formatDate(dateDebutType); const dateFinStr = formatDate(dateFinEffective); const periode = dateDebutStr === dateFinStr ? dateDebutStr : `${dateDebutStr}-${dateFinStr}`; const nombreJours = parseFloat(row.NombreJours); const typeConge = row.typeConge.toLowerCase(); let periodeJournee = row.PeriodeJournee || 'Journée'; if (periodeJournee === 'Journée entière') periodeJournee = 'Journée'; if (typeConge === 'jposf') { employe.jposf.nb += nombreJours; employe.jposf.dates.push(periode); employe.jposf.periodes.push(periodeJournee); } else if (typeConge.includes('récup') || typeConge.includes('recup')) { employe.aap.nb += nombreJours; employe.aap.dates.push(periode); employe.aap.periodes.push(periodeJournee); } else if (typeConge.includes('rtt')) { employe.rtt.nb += nombreJours; employe.rtt.dates.push(periode); employe.rtt.periodes.push(periodeJournee); } else if (typeConge.includes('cp') || typeConge.includes('congé payé') || typeConge.includes('conge paye')) { employe.cp.nb += nombreJours; employe.cp.dates.push(periode); employe.cp.periodes.push(periodeJournee); } else if (typeConge.includes('aap') || typeConge.includes('absence autorisée') || typeConge.includes('enfant malade')) { employe.aap.nb += nombreJours; employe.aap.dates.push(periode); employe.aap.periodes.push(periodeJournee); } else if (typeConge.includes('maladie') || typeConge.includes('arrêt') || typeConge.includes('arret')) { employe.am.nb += nombreJours; employe.am.dates.push(periode); employe.am.periodes.push(periodeJournee); } else { const existingAutre = employe.autres.find(a => a.type === row.typeConge); if (existingAutre) { existingAutre.nb += nombreJours; existingAutre.dates.push(periode); existingAutre.periodes.push(periodeJournee); } else { employe.autres.push({ type: row.typeConge, nb: nombreJours, dates: [periode], periodes: [periodeJournee] }); } } }); return Array.from(employesMap.values()).map(emp => ({ employe: emp.employe, email: emp.email, service: emp.service, rtt: { nb: emp.rtt.nb, dates: emp.rtt.dates.join(' ; '), periodes: emp.rtt.periodes.join(' ; ') }, cp: { nb: emp.cp.nb, dates: emp.cp.dates.join(' ; '), periodes: emp.cp.periodes.join(' ; ') }, aap: { nb: emp.aap.nb, dates: emp.aap.dates.join(' ; '), periodes: emp.aap.periodes.join(' ; ') }, am: { nb: emp.am.nb, dates: emp.am.dates.join(' ; '), periodes: emp.am.periodes.join(' ; ') }, jposf: { nb: emp.jposf.nb, dates: emp.jposf.dates.join(' ; '), periodes: emp.jposf.periodes.join(' ; ') }, recup: { nb: emp.recup.nb, dates: emp.recup.dates.join(' ; '), periodes: emp.recup.periodes.join(' ; ') }, autres: emp.autres.map(a => ({ type: a.type, nb: a.nb, dates: a.dates.join(' ; '), periodes: a.periodes.join(' ; ') })) })); } function grouperParMoisConge(lignes) { // 1. Séparer les lignes par mois de congé const parMois = {}; lignes.forEach(row => { const dateConge = new Date(row.dateDebutType || row.DateDebut); const moisKey = `${dateConge.getFullYear()}-${String(dateConge.getMonth() + 1).padStart(2, '0')}`; if (!parMois[moisKey]) { parMois[moisKey] = []; } parMois[moisKey].push(row); }); // 2. Dans chaque mois, agréger par employé const result = {}; Object.keys(parMois).sort().forEach(mois => { result[mois] = agregerLignesParEmploye(parMois[mois]); }); return result; } function estTeletravail(typeCongeNom) { if (!typeCongeNom) return false; const nom = typeCongeNom.toLowerCase(); return nom.includes('télétravail') || nom.includes('teletravail') || nom.includes('remote'); } /** * Détermine si un type de congé est un arrêt maladie */ function estArretMaladie(typeCongeNom) { if (!typeCongeNom) return false; const nom = typeCongeNom.toLowerCase(); return nom.includes('maladie') || nom.includes('arrêt') || nom.includes('arret') || nom.includes('am'); } /** * Génère le sujet et le corps HTML de l'email selon le contexte * @param {string} collaborateurNom - Prénom Nom du collaborateur * @param {string} datesPeriode - Ex: "du 02/03 au 05/03" ou "02/03 (Matin)" * @param {number} totalJours - Nombre de jours * @param {string[]} typesCongeNoms - Tableau des noms de types de congé * @param {string|null} commentaire - Commentaire optionnel * @param {string} periodeJournee - "Journée entière", "Matin", "Après-midi" * @returns {{ subject: string, body: string }} */ function genererEmailSaisieManuelle( collaborateurNom, datesPeriode, totalJours, typesCongeNoms, commentaire, periodeJournee ) { const tousLesTeletravail = typesCongeNoms.length > 0 && typesCongeNoms.every(t => estTeletravail(t)); const tousArretMaladie = typesCongeNoms.length > 0 && typesCongeNoms.every(t => estArretMaladie(t)); const contientTeletravail = typesCongeNoms.some(t => estTeletravail(t)); const contientMaladie = typesCongeNoms.some(t => estArretMaladie(t)); // ─── CAS 1 : Télétravail pur ───────────────────────────────────────────── if (tousLesTeletravail) { return { subject: '🏠 Télétravail enregistré', body: `

🏠 Télétravail enregistré

Bonjour ${collaborateurNom},

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

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

Pour toute question, contactez le service RH.

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

🩺 Arrêt maladie enregistré

Bonjour ${collaborateurNom},

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

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

Pour toute question, contactez le service RH.

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

📋 Absence mixte enregistrée

Bonjour ${collaborateurNom},

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

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

Pour toute question, contactez le service RH.

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

✅ Congé validé automatiquement

Bonjour ${collaborateurNom},

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

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

Pour toute question, contactez le service RH.

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

📅 JPOSF enregistré

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

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

Période : ${periode_journee}

Jours ajoutés : ${nombreJours}j

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

`; await sendMailGraph(accessToken, 'gtanoreply@ensup.eu', collab.email, subject, body); } } catch (emailError) { console.error('⚠️ Erreur email:', emailError.message); } console.log('✅ ========== JPOSF ENREGISTRÉ ==========\n'); return res.json({ success: true, message: 'JPOSF enregistré avec succès', collaborateur: `${collab.prenom} ${collab.nom}`, date: date, periode: periode_journee, jours_ajoutes: nombreJours, nouveau_solde_recup: nouveauSoldeRecup, demandeId: demandeId }); } catch (error) { console.error('❌ ERREUR JPOSF:', error.message); console.error('Stack:', error.stack); try { await transaction.rollback(); } catch (e) { } return res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); // ⭐ ROUTE GET /api/jposf - Ajoutez cette route dans server.js app.get('/jposf', authenticateToken, async (req, res) => { try { const currentYear = new Date().getFullYear(); console.log('📊 GET /api/jposf - Année:', currentYear); const result = await pool.request() .input('currentYear', sql.Int, currentYear) .query(` SELECT MIN(dc.Id) AS Id, dc.CollaborateurADId, CONCAT(ca.prenom, ' ', ca.nom) AS employe, ca.email, COALESCE(s.Nom, 'Sans service') AS service, COUNT(DISTINCT dc.Id) AS totalEvenements, SUM(dct.NombreJours) AS solde FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id LEFT JOIN Services s ON ca.ServiceId = s.Id JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE tc.Nom = 'JPOSF' AND dc.Statut = 'Validée' AND YEAR(dc.DateDebut) = @currentYear AND (ca.Actif = 1 OR ca.Actif IS NULL) GROUP BY dc.CollaborateurADId, ca.prenom, ca.nom, ca.email, s.Nom ORDER BY ca.nom, ca.prenom `); console.log('📊 JPOSF trouvés:', result.recordset.length); res.json(result.recordset); } catch (error) { console.error('❌ Erreur GET /api/jposf:', error); res.status(500).json({ error: error.message }); } }); // ROUTE GET /demandes avec pagination et filtres // Dans server.js, remplacer la route GET /demandes app.get('/demandes', authenticateToken, async (req, res) => { try { const { limit, statut, serviceid, campusid } = req.query; const request = pool.request(); // Filtres WHERE let whereClause = "WHERE (ca.Actif = 1 OR ca.Actif IS NULL)"; if (statut && statut !== 'all') { whereClause += " AND dc.Statut = @statut"; request.input('statut', sql.VarChar, statut); } if (serviceid && serviceid !== 'all') { whereClause += " AND ca.ServiceId = @serviceid"; request.input('serviceid', sql.Int, parseInt(serviceid)); } if (campusid && campusid !== 'all') { whereClause += " AND ca.CampusId = @campusid"; request.input('campusid', sql.Int, parseInt(campusid)); } let topClause = ''; if (limit) { topClause = `TOP ${parseInt(limit)}`; } // Requête principale const query = ` SELECT ${topClause} dc.Id, dc.CollaborateurADId, dc.DateDebut, dc.DateFin, dc.Statut, dc.Commentaire, dc.DateDemande, dc.NombreJours, dc.ValidateurADId, dc.DateValidation, dc.CommentaireValidation, CONCAT(ca.prenom, ' ', ca.nom) AS nomEmploye, ca.email AS emailEmploye, s.Nom AS service, c.Nom AS campus, -- Agréger les types de congé (pour affichage simple) STUFF(( SELECT DISTINCT ', ' + tc2.Nom FROM DemandeCongeType dct2 JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id WHERE dct2.DemandeCongeId = dc.Id ORDER BY ', ' + tc2.Nom FOR XML PATH('') ), 1, 2, '') AS typesConge FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN Campus c ON ca.CampusId = c.Id ${whereClause} ORDER BY dc.DateDemande DESC `; const result = await request.query(query); const demandes = result.recordset; // Récupérer les détails de tous les types de congé const detailsQuery = ` SELECT dct.DemandeCongeId, tc.Nom AS typeConge, dct.NombreJours AS nombreJours, dct.PeriodeJournee, dc.DateDebut AS dateDebut, dc.DateFin AS dateFin FROM DemandeCongeType dct JOIN TypeConge tc ON dct.TypeCongeId = tc.Id JOIN DemandeConge dc ON dct.DemandeCongeId = dc.Id WHERE dct.DemandeCongeId IN (${demandes.map(d => d.Id).join(',') || '0'}) ORDER BY dct.DemandeCongeId, tc.Nom `; const detailsResult = await pool.request().query(detailsQuery); // Grouper les détails par demande const detailsParDemande = {}; detailsResult.recordset.forEach(detail => { if (!detailsParDemande[detail.DemandeCongeId]) { detailsParDemande[detail.DemandeCongeId] = []; } detailsParDemande[detail.DemandeCongeId].push({ typeConge: detail.typeConge, nombreJours: parseFloat(detail.nombreJours), dateDebut: detail.dateDebut, dateFin: detail.dateFin }); }); // Enrichir chaque demande avec ses détails const demandesEnrichies = demandes.map(demande => ({ Id: demande.Id, nomEmploye: demande.nomEmploye, emailEmploye: demande.emailEmploye, service: demande.service, Campus: demande.campus, typesConge: demande.typesConge || 'Non spécifié', detailsConges: detailsParDemande[demande.Id] || [], // ✅ Toujours présent DateDebut: demande.DateDebut, DateFin: demande.DateFin, NombreJours: demande.NombreJours, Statut: demande.Statut, Commentaire: demande.Commentaire, DateDemande: demande.DateDemande })); console.log(`${demandesEnrichies.length} demandes avec détails`); res.json(demandesEnrichies); } catch (error) { console.error('Erreur GET /demandes:', error); res.status(500).json({ error: error.message }); } }); app.get('/demandes/:id', authenticateToken, async (req, res) => { try { const result = await pool.request() .input('id', sql.Int, req.params.id) .query(` SELECT dc.*, CONCAT(ca.prenom, ' ', ca.nom) AS nomEmploye, ca.email AS emailEmploye, ca.Actif AS employeActif, ca.DateSortie AS employeDateSortie, s.Nom AS service, s.Id AS serviceId, CONCAT(v.prenom, ' ', v.nom) AS nomValidateur, v.email AS emailValidateur FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN CollaborateurAD v ON dc.ValidateurADId = v.id WHERE dc.Id = @id `); const demandes = result.recordset; if (demandes.length === 0) { return res.status(404).json({ error: 'Demande non trouvée' }); } const typesResult = await pool.request() .input('id', sql.Int, req.params.id) .query(` SELECT dct.TypeCongeId as typeId, dct.NombreJours as nombreJours, tc.Nom AS nom, tc.CouleurHex AS couleur FROM DemandeCongeType dct JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dct.DemandeCongeId = @id `); const types = typesResult.recordset; res.json({ ...demandes[0], typesConge: types }); } catch (error) { console.error('❌ Erreur détails demande:', error); res.status(500).json({ error: error.message }); } }); // ================================================ // ROUTE PUT /demandes/:id - AVEC VALIDATION AUTOMATIQUE // Remplacer la route existante dans server.js (ligne ~1438) // ================================================ app.put('/demandes/:id', authenticateToken, async (req, res) => { const transaction = new sql.Transaction(pool); try { await transaction.begin(); const { id } = req.params; const { dateDebut, dateFin, typesConge, commentaire } = req.body; console.log('📝 Modification demande:', { id, dateDebut, dateFin, typesConge }); const demandeResult = await transaction.request() .input('id', sql.Int, id) .query('SELECT Statut, CollaborateurADId FROM DemandeConge WHERE Id = @id'); const demande = demandeResult.recordset; if (demande.length === 0) { await transaction.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 transaction.rollback(); return res.status(403).json({ error: 'Accès non autorisé', details: 'Seuls les Admin, RH, Validateurs ou l\'auteur peuvent modifier cette demande' }); } // ⭐ RESTAURER LES COMPTEURS SI LA DEMANDE ÉTAIT VALIDÉE if (ancienStatut === 'Validée') { console.log('⚠️ Modification d\'une demande validée - restauration des compteurs'); const ancienTypesResult = await transaction.request() .input('id', sql.Int, id) .query('SELECT TypeCongeId, NombreJours FROM DemandeCongeType WHERE DemandeCongeId = @id'); const ancienTypes = ancienTypesResult.recordset; const currentYear = new Date().getFullYear(); for (const type of ancienTypes) { // ⭐ Parser correctement les décimales const nombreJoursARestaurer = parseFloat(type.NombreJours); // Restaurer avec priorité CP N-1 puis CP N if (type.TypeCongeId === 1) { console.log(`\n🔄 Restauration CP - ${nombreJoursARestaurer}j à restaurer`); let joursRestantsARestaurer = nombreJoursARestaurer; // D'abord : Restaurer dans CP N const cpNResult = await transaction.request() .input('collaborateurId', sql.Int, collaborateurId) .input('typeId', sql.Int, type.TypeCongeId) .input('currentYear', sql.Int, currentYear) .query(` SELECT Id, Solde, Total FROM CompteurConges WHERE CollaborateurADId = @collaborateurId AND TypeCongeId = @typeId AND Annee = @currentYear `); if (cpNResult.recordset.length > 0) { const compteurN = cpNResult.recordset[0]; const soldeN = parseFloat(compteurN.Solde || 0); const totalN = parseFloat(compteurN.Total || 0); const aRestaurerN = Math.min(joursRestantsARestaurer, totalN - soldeN); if (aRestaurerN > 0) { await transaction.request() .input('restauration', sql.Decimal(10, 2), aRestaurerN) .input('compteurId', sql.Int, compteurN.Id) .query(` UPDATE CompteurConges SET Solde = Solde + @restauration, DerniereMiseAJour = GETDATE() WHERE Id = @compteurId `); console.log(` ✅ CP N (${currentYear}) : +${aRestaurerN}j restaurés`); joursRestantsARestaurer -= aRestaurerN; } } // Ensuite : Si besoin, restaurer dans CP N-1 if (joursRestantsARestaurer > 0) { console.log(` 🔄 Il reste ${joursRestantsARestaurer}j, restauration dans CP N-1`); const cpN1Result = await transaction.request() .input('collaborateurId', sql.Int, collaborateurId) .input('typeId', sql.Int, type.TypeCongeId) .input('previousYear', sql.Int, currentYear - 1) .query(` SELECT Id, Solde, Total FROM CompteurConges WHERE CollaborateurADId = @collaborateurId AND TypeCongeId = @typeId AND Annee = @previousYear `); if (cpN1Result.recordset.length > 0) { const compteurN1 = cpN1Result.recordset[0]; const soldeN1 = parseFloat(compteurN1.Solde || 0); const totalN1 = parseFloat(compteurN1.Total || 0); const aRestaurerN1 = Math.min(joursRestantsARestaurer, totalN1 - soldeN1); if (aRestaurerN1 > 0) { await transaction.request() .input('restauration', sql.Decimal(10, 2), aRestaurerN1) .input('compteurId', sql.Int, compteurN1.Id) .query(` UPDATE CompteurConges SET Solde = Solde + @restauration, DerniereMiseAJour = GETDATE() WHERE Id = @compteurId `); console.log(` ✅ CP N-1 (${currentYear - 1}) : +${aRestaurerN1}j restaurés`); } } } } else { // Autres types : restauration simple await transaction.request() .input('nombreJours', sql.Decimal(10, 2), nombreJoursARestaurer) .input('collaborateurId', sql.Int, collaborateurId) .input('typeCongeId', sql.Int, type.TypeCongeId) .query(` UPDATE CompteurConges SET Solde = Solde + @nombreJours, DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = @collaborateurId AND TypeCongeId = @typeCongeId AND Annee = YEAR(GETDATE()) `); console.log(` ✅ Type ${type.TypeCongeId} : +${nombreJoursARestaurer}j restaurés`); } } } // ⭐ Calculer le total avec support des décimales const totalJours = typesConge.reduce((sum, type) => { return sum + parseFloat(type.nombreJours || 0); }, 0); const nouveauStatut = 'Validée'; console.log(`📝 Mise à jour demande ${id} - Nouveau statut: ${nouveauStatut}`); // Mise à jour de la demande await transaction.request() .input('dateDebut', sql.Date, dateDebut) .input('dateFin', sql.Date, dateFin) .input('commentaire', sql.NVarChar, commentaire || '') .input('totalJours', sql.Decimal(10, 2), totalJours) .input('nouveauStatut', sql.VarChar, nouveauStatut) .input('validateurId', sql.Int, req.user.id) .input('id', sql.Int, id) .query(` UPDATE DemandeConge SET DateDebut = @dateDebut, DateFin = @dateFin, Commentaire = @commentaire, NombreJours = @totalJours, Statut = @nouveauStatut, ValidateurADId = @validateurId, DateValidation = GETDATE(), CommentaireValidation = 'Validation automatique après modification' WHERE Id = @id `); // Supprimer les anciens types await transaction.request() .input('id', sql.Int, id) .query('DELETE FROM DemandeCongeType WHERE DemandeCongeId = @id'); console.log('✅ Anciens types supprimés'); // Recréer les types avec génération manuelle d'ID for (const type of typesConge) { const maxTypeIdResult = await transaction.request() .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeCongeType'); const typeId = maxTypeIdResult.recordset[0].NextId; // ⭐ Parser correctement le nombre de jours const nombreJours = parseFloat(type.nombreJours || 0); await transaction.request() .input('id', sql.Int, typeId) .input('demandeId', sql.Int, id) .input('typeId', sql.Int, type.typeId) .input('nombreJours', sql.Decimal(10, 2), nombreJours) .query('INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours) VALUES (@id, @demandeId, @typeId, @nombreJours)'); console.log(`✅ Type ${type.typeId} recréé avec ID ${typeId} - ${nombreJours}j`); } // ⭐ DÉDUIRE LES NOUVEAUX COMPTEURS (AVEC PRIORITÉ CP N-1 puis CP N) console.log('💼 Déduction des nouveaux compteurs avec priorité CP N-1...'); const currentYear = new Date().getFullYear(); for (const type of typesConge) { const nombreJours = parseFloat(type.nombreJours || 0); // SI C'EST UN TYPE CP (ID = 1), GÉRER LA PRIORITÉ CP N-1 PUIS CP N if (type.typeId === 1) { console.log(`\n🔄 Traitement CP - ${nombreJours}j à déduire`); let joursRestantsADeduire = nombreJours; // 1️⃣ D'ABORD : Pioche dans CP N-1 const cpN1Result = await transaction.request() .input('collaborateurId', sql.Int, collaborateurId) .input('typeId', sql.Int, type.typeId) .input('previousYear', sql.Int, currentYear - 1) .query(` SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = @collaborateurId AND TypeCongeId = @typeId AND Annee = @previousYear `); if (cpN1Result.recordset.length > 0) { const compteurN1 = cpN1Result.recordset[0]; const soldeN1 = parseFloat(compteurN1.Solde || 0); if (soldeN1 > 0) { const aDeduireN1 = Math.min(soldeN1, joursRestantsADeduire); await transaction.request() .input('deduction', sql.Decimal(10, 2), aDeduireN1) .input('compteurId', sql.Int, compteurN1.Id) .query(` UPDATE CompteurConges SET Solde = Solde - @deduction, DerniereMiseAJour = GETDATE() WHERE Id = @compteurId `); console.log(` ✅ CP N-1 (${currentYear - 1}) : -${aDeduireN1}j`); joursRestantsADeduire -= aDeduireN1; } } // 2️⃣ ENSUITE : S'il reste des jours, pioche dans CP N if (joursRestantsADeduire > 0) { console.log(` 🔄 Il reste ${joursRestantsADeduire}j, pioche dans CP N`); const cpNResult = await transaction.request() .input('collaborateurId', sql.Int, collaborateurId) .input('typeId', sql.Int, type.typeId) .input('currentYear', sql.Int, currentYear) .query(` SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = @collaborateurId AND TypeCongeId = @typeId AND Annee = @currentYear `); if (cpNResult.recordset.length > 0) { const compteurN = cpNResult.recordset[0]; await transaction.request() .input('deduction', sql.Decimal(10, 2), joursRestantsADeduire) .input('compteurId', sql.Int, compteurN.Id) .query(` UPDATE CompteurConges SET Solde = Solde - @deduction, DerniereMiseAJour = GETDATE() WHERE Id = @compteurId `); console.log(` ✅ CP N (${currentYear}) : -${joursRestantsADeduire}j`); } } } else { // Pour les autres types (RTT, Récupération, etc.) await transaction.request() .input('nombreJours', sql.Decimal(10, 2), nombreJours) .input('collaborateurId', sql.Int, collaborateurId) .input('typeId', sql.Int, type.typeId) .query(` UPDATE CompteurConges SET Solde = Solde - @nombreJours, DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = @collaborateurId AND TypeCongeId = @typeId AND Annee = YEAR(GETDATE()) `); console.log(` ✅ Type ${type.typeId} : -${nombreJours}j`); } } const actionDetails = `Modification et validation automatique par ${req.user.role}`; // Historique try { const maxHistIdResult = await transaction.request() .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM HistoriqueActions'); const histId = maxHistIdResult.recordset[0].NextId; await transaction.request() .input('id', sql.Int, histId) .input('userId', sql.Int, req.user.id) .input('details', sql.NVarChar, actionDetails) .input('demandeId', sql.Int, id) .query(` INSERT INTO HistoriqueActions (Id, CollaborateurADId, Action, Details, DemandeCongeId, DateAction) VALUES (@id, @userId, 'Modification et validation', @details, @demandeId, GETDATE()) `); } catch (histError) { console.log('⚠️ Fallback historique sans ID...'); await transaction.request() .input('userId', sql.Int, req.user.id) .input('details', sql.NVarChar, actionDetails) .input('demandeId', sql.Int, id) .query(` INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId, DateAction) VALUES (@userId, 'Modification et validation', @details, @demandeId, GETDATE()) `); } await transaction.commit(); console.log('✅ Modification et validation terminées'); // Notifications SSE notifyClients({ type: 'demande-validated', demandeId: parseInt(id), statut: 'Validée', collaborateurId: collaborateurId, timestamp: new Date().toISOString() }, collaborateurId); notifyClients({ type: 'demande-updated', 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() }); // Webhooks try { await webhookManager.sendWebhook( WEBHOOKS.COLLABORATEURS_URL, EVENTS.DEMANDE_VALIDATED, { demandeId: parseInt(id), statut: 'Validée', collaborateurId: collaborateurId, validateurId: req.user.id } ); 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 modifiée et validée automatiquement', nouveauStatut: nouveauStatut, ancienStatut: ancienStatut }); console.log(`✅ Demande ${id} modifiée et validée par ${req.user.role}`); } catch (error) { await transaction.rollback(); console.error('❌ Erreur modification demande:', error); res.status(500).json({ error: error.message }); } }); app.delete('/demandes/:id', authenticateToken, async (req, res) => { const transaction = new sql.Transaction(pool); try { await transaction.begin(); const { id } = req.params; const demandeResult = await transaction.request() .input('id', sql.Int, id) .query('SELECT Statut, CollaborateurADId FROM DemandeConge WHERE Id = @id'); const demande = demandeResult.recordset; if (demande.length === 0) { await transaction.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 transaction.rollback(); return res.status(403).json({ error: 'Accès non autorisé', details: 'Seuls les Admin, RH, Validateurs ou l\'auteur peuvent supprimer cette demande' }); } // Supprimer d'abord les entrées d'historique await transaction.request() .input('id', sql.Int, id) .query('DELETE FROM HistoriqueActions WHERE DemandeCongeId = @id'); // Supprimer les types de congé await transaction.request() .input('id', sql.Int, id) .query('DELETE FROM DemandeCongeType WHERE DemandeCongeId = @id'); // Supprimer la demande await transaction.request() .input('id', sql.Int, id) .query('DELETE FROM DemandeConge WHERE Id = @id'); await transaction.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 transaction.rollback(); console.error('❌ Erreur suppression demande:', error); res.status(500).json({ error: error.message }); } }); // ⭐ ROUTE VALIDATION AVEC WEBHOOKS // ⭐ ROUTE VALIDATION AVEC WEBHOOKS - CORRIGÉE app.put('/demandes/:id/valider', authenticateToken, async (req, res) => { const transaction = new sql.Transaction(pool); try { await transaction.begin(); const { id } = req.params; const { statut, commentaire } = req.body; console.log('\n🔍 === DÉBUT VALIDATION DEMANDE ==='); const demandeInfoResult = await transaction.request() .input('id', sql.Int, id) .query(` SELECT dc.Id, dc.CollaborateurADId, dc.DateDebut, dc.DateFin, dc.NombreJours, ca.prenom, ca.nom, ca.email as collaborateur_email, ca.Actif, STRING_AGG(tc.Nom, ', ') as TypeConge FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dc.Id = @id GROUP BY dc.Id, dc.CollaborateurADId, dc.DateDebut, dc.DateFin, dc.NombreJours, ca.prenom, ca.nom, ca.email, ca.Actif `); const demandeInfo = demandeInfoResult.recordset; if (demandeInfo.length === 0) { await transaction.rollback(); return res.status(404).json({ error: 'Demande non trouvée' }); } const demande = demandeInfo[0]; const collaborateurId = demande.CollaborateurADId; await transaction.request() .input('statut', sql.VarChar, statut) .input('commentaire', sql.NVarChar, commentaire || '') .input('validateurId', sql.Int, req.user.id) .input('id', sql.Int, id) .query(` UPDATE DemandeConge SET Statut = @statut, CommentaireValidation = @commentaire, ValidateurADId = @validateurId, DateValidation = GETDATE() WHERE Id = @id `); if (statut === 'Validée') { const typesResult = await transaction.request() .input('id', sql.Int, id) .query('SELECT TypeCongeId, NombreJours FROM DemandeCongeType WHERE DemandeCongeId = @id'); const types = typesResult.recordset; const currentYear = new Date().getFullYear(); for (const type of types) { await transaction.request() .input('nombreJours', sql.Decimal(10, 2), type.NombreJours) .input('collaborateurId', sql.Int, collaborateurId) .input('typeCongeId', sql.Int, type.TypeCongeId) .query(` UPDATE CompteurConges SET Solde = Solde - @nombreJours WHERE CollaborateurADId = @collaborateurId AND TypeCongeId = @typeCongeId AND Annee = YEAR(GETDATE()) `); // ✅ AJOUT : Insérer dans DeductionDetails pour cohérence avec /annuler await transaction.request() .input('demandeId', sql.Int, id) .input('typeId', sql.Int, type.TypeCongeId) .input('annee', sql.Int, currentYear) .input('jours', sql.Decimal(10, 2), type.NombreJours) .query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'Année N', @jours) `); } } // ⭐ CORRECTION: Génération manuelle de l'ID pour HistoriqueActions try { const maxHistIdResult = await transaction.request() .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM HistoriqueActions'); const histId = maxHistIdResult.recordset[0].NextId; await transaction.request() .input('id', sql.Int, histId) .input('userId', sql.Int, req.user.id) .input('action', sql.VarChar, statut === 'Validée' ? 'Validation congé' : 'Refus congé') .input('commentaire', sql.NVarChar, commentaire || '') .input('demandeId', sql.Int, id) .query(` INSERT INTO HistoriqueActions (Id, CollaborateurADId, Action, Details, DemandeCongeId, DateAction) VALUES (@id, @userId, @action, @commentaire, @demandeId, GETDATE()) `); console.log(`✅ Historique validation créé avec ID: ${histId}`); } catch (histError) { console.error('⚠️ Erreur historique (non bloquant):', histError.message); } await transaction.commit(); // ⭐ ENVOI EMAIL const accessToken = await getGraphToken(); 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 transaction.rollback(); console.error('\n❌ ERREUR VALIDATION:', error); res.status(500).json({ error: error.message }); } }); // Route pour export paie par plage de dates app.get('/export/paie-plage', authenticateToken, async (req, res) => { const { dateDebut, dateFin } = req.query; if (!dateDebut || !dateFin) { return res.status(400).json({ error: 'Date de début et date de fin requises' }); } console.log(`📊 Export paie - Plage: ${dateDebut} au ${dateFin}`); try { const debut = new Date(dateDebut); const fin = new Date(dateFin); if (isNaN(debut.getTime()) || isNaN(fin.getTime())) { return res.status(400).json({ error: 'Dates invalides' }); } if (debut > fin) { return res.status(400).json({ error: 'La date de début doit être antérieure à la date de fin' }); } const result = await pool.request() .input('dateDebut', sql.Date, dateDebut) .input('dateFin', sql.Date, dateFin) .query(` SELECT ca.id AS collaborateurId, CONCAT(ca.prenom, ' ', ca.nom) AS employe, ca.email, COALESCE(s.Nom, 'Non défini') AS service, tc.Nom AS typeConge, dct.NombreJours, dct.PeriodeJournee, -- ⭐ Dates précises du TYPE de congé COALESCE(dct.DateDebut, dc.DateDebut) AS dateDebutType, COALESCE(dct.DateFin, dc.DateFin) AS dateFinType, dc.DateDebut, dc.DateFin, dc.Statut FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id LEFT JOIN Services s ON ca.ServiceId = s.Id JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dc.Statut = 'Validée' AND (ca.Actif = 1 OR ca.Actif IS NULL) AND ( (COALESCE(dct.DateDebut, dc.DateDebut) BETWEEN @dateDebut AND @dateFin) OR (COALESCE(dct.DateFin, dc.DateFin) BETWEEN @dateDebut AND @dateFin) OR (COALESCE(dct.DateDebut, dc.DateDebut) <= @dateDebut AND COALESCE(dct.DateFin, dc.DateFin) >= @dateFin) ) ORDER BY ca.nom, ca.prenom, COALESCE(dct.DateDebut, dc.DateDebut) `); console.log(`📊 ${result.recordset.length} entrées trouvées`); const dataFormatted = agregerLignesParEmploye(result.recordset); console.log(`✅ Export paie-plage formaté: ${dataFormatted.length} employés`); res.json(dataFormatted); } catch (error) { console.error('❌ Erreur export paie plage:', error); res.status(500).json({ error: 'Erreur lors de la génération du rapport', details: error.message }); } }); // ================================================ // ROUTE GET /export/paie-saisie // Filtre par date de SAISIE de la demande (DateDemande) // À ajouter dans server.js après la route /export/paie-plage // ================================================ app.get('/export/paie-saisie', authenticateToken, async (req, res) => { const { dateSaisieDebut, dateSaisieFin } = req.query; console.log(`📊 Export paie-saisie: saisie entre ${dateSaisieDebut} et ${dateSaisieFin}`); if (!dateSaisieDebut || !dateSaisieFin) { return res.status(400).json({ error: 'Date de saisie début et fin requises' }); } try { // ✅ DEBUG : voir ce qu'il y a en base pour juin const debug = await pool.request() .input('dateSaisieDebut', sql.Date, dateSaisieDebut) .input('dateSaisieFin', sql.Date, dateSaisieFin) .query(` SELECT CONCAT(ca.prenom, ' ', ca.nom) AS employe, dc.DateDemande, dc.DateDebut, dc.DateFin, COALESCE(dct.DateDebut, dc.DateDebut) AS dateDebutType, COALESCE(dct.DateFin, dc.DateFin) AS dateFinType, tc.Nom AS typeConge, dc.Statut FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dc.Statut = 'Validée' AND (ca.Actif = 1 OR ca.Actif IS NULL) AND COALESCE(dct.DateDebut, dc.DateDebut) <= @dateSaisieFin AND COALESCE(dct.DateFin, dc.DateFin) >= @dateSaisieDebut `); console.log('🔍 DEBUG - Résultats sans filtre saisie:'); debug.recordset.forEach(r => { console.log(` 👤 ${r.employe} | Saisie: ${r.DateDemande} | Congé: ${r.DateDebut} → ${r.DateFin} | Type dates: ${r.dateDebutType} → ${r.dateFinType} | Type: ${r.typeConge}`); }); const result = await pool.request() .input('dateSaisieDebut', sql.Date, dateSaisieDebut) .input('dateSaisieFin', sql.Date, dateSaisieFin) .query(` SELECT ca.id AS collaborateurId, CONCAT(ca.prenom, ' ', ca.nom) AS employe, ca.email, COALESCE(s.Nom, 'Non défini') AS service, tc.Nom AS typeConge, dct.NombreJours, dct.PeriodeJournee, COALESCE(dct.DateDebut, dc.DateDebut) AS dateDebutType, COALESCE(dct.DateFin, dc.DateFin) AS dateFinType, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id LEFT JOIN Services s ON ca.ServiceId = s.Id JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dc.Statut = 'Validée' AND (ca.Actif = 1 OR ca.Actif IS NULL) AND COALESCE(dct.DateDebut, dc.DateDebut) <= @dateSaisieFin AND COALESCE(dct.DateFin, dc.DateFin) >= @dateSaisieDebut AND CAST(dc.DateDemande AS DATE) <= @dateSaisieFin ORDER BY ca.nom, ca.prenom, COALESCE(dct.DateDebut, dc.DateDebut) `); console.log(`📊 ${result.recordset.length} entrées trouvées`); const dataFormatted = agregerLignesParEmploye(result.recordset); console.log(`✅ Export paie-saisie formaté: ${dataFormatted.length} employés`); res.json(dataFormatted); } catch (error) { console.error('❌ Erreur export paie-saisie:', error); res.status(500).json({ error: 'Erreur lors de la génération du rapport', details: error.message }); } }); // ================================================ // ROUTES EMPLOYÉS // ================================================ app.get('/employes', authenticateToken, async (req, res) => { try { const currentYear = new Date().getFullYear(); const previousYear = currentYear - 1; const result = await pool.request() .input('currentYear', sql.Int, currentYear) .input('previousYear', sql.Int, previousYear) .query(` SELECT ca.id, ca.nom, ca.prenom, ca.email, ca.role, COALESCE(s.Nom, 'Sans service') as service, ca.Actif, ca.TypeContrat, -- CP Année N (SELECT TOP 1 COALESCE(Solde, 0) FROM CompteurConges cc1 WHERE cc1.CollaborateurADId = ca.id AND cc1.TypeCongeId = (SELECT TOP 1 Id FROM TypeConge WHERE Nom LIKE '%Congé%payé%' OR Nom LIKE '%CP%' ORDER BY Id) AND cc1.Annee = @currentYear) as soldeCPN, -- CP Année N-1 (SELECT TOP 1 COALESCE(Solde, 0) FROM CompteurConges cc2 WHERE cc2.CollaborateurADId = ca.id AND cc2.TypeCongeId = (SELECT TOP 1 Id FROM TypeConge WHERE Nom LIKE '%Congé%payé%' OR Nom LIKE '%CP%' ORDER BY Id) AND cc2.Annee = @previousYear) as soldeCPNMoins1, -- RTT (SELECT TOP 1 COALESCE(Solde, 0) FROM CompteurConges cc3 WHERE cc3.CollaborateurADId = ca.id AND cc3.TypeCongeId = (SELECT TOP 1 Id FROM TypeConge WHERE Nom = 'RTT' ORDER BY Id) AND cc3.Annee = @currentYear) as soldeRTT, -- Récupération (SELECT TOP 1 COALESCE(Solde, 0) FROM CompteurConges cc4 WHERE cc4.CollaborateurADId = ca.id AND cc4.TypeCongeId = (SELECT TOP 1 Id FROM TypeConge WHERE Nom LIKE '%Récup%' OR Nom LIKE '%Recup%' ORDER BY Id) AND cc4.Annee = @currentYear) as soldeRecup FROM CollaborateurAD ca LEFT JOIN Services s ON ca.ServiceId = s.Id WHERE ca.Actif = 1 OR ca.Actif IS NULL ORDER BY ca.nom, ca.prenom `); console.log(`✅ ${result.recordset.length} employés récupérés avec compteurs`); res.json(result.recordset); } catch (error) { console.error('❌ Erreur /api/employes:', error); res.status(500).json({ error: error.message }); } }); // ✅ NOUVELLE ROUTE: Désactiver un collaborateur app.post('/employes/desactiver', authenticateToken, async (req, res) => { try { 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 collabResult = await pool.request() .input('id', sql.Int, collaborateur_id) .query('SELECT prenom, nom, email FROM CollaborateurAD WHERE id = @id'); const collab = collabResult.recordset; if (collab.length === 0) { return res.json({ success: false, message: 'Collaborateur non trouvé' }); } await pool.request() .input('dateSortie', sql.Date, dateSortie) .input('id', sql.Int, collaborateur_id) .query('UPDATE CollaborateurAD SET Actif = 0, DateSortie = @dateSortie WHERE id = @id'); res.json({ success: true, 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 collabResult = await pool.request() .input('id', sql.Int, collaborateur_id) .query('SELECT prenom, nom, email FROM CollaborateurAD WHERE id = @id'); const collab = collabResult.recordset; if (collab.length === 0) { return res.json({ success: false, message: 'Collaborateur non trouvé' }); } await pool.request() .input('id', sql.Int, collaborateur_id) .query('UPDATE CollaborateurAD SET Actif = 1, DateSortie = NULL WHERE id = @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 result = await pool.request().query( 'SELECT Id as id, Nom as nom, CouleurHex as couleur FROM TypeConge WHERE Actif = 1 ORDER BY Nom' ); res.json(result.recordset); } catch (error) { res.status(500).json({ error: error.message }); } }); // Suite - Routes export, compteurs, équipes, stats, etc. app.get('/export/paie', authenticateToken, async (req, res) => { try { const { mois, annee, dateSaisieDebut, dateSaisieFin } = req.query; 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}`; // Le filtre création est actif uniquement si les deux dates sont fournies const filtreCreationActif = dateSaisieDebut && dateSaisieFin; console.log(`📊 Export paie: ${mois}/${annee} (${premierJour} -> ${dernierJourMois})${filtreCreationActif ? ` | Filtre création: ${dateSaisieDebut} -> ${dateSaisieFin}` : ''}`); const request = pool.request() .input('premierJour', sql.Date, premierJour) .input('dernierJourMois', sql.Date, dernierJourMois); // Construire la clause WHERE du filtre de création let whereCreation = ''; if (filtreCreationActif) { request .input('dateSaisieDebut', sql.Date, dateSaisieDebut) .input('dateSaisieFin', sql.Date, dateSaisieFin); whereCreation = `AND CAST(dc.DateDemande AS DATE) >= @dateSaisieDebut AND CAST(dc.DateDemande AS DATE) <= @dateSaisieFin`; } const result = await request.query(` SELECT ca.id AS collaborateurId, CONCAT(ca.prenom, ' ', ca.nom) AS employe, ca.email, COALESCE(s.Nom, 'Non assigné') AS service, tc.Nom AS typeConge, dct.NombreJours, dct.PeriodeJournee, -- Dates précises du TYPE de congé COALESCE(dct.DateDebut, dc.DateDebut) AS dateDebutType, COALESCE(dct.DateFin, dc.DateFin) AS dateFinType, -- Dates de la demande (pour le filtre période) dc.DateDebut, dc.DateFin, dc.Statut FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id LEFT JOIN Services s ON ca.ServiceId = s.Id JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dc.Statut = 'Validée' AND (ca.Actif = 1 OR ca.Actif IS NULL) AND ( -- La période du TYPE chevauche le mois (COALESCE(dct.DateDebut, dc.DateDebut) BETWEEN @premierJour AND @dernierJourMois) OR (COALESCE(dct.DateFin, dc.DateFin) BETWEEN @premierJour AND @dernierJourMois) OR (COALESCE(dct.DateDebut, dc.DateDebut) <= @premierJour AND COALESCE(dct.DateFin, dc.DateFin) >= @dernierJourMois) ) ${whereCreation} ORDER BY ca.nom, ca.prenom, COALESCE(dct.DateDebut, dc.DateDebut) `); console.log(`📊 ${result.recordset.length} entrées trouvées`); const dataFormatted = agregerLignesParEmploye(result.recordset); console.log(`✅ Export formaté: ${dataFormatted.length} employés`); res.json(dataFormatted); } catch (error) { console.error('❌ Erreur export paie:', error); res.status(500).json({ error: error.message }); } }); // Route pour obtenir les informations d'un collaborateur app.get('/employes/:id/info', authenticateToken, async (req, res) => { try { const result = await pool.request() .input('id', sql.Int, req.params.id) .query(` SELECT id, nom, prenom, email, role, TypeContrat, Actif, DateSortie, ServiceId FROM CollaborateurAD WHERE id = @id `); const employe = result.recordset; if (employe.length === 0) { return res.status(404).json({ error: 'Employé non trouvé' }); } 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 /compteurs pour inclure les infos sur les apprentis app.get('/compteurs', authenticateToken, async (req, res) => { try { const includeInactifs = req.query.include_inactifs === 'true'; const { user_id } = req.query; let query = ` SELECT cc.Id AS id, cc.CollaborateurADId AS collaborateurId, cc.TypeCongeId AS typeCongeId, CONCAT(ca.prenom, ' ', ca.nom) AS employe, ca.email AS email, ca.Actif AS Actif, ca.role AS role, ca.TypeContrat AS TypeContrat, COALESCE(s.Nom, 'Sans service') AS service, tc.Nom AS typeConge, cc.Annee AS annee, cc.Total AS total, 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 AND (ca.description IS NULL OR ca.description NOT LIKE '%stagiaire%') `; const request = pool.request(); // Filtre par utilisateur si fourni if (user_id) { query += ' AND cc.CollaborateurADId = @userId'; request.input('userId', sql.Int, parseInt(user_id)); } // Filtre actif/inactif if (!includeInactifs) { query += ' AND (ca.Actif = 1 OR ca.Actif IS NULL)'; } query += ' ORDER BY ca.Actif DESC, ca.nom, ca.prenom, tc.Nom'; const result = await request.query(query); const compteurs = result.recordset; console.log(`✅ ${compteurs.length} compteurs trouvés`); // ⭐ DEBUG: Afficher les types de congés trouvés const typesConges = [...new Set(compteurs.map(c => c.typeConge))]; console.log('📊 Types de congés:', typesConges); // Ajouter l'indicateur apprenti et formater les données const compteursFormates = compteurs.map(c => ({ id: c.id, collaborateurId: c.collaborateurId, typeCongeId: c.typeCongeId, employe: c.employe, email: c.email, service: c.service || 'Sans service', typeConge: c.typeConge, annee: c.annee, total: parseFloat(c.total) || 0, solde: parseFloat(c.solde) || 0, soldeReporte: parseFloat(c.soldeReporte) || 0, consomme: parseFloat(c.consomme) || 0, estApprenti: c.TypeContrat === 'Apprentissage' || c.role === 'Apprenti' || (c.role || '').toLowerCase().includes('apprenti') })); res.json(compteursFormates); } catch (error) { console.error('❌ Erreur récupération compteurs:', error); res.status(500).json({ error: error.message }); } }); app.put('/compteurs/:id', authenticateToken, async (req, res) => { const transaction = new sql.Transaction(pool); try { await transaction.begin(); if (!['Admin', 'RH', 'Validateur'].includes(req.user.role)) { await transaction.rollback(); return res.status(403).json({ error: 'Accès non autorisé' }); } const { id } = req.params; const { total, solde } = req.body; const totalNum = parseFloat(total); const soldeNum = parseFloat(solde); const existingResult = await transaction.request() .input('id', sql.Int, id) .query('SELECT * FROM CompteurConges WHERE Id = @id'); const existing = existingResult.recordset; if (existing.length === 0) { await transaction.rollback(); 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 typeInfoResult = await transaction.request() .input('typeCongeId', sql.Int, typeCongeId) .query('SELECT Nom FROM TypeConge WHERE Id = @typeCongeId'); const typeConge = typeInfoResult.recordset.length > 0 ? typeInfoResult.recordset[0].Nom : 'Inconnu'; // Mise à jour await transaction.request() .input('total', sql.Decimal(10, 2), totalNum) .input('solde', sql.Decimal(10, 2), soldeNum) .input('id', sql.Int, id) .query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id'); await transaction.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 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 transaction.rollback(); console.error('Erreur modification compteur:', 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 transaction = new sql.Transaction(pool); try { await transaction.begin(); if (!['Admin', 'RH'].includes(req.user.role)) { await transaction.rollback(); return res.status(403).json({ error: 'Accès non autorisé' }); } const { collaborateurId, nombreJours, commentaire } = req.body; if (!collaborateurId || !nombreJours || nombreJours <= 0) { await transaction.rollback(); 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 collaborateurResult = await transaction.request() .input('id', sql.Int, collaborateurId) .query('SELECT prenom, nom, email, Actif FROM CollaborateurAD WHERE id = @id'); const collaborateur = collaborateurResult.recordset; if (collaborateur.length === 0) { await transaction.rollback(); return res.status(404).json({ success: false, message: 'Collaborateur non trouvé' }); } if (collaborateur[0].Actif === 0) { await transaction.rollback(); 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 recupTypeResult = await transaction.request() .query("SELECT Id FROM TypeConge WHERE Nom = 'Récupération'"); const recupType = recupTypeResult.recordset; if (recupType.length === 0) { await transaction.rollback(); 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 compteurExistingResult = await transaction.request() .input('collaborateurId', sql.Int, collaborateurId) .input('recupTypeId', sql.Int, recupTypeId) .input('currentYear', sql.Int, currentYear) .query(` SELECT Id, Total, Solde FROM CompteurConges WHERE CollaborateurADId = @collaborateurId AND TypeCongeId = @recupTypeId AND Annee = @currentYear `); const compteurExisting = compteurExistingResult.recordset; let nouveauTotal, nouveauSolde; if (compteurExisting.length > 0) { nouveauTotal = parseFloat(compteurExisting[0].Total) + nombreJours; nouveauSolde = parseFloat(compteurExisting[0].Solde) + nombreJours; // Mettre à jour le compteur existant await transaction.request() .input('total', sql.Decimal(10, 2), nouveauTotal) .input('solde', sql.Decimal(10, 2), nouveauSolde) .input('id', sql.Int, compteurExisting[0].Id) .query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id'); console.log(`✅ Compteur Récupération mis à jour: +${nombreJours}j (nouveau solde: ${nouveauSolde.toFixed(2)}j)`); } else { nouveauTotal = nombreJours; nouveauSolde = nombreJours; // Créer un nouveau compteur await transaction.request() .input('collaborateurId', sql.Int, collaborateurId) .input('recupTypeId', sql.Int, recupTypeId) .input('currentYear', sql.Int, currentYear) .input('total', sql.Decimal(10, 2), nouveauTotal) .input('solde', sql.Decimal(10, 2), nouveauSolde) .query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (@collaborateurId, @recupTypeId, @currentYear, @total, @solde, 0, GETDATE()) `); console.log(`✅ Compteur Récupération créé: ${nombreJours}j`); } // Enregistrer dans l'historique await transaction.request() .input('userId', sql.Int, req.user.id) .input('details', sql.NVarChar, `Ajout de ${nombreJours}j de récupération pour ${collab.prenom} ${collab.nom}${commentaire ? ` - ${commentaire}` : ''}`) .query(` INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction) VALUES (@userId, 'Ajout récupération', @details, GETDATE()) `); await transaction.commit(); // Webhook try { await webhookManager.sendWebhook( WEBHOOKS.COLLABORATEURS_URL, EVENTS.COMPTEUR_UPDATED, { collaborateurId: collaborateurId, typeCongeId: recupTypeId, typeConge: 'Récupération', typeUpdate: 'ajout_manuel_rh', annee: currentYear, nouveauTotal: nouveauTotal, nouveauSolde: nouveauSolde, source: 'bouton_plus_rh', timestamp: new Date().toISOString() } ); console.log('✅ Webhook GTA-RH envoyé (compteur récup mis à jour)'); } catch (webhookError) { console.error('❌ Erreur webhook (non bloquant):', webhookError.message); } // Notifier les clients SSE notifyClients({ type: 'compteur-updated', collaborateurId: collaborateurId, typeConge: 'Récupération', action: 'ajout', nombreJours: nombreJours, timestamp: new Date().toISOString() }, collaborateurId); // Envoyer un email 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 et peuvent être utilisés via une demande de congé.

`; try { await sendMailGraph(accessToken, fromEmail, collab.email, subject, body); console.log('✅ Email de notification envoyé'); } catch (emailError) { console.error('❌ Erreur envoi email:', emailError); } } 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 transaction.rollback(); console.error('❌ Erreur ajout récupération:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); // Suite - Routes équipes, stats, historique // ================================================ // ROUTE POUR RETIRER DES JOURS DE RÉCUPÉRATION // ================================================ app.post('/compteurs/retirer-recup', authenticateToken, async (req, res) => { const transaction = new sql.Transaction(pool); try { await transaction.begin(); if (!['Admin', 'RH'].includes(req.user.role)) { await transaction.rollback(); return res.status(403).json({ error: 'Accès non autorisé' }); } const { collaborateurId, nombreJours, commentaire } = req.body; if (!collaborateurId || !nombreJours || nombreJours <= 0) { await transaction.rollback(); 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 collaborateurResult = await transaction.request() .input('id', sql.Int, collaborateurId) .query('SELECT prenom, nom, email FROM CollaborateurAD WHERE id = @id'); const collaborateur = collaborateurResult.recordset; if (collaborateur.length === 0) { await transaction.rollback(); 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 recupTypeResult = await transaction.request() .query("SELECT Id FROM TypeConge WHERE Nom = 'Récupération'"); const recupType = recupTypeResult.recordset; if (recupType.length === 0) { await transaction.rollback(); 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 compteurResult = await transaction.request() .input('collaborateurId', sql.Int, collaborateurId) .input('recupTypeId', sql.Int, recupTypeId) .input('currentYear', sql.Int, currentYear) .query(` SELECT Id, Total, Solde FROM CompteurConges WHERE CollaborateurADId = @collaborateurId AND TypeCongeId = @recupTypeId AND Annee = @currentYear `); const compteur = compteurResult.recordset; if (compteur.length === 0 || compteur[0].Solde < nombreJours) { await transaction.rollback(); return res.status(400).json({ success: false, message: 'Solde de récupération insuffisant', soldeActuel: compteur.length > 0 ? compteur[0].Solde : 0 }); } const nouveauTotal = Math.max(0, parseFloat(compteur[0].Total) - nombreJours); const nouveauSolde = Math.max(0, parseFloat(compteur[0].Solde) - nombreJours); // Retirer les jours await transaction.request() .input('total', sql.Decimal(10, 2), nouveauTotal) .input('solde', sql.Decimal(10, 2), nouveauSolde) .input('id', sql.Int, compteur[0].Id) .query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id'); console.log(`✅ ${nombreJours}j retirés du compteur Récupération`); // Enregistrer dans l'historique await transaction.request() .input('userId', sql.Int, req.user.id) .input('details', sql.NVarChar, `Retrait de ${nombreJours}j de récupération pour ${collab.prenom} ${collab.nom}${commentaire ? ` - ${commentaire}` : ''}`) .query(` INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction) VALUES (@userId, 'Retrait récupération', @details, GETDATE()) `); await transaction.commit(); // Webhook try { await webhookManager.sendWebhook( WEBHOOKS.COLLABORATEURS_URL, EVENTS.COMPTEUR_UPDATED, { collaborateurId: collaborateurId, typeCongeId: recupTypeId, typeConge: 'Récupération', typeUpdate: 'retrait_manuel_rh', annee: currentYear, nouveauTotal: nouveauTotal, nouveauSolde: nouveauSolde, source: 'bouton_moins_rh', timestamp: new Date().toISOString() } ); console.log('✅ Webhook GTA-RH envoyé (compteur récup mis à jour)'); } catch (webhookError) { console.error('❌ Erreur webhook (non bloquant):', webhookError.message); } // Notifier les clients SSE notifyClients({ type: 'compteur-updated', collaborateurId: collaborateurId, typeConge: 'Récupération', action: 'retrait', nombreJours: nombreJours, timestamp: new Date().toISOString() }, collaborateurId); 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 transaction.rollback(); 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 result = await pool.request().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( STUFF(( SELECT DISTINCT ',' + c1.Nom FROM ServiceAffectation sa1 JOIN Campus c1 ON sa1.CampusId = c1.Id WHERE sa1.ServiceId = s.Id ORDER BY ',' + c1.Nom FOR XML PATH('') ), 1, 1, ''), STUFF(( SELECT DISTINCT ',' + c2.Nom FROM CollaborateurAD ca2 JOIN Campus c2 ON ca2.CampusId = c2.Id WHERE ca2.ServiceId = s.Id AND (ca2.Actif = 1 OR ca2.Actif IS NULL) ORDER BY ',' + c2.Nom FOR XML PATH('') ), 1, 1, ''), 'Non assigné' ) as campus FROM Services s LEFT JOIN CollaborateurAD ca ON s.Id = ca.ServiceId AND (ca.Actif = 1 OR ca.Actif IS NULL) LEFT JOIN DemandeConge dc ON ca.id = dc.CollaborateurADId AND dc.Statut = 'En attente' GROUP BY s.Id, s.Nom HAVING COUNT(DISTINCT CASE WHEN (ca.Actif = 1 OR ca.Actif IS NULL) THEN ca.id END) > 0 OR EXISTS (SELECT 1 FROM ServiceAffectation sa2 WHERE sa2.ServiceId = s.Id) ORDER BY s.Nom `); const equipes = result.recordset; res.json(equipes); } catch (error) { console.error('❌ Erreur /equipes:', error); res.status(500).json({ error: error.message }); } }); app.get('/equipes/:id', authenticateToken, async (req, res) => { try { const result = await pool.request() .input('id', sql.Int, req.params.id) .query(` SELECT s.Id, s.Nom, s.Nom as nomService, COUNT(DISTINCT CASE WHEN (ca.Actif = 1 OR ca.Actif IS NULL) THEN ca.id END) as nombreMembres, COUNT(DISTINCT CASE WHEN dc.Statut = 'En attente' AND (ca.Actif = 1 OR ca.Actif IS NULL) THEN dc.Id END) as demandesEnAttente, COALESCE( STUFF(( SELECT DISTINCT ', ' + c.Nom FROM ServiceAffectation sa JOIN Campus c ON sa.CampusId = c.Id WHERE sa.ServiceId = s.Id ORDER BY ', ' + c.Nom FOR XML PATH('') ), 1, 2, ''), 'Non assigné' ) as campus FROM Services s LEFT JOIN CollaborateurAD ca ON s.Id = ca.ServiceId LEFT JOIN DemandeConge dc ON ca.id = dc.CollaborateurADId WHERE s.Id = @id GROUP BY s.Id, s.Nom `); const equipes = result.recordset; if (equipes.length === 0) { return res.status(404).json({ error: 'Service non trouvé' }); } res.json(equipes[0]); } catch (error) { console.error('Erreur /equipes/:id:', error); res.status(500).json({ error: error.message }); } }); app.get('/equipes/:id/membres', authenticateToken, async (req, res) => { try { const result = await pool.request() .input('id', sql.Int, req.params.id) .query(` SELECT ca.id, CONCAT(ca.prenom, ' ', ca.nom) as nom, ca.email, ca.role, ca.Actif, ca.DateSortie, ca.TypeContrat, (SELECT MAX(Solde) FROM CompteurConges cc WHERE cc.CollaborateurADId = ca.id AND cc.TypeCongeId = 1 AND cc.Annee = YEAR(GETDATE())) as soldeCP, (SELECT MAX(Solde) FROM CompteurConges cc2 WHERE cc2.CollaborateurADId = ca.id AND cc2.TypeCongeId = 2 AND cc2.Annee = YEAR(GETDATE())) as soldeRTT FROM CollaborateurAD ca WHERE ca.ServiceId = @id AND (ca.Actif = 1 OR ca.Actif IS NULL) ORDER BY ca.nom, ca.prenom `); const membres = result.recordset; console.log(`${membres.length} membres trouvés pour le service ${req.params.id}`); res.json(membres); } catch (error) { console.error('Erreur /equipes/:id/membres:', error); res.status(500).json({ error: error.message }); } }); app.get('/stats', authenticateToken, async (req, res) => { try { const result = await pool.request().query(` SELECT COUNT(CASE WHEN Statut = 'En attente' AND (ca.Actif = 1 OR ca.Actif IS NULL) THEN 1 END) as enAttente, COUNT(CASE WHEN Statut = 'Validée' AND MONTH(DateValidation) = MONTH(GETDATE()) AND (ca.Actif = 1 OR ca.Actif IS NULL) THEN 1 END) as valideeCeMois, COUNT(DISTINCT ca.ServiceId) as nombreEquipes FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id WHERE (ca.Actif = 1 OR ca.Actif IS NULL) `); const stats = result.recordset; res.json(stats[0]); } catch (error) { res.status(500).json({ error: error.message }); } }); // Route /historique modifiée pour inclure le collaborateur concerné ET la personne qui a réalisé l'action app.get('/historique', authenticateToken, async (req, res) => { try { const { dateDebut, dateFin, action } = req.query; const request = pool.request(); let whereClause = 'WHERE 1=1'; if (dateDebut) { whereClause += ' AND ha.DateAction >= @dateDebut'; request.input('dateDebut', sql.DateTime, dateDebut); } if (dateFin) { whereClause += ' AND ha.DateAction <= @dateFin'; request.input('dateFin', sql.DateTime, dateFin); } if (action && action !== 'all') { whereClause += ' AND ha.Action = @action'; request.input('action', sql.VarChar, action); } // ✅ REQUÊTE MODIFIÉE : Inclut le collaborateur concerné ET la personne qui a réalisé l'action const query = ` SELECT TOP 500 ha.Id, -- Personne qui a réalisé l'action (celui qui est dans HistoriqueActions.CollaborateurADId) CONCAT(ca.prenom, ' ', ca.nom) AS realisePar, ca.email AS realiseParEmail, -- Collaborateur concerné par l'action (récupéré via la demande de congé) CASE WHEN dc.CollaborateurADId IS NOT NULL THEN CONCAT(collab_concerne.prenom, ' ', collab_concerne.nom) ELSE CONCAT(ca.prenom, ' ', ca.nom) END AS collaborateur, CASE WHEN dc.CollaborateurADId IS NOT NULL THEN collab_concerne.email ELSE ca.email END AS collaborateurEmail, ha.Action, ha.Details, ha.DateAction, ha.AdresseIP, ha.DemandeCongeId FROM HistoriqueActions ha -- Jointure pour récupérer la personne qui a réalisé l'action JOIN CollaborateurAD ca ON ha.CollaborateurADId = ca.id -- Jointure optionnelle pour récupérer la demande de congé associée LEFT JOIN DemandeConge dc ON ha.DemandeCongeId = dc.Id -- Jointure optionnelle pour récupérer le collaborateur concerné par la demande LEFT JOIN CollaborateurAD collab_concerne ON dc.CollaborateurADId = collab_concerne.id ${whereClause} ORDER BY ha.DateAction DESC `; console.log('📊 Requête historique SQL:', query); const result = await request.query(query); const historique = result.recordset || []; console.log(`✅ ${historique.length} entrées d'historique trouvées`); // ⭐ TOUJOURS retourner un tableau res.json(historique); } catch (error) { console.error('❌ Erreur /historique:', error); res.status(500).json({ error: error.message }); } }); // ================================================ // FONCTIONS DE CALCUL CP ET RTT // ================================================ /** * 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) { const entree = new Date(dateEntree); entree.setHours(0, 0, 0, 0); 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(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 collabInfoResult = await pool.request() .input('collaborateurId', sql.Int, collaborateurId) .query('SELECT TypeContrat, DateEntree, role FROM CollaborateurAD WHERE id = @collaborateurId'); const collabInfo = collabInfoResult.recordset; if (collabInfo.length === 0) { throw new Error(`Collaborateur ${collaborateurId} non trouvé`); } 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(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) { const entree = new Date(dateEntree); entree.setHours(0, 0, 0, 0); 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); // Vérifier que les dates sont valides if (isNaN(b1.getTime()) || isNaN(b2.getTime())) { console.error('❌ Date invalide dans calculerAcquisitionFormuleExcel'); return 0; } // 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(annee, typeContrat = '37h') { try { const result = await pool.request() .input('annee', sql.Int, annee) .input('typeContrat', sql.VarChar, typeContrat) .query(` SELECT JoursAnnuels, AcquisitionMensuelle FROM ConfigurationRTT WHERE Annee = @annee AND TypeContrat = @typeContrat `); const config = result.recordset; if (config.length > 0) { return { 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 }; } // Suite et fin - Réinitialisation compteurs, routes API V2, etc. // ======================================== // ROUTE COMPLÈTE : /reinitializeAllCounters // ======================================== app.post('/reinitializeAllCounters', async (req, res) => { const transaction = new sql.Transaction(pool); try { await transaction.begin(); console.log('🔄 Réinitialisation de tous les compteurs...'); const collaborateursResult = await transaction.request().query(` SELECT id, prenom, nom, DateEntree, TypeContrat, CampusId, SocieteId, role FROM CollaborateurAD WHERE Actif = 1 OR Actif IS NULL `); const collaborateurs = collaborateursResult.recordset; console.log(`📋 ${collaborateurs.length} collaborateurs trouvés`); const dateRefParam = req.body.dateReference; const today = dateRefParam ? new Date(dateRefParam) : new Date(); const currentYear = today.getFullYear(); const previousYear = currentYear - 1; const results = []; // Récupérer les types de congés const cpTypeResult = await transaction.request() .query("SELECT Id FROM TypeConge WHERE Nom = 'Congé payé'"); const cpType = cpTypeResult.recordset; const rttTypeResult = await transaction.request() .query("SELECT Id FROM TypeConge WHERE Nom = 'RTT'"); const rttType = rttTypeResult.recordset; for (const collab of collaborateurs) { console.log(`\n${'='.repeat(60)}`); console.log(`👤 ${collab.prenom} ${collab.nom} (ID: ${collab.id})`); console.log(` Type contrat: ${collab.TypeContrat || '37h'}`); console.log(` Date entrée: ${collab.DateEntree ? new Date(collab.DateEntree).toISOString().split('T')[0] : 'NULL'}`); console.log(` Rôle: ${collab.role || 'Non défini'}`); const dateEntree = collab.DateEntree; const typeContrat = collab.TypeContrat || '37h'; const isApprenti = collab.role === 'Apprenti'; // ========================================== // CALCUL CP // ========================================== let acquisCP = calculerAcquisitionCP(today, dateEntree); if (isNaN(acquisCP) || !isFinite(acquisCP)) { console.warn(`⚠️ Acquisition CP invalide - Forcé à 0`); acquisCP = 0; } // ========================================== // CALCUL RTT // ========================================== let acquisRTT = 0; if (!isApprenti) { try { const rttData = await calculerAcquisitionRTT(collab.id, today); acquisRTT = rttData.acquisition; if (isNaN(acquisRTT) || !isFinite(acquisRTT)) { console.warn(`⚠️ Acquisition RTT invalide - Forcé à 0`); acquisRTT = 0; } } catch (error) { console.error(`❌ Erreur calcul RTT:`, error.message); acquisRTT = 0; } } // ========================================== // MISE À JOUR CP N // ========================================== if (cpType.length > 0) { const deductionsCPResult = await transaction.request() .input('collabId', sql.Int, collab.id) .input('typeId', sql.Int, cpType[0].Id) .input('currentYear', sql.Int, currentYear) .query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalConsomme FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = @collabId AND dd.TypeCongeId = @typeId AND dd.Annee = @currentYear AND dc.Statut != 'Refusé' `); let totalConsomme = parseFloat(deductionsCPResult.recordset[0].totalConsomme || 0); if (isNaN(totalConsomme) || !isFinite(totalConsomme)) { console.warn(`⚠️ Total consommé CP invalide - Forcé à 0`); totalConsomme = 0; } const compteurExistingResult = await transaction.request() .input('collabId', sql.Int, collab.id) .input('typeId', sql.Int, cpType[0].Id) .input('currentYear', sql.Int, currentYear) .query(` SELECT SoldeReporte FROM CompteurConges WHERE CollaborateurADId = @collabId AND TypeCongeId = @typeId AND Annee = @currentYear `); const compteurExisting = compteurExistingResult.recordset; let soldeReporte = compteurExisting.length > 0 ? parseFloat(compteurExisting[0].SoldeReporte || 0) : 0; if (isNaN(soldeReporte) || !isFinite(soldeReporte)) { console.warn(`⚠️ Solde reporté CP invalide - Forcé à 0`); soldeReporte = 0; } let nouveauSolde = Math.max(0, acquisCP + soldeReporte - totalConsomme); if (isNaN(nouveauSolde) || !isFinite(nouveauSolde)) { console.warn(`⚠️ Nouveau solde CP invalide - Forcé à 0`); nouveauSolde = 0; } console.log(` 📊 CP - Acquis: ${acquisCP.toFixed(2)}j, Consommé: ${totalConsomme.toFixed(2)}j, Reporté: ${soldeReporte.toFixed(2)}j, Solde: ${nouveauSolde.toFixed(2)}j`); if (compteurExisting.length > 0) { await transaction.request() .input('total', sql.Decimal(10, 2), acquisCP) .input('solde', sql.Decimal(10, 2), nouveauSolde) .input('collabId', sql.Int, collab.id) .input('typeId', sql.Int, cpType[0].Id) .input('currentYear', sql.Int, currentYear) .query(` UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = @collabId AND TypeCongeId = @typeId AND Annee = @currentYear `); } else { await transaction.request() .input('collabId', sql.Int, collab.id) .input('typeId', sql.Int, cpType[0].Id) .input('currentYear', sql.Int, currentYear) .input('total', sql.Decimal(10, 2), acquisCP) .input('solde', sql.Decimal(10, 2), nouveauSolde) .query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (@collabId, @typeId, @currentYear, @total, @solde, 0, GETDATE()) `); } // Créer CP N-1 si nécessaire const cpN1Result = await transaction.request() .input('collabId', sql.Int, collab.id) .input('typeId', sql.Int, cpType[0].Id) .input('previousYear', sql.Int, previousYear) .query(` SELECT Id FROM CompteurConges WHERE CollaborateurADId = @collabId AND TypeCongeId = @typeId AND Annee = @previousYear `); const cpN1 = cpN1Result.recordset; if (cpN1.length === 0) { await transaction.request() .input('collabId', sql.Int, collab.id) .input('typeId', sql.Int, cpType[0].Id) .input('previousYear', sql.Int, previousYear) .query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (@collabId, @typeId, @previousYear, 0, 0, 0, GETDATE()) `); } } // ========================================== // MISE À JOUR RTT N // ========================================== if (rttType.length > 0 && !isApprenti) { const deductionsRTTResult = await transaction.request() .input('collabId', sql.Int, collab.id) .input('typeId', sql.Int, rttType[0].Id) .input('currentYear', sql.Int, currentYear) .query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalConsomme FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = @collabId AND dd.TypeCongeId = @typeId AND dd.Annee = @currentYear AND dc.Statut != 'Refusé' `); let totalConsomme = parseFloat(deductionsRTTResult.recordset[0].totalConsomme || 0); if (isNaN(totalConsomme) || !isFinite(totalConsomme)) { console.warn(`⚠️ Total consommé RTT invalide - Forcé à 0`); totalConsomme = 0; } let nouveauSolde = Math.max(0, acquisRTT - totalConsomme); if (isNaN(nouveauSolde) || !isFinite(nouveauSolde)) { console.warn(`⚠️ Nouveau solde RTT invalide - Forcé à 0`); nouveauSolde = 0; } console.log(` 📊 RTT - Acquis: ${acquisRTT.toFixed(2)}j, Consommé: ${totalConsomme.toFixed(2)}j, Solde: ${nouveauSolde.toFixed(2)}j`); const compteurExistingResult = await transaction.request() .input('collabId', sql.Int, collab.id) .input('typeId', sql.Int, rttType[0].Id) .input('currentYear', sql.Int, currentYear) .query(` SELECT Id FROM CompteurConges WHERE CollaborateurADId = @collabId AND TypeCongeId = @typeId AND Annee = @currentYear `); const compteurExisting = compteurExistingResult.recordset; if (compteurExisting.length > 0) { await transaction.request() .input('total', sql.Decimal(10, 2), acquisRTT) .input('solde', sql.Decimal(10, 2), nouveauSolde) .input('id', sql.Int, compteurExisting[0].Id) .query(` UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id `); } else { await transaction.request() .input('collabId', sql.Int, collab.id) .input('typeId', sql.Int, rttType[0].Id) .input('currentYear', sql.Int, currentYear) .input('total', sql.Decimal(10, 2), acquisRTT) .input('solde', sql.Decimal(10, 2), nouveauSolde) .query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (@collabId, @typeId, @currentYear, @total, @solde, 0, GETDATE()) `); } // Créer RTT N-1 si nécessaire const rttN1Result = await transaction.request() .input('collabId', sql.Int, collab.id) .input('typeId', sql.Int, rttType[0].Id) .input('previousYear', sql.Int, previousYear) .query(` SELECT Id FROM CompteurConges WHERE CollaborateurADId = @collabId AND TypeCongeId = @typeId AND Annee = @previousYear `); const rttN1 = rttN1Result.recordset; if (rttN1.length === 0) { await transaction.request() .input('collabId', sql.Int, collab.id) .input('typeId', sql.Int, rttType[0].Id) .input('previousYear', sql.Int, previousYear) .query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (@collabId, @typeId, @previousYear, 0, 0, 0, GETDATE()) `); } } // Ajouter au résultat results.push({ collaborateur: `${collab.prenom} ${collab.nom}`, typecontrat: typeContrat, cpacquis: acquisCP.toFixed(2), rttacquis: acquisRTT.toFixed(2) }); } await transaction.commit(); console.log('\n✅ Réinitialisation terminée'); res.json({ success: true, message: `Compteurs réinitialisés pour ${collaborateurs.length} collaborateurs`, datereference: today.toISOString().split('T')[0], totalcollaborateurs: collaborateurs.length, results: results }); } catch (error) { await transaction.rollback(); console.error('❌ Erreur réinitialisation:', error); res.status(500).json({ success: false, message: 'Erreur lors de la réinitialisation', error: error.message }); } }); // ============================================================ // ENDPOINTS API V2 // ============================================================ app.get('/v2/compteurs', async (req, res) => { try { const result = await pool.request().query(` SELECT cc.Id AS id, cc.CollaborateurADId AS collaborateurId, cc.TypeCongeId AS typeCongeId, CONCAT(c.nom, ' ', c.prenom) AS employe, c.email AS email, c.service AS service, tc.Nom AS typeConge, cc.Annee AS annee, cc.Total AS total, cc.Solde AS solde, cc.SoldeReporte AS soldeReporte, (cc.Total - cc.Solde) AS consomme, c.TypeContrat AS typeContrat, c.role AS role FROM CompteurConges cc INNER JOIN CollaborateurAD c ON cc.CollaborateurADId = c.id INNER JOIN TypeConge tc ON cc.TypeCongeId = tc.Id WHERE (c.description IS NULL OR c.description NOT LIKE '%stagiaire%') ORDER BY c.nom ASC, cc.Annee DESC `); return res.json(result.recordset); } catch (error) { console.error("❌ Erreur GET compteurs V2 :", error); return res.status(500).json({ message: "Erreur serveur V2" }); } }); app.put('/v2/compteurs/update', async (req, res) => { try { const { collaborateurId, typeConge, annee, nouveauTotal, nouveauSolde, source } = req.body; if (!collaborateurId || !typeConge || !annee) { return res.status(400).json({ message: "Champs manquants" }); } console.log('📝 Modification compteur V2:', { collaborateurId, typeConge, annee, nouveauTotal, nouveauSolde }); // Mapper le nom exact du type de congé let nomTypeConge; switch (typeConge) { case 'CP': nomTypeConge = 'Congé payé'; break; case 'RTT': nomTypeConge = 'RTT'; break; case 'Récupération': case 'Recuperation': case 'recup': nomTypeConge = 'Récupération'; break; default: nomTypeConge = typeConge; } console.log(` Recherche du type: "${nomTypeConge}"`); // Trouver l'ID du type de congé const typeRowResult = await pool.request() .input('nomTypeConge', sql.NVarChar, nomTypeConge) .query("SELECT Id FROM TypeConge WHERE Nom = @nomTypeConge"); const typeRow = typeRowResult.recordset; if (typeRow.length === 0) { console.error(`❌ Type de congé "${nomTypeConge}" introuvable`); return res.status(404).json({ message: `Type de congé "${nomTypeConge}" introuvable`, nomRecherche: nomTypeConge }); } const typeCongeId = typeRow[0].Id; console.log(` ✅ Type trouvé - ID: ${typeCongeId}`); // Trouver compteur const compResult = await pool.request() .input('collaborateurId', sql.Int, collaborateurId) .input('typeCongeId', sql.Int, typeCongeId) .input('annee', sql.Int, annee) .query(` SELECT Id FROM CompteurConges WHERE CollaborateurADId = @collaborateurId AND TypeCongeId = @typeCongeId AND Annee = @annee `); const comp = compResult.recordset; if (comp.length === 0) { console.error(`❌ Compteur introuvable pour collaborateur ${collaborateurId}, type ${typeCongeId}, année ${annee}`); return res.status(404).json({ message: "Compteur introuvable", details: { collaborateurId, typeCongeId, annee } }); } // Mise à jour compteur await pool.request() .input('total', sql.Decimal(10, 2), nouveauTotal) .input('solde', sql.Decimal(10, 2), nouveauSolde) .input('id', sql.Int, comp[0].Id) .query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id'); console.log(` ✅ Compteur mis à jour - Total: ${nouveauTotal}, Solde: ${nouveauSolde}`); // Notifier les clients SSE notifyClients({ type: 'compteur-updated', collaborateurId: collaborateurId, typeConge: nomTypeConge, annee: annee, nouveauTotal: nouveauTotal, nouveauSolde: nouveauSolde, source: source || 'RH_UI', timestamp: new Date().toISOString() }, collaborateurId); // Envoyer webhook try { if (typeof webhookManager !== 'undefined' && WEBHOOKS && EVENTS) { await webhookManager.sendWebhook( WEBHOOKS.COLLABORATEURS_URL, EVENTS.COMPTEUR_UPDATED, { collaborateurId: collaborateurId, typeConge: nomTypeConge, typeUpdate: 'modification_manuelle_rh', annee: annee, nouveauTotal: nouveauTotal, nouveauSolde: nouveauSolde, source: source || 'RH_UI', timestamp: new Date().toISOString() } ); console.log(' ✅ Webhook envoyé au serveur collaborateurs'); } } catch (webhookError) { console.error('⚠️ Erreur webhook (non bloquant):', webhookError.message); } res.json({ success: true, message: "Compteur mis à jour (V2)", details: { typeConge: nomTypeConge, typeCongeId: typeCongeId, nouveauTotal: nouveauTotal, nouveauSolde: nouveauSolde } }); } catch (err) { console.error("❌ Erreur update compteur V2:", err); res.status(500).json({ message: "Erreur serveur", error: err.message }); } }); // ================================================ // ROUTE POUR SAISIR UN JPOSF // ================================================ app.post('/compteurs/saisir-jposf', authenticateToken, async (req, res) => { const transaction = new sql.Transaction(pool); try { console.log('\n📥 ========== DÉBUT SAISIE JPOSF =========='); console.log('📥 Body reçu:', JSON.stringify(req.body, null, 2)); console.log('📥 User:', req.user); await transaction.begin(); console.log('✅ Transaction démarrée'); // Vérifier les permissions if (!['Admin', 'RH'].includes(req.user.role)) { console.log('❌ Accès refusé - Role:', req.user.role); await transaction.rollback(); return res.status(403).json({ success: false, error: 'Accès non autorisé', message: 'Seuls les Admin et RH peuvent saisir des JPOSF' }); } const { collaborateur_id, date, periode_journee, commentaire } = req.body; // Validation des données if (!collaborateur_id) { console.log('❌ collaborateur_id manquant'); await transaction.rollback(); return res.status(400).json({ success: false, message: 'ID du collaborateur manquant' }); } if (!date) { console.log('❌ date manquante'); await transaction.rollback(); return res.status(400).json({ success: false, message: 'Date manquante' }); } if (!periode_journee) { console.log('❌ periode_journee manquante'); await transaction.rollback(); return res.status(400).json({ success: false, message: 'Période de journée manquante' }); } console.log('📋 Données validées:', { collaborateur_id, date, periode_journee }); // ========== ÉTAPE 1: Récupérer le collaborateur ========== console.log('\n🔍 ÉTAPE 1: Récupération du collaborateur...'); const collabResult = await transaction.request() .input('id', sql.Int, parseInt(collaborateur_id)) .query('SELECT id, prenom, nom, email, Actif FROM CollaborateurAD WHERE id = @id'); console.log(' Résultat:', collabResult.recordset.length, 'enregistrement(s)'); if (collabResult.recordset.length === 0) { console.log('❌ Collaborateur non trouvé'); await transaction.rollback(); return res.status(404).json({ success: false, message: `Collaborateur ID ${collaborateur_id} non trouvé` }); } const collab = collabResult.recordset[0]; console.log(' ✅ Collaborateur trouvé:', collab.prenom, collab.nom); if (collab.Actif === false || collab.Actif === 0) { console.log('❌ Collaborateur inactif'); await transaction.rollback(); return res.status(403).json({ success: false, message: 'Impossible d\'ajouter des jours à un compte désactivé' }); } const currentYear = new Date().getFullYear(); const nombreJours = periode_journee === 'Journée entière' ? 1 : 0.5; console.log(' Année:', currentYear); console.log(' Nombre de jours:', nombreJours); // ========== ÉTAPE 2: Récupérer les types de congé ========== console.log('\n🔍 ÉTAPE 2: Récupération des types de congé...'); const jposfTypeResult = await transaction.request() .query("SELECT Id, Nom FROM TypeConge WHERE Nom = 'JPOSF'"); console.log(' JPOSF trouvé:', jposfTypeResult.recordset.length > 0); if (jposfTypeResult.recordset.length === 0) { console.log('❌ Type JPOSF non trouvé dans TypeConge'); // Lister les types disponibles pour debug const allTypesResult = await transaction.request() .query("SELECT Id, Nom FROM TypeConge WHERE Actif = 1 OR Actif IS NULL"); console.log(' Types disponibles:', allTypesResult.recordset.map(t => t.Nom).join(', ')); await transaction.rollback(); return res.status(500).json({ success: false, message: 'Type de congé "JPOSF" non trouvé dans la base de données', details: 'Contactez l\'administrateur pour créer ce type de congé' }); } const recupTypeResult = await transaction.request() .query("SELECT Id, Nom FROM TypeConge WHERE Nom = 'Récupération'"); console.log(' Récupération trouvé:', recupTypeResult.recordset.length > 0); if (recupTypeResult.recordset.length === 0) { console.log('❌ Type Récupération non trouvé dans TypeConge'); await transaction.rollback(); return res.status(500).json({ success: false, message: 'Type de congé "Récupération" non trouvé dans la base de données', details: 'Contactez l\'administrateur pour créer ce type de congé' }); } const jposfTypeId = jposfTypeResult.recordset[0].Id; const recupTypeId = recupTypeResult.recordset[0].Id; console.log(' ✅ JPOSF TypeId:', jposfTypeId); console.log(' ✅ Récupération TypeId:', recupTypeId); // ========== ÉTAPE 3: Mettre à jour le compteur Récupération ========== console.log('\n🔍 ÉTAPE 3: Mise à jour du compteur Récupération...'); const compteurRecupResult = await transaction.request() .input('collaborateurId', sql.Int, parseInt(collaborateur_id)) .input('recupTypeId', sql.Int, recupTypeId) .input('currentYear', sql.Int, currentYear) .query(` SELECT Id, Total, Solde FROM CompteurConges WHERE CollaborateurADId = @collaborateurId AND TypeCongeId = @recupTypeId AND Annee = @currentYear `); let nouveauTotalRecup, nouveauSoldeRecup; if (compteurRecupResult.recordset.length > 0) { const compteur = compteurRecupResult.recordset[0]; nouveauTotalRecup = parseFloat(compteur.Total || 0) + nombreJours; nouveauSoldeRecup = parseFloat(compteur.Solde || 0) + nombreJours; console.log(' Compteur existant - Ancien Total:', compteur.Total, '-> Nouveau:', nouveauTotalRecup); console.log(' Compteur existant - Ancien Solde:', compteur.Solde, '-> Nouveau:', nouveauSoldeRecup); await transaction.request() .input('total', sql.Decimal(10, 2), nouveauTotalRecup) .input('solde', sql.Decimal(10, 2), nouveauSoldeRecup) .input('id', sql.Int, compteur.Id) .query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id'); console.log(' ✅ Compteur Récupération mis à jour'); } else { nouveauTotalRecup = nombreJours; nouveauSoldeRecup = nombreJours; console.log(' Création nouveau compteur - Total:', nouveauTotalRecup, 'Solde:', nouveauSoldeRecup); await transaction.request() .input('collaborateurId', sql.Int, parseInt(collaborateur_id)) .input('recupTypeId', sql.Int, recupTypeId) .input('currentYear', sql.Int, currentYear) .input('total', sql.Decimal(10, 2), nouveauTotalRecup) .input('solde', sql.Decimal(10, 2), nouveauSoldeRecup) .query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (@collaborateurId, @recupTypeId, @currentYear, @total, @solde, 0, GETDATE()) `); console.log(' ✅ Compteur Récupération créé'); } // ========== ÉTAPE 4: Créer la demande JPOSF ========== console.log('\n🔍 ÉTAPE 4: Création de la demande JPOSF...'); // Préparer le commentaire const commentaireFinal = commentaire && commentaire.trim() ? commentaire.trim() : `JPOSF - ${periode_journee}`; console.log(' Commentaire:', commentaireFinal); console.log(' Date:', date); console.log(' ValidateurId:', req.user.id); // ⭐ MÉTHODE ROBUSTE: INSERT puis SELECT séparé try { // D'abord, faire l'INSERT sans OUTPUT await transaction.request() .input('collaborateurId', sql.Int, parseInt(collaborateur_id)) .input('dateDebut', sql.Date, date) .input('dateFin', sql.Date, date) .input('commentaire', sql.NVarChar(500), commentaireFinal) .input('totalJours', sql.Decimal(10, 2), nombreJours) .input('validateurId', sql.Int, req.user.id) .query(` INSERT INTO DemandeConge (CollaborateurADId, DateDebut, DateFin, Statut, Commentaire, DateDemande, NombreJours, ValidateurADId, DateValidation) VALUES (@collaborateurId, @dateDebut, @dateFin, 'Validée', @commentaire, GETDATE(), @totalJours, @validateurId, GETDATE()) `); console.log(' ✅ INSERT DemandeConge réussi'); // Ensuite, récupérer l'ID avec SCOPE_IDENTITY ou en cherchant le dernier enregistrement const lastIdResult = await transaction.request() .query('SELECT SCOPE_IDENTITY() AS Id'); let demandeId = lastIdResult.recordset[0]?.Id; // Si SCOPE_IDENTITY ne fonctionne pas, fallback if (!demandeId) { console.log(' ⚠️ SCOPE_IDENTITY() a retourné NULL, utilisation du fallback...'); const fallbackResult = await transaction.request() .input('collaborateurId', sql.Int, parseInt(collaborateur_id)) .input('dateDebut', sql.Date, date) .query(` SELECT TOP 1 Id FROM DemandeConge WHERE CollaborateurADId = @collaborateurId AND DateDebut = @dateDebut AND Statut = 'Validée' ORDER BY DateDemande DESC, Id DESC `); demandeId = fallbackResult.recordset[0]?.Id; } console.log(' DemandeId récupéré:', demandeId); if (!demandeId) { throw new Error('Impossible de récupérer l\'ID de la demande créée'); } // ========== ÉTAPE 5: Lier avec le type JPOSF ========== console.log('\n🔍 ÉTAPE 5: Liaison avec le type JPOSF...'); await transaction.request() .input('demandeId', sql.Int, demandeId) .input('typeId', sql.Int, jposfTypeId) .input('nombreJours', sql.Decimal(10, 2), nombreJours) .input('periode', sql.NVarChar(50), periode_journee) // ⭐ AJOUTÉ .query('INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee) VALUES (@id, @demandeId, @typeId, @nombreJours, @periode)'); console.log(' ✅ DemandeCongeType créé'); // ========== ÉTAPE 6: Historique ========== console.log('\n🔍 ÉTAPE 6: Enregistrement dans l\'historique...'); await transaction.request() .input('userId', sql.Int, req.user.id) .input('details', sql.NVarChar(500), `Saisie JPOSF - ${date} (${periode_journee}) : +${nombreJours}j pour ${collab.prenom} ${collab.nom}`) .input('demandeId', sql.Int, demandeId) .query(` INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId, DateAction) VALUES (@userId, 'Saisie JPOSF', @details, @demandeId, GETDATE()) `); console.log(' ✅ Historique enregistré'); // ========== COMMIT ========== await transaction.commit(); console.log('\n✅ ========== TRANSACTION COMMIT =========='); // ========== Notifications SSE ========== try { notifyClients({ type: 'demande-validated', demandeId: demandeId, statut: 'Validée', typeConge: 'JPOSF', couleurHex: '#d946ef', collaborateurId: parseInt(collaborateur_id), timestamp: new Date().toISOString() }, parseInt(collaborateur_id)); notifyClients({ type: 'compteur-updated', collaborateurId: parseInt(collaborateur_id), typeConge: 'Récupération', action: 'saisie_jposf_rh', nouveauSolde: nouveauSoldeRecup, timestamp: new Date().toISOString() }, parseInt(collaborateur_id)); console.log('✅ Notifications SSE envoyées'); } catch (sseError) { console.error('⚠️ Erreur SSE (non bloquant):', sseError.message); } // ========== Webhooks ========== try { if (typeof webhookManager !== 'undefined' && WEBHOOKS && EVENTS) { await webhookManager.sendWebhook( WEBHOOKS.COLLABORATEURS_URL, EVENTS.DEMANDE_VALIDATED, { demandeId: demandeId, statut: 'Validée', typeConge: 'JPOSF', collaborateurId: parseInt(collaborateur_id), validateurId: req.user.id } ); await webhookManager.sendWebhook( WEBHOOKS.COLLABORATEURS_URL, EVENTS.COMPTEUR_UPDATED, { collaborateurId: parseInt(collaborateur_id), typeConge: 'Récupération', typeUpdate: 'saisie_jposf' } ); console.log('✅ Webhooks envoyés'); } } catch (webhookError) { console.error('⚠️ Erreur webhook (non bloquant):', webhookError.message); } // ========== Email ========== try { const accessToken = await getGraphToken(); if (accessToken && collab.email) { const fromEmail = 'gtanoreply@ensup.eu'; const subject = '📅 JPOSF enregistré'; const body = `

📅 JPOSF enregistré

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

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

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

Période : ${periode_journee}

Jours ajoutés : ${nombreJours}j

${commentaire ? `

Commentaire : ${commentaire}

` : ''}

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

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

🚫 Annulation de demande de congé

Bonjour ${nomComplet},

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

Statut : Annulée

${commentaire ? `

Motif : ${commentaire}

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

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

' : ''}

Pour toute question, veuillez contacter le service RH.

`; await sendMailGraph(accessToken, 'gtanoreply@ensup.eu', emailEmploye, subject, body); console.log(`📧 Email d'annulation envoyé à ${emailEmploye}`); } } catch (emailError) { console.error('❌ Erreur envoi email (non bloquant):', emailError.message); } // 6. Notification SSE notifyClients({ type: 'demande-cancelled', demandeId: demandeId, collaborateurId: collaborateurId, message: 'Demande annulée' }); // 7. Webhook (optionnel) try { if (typeof webhookManager !== 'undefined' && (ancienStatut === 'En attente' || ancienStatut === 'Validée')) { await webhookManager.sendWebhook( WEBHOOKS.COLLABORATEURS.URL, EVENTS.DEMANDE_CANCELLED, { demandeId: demandeId, collaborateur: nomComplet, dateDebut: demande.DateDebut, dateFin: demande.DateFin, ancienStatut: ancienStatut, compteursRestaurés: true } ); } } catch (webhookError) { console.error('⚠️ Erreur webhook (non bloquant):', webhookError.message); } res.json({ success: true, message: 'Demande annulée avec succès', compteursRestaurés: ancienStatut === 'En attente' || ancienStatut === 'Validée' }); } catch (error) { // ✅ CORRECTION : Vérifier si la transaction est toujours active avant rollback try { if (transaction._acquiredConnection) { await transaction.rollback(); console.log('🔄 Transaction annulée (rollback)'); } } catch (rollbackError) { console.error('⚠️ Erreur rollback:', rollbackError.message); } console.error('❌ Erreur annulation demande:', error); res.status(500).json({ error: 'Erreur lors de l\'annulation de la demande', details: error.message }); } }); // PUT /api/compte-rendu/jour - RH modifie un jour du compte-rendu d'activité app.put('/compte-rendu/jour', authenticateToken, async (req, res) => { const transaction = new sql.Transaction(pool); try { await transaction.begin(); if (!['Admin', 'RH'].includes(req.user.role)) { await transaction.rollback(); return res.status(403).json({ success: false, error: 'Accès non autorisé' }); } const { collaborateurid, jourdate, jourtravaille, reposquotidien, reposhebdo, commentaire } = req.body; if (!collaborateurid || !jourdate) { await transaction.rollback(); return res.status(400).json({ success: false, error: 'collaborateurid et jourdate sont requis' }); } const collaborateurId = parseInt(collaborateurid); const dateObj = new Date(jourdate); const annee = dateObj.getFullYear(); const mois = dateObj.getMonth() + 1; // UPSERT du jour dans CompteRenduActivites const existing = await transaction.request() .input('collaborateurId', sql.Int, collaborateurId) .input('jourDate', sql.Date, jourdate) .query(`SELECT Id FROM CompteRenduActivites WHERE CollaborateurADId = @collaborateurId AND JourDate = @jourDate`); if (existing.recordset.length > 0) { await transaction.request() .input('collaborateurId', sql.Int, collaborateurId) .input('jourDate', sql.Date, jourdate) .input('jourTravaille', sql.Bit, jourtravaille ? 1 : 0) .input('reposQ', sql.Bit, reposquotidien ? 1 : 0) .input('reposH', sql.Bit, reposhebdo ? 1 : 0) .input('commentaire', sql.NVarChar(500), commentaire || '') .query(`UPDATE CompteRenduActivites SET JourTravaille = @jourTravaille, ReposQuotidienRespect = @reposQ, ReposHebdomadaireRespect = @reposH, CommentaireRepos = @commentaire, DateModification = GETDATE() WHERE CollaborateurADId = @collaborateurId AND JourDate = @jourDate`); } else { // ✅ Générer l'ID manuellement const maxIdResult = await transaction.request() .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM CompteRenduActivites'); const newId = maxIdResult.recordset[0].NextId; await transaction.request() .input('id', sql.Int, newId) .input('collaborateurId', sql.Int, collaborateurId) .input('jourDate', sql.Date, jourdate) .input('annee', sql.Int, annee) .input('mois', sql.Int, mois) .input('jourTravaille', sql.Bit, jourtravaille ? 1 : 0) .input('reposQ', sql.Bit, reposquotidien ? 1 : 0) .input('reposH', sql.Bit, reposhebdo ? 1 : 0) .input('commentaire', sql.NVarChar(500), commentaire || '') .query(`INSERT INTO CompteRenduActivites (Id, CollaborateurADId, JourDate, Annee, Mois, JourTravaille, ReposQuotidienRespect, ReposHebdomadaireRespect, CommentaireRepos, DateSaisie, DateModification) VALUES (@id, @collaborateurId, @jourDate, @annee, @mois, @jourTravaille, @reposQ, @reposH, @commentaire, GETDATE(), GETDATE())`); } // Recalculer et mettre à jour CompteRenduMensuel const statsResult = await transaction.request() .input('collaborateurId', sql.Int, collaborateurId) .input('annee', sql.Int, annee) .input('mois', sql.Int, mois) .query(`SELECT COUNT(CASE WHEN JourTravaille = 1 THEN 1 END) AS NbJoursTravailles, COUNT(CASE WHEN JourTravaille = 1 AND ReposQuotidienRespect = 0 THEN 1 END) AS NbReposQ, COUNT(CASE WHEN JourTravaille = 1 AND ReposHebdomadaireRespect = 0 THEN 1 END) AS NbReposH FROM CompteRenduActivites WHERE CollaborateurADId = @collaborateurId AND Annee = @annee AND Mois = @mois`); const stats = statsResult.recordset[0]; const mensuelExist = await transaction.request() .input('collaborateurId', sql.Int, collaborateurId) .input('annee', sql.Int, annee) .input('mois', sql.Int, mois) .query(`SELECT Id FROM CompteRenduMensuel WHERE CollaborateurADId = @collaborateurId AND Annee = @annee AND Mois = @mois`); if (mensuelExist.recordset.length > 0) { await transaction.request() .input('collaborateurId', sql.Int, collaborateurId) .input('annee', sql.Int, annee) .input('mois', sql.Int, mois) .input('nbJours', sql.Decimal(10, 2), stats.NbJoursTravailles) .input('nbReposQ', sql.Int, stats.NbReposQ) .input('nbReposH', sql.Int, stats.NbReposH) .query(`UPDATE CompteRenduMensuel SET NbJoursTravailles = @nbJours, NbJoursNonRespectsReposQuotidien = @nbReposQ, NbJoursNonRespectsReposHebdo = @nbReposH WHERE CollaborateurADId = @collaborateurId AND Annee = @annee AND Mois = @mois`); } else { // ✅ Générer l'ID manuellement pour CompteRenduMensuel aussi const maxMensuelIdResult = await transaction.request() .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM CompteRenduMensuel'); const newMensuelId = maxMensuelIdResult.recordset[0].NextId; await transaction.request() .input('id', sql.Int, newMensuelId) .input('collaborateurId', sql.Int, collaborateurId) .input('annee', sql.Int, annee) .input('mois', sql.Int, mois) .input('nbJours', sql.Decimal(10, 2), stats.NbJoursTravailles) .input('nbReposQ', sql.Int, stats.NbReposQ) .input('nbReposH', sql.Int, stats.NbReposH) .query(`INSERT INTO CompteRenduMensuel (Id, CollaborateurADId, Annee, Mois, NbJoursTravailles, NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo, Verrouille) VALUES (@id, @collaborateurId, @annee, @mois, @nbJours, @nbReposQ, @nbReposH, 0)`); } await transaction.commit(); res.json({ success: true, message: 'Jour mis à jour avec succès' }); } catch (error) { try { await transaction.rollback(); } catch (e) { } console.error('Erreur PUT compte-rendu/jour', error); res.status(500).json({ success: false, error: error.message }); } }); // Verrouiller un mois app.post('/compte-rendu/verrouiller', authenticateToken, async (req, res) => { try { const { collaborateurid, annee, mois } = req.body; // Upsert : créer la ligne si elle n'existe pas, puis verrouiller const existing = await pool.request() .input('collaborateurId', sql.Int, collaborateurid) .input('annee', sql.Int, annee) .input('mois', sql.Int, mois) .query(`SELECT Id FROM CompteRenduMensuel WHERE CollaborateurADId = @collaborateurId AND Annee = @annee AND Mois = @mois`); if (existing.recordset.length > 0) { await pool.request() .input('collaborateurId', sql.Int, collaborateurid) .input('annee', sql.Int, annee) .input('mois', sql.Int, mois) .query(`UPDATE CompteRenduMensuel SET Verrouille = 1 WHERE CollaborateurADId = @collaborateurId AND Annee = @annee AND Mois = @mois`); } else { // Créer l'entrée mensuelle si elle n'existe pas encore await pool.request() .input('collaborateurId', sql.Int, collaborateurid) .input('annee', sql.Int, annee) .input('mois', sql.Int, mois) .query(`INSERT INTO CompteRenduMensuel (CollaborateurADId, Annee, Mois, NbJoursTravailles, NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo, Verrouille) VALUES (@collaborateurId, @annee, @mois, 0, 0, 0, 1)`); } res.json({ success: true, message: 'Mois verrouillé' }); } catch (error) { console.error('Erreur verrouillage:', error); res.status(500).json({ success: false, error: error.message }); } }); // Déverrouiller un mois app.post('/compte-rendu/deverrouiller', authenticateToken, async (req, res) => { try { const { collaborateurid, annee, mois } = req.body; await pool.request() .input('collaborateurId', sql.Int, collaborateurid) .input('annee', sql.Int, annee) .input('mois', sql.Int, mois) .query(`UPDATE CompteRenduMensuel SET Verrouille = 0 WHERE CollaborateurADId = @collaborateurId AND Annee = @annee AND Mois = @mois`); res.json({ success: true, message: 'Mois déverrouillé' }); } catch (error) { console.error('Erreur déverrouillage:', error); res.status(500).json({ success: false, error: error.message }); } }); // ================================================ // CRON — Snapshot quotidien à 23h55 heure de Paris // ================================================ function planifierSnapshotQuotidien() { const maintenant = new Date(); const prochainDeclenchement = new Date(); prochainDeclenchement.setHours(23, 55, 0, 0); // Si 23h55 est déjà passé aujourd'hui, programmer pour demain if (prochainDeclenchement <= maintenant) { prochainDeclenchement.setDate(prochainDeclenchement.getDate() + 1); } const delai = prochainDeclenchement - maintenant; console.log(`[CRON] ✅ Snapshot planifié dans ${Math.round(delai / 1000 / 60)} minutes`); setTimeout(async () => { await executerSnapshotQuotidien(); // Re-planifier pour le lendemain planifierSnapshotQuotidien(); }, delai); } planifierSnapshotQuotidien(); // ================================================ // DÉMARRAGE DU SERVEUR // ================================================ console.log('✅ 99. Toutes les routes définies, démarrage du serveur...'); const server = app.listen(PORT, '0.0.0.0', () => { // ← AJOUT DE '0.0.0.0' console.log('\n================================================'); console.log(`✅ SERVEUR RH DÉMARRÉ sur http://0.0.0.0:${PORT}`); // ← Changé localhost en 0.0.0.0 console.log('🔔 Server-Sent Events activés sur /events'); console.log('🔗 WEBHOOKS configurés:'); console.log(` - Serveur Collaborateurs: ${WEBHOOKS.COLLABORATEURS_URL}`); console.log(` - Route webhook receiver: POST /webhook/receive`); console.log(''); console.log('📋 Routes disponibles:'); console.log(' - GET /events (SSE)'); console.log(' - POST /webhook/receive'); console.log(' - POST /login-dev ✅ SQL SERVER'); console.log(' - GET /demandes'); console.log(' - GET /demandes/:id'); console.log(' - POST /demandes'); console.log(' - PUT /demandes/:id'); console.log(' - DELETE /demandes/:id'); console.log(' - PUT /demandes/:id/valider'); console.log(' - GET /employes'); console.log(' - POST /employes/desactiver'); console.log(' - POST /employes/reactiver'); console.log(' - GET /types-conge'); console.log(' - GET /export/paie'); console.log(' - GET /compteurs'); console.log(' - POST /reinitializeAllCounters ✅ SQL SERVER'); console.log(' - PUT /compteurs/:id'); console.log(' - POST /compteurs/ajouter-recup'); console.log(' - POST /compteurs/retirer-recup'); console.log(' - GET /equipes'); console.log(' - GET /equipes/:id'); console.log(' - GET /equipes/:id/membres'); console.log(' - GET /stats'); console.log(' - GET /historique'); console.log(' - GET /v2/compteurs ✅ API V2'); console.log(' - PUT /v2/compteurs/update ✅ API V2'); console.log(' - GET /getAllDetailedCounters ✅ SQL SERVER'); console.log('================================================'); }); 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); }