// ============================================================================ // 🚀 GTA - GESTION DES TEMPS ET ABSENCES // ============================================================================ // Serveur Backend Node.js avec SQL Server // Port: 3004 // Base de données: GTA (SQL Server) // ============================================================================ import express from 'express'; import sql from 'mssql'; import cors from 'cors'; import axios from 'axios'; import multer from 'multer'; import path from 'path'; import { fileURLToPath } from 'url'; import cron from 'node-cron'; import crypto from 'crypto'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); import WebhookManager from './webhook-utils.js'; import { WEBHOOKS, EVENTS } from './webhook-config.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = 3004; const webhookManager = new WebhookManager(WEBHOOKS.SECRET_KEY); const sseClientsCollab = new Set(); process.on('uncaughtException', (err) => { console.error('💥 ERREUR CRITIQUE NON CATCHÉE:', err); console.error('Stack:', err.stack); // On ne crash pas pour pouvoir déboguer }); process.on('unhandledRejection', (reason, promise) => { console.error('💥 PROMESSE REJETÉE NON GÉRÉE:', reason); console.error('Promise:', promise); }); app.use(cors({ origin: ['http://localhost:3013', 'http://localhost:80', 'https://mygta-dev.ensup-adm.net'], credentials: true })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); const dbConfig = { server: '192.168.0.3', // ⭐ 'server' au lieu de 'host' user: 'gta_app', password: 'GTA2025!Secure', database: 'GTA', port: 1433, // ⭐ Nombre, pas string options: { encrypt: true, // ⭐ Pas de SSL en réseau local trustServerCertificate: true, enableArithAbort: true, connectTimeout: 60000, requestTimeout: 60000 }, pool: { max: 10, min: 0, idleTimeoutMillis: 30000 } }; function nowFR() { const d = new Date(); d.setHours(d.getHours() + 2); return d.toISOString().slice(0, 19).replace('T', ' '); } function formatDateWithoutUTC(date) { if (!date) return null; const d = new Date(date); const year = d.getFullYear(); const month = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }; function formatDateToFrenchTime(date) { if (!date) return null; // Créer un objet Date et le convertir en heure française (Europe/Paris) const d = new Date(date); // Formater en ISO avec le fuseau horaire français return d.toLocaleString('fr-FR', { timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); } // Ou plus simple, pour avoir un format ISO compatible avec le frontend function formatDateToFrenchISO(date) { if (!date) return null; const d = new Date(date); // Convertir en heure française const frenchDate = new Date(d.toLocaleString('en-US', { timeZone: 'Europe/Paris' })); return frenchDate.toISOString(); } /** * Récupère le dernier arrêté validé/clôturé */ async function getDernierArrete(conn) { const [arretes] = await conn.query(` SELECT TOP 1 * FROM ArreteComptable WHERE Statut IN ('Validé', 'Clôturé') ORDER BY DateArrete DESC `); return arretes.length > 0 ? arretes[0] : null; } /** * Vérifie si une date est avant le dernier arrêté */ async function estAvantArrete(conn, date) { const dernierArrete = await getDernierArrete(conn); if (!dernierArrete) { return false; // Pas d'arrêté = toutes les dates sont autorisées } const dateTest = new Date(date); const dateArrete = new Date(dernierArrete.DateArrete); return dateTest <= dateArrete; } async function getSoldeFige(conn, collaborateurId, typeCongeId, annee) { const dernierArrete = await getDernierArrete(conn); if (!dernierArrete) { return null; // Pas d'arrêté } const [soldes] = await conn.query(` SELECT TOP 1 * FROM SoldesFiges WHERE ArreteId = ? AND CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [dernierArrete.Id, collaborateurId, typeCongeId, annee]); return soldes.length > 0 ? soldes[0] : null; } async function calculerAcquisitionDepuisArrete(conn, collaborateurId, typeConge, dateReference = new Date()) { const dernierArrete = await getDernierArrete(conn); const anneeRef = dateReference.getFullYear(); // Déterminer le type de congé const [typeRow] = await conn.query( 'SELECT TOP 1 Id FROM TypeConge WHERE Nom = ?', [typeConge === 'CP' ? 'Congé payé' : 'RTT'] ); if (typeRow.length === 0) { throw new Error(`Type de congé ${typeConge} non trouvé`); } const typeCongeId = typeRow[0].Id; const [collab] = await conn.query('SELECT role FROM CollaborateurAD WHERE id = ?', [collaborateurId]); const isApprenti = collab.length > 0 && collab[0].role === 'Apprenti'; if (typeConge === 'RTT' && isApprenti) { return 0; // ⭐ Les apprentis n'ont pas de RTT } // Si pas d'arrêté, calcul normal depuis le début if (!dernierArrete) { if (typeConge === 'CP') { const [collab] = await conn.query('SELECT DateEntree, CampusId FROM CollaborateurAD WHERE id = ?', [collaborateurId]); const dateEntree = collab.length > 0 ? collab[0].DateEntree : null; return calculerAcquisitionCP(dateReference, dateEntree); } else { const rttData = await calculerAcquisitionRTT(conn, collaborateurId, dateReference); return rttData.acquisition; } } const dateArrete = new Date(dernierArrete.DateArrete); // Si la date de référence est AVANT l'arrêté, utiliser le solde figé if (dateReference <= dateArrete) { const soldeFige = await getSoldeFige(conn, collaborateurId, typeCongeId, anneeRef); return soldeFige ? soldeFige.TotalAcquis : 0; } // Si la date est APRÈS l'arrêté, partir du solde figé + calcul depuis l'arrêté const soldeFige = await getSoldeFige(conn, collaborateurId, typeCongeId, anneeRef); const acquisFigee = soldeFige ? soldeFige.TotalAcquis : 0; // Calculer l'acquisition DEPUIS l'arrêté let acquisDepuisArrete = 0; if (typeConge === 'CP') { const moisDepuisArrete = getMoisTravaillesCP(dateReference, dateArrete); acquisDepuisArrete = moisDepuisArrete * (25 / 12); } else { const [collab] = await conn.query('SELECT TypeContrat, CampusId FROM CollaborateurAD WHERE id = ?', [collaborateurId]); const typeContrat = collab.length > 0 && collab[0].TypeContrat ? collab[0].TypeContrat : '37h'; const config = await getConfigurationRTT(conn, anneeRef, typeContrat); const moisDepuisArrete = getMoisTravaillesRTT(dateReference, dateArrete); acquisDepuisArrete = moisDepuisArrete * config.acquisitionMensuelle; } return acquisFigee + acquisDepuisArrete; } const pool = new sql.ConnectionPool(dbConfig); // ⭐ CONNEXION AU DÉMARRAGE pool.connect() .then(() => { console.log('✅ =========================================='); console.log(' CONNECTÉ À SQL SERVER'); console.log(` Base: ${dbConfig.database}@${dbConfig.host}`); console.log('=========================================='); }) .catch(err => { console.error('❌ =========================================='); console.error(' ERREUR CONNEXION SQL SERVER'); console.error(' Message:', err.message); console.error('=========================================='); }); // ======================================== // 🔧 MONKEY-PATCH MSSQL POUR SUPPORTER "?" // ======================================== // MONKEY-PATCH MSSQL POUR SUPPORTER ? ET LIMIT const originalRequest = sql.Request; sql.Request = function (...args) { const request = new originalRequest(...args); const originalQuery = request.query.bind(request); request.query = async function (queryText, ...queryArgs) { let convertedQuery = queryText; // 🔥 AJOUTÉ ICI try { if (!queryArgs || queryArgs.length === 0 || !Array.isArray(queryArgs[0])) { return await originalQuery(queryText); } const params = queryArgs[0]; params.forEach((value, index) => { request.input(`param${index}`, value); }); let paramIndex = 0; convertedQuery = convertedQuery.replace(/\?/g, () => `@param${paramIndex++}`); // CONVERSION GETDATE() → GETDATE() convertedQuery = convertedQuery.replace(/NOW\(\)/gi, 'GETDATE()'); // CONVERSION LEAST() → CASE WHEN while (convertedQuery.match(/LEAST\s*\(/i)) { convertedQuery = convertedQuery.replace( /LEAST\s*\(\s*([^,]+?)\s*,\s*([^)]+?)\s*\)/i, '(CASE WHEN $1 < $2 THEN $1 ELSE $2 END)' ); } // LIMIT → TOP conversion convertedQuery = convertedQuery.replace( /LIMIT\s+(\d+)\s+OFFSET\s+(\d+)/gi, 'OFFSET $2 ROWS FETCH NEXT $1 ROWS ONLY' ); const simpleLimitMatch = convertedQuery.match(/LIMIT\s+(\d+)(?!\s+OFFSET)/i); if (simpleLimitMatch) { const limitValue = simpleLimitMatch[1]; convertedQuery = convertedQuery.replace(/LIMIT\s+\d+(?!\s+OFFSET)/i, ''); convertedQuery = convertedQuery.replace( /SELECT(\s+DISTINCT)?/i, `SELECT$1 TOP ${limitValue}` ); } return await originalQuery(convertedQuery); } catch (error) { console.error('❌ Erreur query SQL:', error.message); console.error('Query originale:', queryText.substring(0, 300)); console.error('Query convertie:', convertedQuery?.substring(0, 300)); throw error; } }; return request; }; console.log('✅ Driver mssql patché: support des ?, LIMIT, GETDATE() et LEAST() activé'); // ======================================== // ⭐ WRAPPER POUR COMPATIBILITÉ MYSQL // ======================================== /** * Simule pool.getConnection() de MySQL * Retourne un objet avec query(), beginTransaction(), commit(), rollback(), release() */ // ⭐ WRAPPER POUR COMPATIBILITÉ MYSQL // ⭐ WRAPPER POUR COMPATIBILITÉ MYSQL pool.getConnection = async function () { if (!pool.connected) { await pool.connect(); } let transaction = null; return { query: async function (queryText, params = []) { // ⭐ FIX: Déclarer parameterizedQuery EN DEHORS du try block let parameterizedQuery = queryText; try { const request = transaction ? new sql.Request(transaction) : pool.request(); // Ajouter les paramètres (@param0, @param1, ...) params.forEach((value, index) => { request.input(`param${index}`, value); }); // Remplacer ? par @param0, @param1, etc. let paramIndex = 0; parameterizedQuery = parameterizedQuery.replace(/\?/g, () => `@param${paramIndex++}`); // ⭐⭐⭐ CONVERSION LIMIT → TOP (VERSION CORRIGÉE) ⭐⭐⭐ // 1. Gérer LIMIT avec OFFSET parameterizedQuery = parameterizedQuery.replace( /LIMIT\s+(\d+)\s+OFFSET\s+(\d+)/gi, 'OFFSET $2 ROWS FETCH NEXT $1 ROWS ONLY' ); // 2. Marquer tous les LIMIT (même sans espace avant) parameterizedQuery = parameterizedQuery.replace( /\s*LIMIT\s+(\d+)(?!\s+OFFSET)/gi, ' __LIMIT__$1__' ); // 3. Injecter TOP après SELECT let limitValue = null; const limitMatch = parameterizedQuery.match(/__LIMIT__(\d+)__/); if (limitMatch) { limitValue = limitMatch[1]; parameterizedQuery = parameterizedQuery.replace(/__LIMIT__\d+__/g, ''); } if (limitValue) { parameterizedQuery = parameterizedQuery.replace( /(SELECT\s+(?:DISTINCT\s+)?)/i, `$1TOP ${limitValue} ` ); } // ⭐ FIX: Convertir TRUE/FALSE en 1/0 pour SQL Server parameterizedQuery = parameterizedQuery.replace(/\bTRUE\b/gi, '1'); parameterizedQuery = parameterizedQuery.replace(/\bFALSE\b/gi, '0'); const result = await request.query(parameterizedQuery); return [result.recordset || []]; } catch (error) { console.error('❌ Erreur query SQL:', error.message); console.error('Query originale:', queryText); console.error('Query convertie:', parameterizedQuery?.substring(0, 500)); throw error; } }, beginTransaction: async function () { transaction = new sql.Transaction(pool); await transaction.begin(); }, commit: async function () { if (transaction) { await transaction.commit(); transaction = null; } }, rollback: async function () { if (transaction) { await transaction.rollback(); transaction = null; } }, release: function () { console.log('🔄 Connection released (no-op avec mssql)'); } }; }; // ⭐ pool.query() direct (sans transaction) // ⭐ pool.query() direct (sans transaction) pool.query = async function (queryText, params = []) { if (!pool.connected) { await pool.connect(); } // ⭐ FIX: Déclarer parameterizedQuery EN DEHORS du try/catch implicite let parameterizedQuery = queryText; const request = pool.request(); params.forEach((value, index) => { request.input(`param${index}`, value); }); let paramIndex = 0; parameterizedQuery = parameterizedQuery.replace(/\?/g, () => `@param${paramIndex++}`); // ⭐⭐⭐ CONVERSION LIMIT → TOP (VERSION CORRIGÉE) ⭐⭐⭐ // 1. Gérer LIMIT avec OFFSET parameterizedQuery = parameterizedQuery.replace( /LIMIT\s+(\d+)\s+OFFSET\s+(\d+)/gi, 'OFFSET $2 ROWS FETCH NEXT $1 ROWS ONLY' ); // 2. Marquer tous les LIMIT (même sans espace avant) parameterizedQuery = parameterizedQuery.replace( /\s*LIMIT\s+(\d+)(?!\s+OFFSET)/gi, ' __LIMIT__$1__' ); // 3. Injecter TOP après SELECT let limitValue = null; const limitMatch = parameterizedQuery.match(/__LIMIT__(\d+)__/); if (limitMatch) { limitValue = limitMatch[1]; parameterizedQuery = parameterizedQuery.replace(/__LIMIT__\d+__/g, ''); } if (limitValue) { parameterizedQuery = parameterizedQuery.replace( /(SELECT\s+(?:DISTINCT\s+)?)/i, `$1TOP ${limitValue} ` ); } // ⭐ FIX: Convertir TRUE/FALSE en 1/0 pour SQL Server parameterizedQuery = parameterizedQuery.replace(/\bTRUE\b/gi, '1'); parameterizedQuery = parameterizedQuery.replace(/\bFALSE\b/gi, '0'); const result = await request.query(parameterizedQuery); return [result.recordset || []]; }; const AZURE_CONFIG = { tenantId: '9840a2a0-6ae1-4688-b03d-d2ec291be0f9', clientId: '4bb4cc24-bac3-427c-b02c-5d14fc67b561', clientSecret: 'gvf8Q~545Bafn8yYsgjW~QG_P1lpzaRe6gJNgb2t', groupId: 'c1ea877c-6bca-4f47-bfad-f223640813a0' }; const storage = multer.diskStorage({ destination: './uploads/', filename: (req, file, cb) => { cb(null, Date.GETDATE() + path.extname(file.originalname)); } }); const upload = multer({ storage }); const medicalStorage = multer.diskStorage({ destination: './uploads/medical/', filename: (req, file, cb) => { const uniqueSuffix = Date.GETDATE() + '-' + Math.round(Math.random() * 1E9); cb(null, 'medical-' + uniqueSuffix + path.extname(file.originalname)); } }); const ACCES_TRANSVERSAUX = { 'sloisil@ensup.eu': { typeAcces: 'service_multi_campus', serviceNom: 'Pédagogie', description: 'Sandrine - Vue complète Pédagogie (tous campus)' }, 'mbouteiller@ensup.eu': { typeAcces: 'service_multi_campus', serviceNom: 'Admissions', description: 'Morgane - Vue complète Admissions (tous campus)' }, 'vnoel@ensup.eu': { typeAcces: 'service_multi_campus', serviceNom: 'Relations Entreprises', description: 'Viviane - Vue complète Relations Entreprises (tous campus)' }, 'vpierrel@ensup.eu': { typeAcces: 'service_multi_campus', // ✅ CORRIGÉ - même type que les autres serviceNom: 'Administratif & Financier', description: 'Virginie - Vue complète Administratif & Financier (tous campus)' } }; function getUserAccesTransversal(userEmail) { const acces = ACCES_TRANSVERSAUX[userEmail?.toLowerCase()] || null; if (acces) { console.log(`🌐 Accès transversal: ${acces.description}`); } return acces; } const uploadMedical = multer({ storage: medicalStorage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (req, file, cb) => { const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']; if (allowedTypes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Type de fichier non autorisé')); } } }); import fs from 'fs'; if (!fs.existsSync('./uploads/medical')) { fs.mkdirSync('./uploads/medical', { recursive: true }); } app.get('/api/events/collaborateur', (req, res) => { const userId = req.query.user_id; if (!userId) { return res.status(401).json({ error: 'user_id requis' }); } console.log('🔔 Nouvelle connexion SSE collaborateur:', userId); // ⭐ HEADERS CRITIQUES POUR SSE res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); res.setHeader('Access-Control-Allow-Origin', '*'); // ⭐ FLUSH IMMÉDIATEMENT POUR ÉTABLIR LA CONNEXION res.flushHeaders(); const sendEvent = (data) => { try { if (res.writableEnded) { console.log('⚠️ Tentative d\'envoi sur connexion fermée'); return false; } res.write(`data: ${JSON.stringify(data)}\n\n`); return true; } catch (error) { console.error('❌ Erreur envoi SSE:', error); return false; } }; const client = { userId: parseInt(userId), send: sendEvent, res: res // ⭐ Garder référence pour vérifier l'état }; sseClientsCollab.add(client); console.log(`📊 Clients SSE collaborateurs connectés: ${sseClientsCollab.size}`); // ⭐ ÉVÉNEMENT DE CONNEXION sendEvent({ type: 'connected', message: 'Connexion établie', timestamp: new Date().toISOString() }); // ⭐ HEARTBEAT AVEC VÉRIFICATION const heartbeat = setInterval(() => { const success = sendEvent({ type: 'heartbeat', timestamp: new Date().toISOString() }); if (!success) { console.log('💔 Heartbeat échoué, nettoyage...'); clearInterval(heartbeat); sseClientsCollab.delete(client); } }, 30000); // 30 secondes // ⭐ GESTION PROPRE DE LA DÉCONNEXION const cleanup = () => { console.log('🔌 Déconnexion SSE collaborateur:', userId); clearInterval(heartbeat); sseClientsCollab.delete(client); console.log(`📊 Clients SSE collaborateurs connectés: ${sseClientsCollab.size}`); }; req.on('close', cleanup); req.on('error', (err) => { console.error('❌ Erreur SSE connexion:', err.message); cleanup(); }); // ⭐ TIMEOUT DE SÉCURITÉ (optionnel, mais recommandé) req.socket.setTimeout(0); // Désactiver timeout pour SSE }); const notifyCollabClients = (event, targetUserId = null) => { console.log( `📢 Notification SSE Collab: ${event.type}`, targetUserId ? `pour user ${targetUserId}` : 'pour tous' ); const deadClients = []; sseClientsCollab.forEach(client => { // ⭐ FILTRER PAR USER SI NÉCESSAIRE if (targetUserId && client.userId !== targetUserId) { return; } // ⭐ VÉRIFIER SI LA CONNEXION EST TOUJOURS ACTIVE if (client.res && client.res.writableEnded) { console.log(`💀 Client mort détecté: ${client.userId}`); deadClients.push(client); return; } // ⭐ ENVOYER L'ÉVÉNEMENT const success = client.send(event); if (!success) { deadClients.push(client); } }); // ⭐ NETTOYER LES CLIENTS MORTS deadClients.forEach(client => { console.log(`🧹 Nettoyage client mort: ${client.userId}`); sseClientsCollab.delete(client); }); if (deadClients.length > 0) { console.log(`📊 Clients SSE après nettoyage: ${sseClientsCollab.size}`); } }; const sseClients = new Set(); // 🔌 ROUTE SSE POUR LE CALENDRIER app.get('/api/sse', (req, res) => { const userId = req.query.user_id; if (!userId) { return res.status(400).json({ error: 'user_id requis' }); } console.log('🔌 Nouvelle connexion SSE:', userId); res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); 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) { console.error('❌ Erreur heartbeat:', error); clearInterval(heartbeat); } }, 30000); req.on('close', () => { console.log('🔌 Déconnexion SSE:', 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 => { // Si userId est spécifié, envoyer seulement à ce client if (userId && client.id !== userId) { return; } try { client.send(event); } catch (error) { console.error('❌ Erreur envoi event:', error); } }); }; app.post('/api/webhook/receive', async (req, res) => { try { const signature = req.headers['x-webhook-signature']; const payload = req.body; console.log('\n📥 === WEBHOOK REÇU (COLLABORATEURS) ==='); console.log('Event:', payload.event); console.log('Data:', JSON.stringify(payload.data, null, 2)); // Vérifier la signature if (!webhookManager.verifySignature(payload, signature)) { console.error('❌ Signature webhook invalide'); return res.status(401).json({ error: 'Signature invalide' }); } const { event, data } = payload; // Traiter selon le type d'événement switch (event) { case EVENTS.COMPTEUR_UPDATED: console.log('📊 WEBHOOK COMPTEUR_UPDATED REÇU'); console.log('Collaborateur:', data.collaborateurId); console.log('Type mise à jour:', data.typeUpdate); console.log('Type congé:', data.typeConge); console.log('Année:', data.annee); console.log('Nouveau Total:', data.nouveauTotal + 'j'); console.log('Nouveau Solde:', data.nouveauSolde + 'j'); console.log('Source:', data.source); // ✅ PAS D'UPDATE EN BASE (car même DB partagée) // ✅ UNIQUEMENT NOTIFICATION SSE pour rafraîchir l'interface notifyCollabClients({ type: 'compteur-updated', collaborateurId: data.collaborateurId, typeConge: data.typeConge, annee: data.annee, typeUpdate: data.typeUpdate, nouveauTotal: data.nouveauTotal, nouveauSolde: data.nouveauSolde, source: data.source, timestamp: new Date().toISOString() }, data.collaborateurId); console.log('✅ Notification SSE envoyée au collaborateur', data.collaborateurId); break; 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.typeConge}`); console.log(` Couleur: ${data.couleurHex}`); // Notifier les clients SSE avec TOUTES les infos notifyClients({ type: 'demande-validated', demandeId: data.demandeId, statut: data.statut, typeConge: data.typeConge, couleurHex: data.couleurHex || '#d946ef', date: data.date, periode: data.periode, collaborateurId: data.collaborateurId, timestamp: new Date().toISOString() }, data.collaborateurId); // Notifier les RH aussi notifyClients({ type: 'demande-list-updated', action: 'validation-collab', demandeId: data.demandeId, statut: data.statut, typeConge: data.typeConge, couleurHex: data.couleurHex || '#d946ef', timestamp: new Date().toISOString() }); console.log(' 📢 Notifications SSE envoyées'); break; case EVENTS.DEMANDE_UPDATED: console.log('\n✏️ === WEBHOOK DEMANDE_UPDATED REÇU ==='); console.log(` Demande: ${data.demandeId}`); console.log(` Collaborateur: ${data.collaborateurId}`); notifyCollabClients({ type: 'demande-updated-rh', demandeId: data.demandeId, timestamp: new Date().toISOString() }, data.collaborateurId); console.log(' 📢 Notification modification envoyée'); break; case EVENTS.DEMANDE_DELETED: console.log('\n🗑️ === WEBHOOK DEMANDE_DELETED REÇU ==='); console.log(` Demande: ${data.demandeId}`); console.log(` Collaborateur: ${data.collaborateurId}`); notifyCollabClients({ type: 'demande-deleted-rh', demandeId: data.demandeId, timestamp: new Date().toISOString() }, data.collaborateurId); console.log(' 📢 Notification suppression envoyée'); 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 }); } }); function getDateFinMoisPrecedent(referenceDate = new Date()) { const now = new Date(referenceDate); now.setHours(0, 0, 0, 0); return new Date(now.getFullYear(), now.getMonth(), 0); } function parseDateYYYYMMDD(s) { if (!s) return null; if (/^\d{4}-\d{2}-\d{2}$/.test(s)) { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); } return new Date(s); } const LEAVE_RULES = { CP: { nom: 'Congé payé', joursAnnuels: 25, periodeDebut: { mois: 6, jour: 1 }, periodeFin: { mois: 5, jour: 31 }, acquisitionMensuelle: 25 / 12, reportable: true, periodeReport: 'exercice' }, RTT: { nom: 'RTT', joursAnnuels: 10, periodeDebut: { mois: 1, jour: 1 }, periodeFin: { mois: 12, jour: 31 }, acquisitionMensuelle: 10 / 12, reportable: false, periodeReport: null } }; // ======================================== // NOUVELLES FONCTIONS POUR RTT VARIABLES // ======================================== /** * Récupère la configuration RTT pour une année et un type de contrat donnés * RÈGLES : * - 37h : toujours 10 RTT/an (0.8333/mois) * - Forfait jour 2025 : 10 RTT/an (0.8333/mois) * - Forfait jour 2026+ : 12 RTT/an (1.0/mois) */ async function getConfigurationRTT(conn, annee, typeContrat = '37h') { try { // D'abord chercher en base de données const [config] = await conn.query( `SELECT JoursAnnuels, AcquisitionMensuelle FROM ConfigurationRTT WHERE Annee = ? AND TypeContrat = ? LIMIT 1`, [annee, typeContrat] ); if (config.length > 0) { return { joursAnnuels: parseFloat(config[0].JoursAnnuels), acquisitionMensuelle: parseFloat(config[0].AcquisitionMensuelle) }; } // Si pas en base, utiliser les règles par défaut console.warn(`⚠️ Pas de config RTT en base pour ${annee}/${typeContrat}, utilisation des règles par défaut`); return getConfigurationRTTDefaut(annee, typeContrat); } catch (error) { console.error('Erreur getConfigurationRTT:', error); return getConfigurationRTTDefaut(annee, typeContrat); } } function getConfigurationRTTDefaut(annee, typeContrat) { // 37h : toujours 10 RTT/an if (typeContrat === '37h' || typeContrat === 'temps_partiel') { return { joursAnnuels: 10, acquisitionMensuelle: 10 / 12 // 0.8333 }; } // Forfait jour : dépend de l'année if (typeContrat === 'forfait_jour') { 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 }; } /** * Calcule l'acquisition RTT avec la formule Excel exacte */ async function calculerAcquisitionRTT(conn, collaborateurId, dateReference = new Date()) { const d = new Date(dateReference); d.setHours(0, 0, 0, 0); const annee = d.getFullYear(); // 1️⃣ Récupérer les infos du collaborateur const [collabInfo] = await conn.query( `SELECT TypeContrat, DateEntree, role FROM CollaborateurAD WHERE id = ?`, [collaborateurId] ); if (collabInfo.length === 0) { throw new Error(`Collaborateur ${collaborateurId} non trouvé`); } const typeContrat = collabInfo[0].TypeContrat || '37h'; const dateEntree = collabInfo[0].DateEntree; const isApprenti = collabInfo[0].role === 'Apprenti'; // 2️⃣ Apprentis = pas de RTT if (isApprenti) { return { acquisition: 0, moisTravailles: 0, config: { joursAnnuels: 0, acquisitionMensuelle: 0 }, typeContrat: typeContrat }; } // 3️⃣ Récupérer la configuration RTT (avec règles 2025/2026) const config = await getConfigurationRTT(conn, annee, typeContrat); console.log(`📊 Config RTT ${annee}/${typeContrat}: ${config.joursAnnuels}j/an (${config.acquisitionMensuelle.toFixed(4)}/mois)`); // 4️⃣ Début d'acquisition = 01/01/N ou date d'entrée si postérieure let dateDebutAcquis = new Date(annee, 0, 1); // 01/01/N dateDebutAcquis.setHours(0, 0, 0, 0); if (dateEntree) { const entree = new Date(dateEntree); entree.setHours(0, 0, 0, 0); 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 (pour info) 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 }; } /** * Calcule l'acquisition avec la formule Excel exacte : * E1 * ((JOUR(FIN.MOIS(B1;0)) - JOUR(B1) + 1) / JOUR(FIN.MOIS(B1;0)) * + DATEDIF(B1;B2;"m") - 1 * + JOUR(B2) / JOUR(FIN.MOIS(B2;0))) */ 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); // 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; } /** * Équivalent de DATEDIF(date1, date2, "m") en JavaScript */ function dateDifMonths(date1, date2) { const d1 = new Date(date1); const d2 = new Date(date2); 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); } /** * 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️⃣ 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) { const entree = new Date(dateEntree); entree.setHours(0, 0, 0, 0); if (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); } // ======================================== // CALCUL CP INTELLIGENT (MODE AUTO) // ======================================== /** * Calcule l'acquisition CP avec détection automatique du mode */ function calculerAcquisitionCP_Smart(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️⃣ 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️⃣ Obtenir le mode de calcul const modeInfo = getModeCalcul('CP', dateEntree); const dateDebutAcquis = modeInfo.dateDebut; console.log(` 📅 ${modeInfo.description}`); // 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); } // ======================================== // CALCUL RTT INTELLIGENT (MODE AUTO) // ======================================== /** * Calcule l'acquisition RTT avec détection automatique du mode */ async function calculerAcquisitionRTT_Smart(conn, collaborateurId, dateReference = new Date()) { const d = new Date(dateReference); d.setHours(0, 0, 0, 0); const annee = d.getFullYear(); // 1️⃣ Récupérer les infos du collaborateur const [collabInfo] = await conn.query( `SELECT TypeContrat, DateEntree, role FROM CollaborateurAD WHERE id = ?`, [collaborateurId] ); if (collabInfo.length === 0) { throw new Error(`Collaborateur ${collaborateurId} non trouvé`); } const typeContrat = collabInfo[0].TypeContrat || '37h'; const dateEntree = collabInfo[0].DateEntree; const isApprenti = collabInfo[0].role === 'Apprenti'; // 2️⃣ Apprentis = pas de RTT if (isApprenti) { return { acquisition: 0, moisTravailles: 0, config: { joursAnnuels: 0, acquisitionMensuelle: 0 }, typeContrat: typeContrat, mode: 'APPRENTI' }; } // 3️⃣ Récupérer la configuration RTT (avec règles 2025/2026) const config = await getConfigurationRTT(conn, annee, typeContrat); // 4️⃣ Obtenir le mode de calcul const modeInfo = getModeCalcul('RTT', dateEntree); const dateDebutAcquis = modeInfo.dateDebut; console.log(` 📅 ${modeInfo.description}`); // 5️⃣ Calculer avec la formule Excel const acquisition = calculerAcquisitionFormuleExcel(dateDebutAcquis, d, config.acquisitionMensuelle); // 6️⃣ Calculer les mois travaillés (pour info) 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, mode: modeInfo.mode }; } // ======================================== // FONCTION DE DÉTECTION AUTOMATIQUE DU MODE // ======================================== /** * Détermine automatiquement si on doit utiliser le mode transition * en comparant la date actuelle avec le début de l'exercice */ function isInExerciceActuel(typeConge) { const today = new Date(); today.setHours(0, 0, 0, 0); const currentYear = today.getFullYear(); const currentMonth = today.getMonth() + 1; // 1-12 if (typeConge === 'CP') { // Exercice CP : 01/06/N → 31/05/N+1 // Déterminer le début de l'exercice ACTUEL let debutExercice; if (currentMonth >= 6) { // On est entre juin et décembre → exercice commence le 01/06 de cette année debutExercice = new Date(currentYear, 5, 1); // 01/06/N } else { // On est entre janvier et mai → exercice a commencé le 01/06 de l'année dernière debutExercice = new Date(currentYear - 1, 5, 1); // 01/06/N-1 } // ⭐ Si aujourd'hui est le PREMIER JOUR du nouvel exercice ou après → MODE NORMAL // ⭐ Si on est encore dans l'exercice commencé avant → MODE TRANSITION const finExercice = new Date(debutExercice); finExercice.setFullYear(finExercice.getFullYear() + 1); finExercice.setMonth(4, 31); // 31/05/N+1 // Si on vient de passer le 01/06, c'est le nouvel exercice → mode NORMAL const nouveauExercice = new Date(currentYear, 5, 1); return today < nouveauExercice; // TRUE = mode transition (avant le 01/06) } else if (typeConge === 'RTT') { // Année RTT : 01/01/N → 31/12/N const debutAnnee = new Date(currentYear, 0, 1); // 01/01/N // Si on vient de passer le 01/01, c'est la nouvelle année → mode NORMAL return today < debutAnnee; // TRUE = mode transition (avant le 01/01) } return false; } /** * Version améliorée : retourne le mode ET la date de début à utiliser */ /** * Détermine le mode de calcul et la date de début selon le contexte * @param {string} typeConge - 'CP' ou 'RTT' * @param {Date|null} dateEntree - Date d'entrée du collaborateur * @returns {Object} { mode, dateDebut, description } */ function getModeCalcul(typeConge, dateEntree = null) { const today = new Date(); today.setHours(0, 0, 0, 0); const currentYear = today.getFullYear(); const currentMonth = today.getMonth() + 1; if (typeConge === 'CP') { // Déterminer le début de l'exercice ACTUEL let debutExerciceActuel; if (currentMonth >= 6) { debutExerciceActuel = new Date(currentYear, 5, 1); // 01/06/N } else { debutExerciceActuel = new Date(currentYear - 1, 5, 1); // 01/06/N-1 } debutExerciceActuel.setHours(0, 0, 0, 0); // ⭐ RÈGLE DE BASCULE : // On bascule en mode NORMAL uniquement si : // 1. On est dans un NOUVEL exercice (après le prochain 01/06) // 2. ET la personne est arrivée APRÈS le début de ce nouvel exercice // Calculer le début du PROCHAIN exercice const prochainExercice = new Date(currentYear, 5, 1); // 01/06/N if (currentMonth < 6) { // Si on est avant juin, le prochain exercice est cette année prochainExercice.setFullYear(currentYear); } else { // Si on est après juin, le prochain exercice est l'année prochaine prochainExercice.setFullYear(currentYear + 1); } // ⭐ BASCULE : on passe en mode NORMAL seulement si aujourd'hui >= prochain exercice if (today >= prochainExercice && dateEntree) { const entree = new Date(dateEntree); entree.setHours(0, 0, 0, 0); // Si la personne est arrivée APRÈS le début du nouvel exercice if (entree >= prochainExercice) { return { mode: 'NORMAL', dateDebut: entree, description: `CP avec DateEntree (${entree.toLocaleDateString('fr-FR')})` }; } } // ⭐ MODE TRANSITION : calcul depuis début de l'exercice actuel (SANS DateEntree) return { mode: 'TRANSITION', dateDebut: debutExerciceActuel, description: `CP mode transition (depuis ${debutExerciceActuel.toLocaleDateString('fr-FR')})` }; } else if (typeConge === 'RTT') { const debutAnneeActuelle = new Date(currentYear, 0, 1); // 01/01/N debutAnneeActuelle.setHours(0, 0, 0, 0); // ⭐ RÈGLE DE BASCULE : // On bascule en mode NORMAL uniquement si : // 1. On est dans une NOUVELLE année (après le prochain 01/01) // 2. ET la personne est arrivée APRÈS le début de cette nouvelle année // Calculer le début de la PROCHAINE année const prochaineAnnee = new Date(currentYear + 1, 0, 1); // 01/01/N+1 // ⭐ BASCULE : on passe en mode NORMAL seulement si aujourd'hui >= prochaine année if (today >= prochaineAnnee && dateEntree) { const entree = new Date(dateEntree); entree.setHours(0, 0, 0, 0); // Si la personne est arrivée APRÈS le début de la nouvelle année if (entree >= prochaineAnnee) { return { mode: 'NORMAL', dateDebut: entree, description: `RTT avec DateEntree (${entree.toLocaleDateString('fr-FR')})` }; } } // ⭐ MODE TRANSITION : calcul depuis début de l'année actuelle (SANS DateEntree) return { mode: 'TRANSITION', dateDebut: debutAnneeActuelle, description: `RTT mode transition (depuis ${debutAnneeActuelle.toLocaleDateString('fr-FR')})` }; } return null; } // ======================================== // TÂCHES CRON // ======================================== cron.schedule('1 0 1 1 *', async () => { console.log('\n🎉 ===== RÉINITIALISATION RTT - 1ER JANVIER ====='); const conn = await pool.getConnection(); try { await conn.beginTransaction(); const today = new Date(); const nouvelleAnnee = today.getFullYear(); const ancienneAnnee = nouvelleAnnee - 1; console.log(` 📅 Passage de ${ancienneAnnee} à ${nouvelleAnnee}`); const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ?', ['RTT']); if (rttType.length === 0) { throw new Error('Type de congé RTT introuvable'); } const rttTypeId = rttType[0].Id; const [collaborateurs] = await conn.query(` SELECT id, prenom, nom, TypeContrat, role FROM CollaborateurAD WHERE (Actif = 1 OR Actif IS NULL) AND (role IS NULL OR role != 'Apprenti') `); console.log(` 👥 ${collaborateurs.length} collaborateurs à traiter`); let compteursReinitialises = 0; for (const collab of collaborateurs) { const collaborateurId = collab.id; const [ancienCompteur] = await conn.query(` SELECT Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttTypeId, ancienneAnnee]); if (ancienCompteur.length > 0) { const soldeAncien = parseFloat(ancienCompteur[0].Solde || 0); console.log(` 👤 ${collab.prenom} ${collab.nom}: ${soldeAncien.toFixed(2)}j RTT perdus`); await conn.query(` UPDATE CompteurConges SET DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttTypeId, ancienneAnnee]); } const [nouveauCompteur] = await conn.query(` SELECT id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttTypeId, nouvelleAnnee]); if (nouveauCompteur.length === 0) { await conn.query(` INSERT INTO CompteurConges ( CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour ) VALUES (?, ?, ?, 0, 0, 0, GETDATE()) `, [collaborateurId, rttTypeId, nouvelleAnnee]); console.log(` ✅ Compteur RTT ${nouvelleAnnee} créé à 0`); } else { await conn.query(` UPDATE CompteurConges SET Total = 0, Solde = 0, SoldeReporte = 0, DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttTypeId, nouvelleAnnee]); console.log(` ✅ Compteur RTT ${nouvelleAnnee} réinitialisé à 0`); } compteursReinitialises++; } await conn.commit(); console.log(`\n✅ Réinitialisation RTT terminée : ${compteursReinitialises} compteurs`); } catch (error) { await conn.rollback(); console.error('❌ Erreur réinitialisation RTT:', error); } finally { conn.release(); } }, { timezone: "Europe/Paris" }); // ============================================================================ // 📅 CRON REPORT CP - 31 mai 23h59 // ============================================================================ cron.schedule('59 23 31 5 *', async () => { console.log('📅 [CRON] Traitement fin d\'exercice CP...'); try { const conn = await pool.getConnection(); await conn.beginTransaction(); const [collaborateurs] = await conn.query('SELECT id, CampusId FROM CollaborateurAD'); let successCount = 0; for (const collab of collaborateurs) { try { await processEndOfExerciceCP(conn, collab.id); successCount++; } catch (error) { console.error(`❌ Erreur CP pour ${collab.id}:`, error.message); } } await conn.commit(); console.log(`✅ [CRON] ${successCount}/${collaborateurs.length} CP reportés`); conn.release(); } catch (error) { console.error('❌ [CRON] Erreur traitement fin d\'exercice:', error); } }); // ============================================================================ // 📊 CRON ARRÊTÉS MENSUELS // ============================================================================ cron.schedule('55 23 28-31 * *', async () => { const today = new Date(); const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0); if (today.getDate() === lastDay.getDate()) { console.log(`📅 [CRON] Création arrêté fin de mois: ${today.toISOString().split('T')[0]}`); const conn = await pool.getConnection(); try { await conn.beginTransaction(); const annee = today.getFullYear(); const mois = today.getMonth() + 1; const dateArrete = today.toISOString().split('T')[0]; const [existing] = await conn.query( 'SELECT Id FROM ArreteComptable WHERE Annee = ? AND Mois = ?', [annee, mois] ); if (existing.length > 0) { console.log(`⚠️ [CRON] Arrêté ${mois}/${annee} existe déjà, skip`); await conn.rollback(); conn.release(); return; } const [result] = await conn.query(` INSERT INTO ArreteComptable (DateArrete, Annee, Mois, Libelle, Description, Statut, DateCreation) VALUES (?, ?, ?, ?, ?, 'En cours', GETDATE()) `, [ dateArrete, annee, mois, `Arrêté comptable ${getMonthName(mois)} ${annee}`, `Arrêté mensuel automatique - Clôture des soldes au ${dateArrete}` ]); const arreteId = result.insertId; console.log(`✅ [CRON] Arrêté créé: ID ${arreteId}`); await conn.query('CALL sp_creer_snapshot_arrete(?)', [arreteId]); console.log(`📸 [CRON] Snapshot créé pour l'arrêté ${arreteId}`); const [count] = await conn.query( 'SELECT COUNT(*) as total FROM SoldesFiges WHERE ArreteId = ?', [arreteId] ); await conn.commit(); console.log(`🎉 [CRON] Arrêté ${mois}/${annee} terminé: ${count[0].total} soldes figés`); } catch (error) { await conn.rollback(); console.error(`❌ [CRON] Erreur création arrêté:`, error.message); } finally { conn.release(); } } }); // ============================================================================ // 📧 CRON MAILS COMPTE-RENDU // ============================================================================ cron.schedule('0 9 1 * *', async () => { console.log('📧 Envoi mails compte-rendu mensuel...'); const conn = await pool.getConnection(); const [cadres] = await conn.query(` SELECT id, email, prenom, nom FROM CollaborateurAD WHERE TypeContrat = 'forfait_jour' AND (actif = 1 OR actif IS NULL) `); const moisPrecedent = new Date(); moisPrecedent.setMonth(moisPrecedent.getMonth() - 1); for (const cadre of cadres) { // Envoyer mail via Microsoft Graph API // Enregistrer dans MailsCompteRendu } conn.release(); }); // ============================================================================ // 🔔 CRON RELANCES // ============================================================================ cron.schedule('0 9 * * 1', async () => { console.log('🔔 Relance hebdomadaire compte-rendu...'); const conn = await pool.getConnection(); const moisCourant = new Date().getMonth() + 1; const anneeCourante = new Date().getFullYear(); const [nonValides] = await conn.query(` SELECT DISTINCT ca.id, ca.email, ca.prenom, ca.nom FROM CollaborateurAD ca LEFT JOIN CompteRenduMensuel crm ON ca.id = crm.CollaborateurADId AND crm.Annee = ? AND crm.Mois = ? WHERE ca.TypeContrat = 'forfait_jour' AND (crm.Statut IS NULL OR crm.Statut != 'Validé') `, [anneeCourante, moisCourant - 1]); for (const cadre of nonValides) { // Envoyer relance } conn.release(); }); // ============================================================================ // 🚀 RATTRAPAGE IMMÉDIAT - 5 minutes après démarrage // ============================================================================ setTimeout(() => { console.log('🚀 ===== EXÉCUTION IMMÉDIATE - RATTRAPAGE DEPUIS LE 1ER DU MOIS ====='); updateMonthlyCounters(true); // true = mode rattrapage }, 5 * 60 * 1000); console.log('⏰ CRON quotidien programmé : 00h00 Europe/Paris'); console.log('⏰ CRON réinitialisation RTT programmé : 1er janvier à 00h01'); console.log(`⏰ CRON immédiat programmé dans 5 minutes`); // ⭐ Fonction helper pour les noms de mois function getMonthName(mois) { const mois_names = ['', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; return mois_names[mois] || mois; } async function getGraphToken() { try { const params = new URLSearchParams({ grant_type: 'client_credentials', client_id: AZURE_CONFIG.clientId, client_secret: AZURE_CONFIG.clientSecret, scope: 'https://graph.microsoft.com/.default' }); const response = await axios.post( `https://login.microsoftonline.com/${AZURE_CONFIG.tenantId}/oauth2/v2.0/token`, params.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } ); return response.data.access_token; } catch (error) { console.error('Erreur obtention token:', error); return null; } } async function sendMailGraph(accessToken, fromEmail, toEmail, subject, bodyHtml) { try { await axios.post( `https://graph.microsoft.com/v1.0/users/${fromEmail}/sendMail`, { message: { subject, body: { contentType: 'HTML', content: bodyHtml }, toRecipients: [{ emailAddress: { address: toEmail } }] }, saveToSentItems: false }, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' } } ); return true; } catch (error) { console.error('Erreur envoi email:', error); return false; } } function getWorkingDays(startDate, endDate) { let workingDays = 0; const current = new Date(startDate); const end = new Date(endDate); while (current <= end) { const dayOfWeek = current.getDay(); if (dayOfWeek !== 0 && dayOfWeek !== 6) { workingDays++; } current.setDate(current.getDate() + 1); } return workingDays; } function formatDate(date) { const d = new Date(date); const day = String(d.getDate()).padStart(2, '0'); const month = String(d.getMonth() + 1).padStart(2, '0'); const year = d.getFullYear(); return `${day}/${month}/${year}`; } function getExerciceCP(date = new Date()) { const d = new Date(date); const annee = d.getFullYear(); const mois = d.getMonth() + 1; if (mois >= 1 && mois <= 5) { return `${annee - 1}-${annee}`; } return `${annee}-${annee + 1}`; } function getMoisTravaillesCP(date = new Date(), dateEntree = null) { const d = new Date(date); d.setHours(0, 0, 0, 0); const annee = d.getFullYear(); const mois = d.getMonth() + 1; let debutExercice; if (mois >= 6) { debutExercice = new Date(annee, 5, 1); } else { debutExercice = new Date(annee - 1, 5, 1); } debutExercice.setHours(0, 0, 0, 0); if (dateEntree) { const entree = new Date(dateEntree); entree.setHours(0, 0, 0, 0); if (entree > debutExercice) { debutExercice = entree; } } // ✅ Calculer jusqu'à aujourd'hui const diffMs = d - debutExercice; const diffJours = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + 1; const moisTravailles = diffJours / 30.44; return Math.max(0, Math.min(12, moisTravailles)); } function getMoisTravaillesRTT(date = new Date(), dateEntree = null) { const d = new Date(date); d.setHours(0, 0, 0, 0); const annee = d.getFullYear(); let debutAnnee = new Date(annee, 0, 1); debutAnnee.setHours(0, 0, 0, 0); if (dateEntree) { const entree = new Date(dateEntree); entree.setHours(0, 0, 0, 0); if (entree.getFullYear() === annee && entree > debutAnnee) { debutAnnee = entree; } else if (entree.getFullYear() > annee) { return 0; } } const diffMs = d - debutAnnee; const diffJours = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + 1; const moisTravailles = diffJours / 30.44; return Math.max(0, Math.min(12, moisTravailles)); } function calculerAcquisitionCumulee(typeConge, dateReference = new Date(), dateEntree = null) { const rules = LEAVE_RULES[typeConge]; if (!rules) return 0; let moisTravailles; if (typeConge === 'CP') { moisTravailles = getMoisTravaillesCP(dateReference, dateEntree); } else { moisTravailles = getMoisTravaillesRTT(dateReference, dateEntree); } const acquisition = moisTravailles * rules.acquisitionMensuelle; return Math.round(acquisition * 100) / 100; } async function processEndOfYearRTT(conn, collaborateurId) { const currentYear = new Date().getFullYear(); const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']); if (rttType.length === 0) return null; await conn.query( `UPDATE CompteurConges SET Solde = 0, Total = 0, SoldeReporte = 0, DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, rttType[0].Id, currentYear] ); return { type: 'RTT', action: 'reset_end_of_year', annee: currentYear }; } async function processEndOfExerciceCP(conn, collaborateurId) { const today = new Date(); const currentYear = today.getFullYear(); const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']); if (cpType.length === 0) return null; const cpTypeId = cpType[0].Id; const [currentCounter] = await conn.query( `SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, cpTypeId, currentYear] ); if (currentCounter.length === 0) return null; const soldeAReporter = parseFloat(currentCounter[0].Solde); const [nextYearCounter] = await conn.query( `SELECT Id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, cpTypeId, currentYear + 1] ); if (nextYearCounter.length > 0) { await conn.query( `UPDATE CompteurConges SET SoldeReporte = ?, Solde = Solde + ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [soldeAReporter, soldeAReporter, nextYearCounter[0].Id] ); } else { await conn.query( `INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, 0, ?, ?, GETDATE())`, [collaborateurId, cpTypeId, currentYear + 1, soldeAReporter, soldeAReporter] ); } return { type: 'CP', action: 'report_exercice', soldeReporte: soldeAReporter }; } async function deductLeaveBalance(conn, collaborateurId, typeCongeId, nombreJours) { const currentYear = new Date().getFullYear(); const previousYear = currentYear - 1; let joursRestants = nombreJours; const deductions = []; const [compteurN1] = await conn.query( `SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, previousYear] ); if (compteurN1.length > 0 && compteurN1[0].SoldeReporte > 0) { const soldeN1 = parseFloat(compteurN1[0].SoldeReporte); const aDeduireN1 = Math.min(soldeN1, joursRestants); if (aDeduireN1 > 0) { await conn.query( `UPDATE CompteurConges SET SoldeReporte = CASE WHEN (SoldeReporte - ?) < 0 THEN 0 ELSE (SoldeReporte - ?) END, Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END WHERE Id = ?`, [aDeduireN1, aDeduireN1, compteurN1[0].Id] ); deductions.push({ annee: previousYear, type: 'Reporté N-1', joursUtilises: aDeduireN1, soldeAvant: soldeN1 }); joursRestants -= aDeduireN1; } } if (joursRestants > 0) { const [compteurN] = await conn.query( `SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, currentYear] ); if (compteurN.length > 0) { const soldeN = parseFloat(compteurN[0].Solde) - parseFloat(compteurN[0].SoldeReporte || 0); const aDeduireN = Math.min(soldeN, joursRestants); if (aDeduireN > 0) { await conn.query( `UPDATE CompteurConges SET Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END WHERE Id = ?`, [aDeduireN, compteurN[0].Id] ); deductions.push({ annee: currentYear, type: 'Année actuelle N', joursUtilises: aDeduireN, soldeAvant: soldeN }); joursRestants -= aDeduireN; } } } return { success: joursRestants === 0, joursDeduitsTotal: nombreJours - joursRestants, joursNonDeduits: joursRestants, details: deductions }; } async function checkLeaveBalance(conn, collaborateurId, repartition) { const currentYear = new Date().getFullYear(); const previousYear = currentYear - 1; const verification = []; for (const rep of repartition) { const typeCode = rep.TypeConge; const joursNecessaires = parseFloat(rep.NombreJours); if (typeCode === 'ABS' || typeCode === 'Formation') continue; const typeName = typeCode === 'CP' ? 'Congé payé' : typeCode === 'RTT' ? 'RTT' : typeCode; const [typeRow] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [typeName]); if (typeRow.length === 0) continue; const typeCongeId = typeRow[0].Id; const [compteurN1] = await conn.query( `SELECT SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, previousYear] ); const [compteurN] = await conn.query( `SELECT Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, currentYear] ); const soldeN1 = compteurN1.length > 0 ? parseFloat(compteurN1[0].SoldeReporte || 0) : 0; const soldeN = compteurN.length > 0 ? parseFloat(compteurN[0].Solde || 0) - parseFloat(compteurN[0].SoldeReporte || 0) : 0; const soldeTotal = soldeN1 + soldeN; verification.push({ type: typeName, joursNecessaires, soldeN1, soldeN, soldeTotal, suffisant: soldeTotal >= joursNecessaires, deficit: Math.max(0, joursNecessaires - soldeTotal) }); } const insuffisants = verification.filter(v => !v.suffisant); return { valide: insuffisants.length === 0, details: verification, insuffisants }; } // ============================================================================ // 📅 FONCTION UTILITAIRE - Obtenir le nombre de jours du mois // ============================================================================ function getJoursDuMois(date) { const annee = date.getFullYear(); const mois = date.getMonth(); // 0-11 // Le jour 0 du mois suivant = dernier jour du mois actuel const dernierJour = new Date(annee, mois + 1, 0); return dernierJour.getDate(); } // ============================================================================ // 🔄 FONCTION DE MISE À JOUR DES COMPTEURS (CORRIGÉE - DÉCRÉMENTE) // ============================================================================ async function updateMonthlyCounters(rattrapage = false) { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const today = new Date(); const currentYear = today.getFullYear(); const currentMonth = today.getMonth(); const jourActuel = today.getDate(); // ⭐ Obtenir le nombre de jours du mois actuel const joursDuMois = getJoursDuMois(today); console.log(`\n🔄 === MISE À JOUR ${rattrapage ? 'RATTRAPAGE' : 'QUOTIDIENNE'} COMPTEURS - ${today.toLocaleDateString('fr-FR')} ===`); console.log(` 📅 Mois actuel : ${joursDuMois} jours`); // ⭐ RATTRAPAGE : Calculer les jours manqués depuis le 1er du mois let joursARattraper = 1; // Par défaut : incrément d'1 jour if (rattrapage) { joursARattraper = jourActuel; // Du 1er au jour actuel console.log(` 📅 Rattrapage depuis le 1er du mois : ${joursARattraper} jours`); } // Récupérer tous les collaborateurs actifs const [collaborateurs] = await conn.query(` SELECT id, prenom, nom, DateEntree, TypeContrat, role FROM CollaborateurAD WHERE (Actif = 1 OR Actif IS NULL) `); const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ?', ['Congé payé']); const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ?', ['RTT']); let compteursMisAJour = 0; for (const collab of collaborateurs) { const collaborateurId = collab.id; const dateEntree = collab.DateEntree; const typeContrat = collab.TypeContrat || '37h'; const role = collab.role; console.log(`\n 👤 ${collab.prenom} ${collab.nom}`); // ==================================== // 📅 CP - Incrément avec prorata mensuel // ==================================== if (cpType.length > 0) { const modeCP = getModeCalcul('CP', dateEntree); // ⭐ Calcul acquisition mensuelle const acquisitionMensuelleCP = 25 / 12; // 2.0833j/mois // ⭐ Calcul acquisition quotidienne selon le nombre de jours du mois const incrementJournalierCP = acquisitionMensuelleCP / joursDuMois; const incrementTotal = incrementJournalierCP * joursARattraper; console.log(` CP: ${incrementJournalierCP.toFixed(6)}j/jour (${acquisitionMensuelleCP.toFixed(4)}j/${joursDuMois}j)`); // Vérifier si compteur existe const [compteurCP] = await conn.query(` SELECT Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, cpType[0].Id, currentYear]); if (compteurCP.length > 0) { const totalAvant = parseFloat(compteurCP[0].Total || 0); const soldeAvant = parseFloat(compteurCP[0].Solde || 0); // ⭐ INCRÉMENTER (ne pas écraser) await conn.query(` UPDATE CompteurConges SET Total = LEAST(Total + ?, 25), Solde = LEAST(Solde + ?, 25 + COALESCE(SoldeReporte, 0)), DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [incrementTotal, incrementTotal, collaborateurId, cpType[0].Id, currentYear]); console.log(` CP: ${totalAvant.toFixed(2)}j → ${(totalAvant + incrementTotal).toFixed(2)}j (+${incrementTotal.toFixed(4)}j) [${modeCP.mode}]`); compteursMisAJour++; } else { // Créer compteur initial avec Smart const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree); // Générer l'ID manuellement const compteurCPId = await getNextId(conn, 'CompteurConges'); await conn.query(` INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, ?, 0, GETDATE()) `, [compteurCPId, collaborateurId, cpType[0].Id, currentYear, acquisCP, acquisCP]); console.log(` ✅ CP créé avec ID ${compteurCPId}: ${acquisCP.toFixed(2)}j [${modeCP.mode}]`); compteursMisAJour++; } } // ==================================== // 📅 RTT - Incrément avec prorata mensuel // ==================================== if (rttType.length > 0 && role !== 'Apprenti') { const modeRTT = getModeCalcul('RTT', dateEntree); const rttConfig = await getConfigurationRTT(conn, currentYear, typeContrat); // ⭐ Calcul acquisition mensuelle const acquisitionMensuelleRTT = rttConfig.joursAnnuels / 12; // ⭐ Calcul acquisition quotidienne selon le nombre de jours du mois const incrementJournalierRTT = acquisitionMensuelleRTT / joursDuMois; const incrementTotal = incrementJournalierRTT * joursARattraper; console.log(` RTT: ${incrementJournalierRTT.toFixed(6)}j/jour (${acquisitionMensuelleRTT.toFixed(4)}j/${joursDuMois}j)`); // Vérifier si compteur existe const [compteurRTT] = await conn.query(` SELECT Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttType[0].Id, currentYear]); if (compteurRTT.length > 0) { const totalAvant = parseFloat(compteurRTT[0].Total || 0); const soldeAvant = parseFloat(compteurRTT[0].Solde || 0); // ⭐ INCRÉMENTER (ne pas écraser) await conn.query(` UPDATE CompteurConges SET Total = LEAST(Total + ?, ?), Solde = LEAST(Solde + ?, ?), DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [incrementTotal, rttConfig.joursAnnuels, incrementTotal, rttConfig.joursAnnuels, collaborateurId, rttType[0].Id, currentYear]); console.log(` RTT: ${totalAvant.toFixed(2)}j → ${(totalAvant + incrementTotal).toFixed(2)}j (+${incrementTotal.toFixed(4)}j) [${modeRTT.mode}]`); compteursMisAJour++; } else { // Créer compteur initial avec Smart const rttData = await calculerAcquisitionRTT_Smart(conn, collaborateurId, today); // ✅ CODE CORRIGÉ - Génération manuelle de l'ID console.log(` 🆕 RTT créé pour ${collaborateurId}`); // Générer l'ID manuellement const compteurRTTId = await getNextId(conn, 'CompteurConges'); await conn.query(` INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, GETDATE()) `, [compteurRTTId, collaborateurId, rttType[0].Id, currentYear, rttData.acquisition, rttData.acquisition]); console.log(` ✅ RTT créé avec ID ${compteurRTTId}: ${rttData.acquisition.toFixed(2)}j [${modeRTT.mode}]`); compteursMisAJour++; } } } await conn.commit(); console.log(`\n✅ Mise à jour terminée : ${compteursMisAJour} compteurs pour ${collaborateurs.length} collaborateurs`); } catch (error) { await conn.rollback(); console.error('❌ Erreur mise à jour compteurs:', error); throw error; } finally { conn.release(); } } // ============================================================================ // ⏰ PLANIFICATION DES CRONS // ============================================================================ // ❌ DÉSACTIVER le rattrapage immédiat (commenté) // ✅ EXÉCUTION QUOTIDIENNE à 00h01 (à partir de demain - DÉCRÉMENTE) cron.schedule('1 0 * * *', () => { console.log('🕐 ===== EXÉCUTION QUOTIDIENNE AUTOMATIQUE - 00h01 ====='); updateMonthlyCounters(false); // false = incrément normal d'1 jour }, { timezone: "Europe/Paris" }); console.log('⏰ CRON quotidien programmé : 00h01 Europe/Paris (à partir de demain)'); console.log('⏰ CRON réinitialisation RTT programmé : 1er janvier à 00h01'); console.log('⚠️ Rattrapage immédiat DÉSACTIVÉ'); // ============================================================================ // 🎉 CRON - RÉINITIALISATION RTT AU 1ER JANVIER // ============================================================================ cron.schedule('1 0 1 1 *', async () => { console.log('\n🎉 ===== RÉINITIALISATION RTT - 1ER JANVIER ====='); const conn = await pool.getConnection(); try { await conn.beginTransaction(); const today = new Date(); const nouvelleAnnee = today.getFullYear(); const ancienneAnnee = nouvelleAnnee - 1; console.log(` 📅 Passage de ${ancienneAnnee} à ${nouvelleAnnee}`); // Récupérer le TypeCongeId pour RTT const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ?', ['RTT']); if (rttType.length === 0) { throw new Error('Type de congé RTT introuvable'); } const rttTypeId = rttType[0].Id; // Récupérer tous les collaborateurs actifs (sauf apprentis) const [collaborateurs] = await conn.query(` SELECT id, prenom, nom, TypeContrat, role FROM CollaborateurAD WHERE (Actif = 1 OR Actif IS NULL) AND (role IS NULL OR role != 'Apprenti') `); console.log(` 👥 ${collaborateurs.length} collaborateurs à traiter`); let compteursReinitialises = 0; for (const collab of collaborateurs) { const collaborateurId = collab.id; // 1️⃣ Archiver/Marquer l'ancien compteur RTT N-1 const [ancienCompteur] = await conn.query(` SELECT Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttTypeId, ancienneAnnee]); if (ancienCompteur.length > 0) { const soldeAncien = parseFloat(ancienCompteur[0].Solde || 0); console.log(` 👤 ${collab.prenom} ${collab.nom}: ${soldeAncien.toFixed(2)}j RTT perdus`); // Marquer l'ancien compteur comme "clos" await conn.query(` UPDATE CompteurConges SET DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttTypeId, ancienneAnnee]); } // 2️⃣ Créer ou réinitialiser le compteur RTT pour la nouvelle année const [nouveauCompteur] = await conn.query(` SELECT id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttTypeId, nouvelleAnnee]); if (nouveauCompteur.length === 0) { // Créer le compteur à 0 await conn.query(` INSERT INTO CompteurConges ( CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour ) VALUES (?, ?, ?, 0, 0, 0, GETDATE()) `, [collaborateurId, rttTypeId, nouvelleAnnee]); console.log(` ✅ Compteur RTT ${nouvelleAnnee} créé à 0`); } else { // Le compteur existe déjà, le remettre à 0 await conn.query(` UPDATE CompteurConges SET Total = 0, Solde = 0, SoldeReporte = 0, DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttTypeId, nouvelleAnnee]); console.log(` ✅ Compteur RTT ${nouvelleAnnee} réinitialisé à 0`); } compteursReinitialises++; } await conn.commit(); console.log(`\n✅ Réinitialisation RTT terminée : ${compteursReinitialises} compteurs`); } catch (error) { await conn.rollback(); console.error('❌ Erreur réinitialisation RTT:', error); } finally { conn.release(); } }, { timezone: "Europe/Paris" }); console.log('⏰ CRON réinitialisation RTT programmé : 1er janvier à 00h01'); // ======================================== // MISE À JOUR DE updateMonthlyCounters // ======================================== async function updateMonthlyCounters_Smart(conn, collaborateurId, dateReference = null) { const today = dateReference ? new Date(dateReference) : new Date(); const currentYear = today.getFullYear(); const updates = []; // Récupérer les infos du collaborateur const [collabInfo] = await conn.query(` SELECT DateEntree, TypeContrat, CampusId, role FROM CollaborateurAD WHERE id = ? `, [collaborateurId]); if (collabInfo.length === 0) { throw new Error(`Collaborateur ${collaborateurId} non trouvé`); } const dateEntree = collabInfo[0].DateEntree || null; const typeContrat = collabInfo[0].TypeContrat || '37h'; const isApprenti = collabInfo[0].role === 'Apprenti'; console.log(`\n📊 === Mise à jour pour collaborateur ${collaborateurId} ===`); console.log(` Date référence: ${today.toLocaleDateString('fr-FR')}`); // ====================================== // CP (Congés Payés) // ====================================== const acquisitionCP = calculerAcquisitionCP_Smart(today, dateEntree); console.log(` CP - Acquisition: ${acquisitionCP.toFixed(2)}j`); const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']); if (cpType.length > 0) { const cpTypeId = cpType[0].Id; const [existingCP] = await conn.query(` SELECT Id, Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, cpTypeId, currentYear]); if (existingCP.length > 0) { const ancienTotal = parseFloat(existingCP[0].Total || 0); const ancienSolde = parseFloat(existingCP[0].Solde || 0); const soldeReporte = parseFloat(existingCP[0].SoldeReporte || 0); const incrementAcquis = acquisitionCP - ancienTotal; if (incrementAcquis > 0) { console.log(` CP - Nouveaux jours: +${incrementAcquis.toFixed(2)}j`); // Gérer le remboursement d'anticipé (logique existante) const [anticipeUtilise] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalAnticipe FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction = 'N Anticip' AND dc.Statut != 'Refusée' AND dd.JoursUtilises > 0 `, [collaborateurId, cpTypeId, currentYear]); const anticipePris = parseFloat(anticipeUtilise[0]?.totalAnticipe || 0); if (anticipePris > 0) { const aRembourser = Math.min(incrementAcquis, anticipePris); console.log(` 💳 CP - Remboursement anticipé: ${aRembourser.toFixed(2)}j`); // [Logique de remboursement complète - identique à avant] const [deductionsAnticipees] = await conn.query(` SELECT dd.Id, dd.DemandeCongeId, dd.JoursUtilises FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction = 'N Anticip' AND dc.Statut != 'Refusée' AND dd.JoursUtilises > 0 ORDER BY dd.Id ASC `, [collaborateurId, cpTypeId, currentYear]); let resteARembourser = aRembourser; for (const deduction of deductionsAnticipees) { if (resteARembourser <= 0) break; const joursAnticipes = parseFloat(deduction.JoursUtilises); const aDeduiteDeCetteDeduction = Math.min(resteARembourser, joursAnticipes); await conn.query(` UPDATE DeductionDetails SET JoursUtilises = CASE WHEN (JoursUtilises - ?) < 0 THEN 0 ELSE (JoursUtilises - ?) END WHERE Id = ? `, [aDeduiteDeCetteDeduction, deduction.Id]); const [existingAnneeN] = await conn.query(` SELECT Id, JoursUtilises FROM DeductionDetails WHERE DemandeCongeId = ? AND TypeCongeId = ? AND Annee = ? AND TypeDeduction IN ('Année N', 'Anne N', 'Anne actuelle N') `, [deduction.DemandeCongeId, cpTypeId, currentYear]); if (existingAnneeN.length > 0) { await conn.query(` UPDATE DeductionDetails SET JoursUtilises = JoursUtilises + ? WHERE Id = ? `, [aDeduiteDeCetteDeduction, existingAnneeN[0].Id]); } else { await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Année N', ?) `, [deduction.DemandeCongeId, cpTypeId, currentYear, aDeduiteDeCetteDeduction]); } resteARembourser -= aDeduiteDeCetteDeduction; } // Supprimer les déductions anticipées à zéro await conn.query(` DELETE FROM DeductionDetails WHERE TypeCongeId = ? AND Annee = ? AND TypeDeduction = 'N Anticip' AND JoursUtilises <= 0 `, [cpTypeId, currentYear]); } } // Recalculer le solde const [consomme] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction NOT IN ('Accum Récup', 'Accum Recup') AND dc.Statut != 'Refusée' `, [collaborateurId, cpTypeId, currentYear]); const totalConsomme = parseFloat(consomme[0].total || 0); const nouveauSolde = Math.max(0, acquisitionCP + soldeReporte - totalConsomme); await conn.query(` UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ? `, [acquisitionCP, nouveauSolde, existingCP[0].Id]); updates.push({ type: 'CP', acquisitionCumulee: acquisitionCP, increment: incrementAcquis, nouveauSolde: nouveauSolde }); } else { // Créer le compteur await conn.query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, GETDATE()) `, [collaborateurId, cpTypeId, currentYear, acquisitionCP, acquisitionCP]); updates.push({ type: 'CP', action: 'created', acquisitionCumulee: acquisitionCP }); } } // ====================================== // RTT (identique avec Smart) // ====================================== if (!isApprenti) { const rttData = await calculerAcquisitionRTT(conn, collaborateurId, today); const acquisitionRTT = rttData.acquisition; const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']); if (rttType.length > 0) { const rttTypeId = rttType[0].Id; // 1️⃣ Récupérer le compteur existant const [existingRTT] = await conn.query(` SELECT Id, Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttTypeId, currentYear]); if (existingRTT.length > 0) { const ancienTotal = parseFloat(existingRTT[0].Total || 0); const ancienSolde = parseFloat(existingRTT[0].Solde || 0); console.log(` RTT - Ancien acquis: ${ancienTotal.toFixed(2)}j`); console.log(` RTT - Nouvel acquis: ${acquisitionRTT.toFixed(2)}j`); // 2️⃣ Calculer l'incrément d'acquisition const incrementAcquis = acquisitionRTT - ancienTotal; if (incrementAcquis > 0) { console.log(` RTT - Nouveaux jours ce mois: +${incrementAcquis.toFixed(2)}j`); // 3️⃣ Vérifier si le collaborateur a de l'anticipé utilisé const [anticipeUtilise] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalAnticipe FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction = 'N Anticip' AND dc.Statut != 'Refusée' AND dd.JoursUtilises > 0 `, [collaborateurId, rttTypeId, currentYear]); const anticipePris = parseFloat(anticipeUtilise[0]?.totalAnticipe || 0); if (anticipePris > 0) { // 4️⃣ Calculer le montant à rembourser const aRembourser = Math.min(incrementAcquis, anticipePris); console.log(` 💳 RTT - Anticipé à rembourser: ${aRembourser.toFixed(2)}j (sur ${anticipePris.toFixed(2)}j)`); // 5️⃣ Rembourser l'anticipé const [deductionsAnticipees] = await conn.query(` SELECT dd.Id, dd.DemandeCongeId, dd.JoursUtilises FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction = 'N Anticip' AND dc.Statut != 'Refusée' AND dd.JoursUtilises > 0 ORDER BY dd.Id ASC `, [collaborateurId, rttTypeId, currentYear]); let resteARembourser = aRembourser; for (const deduction of deductionsAnticipees) { if (resteARembourser <= 0) break; const joursAnticipes = parseFloat(deduction.JoursUtilises); const aDeduiteDeCetteDeduction = Math.min(resteARembourser, joursAnticipes); // Réduire l'anticipé await conn.query(` UPDATE DeductionDetails SET JoursUtilises = CASE WHEN (JoursUtilises - ?) < 0 THEN 0 ELSE (JoursUtilises - ?) END WHERE Id = ? `, [aDeduiteDeCetteDeduction, deduction.Id]); // Vérifier si une déduction "Année N" existe déjà const [existingAnneeN] = await conn.query(` SELECT Id, JoursUtilises FROM DeductionDetails WHERE DemandeCongeId = ? AND TypeCongeId = ? AND Annee = ? AND TypeDeduction IN ('Année N', 'Anne N') `, [deduction.DemandeCongeId, rttTypeId, currentYear]); if (existingAnneeN.length > 0) { await conn.query(` UPDATE DeductionDetails SET JoursUtilises = JoursUtilises + ? WHERE Id = ? `, [aDeduiteDeCetteDeduction, existingAnneeN[0].Id]); } else { await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Année N', ?) `, [deduction.DemandeCongeId, rttTypeId, currentYear, aDeduiteDeCetteDeduction]); } resteARembourser -= aDeduiteDeCetteDeduction; console.log(` ✅ RTT - Remboursé ${aDeduiteDeCetteDeduction.toFixed(2)}j (Demande ${deduction.DemandeCongeId})`); } // Supprimer les déductions anticipées à zéro await conn.query(` DELETE FROM DeductionDetails WHERE TypeCongeId = ? AND Annee = ? AND TypeDeduction = 'N Anticip' AND JoursUtilises <= 0 `, [rttTypeId, currentYear]); } } // 6️⃣ Recalculer le solde total const [consomme] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction NOT IN ('Accum Récup', 'Accum Recup', 'Récup Dosée') AND dc.Statut != 'Refusée' `, [collaborateurId, rttTypeId, currentYear]); const totalConsomme = parseFloat(consomme[0].total || 0); const nouveauSolde = Math.max(0, acquisitionRTT - totalConsomme); console.log(` RTT - Consommé total: ${totalConsomme.toFixed(2)}j`); console.log(` RTT - Nouveau solde: ${nouveauSolde.toFixed(2)}j`); // 7️⃣ Mettre à jour le compteur await conn.query(` UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ? `, [acquisitionRTT, nouveauSolde, existingRTT[0].Id]); updates.push({ type: 'RTT', annee: currentYear, typeContrat: rttData.typeContrat, config: `${rttData.config.joursAnnuels}j/an`, moisTravailles: rttData.moisTravailles, acquisitionCumulee: acquisitionRTT, increment: incrementAcquis, nouveauSolde: nouveauSolde }); } else { // Créer le compteur s'il n'existe pas await conn.query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, GETDATE()) `, [collaborateurId, rttTypeId, currentYear, acquisitionRTT, acquisitionRTT]); console.log(` RTT - Compteur créé: ${acquisitionRTT.toFixed(2)}j`); updates.push({ type: 'RTT', annee: currentYear, typeContrat: rttData.typeContrat, config: `${rttData.config.joursAnnuels}j/an`, moisTravailles: rttData.moisTravailles, acquisitionCumulee: acquisitionRTT, action: 'created', nouveauSolde: acquisitionRTT }); } } } console.log(`✅ Mise à jour terminée pour collaborateur ${collaborateurId}\n`); return updates; } // ======================================== // ROUTES API // ======================================== app.post('/api/login', async (req, res) => { try { const { email, mot_de_passe, entraUserId, userPrincipalName } = req.body; const accessToken = req.headers.authorization?.replace('Bearer ', ''); if (accessToken && entraUserId) { const [users] = await pool.query(` SELECT ca.*, s.Nom as service, so.Nom as societe_nom FROM CollaborateurAD ca LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN Societe so ON ca.SocieteId = so.Id WHERE ca.entraUserId=? OR ca.email=? LIMIT 1 `, [entraUserId, email]); if (users.length === 0) return res.json({ success: false, message: 'Utilisateur non autorisé' }); const user = users[0]; try { const graphResponse = await axios.get(`https://graph.microsoft.com/v1.0/users/${userPrincipalName}/memberOf?$select=id`, { headers: { Authorization: `Bearer ${accessToken}` } }); const userGroups = graphResponse.data.value.map(g => g.id); const [allowedGroups] = await pool.query('SELECT Id FROM EntraGroups WHERE IsActive=1'); const allowed = allowedGroups.map(g => g.Id); const authorized = userGroups.some(g => allowed.includes(g)); if (authorized) { return res.json({ success: true, message: 'Connexion réussie via Azure AD', user: { id: user.id, prenom: user.prenom || 'Prénom', nom: user.nom || 'Nom', email: user.email, role: user.role || 'Collaborateur', service: user.service || 'Non défini', societeId: user.SocieteId, societeNom: user.societe_nom || 'Non défini', typeContrat: user.TypeContrat || '37h', description: user.description || null, dateEntree: user.DateEntree || null, campusId: user.CampusId || null } }); } else { return res.json({ success: false, message: 'Utilisateur non autorisé' }); } } catch (error) { return res.json({ success: false, message: 'Erreur vérification groupes' }); } } if (email && mot_de_passe) { const [users] = await pool.query(` SELECT u.ID, u.Prenom, u.Nom, u.Email, u.Role, u.ServiceId, s.Nom AS ServiceNom FROM Users u LEFT JOIN Services s ON u.ServiceId = s.Id WHERE u.Email = ? AND u.MDP = ? `, [email, mot_de_passe]); if (users.length === 1) { return res.json({ success: true, message: 'Connexion réussie', user: { id: users[0].ID, prenom: users[0].Prenom, nom: users[0].Nom, email: users[0].Email, role: users[0].Role, service: users[0].ServiceNom || 'Non défini' } }); } return res.json({ success: false, message: 'Identifiants incorrects' }); } res.json({ success: false, message: 'Aucune méthode de connexion fournie' }); } catch (error) { res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); app.post('/api/check-user-groups', async (req, res) => { try { const { userPrincipalName } = req.body; const accessToken = req.headers.authorization?.replace('Bearer ', ''); if (!userPrincipalName || !accessToken) return res.json({ authorized: false, message: 'Email ou token manquant' }); // 1. Vérification locale const [users] = await pool.query(` SELECT ca.id, ca.entraUserId, ca.prenom, ca.nom, ca.email, s.Nom as service, ca.role, ca.CampusId, ca.SocieteId, so.Nom as societe_nom FROM CollaborateurAD ca LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN Societe so ON ca.SocieteId = so.Id WHERE ca.email = ? LIMIT 1 `, [userPrincipalName]); if (users.length > 0) { const user = users[0]; // Si l'utilisateur est inactif, on le bloque if (user.Actif === 0) return res.json({ authorized: false, message: 'Compte désactivé' }); return res.json({ authorized: true, role: user.role, groups: [user.role], localUserId: user.id, user: { ...user, societeId: user.SocieteId, societeNom: user.societe_nom } }); } // 2. Si pas trouvé, interrogation Microsoft Graph const userGraph = await axios.get(`https://graph.microsoft.com/v1.0/users/${userPrincipalName}?$select=id,displayName,givenName,surname,mail,department,jobTitle`, { headers: { Authorization: `Bearer ${accessToken}` } }); const userInfo = userGraph.data; const checkMemberResponse = await axios.post(`https://graph.microsoft.com/v1.0/users/${userInfo.id}/checkMemberGroups`, { groupIds: [AZURE_CONFIG.groupId] }, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' } }); const isInGroup = checkMemberResponse.data.value.includes(AZURE_CONFIG.groupId); if (!isInGroup) return res.json({ authorized: false, message: 'Utilisateur non autorisé (Hors groupe)' }); // 3. ⭐ INSERTION AVEC VALEURS PAR DÉFAUT CRITIQUES // On met SocieteId=1 et TypeContrat='37h' par défaut pour éviter les bugs de calcul const [result] = await pool.query( `INSERT INTO CollaborateurAD (entraUserId, prenom, nom, email, service, role, SocieteId, Actif, DateEntree, TypeContrat) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ userInfo.id, userInfo.givenName || 'Prénom', userInfo.surname || 'Nom', userInfo.mail || userPrincipalName, userInfo.department, 'Collaborateur', 1, // SocieteId par défaut (ex: 1 = ENSUP) 1, // Actif = 1 (Important !) new Date(), // DateEntree = Aujourd'hui '37h' // TypeContrat par défaut ] ); res.json({ authorized: true, role: 'Collaborateur', groups: ['Collaborateur'], localUserId: result.insertId, user: { id: result.insertId, entraUserId: userInfo.id, prenom: userInfo.givenName, nom: userInfo.surname, email: userInfo.mail, service: userInfo.department, role: 'Collaborateur', societeId: 1, societeNom: 'Défaut' } }); } catch (error) { console.error("Erreur check-user-groups:", error); res.json({ authorized: false, message: 'Erreur serveur', error: error.message }); } }); // ======================================== // ✅ CODE CORRIGÉ POUR getDetailedLeaveCounters // À remplacer dans server.js à partir de la ligne ~1600 // ======================================== app.get('/api/getDetailedLeaveCounters', async (req, res) => { try { const userIdParam = req.query.user_id; if (!userIdParam) { return res.json({ success: false, message: 'ID utilisateur manquant' }); } const conn = await pool.getConnection(); const isUUID = userIdParam.length > 10 && userIdParam.includes('-'); const userQuery = ` SELECT ca.id, ca.prenom, ca.nom, ca.email, ca.role, ca.TypeContrat, ca.DateEntree, ca.CampusId, ca.SocieteId, s.Nom as service, so.Nom as societeNom, ca.description FROM CollaborateurAD ca LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN Societe so ON ca.SocieteId = so.Id WHERE ${isUUID ? 'ca.entraUserId' : 'ca.id'} = ? AND (ca.Actif = 1 OR ca.Actif IS NULL) LIMIT 1 `; const [userInfo] = await conn.query(userQuery, [userIdParam]); if (userInfo.length === 0) { conn.release(); return res.json({ success: false, message: 'Utilisateur non trouvé' }); } const user = userInfo[0]; const userId = user.id; const dateEntree = user.DateEntree; const typeContrat = user.TypeContrat || '37h'; const today = new Date(); const currentYear = today.getFullYear(); const previousYear = currentYear - 1; console.log('\n📊 === CALCUL COMPTEURS ==='); console.log('User:', user.prenom, user.nom, '(ID:', userId, ')'); console.log('Date référence:', today.toLocaleDateString('fr-FR')); // ═══════════════════════════════════════════════════════════ // 1️⃣ CALCUL AVEC LES FORMULES INTELLIGENTES // ═══════════════════════════════════════════════════════════ // CP N const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree); console.log('🧮 CP calculé:', acquisCP.toFixed(2) + 'j'); // RTT N let acquisRTT = 0; let rttTypeId = null; const [rttType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['RTT']); if (rttType.length > 0 && user.role !== 'Apprenti') { rttTypeId = rttType[0].Id; const rttData = await calculerAcquisitionRTT_Smart(conn, userId, today); acquisRTT = rttData.acquisition; console.log('🧮 RTT calculé:', acquisRTT.toFixed(2) + 'j'); } // ═══════════════════════════════════════════════════════════ // 2️⃣ RÉCUPÉRER INFOS POUR CALCUL DES SOLDES // ═══════════════════════════════════════════════════════════ const [cpType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['Congé payé']); if (cpType.length === 0) { conn.release(); return res.json({ success: false, message: 'Type de congé CP non trouvé' }); } // Solde reporté CP let soldeReporte = 0; const [compteurCP] = await conn.query(` SELECT SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [userId, cpType[0].Id, currentYear] ); if (compteurCP.length > 0) { soldeReporte = parseFloat(compteurCP[0].SoldeReporte) || 0; } // Consommé CP const [consommeCP] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction NOT IN ('Accum Récup', 'Accum Recup') AND dc.Statut != 'Refusé'`, [userId, cpType[0].Id, currentYear] ); const totalConsommeCP = parseFloat(consommeCP[0].total) || 0; const nouveauSoldeCP = Math.max(0, acquisCP + soldeReporte - totalConsommeCP); console.log('💰 CP - Acquis:', acquisCP.toFixed(2), '+ Reporté:', soldeReporte.toFixed(2), '- Consommé:', totalConsommeCP.toFixed(2), '= Solde:', nouveauSoldeCP.toFixed(2)); // Consommé RTT let totalConsommeRTT = 0; let nouveauSoldeRTT = 0; if (rttTypeId) { const [consommeRTT] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction NOT IN ('Accum Récup', 'Accum Recup', 'Récup Dose') AND dc.Statut != 'Refusé'`, [userId, rttTypeId, currentYear] ); totalConsommeRTT = parseFloat(consommeRTT[0].total) || 0; nouveauSoldeRTT = Math.max(0, acquisRTT - totalConsommeRTT); console.log('💰 RTT - Acquis:', acquisRTT.toFixed(2), '- Consommé:', totalConsommeRTT.toFixed(2), '= Solde:', nouveauSoldeRTT.toFixed(2)); } // ═══════════════════════════════════════════════════════════ // 3️⃣ ENVOYER À GTA-RH POUR SYNCHRONISATION // ═══════════════════════════════════════════════════════════ try { const rhUrl = process.env.RH_SERVER_URL || 'http://192.168.0.4:3001'; const syncPayload = { collaborateurId: userId, annee: currentYear, compteurs: [ { typeConge: 'Congé payé', typeCongeId: cpType[0].Id, total: parseFloat(acquisCP.toFixed(2)), solde: parseFloat(nouveauSoldeCP.toFixed(2)), source: 'calcul_gta' } ] }; if (rttTypeId) { syncPayload.compteurs.push({ typeConge: 'RTT', typeCongeId: rttTypeId, total: parseFloat(acquisRTT.toFixed(2)), solde: parseFloat(nouveauSoldeRTT.toFixed(2)), source: 'calcul_gta' }); } console.log('📤 Envoi synchronisation vers GTA-RH...'); const syncResponse = await fetch(`${rhUrl}/api/syncCompteursFromGTA`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(syncPayload) }); if (syncResponse.ok) { const syncResult = await syncResponse.json(); console.log('✅ Synchronisation GTA-RH réussie:', syncResult.message); } else { console.warn('⚠️ Synchronisation GTA-RH échouée:', syncResponse.status); } } catch (syncError) { console.error('❌ Erreur synchronisation GTA-RH:', syncError.message); // On continue même si la sync échoue } // ═══════════════════════════════════════════════════════════ // 4️⃣ RELIRE LA BASE POUR AFFICHER LES VRAIES VALEURS // ═══════════════════════════════════════════════════════════ console.log('\n📖 Lecture base après synchronisation...'); // Ancienneté const ancienneteMs = today - new Date(dateEntree || today); const ancienneteMois = Math.floor(ancienneteMs / (1000 * 60 * 60 * 24 * 30.44)); let counters = { user: { id: user.id, nom: `${user.prenom} ${user.nom}`, prenom: user.prenom, nomFamille: user.nom, email: user.email, service: user.service || 'Non défini', role: user.role, description: user.description, typeContrat: typeContrat, societeId: user.SocieteId, societeNom: user.societeNom || 'Non défini', dateEntree: dateEntree ? formatDateWithoutUTC(dateEntree) : null, ancienneteMois: ancienneteMois, ancienneteAnnees: Math.floor(ancienneteMois / 12), ancienneteMoisRestants: ancienneteMois % 12 }, dateReference: today.toISOString().split('T')[0], exerciceCP: getExerciceCP(today), anneeRTT: currentYear, cpN1: null, cpN: null, rttN: null, recupN: null, totalDisponible: { cp: 0, rtt: 0, recup: 0, total: 0 } }; // ✅ CP N-1 (Report) - LECTURE BASE const [cpN1Data] = await conn.query(` SELECT Annee, Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [userId, cpType[0].Id, previousYear] ); if (cpN1Data.length > 0) { const totalAcquis = parseFloat(cpN1Data[0].Total) || 0; const soldeReporte = parseFloat(cpN1Data[0].Solde) || 0; const pris = Math.max(0, totalAcquis - soldeReporte); counters.cpN1 = { annee: previousYear, exercice: `${previousYear}-${previousYear + 1}`, reporte: parseFloat(totalAcquis.toFixed(2)), pris: parseFloat(pris.toFixed(2)), solde: parseFloat(soldeReporte.toFixed(2)), pourcentageUtilise: totalAcquis > 0 ? parseFloat((pris / totalAcquis * 100).toFixed(1)) : 0 }; counters.totalDisponible.cp += counters.cpN1.solde; console.log('✅ CP N-1 BASE: Acquis=' + totalAcquis + 'j, Solde=' + soldeReporte + 'j'); } // ✅ CP N - LECTURE BASE const [cpNData] = await conn.query(` SELECT Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [userId, cpType[0].Id, currentYear] ); if (cpNData.length > 0) { const totalAcquis = parseFloat(cpNData[0].Total) || 0; const soldeBDD = parseFloat(cpNData[0].Solde) || 0; const soldeReporteBDD = parseFloat(cpNData[0].SoldeReporte) || 0; const soldeReel = Math.max(0, soldeBDD - soldeReporteBDD); const pris = Math.max(0, totalAcquis - soldeReel); counters.cpN = { annee: currentYear, exercice: getExerciceCP(today), totalAnnuel: 25.00, acquis: parseFloat(totalAcquis.toFixed(2)), pris: parseFloat(pris.toFixed(2)), solde: parseFloat(soldeReel.toFixed(2)), pourcentageUtilise: totalAcquis > 0 ? parseFloat((pris / totalAcquis * 100).toFixed(1)) : 0 }; counters.totalDisponible.cp += counters.cpN.solde; console.log('✅ CP N BASE: Acquis=' + totalAcquis + 'j, Solde=' + soldeReel + 'j'); } // ✅ RTT N - LECTURE BASE if (rttType.length > 0 && user.role !== 'Apprenti') { const [rttNData] = await conn.query(` SELECT Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [userId, rttTypeId, currentYear] ); if (rttNData.length > 0) { const totalAcquis = parseFloat(rttNData[0].Total) || 0; const soldeBDD = parseFloat(rttNData[0].Solde) || 0; const pris = Math.max(0, totalAcquis - soldeBDD); const rttConfig = await getConfigurationRTT(conn, currentYear, typeContrat); counters.rttN = { annee: currentYear, typeContrat: typeContrat, totalAnnuel: parseFloat(rttConfig.joursAnnuels.toFixed(2)), acquis: parseFloat(totalAcquis.toFixed(2)), pris: parseFloat(pris.toFixed(2)), solde: parseFloat(soldeBDD.toFixed(2)), pourcentageUtilise: totalAcquis > 0 ? parseFloat((pris / totalAcquis * 100).toFixed(1)) : 0 }; counters.totalDisponible.rtt += counters.rttN.solde; console.log('✅ RTT BASE: Acquis=' + totalAcquis + 'j, Solde=' + soldeBDD + 'j'); } } // ✅ Récup - LECTURE BASE const [recupType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['Récupération']); if (recupType.length > 0) { const [recupData] = await conn.query(` SELECT COALESCE(SUM(CASE WHEN dd.TypeDeduction IN ('Accum Récup', 'Accum Recup') THEN dd.JoursUtilises ELSE 0 END), 0) as acquis, COALESCE(SUM(CASE WHEN dd.TypeDeduction = 'Récup Dose' THEN dd.JoursUtilises ELSE 0 END), 0) as pris FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dc.Statut != 'Refusé'`, [userId, recupType[0].Id, currentYear] ); if (recupData.length > 0) { const acquis = parseFloat(recupData[0].acquis) || 0; const pris = parseFloat(recupData[0].pris) || 0; const solde = Math.max(0, acquis - pris); counters.recupN = { annee: currentYear, acquis: parseFloat(acquis.toFixed(2)), pris: parseFloat(pris.toFixed(2)), solde: parseFloat(solde.toFixed(2)), message: "Jours de récupération accumulés suite à du temps supplémentaire" }; counters.totalDisponible.recup = counters.recupN.solde; console.log('✅ RÉCUP BASE: Acquis=' + acquis + 'j, Solde=' + solde + 'j'); } } // Total disponible counters.totalDisponible.total = counters.totalDisponible.cp + counters.totalDisponible.rtt + counters.totalDisponible.recup; conn.release(); console.log('\n✅ Réponse envoyée au frontend'); console.log(' CP disponible:', counters.totalDisponible.cp + 'j'); console.log(' RTT disponible:', counters.totalDisponible.rtt + 'j'); console.log(' RÉCUP disponible:', counters.totalDisponible.recup + 'j'); console.log(' TOTAL disponible:', counters.totalDisponible.total + 'j'); res.json({ success: true, data: counters }); } catch (error) { console.error('❌ Erreur getDetailedLeaveCounters:', error); res.status(500).json({ success: false, message: error.message }); } }); app.post('/api/updateCounters', async (req, res) => { const conn = await pool.getConnection(); try { const { collaborateur_id } = req.body; if (!collaborateur_id) return res.json({ success: false, message: 'ID collaborateur manquant' }); await conn.beginTransaction(); const updates = await updateMonthlyCounters(conn, collaborateur_id, new Date()); await conn.commit(); res.json({ success: true, message: 'Compteurs mis à jour', updates }); } catch (error) { await conn.rollback(); res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } finally { conn.release(); } }); app.post('/api/updateAllCounters', async (req, res) => { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [collaborateurs] = await conn.query('SELECT id, CampusId FROM CollaborateurAD WHERE actif = 1 OR actif IS NULL'); const allUpdates = []; for (const collab of collaborateurs) { const updates = await updateMonthlyCounters(conn, collab.id, new Date()); allUpdates.push({ collaborateur_id: collab.id, updates }); } await conn.commit(); res.json({ success: true, message: `Compteurs mis à jour pour ${collaborateurs.length} collaborateurs`, total_collaborateurs: collaborateurs.length, details: allUpdates }); } catch (error) { await conn.rollback(); res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } finally { conn.release(); } }); async function deductLeaveBalanceWithTracking(conn, collaborateurId, typeCongeId, nombreJours, demandeCongeId) { const currentYear = new Date().getFullYear(); const previousYear = currentYear - 1; let joursRestants = nombreJours; const deductions = []; // Étape 1: Déduire du reporté N-1 d'abord const [compteurN1] = await conn.query( `SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, previousYear] ); if (compteurN1.length > 0 && compteurN1[0].SoldeReporte > 0) { const soldeN1 = parseFloat(compteurN1[0].SoldeReporte); const aDeduireN1 = Math.min(soldeN1, joursRestants); if (aDeduireN1 > 0) { // Déduction dans la base await conn.query( `UPDATE CompteurConges SET SoldeReporte = CASE WHEN (SoldeReporte - ?) < 0 THEN 0 ELSE (SoldeReporte - ?) END, Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END WHERE Id = ?`, [aDeduireN1, aDeduireN1, compteurN1[0].Id] ); // Sauvegarde du détail de la déduction await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Accum Récup', ?) `, [demandeId, recupType[0].Id, currentYear, recupJours]); deductions.push({ annee: previousYear, type: 'Reporté N-1', joursUtilises: aDeduireN1, soldeAvant: soldeN1 }); joursRestants -= aDeduireN1; } } // Étape 2: Déduire de l'année N si besoin if (joursRestants > 0) { const [compteurN] = await conn.query( `SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, currentYear] ); if (compteurN.length > 0) { const soldeN = parseFloat(compteurN[0].Solde) - parseFloat(compteurN[0].SoldeReporte || 0); const aDeduireN = Math.min(soldeN, joursRestants); if (aDeduireN > 0) { // Déduction dans la base await conn.query( `UPDATE CompteurConges SET Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END WHERE Id = ?`, [aDeduireN, compteurN[0].Id] ); // Sauvegarde du détail de la déduction await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Année N', ?)`, [demandeCongeId, typeCongeId, currentYear, aDeduireN] ); deductions.push({ annee: currentYear, type: 'Année actuelle N', joursUtilises: aDeduireN, soldeAvant: soldeN }); joursRestants -= aDeduireN; } } } return { success: joursRestants === 0, joursDeduitsTotal: nombreJours - joursRestants, joursNonDeduits: joursRestants, details: deductions }; }; async function restoreLeaveBalance(conn, demandeCongeId, collaborateurId) { try { console.log(`\n🔄 === RESTAURATION COMPTEURS ===`); console.log(`Demande ID: ${demandeCongeId}`); console.log(`Collaborateur ID: ${collaborateurId}`); const [deductions] = await conn.query( `SELECT 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 = ? ORDER BY dd.Id DESC`, [demandeCongeId] ); console.log(`📊 ${deductions.length} déductions trouvées`); if (deductions.length === 0) { console.log('⚠️ Aucune déduction trouvée pour cette demande'); return { success: false, message: 'Aucune déduction à restaurer' }; } const restorations = []; for (const deduction of deductions) { const { TypeCongeId, Annee, TypeDeduction, JoursUtilises, TypeNom } = deduction; console.log(`\n🔍 Traitement: ${TypeNom} - ${TypeDeduction} - ${JoursUtilises}j (Année: ${Annee})`); // ⭐ NOUVEAU : Gestion des Récup posées if (TypeDeduction === 'Récup Posée') { console.log(`🔄 Restauration Récup posée: +${JoursUtilises}j`); const [compteur] = await conn.query( `SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, TypeCongeId, Annee] ); if (compteur.length > 0) { const ancienSolde = parseFloat(compteur[0].Solde || 0); const nouveauSolde = ancienSolde + parseFloat(JoursUtilises); await conn.query( `UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [nouveauSolde, compteur[0].Id] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ Récup restaurée: ${ancienSolde} → ${nouveauSolde}`); } continue; } // 🔹 N+1 Anticipé - ⭐ RESTAURATION CORRECTE if (TypeDeduction === 'N+1 Anticipé') { console.log(`🔄 Restauration N+1 Anticipé: +${JoursUtilises}j`); const [compteur] = await conn.query( `SELECT Id, SoldeAnticipe FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, TypeCongeId, Annee] ); if (compteur.length > 0) { const ancienSolde = parseFloat(compteur[0].SoldeAnticipe || 0); const nouveauSolde = ancienSolde + parseFloat(JoursUtilises); await conn.query( `UPDATE CompteurConges SET SoldeAnticipe = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [nouveauSolde, compteur[0].Id] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ N+1 Anticipé restauré: ${ancienSolde} → ${nouveauSolde}`); } else { // ⭐ Créer le compteur N+1 s'il n'existe pas await conn.query( `INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, SoldeAnticipe, DerniereMiseAJour) VALUES (?, ?, ?, 0, 0, 0, ?, GETDATE())`, [collaborateurId, TypeCongeId, Annee, JoursUtilises] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ Compteur N+1 créé avec ${JoursUtilises}j anticipés`); } continue; } // 🔹 N Anticipé - ⭐ RESTAURATION CORRECTE if (TypeDeduction === 'N Anticipé') { console.log(`🔄 Restauration N Anticipé: +${JoursUtilises}j`); const [compteur] = await conn.query( `SELECT Id, SoldeAnticipe FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, TypeCongeId, Annee] ); if (compteur.length > 0) { const ancienSolde = parseFloat(compteur[0].SoldeAnticipe || 0); const nouveauSolde = ancienSolde + parseFloat(JoursUtilises); await conn.query( `UPDATE CompteurConges SET SoldeAnticipe = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [nouveauSolde, compteur[0].Id] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ N Anticipé restauré: ${ancienSolde} → ${nouveauSolde}`); } else { // ⭐ Créer le compteur s'il n'existe pas (cas rare) await conn.query( `INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, SoldeAnticipe, DerniereMiseAJour) VALUES (?, ?, ?, 0, 0, 0, ?, GETDATE())`, [collaborateurId, TypeCongeId, Annee, JoursUtilises] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ Compteur N créé avec ${JoursUtilises}j anticipés`); } continue; } // 🔹 Reporté N-1 if (TypeDeduction === 'Reporté N-1' || TypeDeduction === 'Report N-1') { const [compteur] = await conn.query( `SELECT Id, SoldeReporte, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, TypeCongeId, Annee] ); if (compteur.length > 0) { const ancienSolde = parseFloat(compteur[0].Solde || 0); const nouveauSolde = ancienSolde + parseFloat(JoursUtilises); await conn.query( `UPDATE CompteurConges SET SoldeReporte = SoldeReporte + ?, Solde = Solde + ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [JoursUtilises, JoursUtilises, compteur[0].Id] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ Reporté restauré: ${ancienSolde} → ${nouveauSolde}`); } } // 🔹 Année N else if (TypeDeduction === 'Année N') { const [compteur] = await conn.query( `SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, TypeCongeId, Annee] ); if (compteur.length > 0) { const ancienSolde = parseFloat(compteur[0].Solde || 0); const nouveauSolde = ancienSolde + parseFloat(JoursUtilises); await conn.query( `UPDATE CompteurConges SET Solde = Solde + ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [JoursUtilises, compteur[0].Id] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ Année N restaurée: ${ancienSolde} → ${nouveauSolde}`); } } } // ⭐ IMPORTANT : Recalculer les soldes anticipés après restauration console.log(`\n🔄 Recalcul des soldes anticipés...`); await updateSoldeAnticipe(conn, collaborateurId); console.log(`\n✅ Restauration terminée: ${restorations.length} opérations\n`); return { success: true, restorations, message: `${restorations.length} restaurations effectuées` }; } catch (error) { console.error('❌ Erreur lors de la restauration des soldes:', error); throw error; } } app.get('/api/testProrata', async (req, res) => { try { const userId = parseInt(req.query.user_id || 0); if (userId <= 0) return res.json({ success: false, message: 'ID utilisateur requis' }); const conn = await pool.getConnection(); const [userInfo] = await conn.query(`SELECT id, prenom, nom, DateEntree, TypeContrat, CampusId FROM CollaborateurAD WHERE id = ?`, [userId]); if (userInfo.length === 0) { conn.release(); return res.json({ success: false, message: 'Utilisateur non trouvé' }); } const user = userInfo[0]; const dateEntree = user.DateEntree; const typeContrat = user.TypeContrat || '37h'; const today = new Date(); const moisCP = getMoisTravaillesCP(today, dateEntree); const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree); const rttData = await calculerAcquisitionRTT_Smart(conn, userId, today); conn.release(); res.json({ success: true, user: { id: user.id, nom: `${user.prenom} ${user.nom}`, dateEntree: dateEntree ? dateEntree.toISOString().split('T')[0] : null, typeContrat: typeContrat }, dateReference: today.toISOString().split('T')[0], calculs: { CP: { moisTravailles: parseFloat(moisCP.toFixed(2)), acquisitionMensuelle: 25 / 12, acquisitionCumulee: acquisCP, formule: `${moisCP.toFixed(2)} mois × ${(25 / 12).toFixed(2)}j/mois = ${acquisCP}j` }, RTT: { moisTravailles: rttData.moisTravailles, acquisitionMensuelle: rttData.config.acquisitionMensuelle, acquisitionCumulee: rttData.acquisition, totalAnnuel: rttData.config.joursAnnuels, formule: `${rttData.moisTravailles} mois × ${rttData.config.acquisitionMensuelle.toFixed(6)}j/mois = ${rttData.acquisition}j` } } }); } catch (error) { console.error('Erreur testProrata:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); app.post('/api/fixAllCounters', async (req, res) => { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const today = new Date(); const currentYear = today.getFullYear(); const [collaborateurs] = await conn.query('SELECT id, prenom, nom, DateEntree, CampusId FROM CollaborateurAD WHERE (actif = 1 OR actif IS NULL)'); console.log(`🔄 Correction de ${collaborateurs.length} compteurs...`); const corrections = []; for (const collab of collaborateurs) { const dateEntree = collab.DateEntree; const moisCP = getMoisTravaillesCP(today, dateEntree); const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree); const rttData = await calculerAcquisitionRTT_Smart(conn, collab.id, today); const acquisRTT = rttData.acquisition; const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']); const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']); if (cpType.length > 0) { const [existingCP] = await conn.query(`SELECT Id, Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collab.id, cpType[0].Id, currentYear]); if (existingCP.length > 0) { const ancienTotal = parseFloat(existingCP[0].Total); const ancienSolde = parseFloat(existingCP[0].Solde); const difference = acquisCP - ancienTotal; const nouveauSolde = Math.max(0, ancienSolde + difference); await conn.query(`UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [acquisCP, nouveauSolde, existingCP[0].Id]); corrections.push({ collaborateur: `${collab.prenom} ${collab.nom}`, type: 'CP', ancienTotal: ancienTotal.toFixed(2), nouveauTotal: acquisCP.toFixed(2), ancienSolde: ancienSolde.toFixed(2), nouveauSolde: nouveauSolde.toFixed(2) }); } } if (rttType.length > 0) { const [existingRTT] = await conn.query(`SELECT Id, Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collab.id, rttType[0].Id, currentYear]); if (existingRTT.length > 0) { const ancienTotal = parseFloat(existingRTT[0].Total); const ancienSolde = parseFloat(existingRTT[0].Solde); const difference = acquisRTT - ancienTotal; const nouveauSolde = Math.max(0, ancienSolde + difference); await conn.query(`UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [acquisRTT, nouveauSolde, existingRTT[0].Id]); corrections.push({ collaborateur: `${collab.prenom} ${collab.nom}`, type: 'RTT', ancienTotal: ancienTotal.toFixed(2), nouveauTotal: acquisRTT.toFixed(2), ancienSolde: ancienSolde.toFixed(2), nouveauSolde: nouveauSolde.toFixed(2) }); } } } await conn.commit(); res.json({ success: true, message: `✅ ${collaborateurs.length} compteurs corrigés`, corrections: corrections }); } catch (error) { await conn.rollback(); console.error('❌ Erreur correction compteurs:', error); res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } finally { conn.release(); } }); app.post('/api/processEndOfYear', async (req, res) => { const conn = await pool.getConnection(); try { const { collaborateur_id } = req.body; await conn.beginTransaction(); let result; if (collaborateur_id) { result = await processEndOfYearRTT(conn, collaborateur_id); } else { const [collaborateurs] = await conn.query('SELECT id, CampusId FROM CollaborateurAD'); const results = []; for (const c of collaborateurs) { const r = await processEndOfYearRTT(conn, c.id); if (r) results.push({ collaborateur_id: c.id, ...r }); } result = results; } await conn.commit(); res.json({ success: true, message: 'Traitement de fin d\'année effectué', result }); } catch (error) { await conn.rollback(); res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } finally { conn.release(); } }); app.post('/api/processEndOfExercice', async (req, res) => { const conn = await pool.getConnection(); try { const { collaborateur_id } = req.body; await conn.beginTransaction(); let result; if (collaborateur_id) { result = await processEndOfExerciceCP(conn, collaborateur_id); } else { const [collaborateurs] = await conn.query('SELECT id, CampusId FROM CollaborateurAD'); const results = []; for (const c of collaborateurs) { const r = await processEndOfExerciceCP(conn, c.id); if (r) results.push({ collaborateur_id: c.id, ...r }); } result = results; } await conn.commit(); res.json({ success: true, message: 'Traitement de fin d\'exercice CP effectué', result }); } catch (error) { await conn.rollback(); res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } finally { conn.release(); } }); app.get('/api/getAcquisitionDetails', async (req, res) => { try { const today = new Date(); const details = { date_reference: today.toISOString().split('T')[0], CP: { exercice: getExerciceCP(today), mois_travailles: getMoisTravaillesCP(today), acquisition_mensuelle: LEAVE_RULES.CP.acquisitionMensuelle, acquisition_cumulee: calculerAcquisitionCumulee('CP', today), total_annuel: LEAVE_RULES.CP.joursAnnuels, periode: '01/06 - 31/05', reportable: LEAVE_RULES.CP.reportable }, RTT: { annee: today.getFullYear(), mois_travailles: getMoisTravaillesRTT(today), acquisition_mensuelle: LEAVE_RULES.RTT.acquisitionMensuelle, acquisition_cumulee: calculerAcquisitionCumulee('RTT', today), total_annuel: LEAVE_RULES.RTT.joursAnnuels, periode: '01/01 - 31/12', reportable: LEAVE_RULES.RTT.reportable } }; res.json({ success: true, details }); } catch (error) { res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } }); app.get('/api/getLeaveCounters', async (req, res) => { try { const userId = parseInt(req.query.user_id || 0); const data = {}; if (userId > 0) { const [rows] = await pool.query(`SELECT tc.Nom, cc.Annee, cc.Solde, cc.Total, cc.SoldeReporte FROM CompteurConges cc JOIN TypeConge tc ON cc.TypeCongeId = tc.Id WHERE cc.CollaborateurADId = ?`, [userId]); rows.forEach(row => { data[row.Nom] = { Annee: row.Annee, Solde: parseFloat(row.Solde), Total: parseFloat(row.Total), SoldeReporte: parseFloat(row.SoldeReporte) }; }); } res.json({ success: true, message: 'Compteurs récupérés', counters: data }); } catch (error) { res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } }); app.get('/api/getEmploye', async (req, res) => { try { const id = parseInt(req.query.id || 0); if (id <= 0) return res.json({ success: false, message: 'ID invalide' }); const conn = await pool.getConnection(); // 1️⃣ Récupérer les infos du collaborateur const [rows] = await conn.query(` SELECT ca.id, ca.Nom, ca.Prenom, ca.Email, ca.role, ca.TypeContrat, ca.DateEntree, ca.CampusId, ca.SocieteId, s.Nom as service, so.Nom as societe_nom FROM CollaborateurAD ca LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN Societe so ON ca.SocieteId = so.Id WHERE ca.id = ? `, [id]); if (rows.length === 0) { conn.release(); return res.json({ success: false, message: 'Collaborateur non trouvé' }); } const employee = rows[0]; // 2️⃣ Déterminer si c'est un apprenti const isApprenti = normalizeRole(employee.role) === 'apprenti'; // 3️⃣ Récupérer les compteurs CP const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']); let cpTotal = 0, cpSolde = 0; if (cpType.length > 0) { const currentYear = new Date().getFullYear(); const previousYear = currentYear - 1; // CP N-1 (reporté) const [cpN1] = await conn.query(` SELECT SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [id, cpType[0].Id, previousYear]); // CP N (année courante) const [cpN] = await conn.query(` SELECT Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [id, cpType[0].Id, currentYear]); const cpN1Solde = cpN1.length > 0 ? parseFloat(cpN1[0].SoldeReporte || 0) : 0; const cpNSolde = cpN.length > 0 ? parseFloat(cpN[0].Solde || 0) : 0; const cpNReporte = cpN.length > 0 ? parseFloat(cpN[0].SoldeReporte || 0) : 0; cpTotal = cpN1Solde + (cpNSolde - cpNReporte); cpSolde = cpTotal; } // 4️⃣ Récupérer les compteurs RTT (sauf pour apprentis) let rttTotal = 0, rttSolde = 0; if (!isApprenti) { const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']); if (rttType.length > 0) { const currentYear = new Date().getFullYear(); const [rttN] = await conn.query(` SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [id, rttType[0].Id, currentYear]); rttTotal = rttN.length > 0 ? parseFloat(rttN[0].Solde || 0) : 0; rttSolde = rttTotal; } } conn.release(); // 5️⃣ Retourner les données complètes res.json({ success: true, employee: { id: employee.id, Nom: employee.Nom || 'Non défini', Prenom: employee.Prenom || 'Non défini', Email: employee.Email || 'Non défini', role: employee.role || 'Collaborateur', TypeContrat: employee.TypeContrat || '37h', DateEntree: employee.DateEntree, CampusId: employee.CampusId, SocieteId: employee.SocieteId, service: employee.service || 'Non défini', societe_nom: employee.societe_nom || 'Non défini', conges_restants: parseFloat(cpSolde.toFixed(2)), rtt_restants: parseFloat(rttSolde.toFixed(2)) } }); } catch (error) { console.error('❌ Erreur getEmploye:', error); res.status(500).json({ success: false, message: 'Erreur DB', error: error.message }); } }); app.get('/api/getEmployeRequest', async (req, res) => { try { const id = parseInt(req.query.id || 0); if (id <= 0) return res.json({ success: false, message: 'ID invalide' }); const [rows] = await pool.query(` SELECT dc.Id, dc.DateDebut, dc.DateFin, dc.NombreJours as days, dc.Statut as status, dc.DateDemande, GROUP_CONCAT(DISTINCT tc.Nom ORDER BY tc.Nom SEPARATOR ', ') AS type, CONCAT( DATE_FORMAT(dc.DateDebut, '%d/%m/%Y'), IF(dc.DateDebut = dc.DateFin, '', CONCAT(' - ', DATE_FORMAT(dc.DateFin, '%d/%m/%Y'))) ) as date_display FROM DemandeConge dc LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dc.CollaborateurADId = ? GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.NombreJours, dc.Statut, dc.DateDemande ORDER BY dc.DateDemande DESC `, [id]); res.json({ success: true, requests: rows }); } catch (error) { console.error('❌ Erreur getEmployeRequest:', error); res.status(500).json({ success: false, message: 'Erreur DB', error: error.message }); } }); app.get('/api/getRequests', async (req, res) => { try { const userId = req.query.user_id; if (!userId) return res.json({ success: false, message: 'ID utilisateur manquant' }); // 🔍 Déterminer si c'est un UUID ou un ID numérique const isUUID = userId.length > 10 && userId.includes('-'); console.log(`📝 Type userId détecté: ${isUUID ? 'UUID (entraUserId)' : 'ID numérique'}`); let mainRequest = pool.request(); let whereClause; if (isUUID) { // Si UUID, chercher d'abord le CollaborateurADId const lookupRequest = pool.request(); lookupRequest.input('entraUserId', userId); const userLookup = await lookupRequest.query(` SELECT id FROM CollaborateurAD WHERE entraUserId = @entraUserId `); if (userLookup.recordset.length === 0) { return res.json({ success: false, message: 'Utilisateur non trouvé', requests: [], total: 0 }); } const collaborateurId = userLookup.recordset[0].id; console.log(`✅ CollaborateurADId trouvé: ${collaborateurId}`); // Créer une nouvelle request pour la requête principale mainRequest.input('collaborateurId', collaborateurId); whereClause = 'dc.CollaborateurADId = @collaborateurId'; } else { // Si ID numérique, utiliser directement mainRequest.input('userId', parseInt(userId)); whereClause = '(dc.EmployeeId = @userId OR dc.CollaborateurADId = @userId)'; } // ✅ REQUÊTE CORRIGÉE pour MSSQL avec la table de liaison const result = await mainRequest.query(` SELECT dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.CommentaireValidation, dc.Validateur, dc.DocumentJoint, ( SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom) FROM ( SELECT DISTINCT tc2.Nom FROM DemandeCongeType dct2 JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id WHERE dct2.DemandeCongeId = dc.Id ) AS DistinctTypes ) AS TypeConges, ( SELECT SUM(dct2.NombreJours) FROM DemandeCongeType dct2 WHERE dct2.DemandeCongeId = dc.Id ) AS NombreJoursTotal FROM DemandeConge dc WHERE ${whereClause} ORDER BY dc.DateDemande DESC `); const rows = result.recordset; console.log(`📋 ${rows.length} demandes trouvées pour userId: ${userId}`); const requests = rows.map(row => { const workingDays = getWorkingDays(row.DateDebut, row.DateFin); const dateDisplay = row.DateDebut === row.DateFin ? formatDate(row.DateDebut) : `${formatDate(row.DateDebut)} - ${formatDate(row.DateFin)}`; let fileUrl = null; if (row.TypeConges && row.TypeConges.includes('Congé maladie') && row.DocumentJoint) { fileUrl = `/uploads/${path.basename(row.DocumentJoint)}`; } return { id: row.Id, type: row.TypeConges || 'Non défini', // ✅ Gérer le cas null startDate: row.DateDebut, endDate: row.DateFin, dateDisplay, days: row.NombreJoursTotal || workingDays, // ✅ Utiliser le total de la table de liaison status: row.Statut, reason: row.Commentaire || 'Aucun commentaire', submittedAt: row.DateDemande, submittedDisplay: formatDate(row.DateDemande), validator: row.Validateur || null, validationComment: row.CommentaireValidation || null, fileUrl }; }); res.json({ success: true, message: 'Demandes récupérées', requests, total: requests.length }); } catch (error) { console.error('❌ Erreur getRequests:', error); res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } }); app.get('/api/getAllTeamRequests', async (req, res) => { try { const managerId = req.query.SuperieurId; if (!managerId) return res.json({ success: false, message: 'Paramètre SuperieurId manquant' }); const [rows] = await pool.query(`SELECT dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.DocumentJoint, dc.CollaborateurADId AS employee_id, CONCAT(ca.Prenom, ' ', ca.Nom) as employee_name, ca.Email as employee_email, tc.Nom as type FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id JOIN TypeConge tc ON dc.TypeCongeId = tc.Id JOIN HierarchieValidationAD hv ON hv.CollaborateurId = ca.id WHERE hv.SuperieurId = ? ORDER BY dc.DateDemande DESC`, [managerId]); const requests = rows.map(row => ({ id: row.Id, employee_id: row.employee_id, employee_name: row.employee_name, employee_email: row.employee_email, type: row.type, start_date: row.DateDebut, end_date: row.DateFin, date_display: row.DateDebut === row.DateFin ? formatDate(row.DateDebut) : `${formatDate(row.DateDebut)} - ${formatDate(row.DateFin)}`, days: getWorkingDays(row.DateDebut, row.DateFin), status: row.Statut, reason: row.Commentaire || '', file: row.DocumentJoint || null, submitted_at: row.DateDemande, submitted_display: formatDate(row.DateDemande) })); res.json({ success: true, requests }); } catch (error) { res.status(500).json({ success: false, message: 'Erreur DB', error: error.message }); } }); app.get('/api/getPendingRequests', async (req, res) => { try { const validatorId = req.query.validator_id; if (!validatorId) { return res.json({ success: false, message: 'ID validateur manquant' }); } const conn = await pool.getConnection(); // Récupérer les infos du validateur const [managerRows] = await conn.query( 'SELECT TOP 1 ServiceId, CampusId, role FROM CollaborateurAD WHERE id = ?', [validatorId] ); if (managerRows.length === 0) { conn.release(); return res.json({ success: false, message: 'Validateur non trouvé' }); } const serviceId = managerRows[0].ServiceId; const campusId = managerRows[0].CampusId; const role = normalizeRole(managerRows[0].role); let requests; if (role === 'admin' || role === 'president' || role === 'rh') { // Admin/President/RH : toutes les demandes en attente [requests] = await conn.query(` SELECT dc.Id, dc.CollaborateurADId, dc.DateDebut, dc.DateFin, dc.NombreJours, dc.Statut, dc.Commentaire, tc.Nom as TypeConge, ca.prenom, ca.nom, s.Nom as service_name, camp.Nom as campus_name FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.Id JOIN TypeConge tc ON dc.TypeCongeId = tc.Id LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN Campus camp ON ca.CampusId = camp.Id WHERE dc.Statut = 'En attente' ORDER BY dc.DateCreation DESC `); } else if (role === 'validateur' || role === 'directeur de campus') { // Validateur/Directeur : leurs collaborateurs directs via hiérarchie [requests] = await conn.query(` SELECT DISTINCT dc.Id, dc.CollaborateurADId, dc.DateDebut, dc.DateFin, dc.NombreJours, dc.Statut, dc.Commentaire, tc.Nom as TypeConge, ca.prenom, ca.nom, s.Nom as service_name, camp.Nom as campus_name FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.Id JOIN HierarchieValidationAD hv ON ca.id = hv.CollaborateurId JOIN TypeConge tc ON dc.TypeCongeId = tc.Id LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN Campus camp ON ca.CampusId = camp.Id WHERE dc.Statut = 'En attente' AND hv.SuperieurId = ? ORDER BY dc.DateCreation DESC `, [validatorId]); } else { conn.release(); return res.json({ success: false, message: 'Rôle non autorisé pour validation' }); } conn.release(); res.json({ success: true, message: 'Demandes récupérées', requests, service_id: serviceId, campus_id: campusId }); } catch (error) { console.error('❌ Erreur getPendingRequests:', error); res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } }); // Route getTeamMembers - Membres de l'équipe app.get('/api/getTeamMembers', async (req, res) => { try { const managerId = req.query.manager_id; if (!managerId) return res.json({ success: false, message: 'ID manager manquant' }); const managerRequest = pool.request(); managerRequest.input('managerId', managerId); const managerResult = await managerRequest.query(` SELECT TOP 1 ServiceId, CampusId, role, email FROM CollaborateurAD WHERE id = @managerId `); if (managerResult.recordset.length === 0) { return res.json({ success: false, message: 'Manager non trouvé' }); } const managerInfo = managerResult.recordset[0]; const serviceId = managerInfo.ServiceId; const campusId = managerInfo.CampusId; const role = normalizeRole(managerInfo.role); console.log(`🔍 getTeamMembers - Manager: ${managerId}, Role: ${role}, Campus: ${campusId}`); let members; if (role === 'admin' || role === 'president' || role === 'rh') { // CAS 1: Admin/President/RH - Vue globale console.log("CAS 1: Admin/President/RH - Vue globale"); const membersRequest = pool.request(); membersRequest.input('managerId', managerId); const membersResult = await membersRequest.query(` SELECT c.id, c.nom, c.prenom, c.email, c.role, s.Nom as service_name, c.CampusId, camp.Nom as campus_name FROM CollaborateurAD c JOIN Services s ON c.ServiceId = s.Id LEFT JOIN Campus camp ON c.CampusId = camp.Id WHERE c.id != @managerId AND (c.actif = 1 OR c.actif IS NULL) ORDER BY c.prenom, c.nom `); members = membersResult.recordset; console.log(` ✅ ${members.length} collaborateur(s) au total`); } else if (role === 'validateur' || role === 'directeur de campus') { // CAS 2: Validateur/Directeur - Collaborateurs directs via hiérarchie console.log("CAS 2: Validateur/Directeur - Collaborateurs directs via hiérarchie"); const membersRequest = pool.request(); membersRequest.input('managerId', managerId); const membersResult = await membersRequest.query(` SELECT DISTINCT c.id, c.nom, c.prenom, c.email, c.role, s.Nom as service_name, c.CampusId, camp.Nom as campus_name FROM CollaborateurAD c JOIN HierarchieValidationAD hv ON c.id = hv.CollaborateurId JOIN Services s ON c.ServiceId = s.Id LEFT JOIN Campus camp ON c.CampusId = camp.Id WHERE hv.SuperieurId = @managerId AND (c.actif = 1 OR c.actif IS NULL) ORDER BY c.prenom, c.nom `); members = membersResult.recordset; console.log(` ✅ ${members.length} collaborateur(s) sous ${managerId}`); } else if (role === 'collaborateur' || role === 'apprenti') { // CAS 3: Collaborateur/Apprenti - Collègues du même service ET campus console.log("CAS 3: Collaborateur/Apprenti - Collègues du même service et campus"); const membersRequest = pool.request(); membersRequest.input('managerId', managerId); membersRequest.input('serviceId', serviceId); membersRequest.input('campusId', campusId); const membersResult = await membersRequest.query(` SELECT c.id, c.nom, c.prenom, c.email, c.role, s.Nom as service_name, c.CampusId, camp.Nom as campus_name FROM CollaborateurAD c JOIN Services s ON c.ServiceId = s.Id LEFT JOIN Campus camp ON c.CampusId = camp.Id WHERE c.ServiceId = @serviceId AND c.CampusId = @campusId AND c.id != @managerId AND (c.actif = 1 OR c.actif IS NULL) ORDER BY c.prenom, c.nom `); members = membersResult.recordset; console.log(` ✅ ${members.length} collègue(s) trouvé(s)`); } else { return res.json({ success: false, message: 'Rôle non autorisé' }); } res.json({ success: true, team_members: members || [], service_id: serviceId, campus_id: campusId }); } catch (error) { console.error('❌ Erreur getTeamMembers:', error); res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } }); app.get('/api/getNotifications', async (req, res) => { try { const userIdParam = req.query.user_id; if (!userIdParam) { return res.json({ success: false, message: 'ID utilisateur manquant' }); } const conn = await pool.getConnection(); // ✅ Déterminer si c'est un UUID ou un ID numérique const isUUID = userIdParam.length > 10 && userIdParam.includes('-'); // ✅ Récupérer l'ID numérique si on a un UUID let userId = userIdParam; if (isUUID) { const [userRows] = await conn.query( 'SELECT id, CampusId FROM CollaborateurAD WHERE entraUserId = ? AND (Actif = 1 OR Actif IS NULL)', [userIdParam] ); if (userRows.length === 0) { conn.release(); return res.json({ success: false, message: 'Utilisateur non trouvé ou compte désactivé' }); } userId = userRows[0].id; } else { userId = parseInt(userIdParam); } // ✅ Utiliser l'ID numérique pour la requête const [notifications] = await conn.query(` SELECT * FROM Notifications WHERE CollaborateurADId = ? ORDER BY DateCreation DESC LIMIT 50 `, [userId]); conn.release(); res.json({ success: true, notifications: notifications || [] }); } catch (error) { console.error('Erreur getNotifications:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); app.post('/api/markNotificationRead', async (req, res) => { try { const { notificationId } = req.body; if (!notificationId || notificationId <= 0) return res.status(400).json({ success: false, message: 'ID notification invalide' }); await pool.query('UPDATE Notifications SET lu = 1 WHERE Id = ?', [notificationId]); res.json({ success: true, message: 'Notification marquée comme lue' }); } catch (error) { res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } }); // À ajouter avant app.listen() /** * POST /saisirRecupJour * Saisir une journée de récupération (samedi travaillé) */ app.post('/api/saisirRecupJour', async (req, res) => { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const { user_id, date, // Date du samedi travaillé nombre_heures = 1, // Par défaut 1 jour = 1 samedi commentaire } = req.body; console.log('\n📝 === SAISIE RÉCUP ==='); console.log('User ID:', user_id); console.log('Date:', date); console.log('Heures:', nombre_heures); if (!user_id || !date) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Données manquantes' }); } // Vérifier que c'est bien un samedi const dateObj = new Date(date); const dayOfWeek = dateObj.getDay(); if (dayOfWeek !== 6) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'La récupération ne peut être saisie que pour un samedi' }); } // Vérifier que ce samedi n'a pas déjà été saisi const [existing] = await conn.query(` SELECT dc.Id FROM DemandeConge dc JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dc.CollaborateurADId = ? AND dc.DateDebut = ? AND tc.Nom = 'Récupération' `, [user_id, date]); if (existing.length > 0) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Ce samedi a déjà été déclaré' }); } // Récupérer infos utilisateur const [userInfo] = await conn.query( 'SELECT prenom, nom, email, CampusId FROM CollaborateurAD WHERE id = ?', [user_id] ); if (userInfo.length === 0) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Utilisateur non trouvé' }); } const user = userInfo[0]; const userName = `${user.prenom} ${user.nom}`; const dateFormatted = dateObj.toLocaleDateString('fr-FR'); // Récupérer le type Récupération const [recupType] = await conn.query( 'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Récupération'] ); if (recupType.length === 0) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Type Récupération non trouvé' }); } const recupTypeId = recupType[0].Id; const currentYear = dateObj.getFullYear(); // CRÉER LA DEMANDE (validée automatiquement) const [result] = await conn.query(` INSERT INTO DemandeConge (CollaborateurADId, DateDebut, DateFin, TypeCongeId, Statut, DateDemande, Commentaire, NombreJours) VALUES (?, ?, ?, ?, 'Validée', GETDATE(), ?, ?) `, [user_id, date, date, recupTypeId, commentaire || `Samedi travaillé - ${dateFormatted}`, nombre_heures]); const demandeId = result.insertId; // SAUVEGARDER DANS DemandeCongeType await conn.query(` INSERT INTO DemandeCongeType (DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee) VALUES (?, ?, ?, 'Journée entière') `, [demandeId, recupTypeId, nombre_heures]); // ACCUMULER DANS LE COMPTEUR const [compteur] = await conn.query(` SELECT Id, Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [user_id, recupTypeId, currentYear]); if (compteur.length > 0) { await conn.query(` UPDATE CompteurConges SET Total = Total + ?, Solde = Solde + ?, DerniereMiseAJour = GETDATE() WHERE Id = ? `, [nombre_heures, nombre_heures, compteur[0].Id]); console.log(`✅ Compteur mis à jour: ${parseFloat(compteur[0].Solde) + nombre_heures}j`); } else { await conn.query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, GETDATE()) `, [user_id, recupTypeId, currentYear, nombre_heures, nombre_heures]); console.log(`✅ Compteur créé: ${nombre_heures}j`); } // ENREGISTRER L'ACCUMULATION await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Accum Récup', ?) `, [demandeId, recupTypeId, currentYear, nombre_heures]); // CRÉER NOTIFICATION await conn.query(` INSERT INTO Notifications (CollaborateurADId, Type, Titre, Message, DemandeCongeId, DateCreation, Lu) VALUES (?, 'Success', '✅ Récupération enregistrée', ?, ?, GETDATE(), 0) `, [ user_id, `Samedi ${dateFormatted} enregistré : +${nombre_heures}j de récupération`, demandeId ]); await conn.commit(); conn.release(); res.json({ success: true, message: `Samedi ${dateFormatted} enregistré`, jours_ajoutes: nombre_heures, demande_id: demandeId }); } catch (error) { await conn.rollback(); if (conn) conn.release(); console.error('❌ Erreur saisie récup:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); /** * GET /getMesSamedis * Récupérer les samedis déjà déclarés */ app.get('/api/getMesSamedis', async (req, res) => { try { const { user_id, annee } = req.query; const conn = await pool.getConnection(); const [samedis] = await conn.query(` SELECT dc.Id, DATE_FORMAT(dc.DateDebut, '%Y-%m-%d') as date, dc.NombreJours as jours, dc.Commentaire as commentaire, DATE_FORMAT(dc.DateDemande, '%d/%m/%Y à %H:%i') as date_saisie FROM DemandeConge dc JOIN TypeConge tc ON dc.TypeCongeId = tc.Id WHERE dc.CollaborateurADId = ? AND tc.Nom = 'Récupération' AND YEAR(dc.DateDebut) = ? ORDER BY dc.DateDebut DESC `, [user_id, annee]); conn.release(); res.json({ success: true, samedis: samedis }); } catch (error) { console.error('Erreur getMesSamedis:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); async function checkLeaveBalanceWithAnticipation(conn, collaborateurId, repartition, dateDebut) { const dateDebutObj = new Date(dateDebut); const currentYear = dateDebutObj.getFullYear(); const previousYear = currentYear - 1; console.log('\n🔍 === CHECK SOLDES AVEC ANTICIPATION ==='); console.log(`📅 Date demande: ${dateDebut}`); console.log(`📅 Année demande: ${currentYear}`); const verification = []; for (const rep of repartition) { const typeCode = rep.TypeConge; const joursNecessaires = parseFloat(rep.NombreJours || 0); if (typeCode === 'ABS' || typeCode === 'Formation') { continue; } const typeName = typeCode === 'CP' ? 'Congé payé' : typeCode === 'RTT' ? 'RTT' : typeCode; const [typeRow] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [typeName]); if (typeRow.length === 0) { continue; } const typeCongeId = typeRow[0].Id; // ==================================== // 1️⃣ Récupérer les infos du collaborateur // ==================================== const [collabInfo] = await conn.query(` SELECT DateEntree, TypeContrat, role FROM CollaborateurAD WHERE id = ? `, [collaborateurId]); const dateEntree = collabInfo[0]?.DateEntree || null; const typeContrat = collabInfo[0]?.TypeContrat || '37h'; const isApprenti = collabInfo[0]?.role === 'Apprenti'; // ==================================== // 2️⃣ Calculer l'acquisition à la date de la demande // ==================================== let acquisALaDate = 0; let budgetAnnuel = 0; if (typeCode === 'CP') { acquisALaDate = calculerAcquisitionCP_Smart(dateDebutObj, dateEntree); budgetAnnuel = 25; console.log(`💰 Acquisition CP à la date ${dateDebut}: ${acquisALaDate.toFixed(2)}j`); } else if (typeCode === 'RTT' && !isApprenti) { const rttData = await calculerAcquisitionRTT_Smart(conn, collaborateurId, dateDebutObj); acquisALaDate = rttData.acquisition; budgetAnnuel = rttData.config.joursAnnuels; console.log(`💰 Acquisition RTT à la date ${dateDebut}: ${acquisALaDate.toFixed(2)}j`); } // ==================================== // 3️⃣ Récupérer le report N-1 (CP uniquement) // ==================================== let reporteN1 = 0; if (typeCode === 'CP') { const [compteurN1] = await conn.query(` SELECT Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, previousYear]); if (compteurN1.length > 0) { reporteN1 = parseFloat(compteurN1[0].Solde || 0); } } // ==================================== // 4️⃣ Calculer ce qui a déjà été posé (SANS l'anticipé) // ==================================== const [dejaPose] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction NOT IN ('N Anticip', 'N+1 Anticip', 'Accum Récup', 'Accum Recup') AND dc.Statut != 'Refusée' `, [collaborateurId, typeCongeId, currentYear]); const dejaPoseNormal = parseFloat(dejaPose[0]?.total || 0); // ==================================== // 5️⃣ Calculer l'anticipé déjà utilisé // ==================================== const [anticipeUtilise] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction = 'N Anticip' AND dc.Statut != 'Refusée' `, [collaborateurId, typeCongeId, currentYear]); const dejaPoseAnticipe = parseFloat(anticipeUtilise[0]?.total || 0); // ==================================== // 6️⃣ Calculer l'anticipé disponible // ==================================== const anticipableMax = Math.max(0, budgetAnnuel - acquisALaDate); const anticipeDisponible = Math.max(0, anticipableMax - dejaPoseAnticipe); console.log(`💳 Anticipé max possible: ${anticipableMax.toFixed(2)}j`); console.log(`💳 Anticipé déjà utilisé: ${dejaPoseAnticipe.toFixed(2)}j`); console.log(`💳 Anticipé disponible: ${anticipeDisponible.toFixed(2)}j`); // ==================================== // 7️⃣ Calculer le solde TOTAL disponible // ==================================== const soldeActuel = Math.max(0, reporteN1 + acquisALaDate - dejaPoseNormal); const soldeTotal = soldeActuel + anticipeDisponible; console.log(`📊 Soldes détaillés ${typeCode}:`); console.log(` - Report N-1: ${reporteN1.toFixed(2)}j`); console.log(` - Acquis à date: ${acquisALaDate.toFixed(2)}j`); console.log(` - Déjà posé (normal): ${dejaPoseNormal.toFixed(2)}j`); console.log(` - Solde actuel: ${soldeActuel.toFixed(2)}j`); console.log(` - Anticipé disponible: ${anticipeDisponible.toFixed(2)}j`); console.log(` ✅ TOTAL DISPONIBLE: ${soldeTotal.toFixed(2)}j`); // ==================================== // 8️⃣ Vérifier la suffisance // ==================================== const suffisant = soldeTotal >= joursNecessaires; const deficit = Math.max(0, joursNecessaires - soldeTotal); verification.push({ type: typeName, joursNecessaires, reporteN1, acquisALaDate, dejaPoseNormal, dejaPoseAnticipe, soldeActuel, anticipeDisponible, soldeTotal, suffisant, deficit }); console.log(`🔍 Vérification ${typeCode}: ${joursNecessaires}j demandés vs ${soldeTotal.toFixed(2)}j disponibles → ${suffisant ? '✅ OK' : '❌ INSUFFISANT'}`); } const insuffisants = verification.filter(v => !v.suffisant); return { valide: insuffisants.length === 0, details: verification, insuffisants }; } /** * Déduit les jours d'un compteur avec gestion de l'anticipation * Ordre de déduction : N-1 → N → N Anticip */ // ======================================== // 💰 FONCTION DE DÉDUCTION AVEC ANTICIPATION (SANS ID MANUEL) // ======================================== async function deductLeaveBalanceWithAnticipation(conn, collaborateurId, typeCongeId, nombreJours, demandeCongeId, dateDebut) { const dateDebutObj = new Date(dateDebut); const currentYear = dateDebutObj.getFullYear(); const previousYear = currentYear - 1; let joursRestants = nombreJours; const deductions = []; console.log(`💳 === DÉDUCTION AVEC ANTICIPATION ===`); console.log(` Collaborateur: ${collaborateurId}`); console.log(` Type congé: ${typeCongeId}`); console.log(` Jours à déduire: ${nombreJours}j`); console.log(` Date début: ${dateDebut}`); // ===== ÉTAPE 1 : Déduire du REPORT N-1 ===== try { const [compteurN1] = await conn.query(` SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, previousYear]); if (compteurN1.length > 0 && compteurN1[0].SoldeReporte > 0) { const soldeN1 = parseFloat(compteurN1[0].SoldeReporte || 0); const aDeduireN1 = Math.min(soldeN1, joursRestants); if (aDeduireN1 > 0) { await conn.query(` UPDATE CompteurConges SET SoldeReporte = SoldeReporte - ?, Solde = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ? `, [aDeduireN1, aDeduireN1, compteurN1[0].Id]); // ⭐ SANS SPÉCIFIER L'ID await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, ?, ?) `, [demandeCongeId, typeCongeId, previousYear, 'Report N-1', aDeduireN1]); deductions.push({ annee: previousYear, type: 'Report N-1', joursUtilises: aDeduireN1 }); joursRestants -= aDeduireN1; console.log(` ✅ Report N-1: ${aDeduireN1.toFixed(2)}j déduits - reste ${joursRestants}j`); } } } catch (error) { console.error('❌ Erreur déduction N-1:', error.message); throw error; } // ===== ÉTAPE 2 : Déduire du SOLDE N ===== if (joursRestants > 0) { try { const [compteurN] = await conn.query(` SELECT Id, Solde, SoldeReporte, Total FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, currentYear]); if (compteurN.length > 0) { const soldeN = parseFloat(compteurN[0].Solde) - parseFloat(compteurN[0].SoldeReporte || 0); const aDeduireN = Math.min(soldeN, joursRestants); if (aDeduireN > 0) { await conn.query(` UPDATE CompteurConges SET Solde = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ? `, [aDeduireN, compteurN[0].Id]); // ⭐ SANS SPÉCIFIER L'ID await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, ?, ?) `, [demandeCongeId, typeCongeId, currentYear, 'Année N', aDeduireN]); deductions.push({ annee: currentYear, type: 'Année N', joursUtilises: aDeduireN }); joursRestants -= aDeduireN; console.log(` ✅ Solde N: ${aDeduireN.toFixed(2)}j déduits - reste ${joursRestants}j`); } } } catch (error) { console.error('❌ Erreur déduction N:', error.message); throw error; } } // ===== ÉTAPE 3 : ANTICIPÉ ===== if (joursRestants > 0) { console.log(` 💳 Il reste ${joursRestants.toFixed(2)}j à déduire → Anticipé`); try { const [collabInfo] = await conn.query(` SELECT DateEntree, TypeContrat, role FROM CollaborateurAD WHERE id = ? `, [collaborateurId]); const dateEntree = collabInfo[0]?.DateEntree || null; const [typeInfo] = await conn.query(` SELECT Nom FROM TypeConge WHERE Id = ? `, [typeCongeId]); const typeNom = typeInfo[0]?.Nom; let budgetAnnuel = 0; let acquisALaDate = 0; if (typeNom === 'Congé payé') { acquisALaDate = calculerAcquisitionCP_Smart(dateDebutObj, dateEntree); budgetAnnuel = 25; } else if (typeNom === 'RTT') { const rttData = await calculerAcquisitionRTT_Smart(conn, collaborateurId, dateDebutObj); acquisALaDate = rttData.acquisition; budgetAnnuel = rttData.config.joursAnnuels; } const anticipableMax = Math.max(0, budgetAnnuel - acquisALaDate); const [anticipeUtilise] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction = 'N Anticip' AND dc.Statut != 'Refusée' `, [collaborateurId, typeCongeId, currentYear]); const dejaPrisAnticipe = parseFloat(anticipeUtilise[0]?.total || 0); const anticipeDisponible = Math.max(0, anticipableMax - dejaPrisAnticipe); console.log(` 💳 Anticipable max: ${anticipableMax.toFixed(2)}j`); console.log(` 💳 Déjà pris: ${dejaPrisAnticipe.toFixed(2)}j`); console.log(` 💳 Disponible: ${anticipeDisponible.toFixed(2)}j`); if (anticipeDisponible >= joursRestants) { // ⭐ SANS SPÉCIFIER L'ID await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, ?, ?) `, [demandeCongeId, typeCongeId, currentYear, 'N Anticip', joursRestants]); deductions.push({ annee: currentYear, type: 'N Anticip', joursUtilises: joursRestants }); console.log(` ✅ Anticipé: ${joursRestants.toFixed(2)}j`); joursRestants = 0; } else { return { success: false, joursDeduitsTotal: nombreJours - joursRestants, joursNonDeduits: joursRestants, details: deductions, error: `Solde insuffisant (manque ${joursRestants.toFixed(2)}j)` }; } } catch (error) { console.error('❌ Erreur anticipé:', error.message); throw error; } } console.log(` ✅ Déduction OK - Total: ${(nombreJours - joursRestants).toFixed(2)}j`); return { success: joursRestants === 0, joursDeduitsTotal: nombreJours - joursRestants, joursNonDeduits: joursRestants, details: deductions }; } // ======================================== // 🔧 FONCTION HELPER - GÉNÉRATION D'ID // ======================================== // ✅ VERSION CORRIGÉE async function getNextId(connection, tableName) { try { const [result] = await connection.query( `SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM ${tableName}` ); return result[0].NextId; } catch (error) { console.error(`❌ Erreur génération ID pour ${tableName}:`, error.message); throw error; } } app.post('/api/submitLeaveRequest', uploadMedical.array('medicalDocuments', 5), async (req, res) => { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const currentYear = new Date().getFullYear(); // ✅ Récupérer les fichiers uploadés const uploadedFiles = req.files || []; console.log('📎 Fichiers médicaux reçus:', uploadedFiles.length); // ✅ Les données arrivent différemment avec FormData const DateDebut = req.body.DateDebut; const DateFin = req.body.DateFin; const NombreJours = parseFloat(req.body.NombreJours); const Email = req.body.Email; const Nom = req.body.Nom; const Commentaire = req.body.Commentaire || ''; const statut = req.body.statut || null; // ✅ Parser la répartition (elle arrive en string depuis FormData) let Repartition; try { Repartition = JSON.parse(req.body.Repartition || '[]'); } catch (parseError) { console.error('❌ Erreur parsing Repartition:', parseError); if (req.files) { req.files.forEach(file => { if (fs.existsSync(file.path)) { fs.unlinkSync(file.path); } }); } return res.status(400).json({ success: false, message: 'Erreur de format de la répartition' }); } if (!DateDebut || !DateFin || !Repartition || !Email || !Nom) { uploadedFiles.forEach(file => { if (fs.existsSync(file.path)) { fs.unlinkSync(file.path); } }); return res.json({ success: false, message: 'Données manquantes' }); } // ✅ VALIDATION : Si arrêt maladie, il faut au moins 1 fichier const hasABS = Repartition.some(r => r.TypeConge === 'ABS'); if (hasABS && uploadedFiles.length === 0) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Un justificatif médical est obligatoire pour un arrêt maladie' }); } // ⭐ VALIDATION DE LA RÉPARTITION console.log('\n📥 === SOUMISSION DEMANDE CONGÉ ==='); console.log('Email:', Email); console.log('Période:', DateDebut, '→', DateFin); console.log('Nombre de jours total:', NombreJours); console.log('Répartition reçue:', JSON.stringify(Repartition, null, 2)); // ⭐ Ne compter que CP, RTT ET RÉCUP dans la répartition const sommeRepartition = Repartition.reduce((sum, r) => { if (r.TypeConge === 'CP' || r.TypeConge === 'RTT' || r.TypeConge === 'Récup') { return sum + parseFloat(r.NombreJours || 0); } return sum; }, 0); console.log('Somme répartition CP+RTT+Récup:', sommeRepartition.toFixed(2)); // ⭐ VALIDATION : La somme doit correspondre au total const hasCountableLeave = Repartition.some(r => r.TypeConge === 'CP' || r.TypeConge === 'RTT' || r.TypeConge === 'Récup' ); if (hasCountableLeave && Math.abs(sommeRepartition - NombreJours) > 0.01) { console.error('❌ ERREUR : Répartition incohérente !'); uploadedFiles.forEach(file => { if (fs.existsSync(file.path)) { fs.unlinkSync(file.path); } }); await conn.rollback(); conn.release(); return res.json({ success: false, message: `Erreur de répartition : la somme (${sommeRepartition.toFixed(2)}j) ne correspond pas au total (${NombreJours}j)` }); } console.log('✅ Validation répartition OK'); // ⭐ Récup n'est PAS une demande auto-validée const isFormationOnly = Repartition.length === 1 && Repartition[0].TypeConge === 'Formation'; const statutDemande = statut || (isFormationOnly ? 'Validée' : 'En attente'); console.log('🔍 Type de demande:', { isFormationOnly, statut: statutDemande }); const [collabAD] = await conn.query('SELECT id, CampusId FROM CollaborateurAD WHERE email = ? LIMIT 1', [Email]); const isAD = collabAD.length > 0; const collaborateurId = isAD ? collabAD[0].id : null; let employeeId = null; if (!isAD) { const [user] = await conn.query('SELECT ID FROM Users WHERE Email = ? LIMIT 1', [Email]); if (user.length === 0) { uploadedFiles.forEach(file => { if (fs.existsSync(file.path)) { fs.unlinkSync(file.path); } }); await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Utilisateur non trouvé' }); } employeeId = user[0].ID; } // ======================================== // ÉTAPE 1 : Vérification des soldes AVANT tout (MODE MIXTE AVEC ANTICIPATION N+1) // ======================================== if (isAD && collaborateurId && !isFormationOnly) { console.log('\n🔍 Vérification des soldes en mode mixte avec anticipation...'); console.log('Date début:', DateDebut); const [userRole] = await conn.query('SELECT role FROM CollaborateurAD WHERE id = ?', [collaborateurId]); const isApprenti = userRole.length > 0 && userRole[0].role === 'Apprenti'; const checkResult = await checkLeaveBalanceWithAnticipation( conn, collaborateurId, Repartition, DateDebut ); if (!checkResult.valide) { uploadedFiles.forEach(file => { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); }); await conn.rollback(); conn.release(); const messagesErreur = checkResult.insuffisants.map(ins => { return `${ins.type}: ${ins.joursNecessaires}j demandés mais seulement ${ins.soldeTotal.toFixed(2)}j disponibles (déficit: ${ins.deficit.toFixed(2)}j)`; }).join('\n'); return res.json({ success: false, message: `❌ Solde(s) insuffisant(s):\n${messagesErreur}`, details: checkResult.details, insuffisants: checkResult.insuffisants }); } console.log('✅ Tous les soldes sont suffisants (incluant anticipation si nécessaire)\n'); } // ======================================== // ÉTAPE 2 : CRÉER LA DEMANDE (AVEC GÉNÉRATION MANUELLE D'ID) // ======================================== console.log('\n📝 Création de la demande...'); const typeIds = []; for (const rep of Repartition) { const code = rep.TypeConge; if (code === 'ABS' || code === 'Formation') { continue; } const name = code === 'CP' ? 'Congé payé' : code === 'RTT' ? 'RTT' : code === 'Récup' ? 'Récupération' : code; const [typeRow] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [name]); if (typeRow.length > 0) typeIds.push(typeRow[0].Id); } if (typeIds.length === 0) { const firstType = Repartition[0]?.TypeConge; const name = firstType === 'Formation' ? 'Formation' : firstType === 'ABS' ? 'Congé maladie' : 'Congé payé'; const [typeRow] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [name]); if (typeRow.length > 0) { typeIds.push(typeRow[0].Id); } else { uploadedFiles.forEach(file => { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); }); await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Aucun type de congé valide' }); } } const typeCongeIdCsv = typeIds.join(','); // 🔥 GÉNÉRATION MANUELLE DE L'ID (CONTOURNEMENT IDENTITY) const [maxIdResult] = await conn.query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeConge'); const demandeId = maxIdResult[0].NextId; console.log(`🆔 ID généré manuellement: ${demandeId}`); // 🔥 INSERT AVEC ID EXPLICITE await conn.query(` INSERT INTO DemandeConge (Id, CollaborateurADId, DateDebut, DateFin, TypeCongeId, Statut, DateDemande, Commentaire, Validateur, NombreJours) VALUES (?, ?, ?, ?, ?, ?, GETDATE(), ?, ?, ?) `, [ demandeId, collaborateurId, DateDebut, DateFin, typeCongeIdCsv, statutDemande, Commentaire || '', '', NombreJours ]); console.log(`✅ Demande créée avec ID ${demandeId} - Statut: ${statutDemande}`); // ======================================== // ÉTAPE 3 : Sauvegarder les fichiers médicaux // ======================================== if (uploadedFiles.length > 0) { console.log('\n📎 Sauvegarde des fichiers médicaux...'); for (const file of uploadedFiles) { await conn.query(` INSERT INTO DocumentsMedicaux (DemandeCongeId, NomFichier, CheminFichier, TypeMime, TailleFichier, DateUpload) VALUES (?, ?, ?, ?, ?, GETDATE()) `, [demandeId, file.originalname, file.path, file.mimetype, file.size]); console.log(` ✓ ${file.originalname}`); } } // ======================================== // ÉTAPE 4 : Sauvegarder la répartition // ======================================== // ======================================== // ÉTAPE 4 : Sauvegarder la répartition // ======================================== // 5️⃣ Sauvegarder la répartition console.log('\n📊 Sauvegarde de la répartition en base...'); for (const rep of Repartition) { const code = rep.TypeConge; const name = code === 'CP' ? 'Congé payé' : code === 'RTT' ? 'RTT' : code === 'ABS' ? 'Congé maladie' : code === 'Formation' ? 'Formation' : code === 'Récup' ? 'Récupération' : code; const [typeRow] = await conn.query( 'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [name] ); if (typeRow.length > 0) { // ⭐ GÉNÉRER L'ID MANUELLEMENT const demandeCongeTypeId = await getNextId(conn, 'DemandeCongeType'); await conn.query(` INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee) VALUES (?, ?, ?, ?, ?) `, [ demandeCongeTypeId, demandeId, typeRow[0].Id, rep.NombreJours, rep.PeriodeJournee || 'Journée entière' ]); console.log(` ✓ ${name}: ${rep.NombreJours}j (${rep.PeriodeJournee || 'Journée entière'})`); } } // ======================================== // ÉTAPE 5 : Déduction des compteurs CP/RTT/RÉCUP (AVEC ANTICIPATION N+1) // ======================================== if (isAD && collaborateurId && !isFormationOnly) { console.log('\n📉 Déduction des compteurs (avec anticipation N+1)...'); for (const rep of Repartition) { if (rep.TypeConge === 'ABS' || rep.TypeConge === 'Formation') { console.log(` ⏩ ${rep.TypeConge} ignoré (pas de déduction)`); continue; } // ⭐ TRAITEMENT SPÉCIAL POUR RÉCUP if (rep.TypeConge === 'Récup') { const [recupType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Récupération']); if (recupType.length > 0) { await conn.query(` UPDATE CompteurConges SET Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END, DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [rep.NombreJours, rep.NombreJours, collaborateurId, recupType[0].Id, currentYear]); await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Récup Posée', ?) `, [demandeId, recupType[0].Id, currentYear, rep.NombreJours]); console.log(` ✓ Récup: ${rep.NombreJours}j déduits`); } continue; } // ⭐ CP et RTT : AVEC ANTICIPATION N+1 const name = rep.TypeConge === 'CP' ? 'Congé payé' : 'RTT'; const [typeRow] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [name]); if (typeRow.length > 0) { const deductResult = await deductLeaveBalanceWithAnticipation( conn, collaborateurId, typeRow[0].Id, rep.NombreJours, demandeId, DateDebut ); console.log(` ✓ ${name}: ${rep.NombreJours}j déduits`); if (deductResult.details && deductResult.details.length > 0) { deductResult.details.forEach(d => { console.log(` - ${d.type} (${d.annee}): ${d.joursUtilises}j`); }); } } } await updateSoldeAnticipe(conn, collaborateurId); console.log('✅ Déductions terminées\n'); } // ======================================== // ÉTAPE 6 : Notifications (Formation uniquement) // ======================================== const dateDebut = new Date(DateDebut).toLocaleDateString('fr-FR'); const dateFin = new Date(DateFin).toLocaleDateString('fr-FR'); const datesPeriode = dateDebut === dateFin ? dateDebut : `du ${dateDebut} au ${dateFin}`; if (isFormationOnly && isAD && collaborateurId) { await conn.query(` INSERT INTO Notifications (CollaborateurADId, Type, Titre, Message, DemandeCongeId, DateCreation, Lu) VALUES (?, ?, ?, ?, ?, GETDATE(), 0) `, [ collaborateurId, 'Success', '✅ Formation validée automatiquement', `Votre période de formation ${datesPeriode} a été validée automatiquement.`, demandeId ]); console.log('\n📬 Notification formation créée'); } // ======================================== // ÉTAPE 7 : Récupérer les managers // ======================================== let managers = []; if (isAD) { const [rows] = await conn.query(` SELECT c.email FROM HierarchieValidationAD hv JOIN CollaborateurAD c ON hv.SuperieurId = c.id WHERE hv.CollaborateurId = ? `, [collaborateurId]); managers = rows.map(r => r.email); } await conn.commit(); console.log('\n🎉 Transaction validée\n'); // ======================================== // ÉTAPE 8 : Notifier les clients SSE // ======================================== if (isFormationOnly && isAD && collaborateurId) { notifyCollabClients({ type: 'demande-validated', demandeId: parseInt(demandeId), statut: 'Validée', timestamp: new Date().toISOString() }, collaborateurId); } // ======================================== // ENVOI DES EMAILS // ======================================== const accessToken = await getGraphToken(); if (accessToken) { const fromEmail = 'gtanoreply@ensup.eu'; const typesConges = Repartition.map(rep => { const typeNom = rep.TypeConge === 'CP' ? 'Congé payé' : rep.TypeConge === 'RTT' ? 'RTT' : rep.TypeConge === 'ABS' ? 'Congé maladie' : rep.TypeConge === 'Formation' ? 'Formation' : rep.TypeConge === 'Récup' ? 'Récupération' : rep.TypeConge; return `${typeNom}: ${rep.NombreJours}j`; }).join(' | '); if (isFormationOnly) { const subjectCollab = '✅ Formation enregistrée et validée'; const bodyCollab = `

✅ Formation validée

Bonjour ${Nom},

Votre période de formation a été automatiquement validée.

Période : ${datesPeriode}

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

`; try { await sendMailGraph(accessToken, fromEmail, Email, subjectCollab, bodyCollab); } catch (mailError) { console.error('❌ Erreur email:', mailError.message); } } else { const subjectCollab = '✅ Confirmation de réception de votre demande de congé'; const bodyCollab = `

✅ Demande enregistrée

Bonjour ${Nom},

Votre demande de congé a bien été enregistrée.

Type : ${typesConges}

Période : ${datesPeriode}

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

`; try { await sendMailGraph(accessToken, fromEmail, Email, subjectCollab, bodyCollab); } catch (mailError) { console.error('❌ Erreur email:', mailError.message); } for (const managerEmail of managers) { const subjectManager = `📋 Nouvelle demande de congé - ${Nom}`; const bodyManager = `

📋 Nouvelle demande

${Nom} a soumis une nouvelle demande.

Type : ${typesConges}

Période : ${datesPeriode}

`; try { await sendMailGraph(accessToken, fromEmail, managerEmail, subjectManager, bodyManager); } catch (mailError) { console.error('❌ Erreur email manager:', mailError.message); } } } } res.json({ success: true, message: isFormationOnly ? 'Formation enregistrée et validée automatiquement' : 'Demande soumise', request_id: demandeId, managers, auto_validated: isFormationOnly, files_uploaded: uploadedFiles.length }); } catch (error) { await conn.rollback(); if (req.files) { req.files.forEach(file => { if (fs.existsSync(file.path)) { fs.unlinkSync(file.path); console.log(`🗑️ Fichier supprimé: ${file.originalname}`); } }); } console.error('\n❌ ERREUR submitLeaveRequest:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } finally { conn.release(); } }); app.get('/api/download-medical/:documentId', async (req, res) => { try { const { documentId } = req.params; const conn = await pool.getConnection(); const [docs] = await conn.query( 'SELECT TOP 1 * FROM DocumentsMedicaux WHERE Id = ?', [documentId] ); conn.release(); if (docs.length === 0) { return res.status(404).json({ success: false, message: 'Document non trouvé' }); } const doc = docs[0]; if (!fs.existsSync(doc.CheminFichier)) { return res.status(404).json({ success: false, message: 'Fichier introuvable' }); } res.download(doc.CheminFichier, doc.NomFichier); } catch (error) { console.error('Erreur téléchargement:', error); res.status(500).json({ success: false, message: 'Erreur serveur' }); } }); // Récupérer les documents d'une demande app.get('/api/medical-documents/:demandeId', async (req, res) => { try { const { demandeId } = req.params; const conn = await pool.getConnection(); const [docs] = await conn.query( `SELECT Id, NomFichier, TypeMime, TailleFichier, DateUpload FROM DocumentsMedicaux WHERE DemandeCongeId = ? ORDER BY DateUpload DESC`, [demandeId] ); conn.release(); res.json({ success: true, documents: docs.map(doc => ({ id: doc.Id, nom: doc.NomFichier, type: doc.TypeMime, taille: doc.TailleFichier, date: doc.DateUpload, downloadUrl: `/download-medical/${doc.Id}` })) }); } catch (error) { console.error('Erreur récupération documents:', error); res.status(500).json({ success: false, message: 'Erreur serveur' }); } }); app.post('/api/validateRequest', async (req, res) => { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const { request_id, action, validator_id, comment } = req.body; console.log(`\n🔍 Validation demande #${request_id} - Action: ${action}`); if (!request_id || !action || !validator_id) { return res.json({ success: false, message: 'Données manquantes' }); } // Récupérer le validateur const [validator] = await conn.query( 'SELECT TOP 1 Id, prenom, nom, email, CampusId FROM CollaborateurAD WHERE Id = ?', [validator_id] ); if (validator.length === 0) { throw new Error('Validateur introuvable'); } // Récupérer la demande const [requests] = await conn.query( `SELECT TOP 1 dc.Id, dc.CollaborateurADId, dc.TypeCongeId, dc.NombreJours, dc.DateDebut, dc.DateFin, dc.Commentaire, dc.Statut, ca.prenom, ca.nom, ca.email as collaborateur_email, tc.Nom as TypeConge FROM DemandeConge dc JOIN TypeConge tc ON dc.TypeCongeId = tc.Id LEFT JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.Id WHERE dc.Id = ?`, [request_id] ); if (requests.length === 0) { throw new Error('Demande non trouvée'); } const request = requests[0]; console.log('\n=== DONNÉES RÉCUPÉRÉES ==='); console.log('request:', JSON.stringify(request, null, 2)); console.log('validator:', JSON.stringify(validator[0], null, 2)); console.log('========================\n'); if (request.Statut !== 'En attente') { throw new Error(`La demande a déjà été traitée (Statut: ${request.Statut})`); } const newStatus = action === 'approve' ? 'Validée' : 'Refusée'; // Si refus, restaurer les soldes if (action === 'reject' && request.CollaborateurADId) { console.log(`\n🔄 REFUS - Restauration des soldes...`); const restoration = await restoreLeaveBalance(conn, request_id, request.CollaborateurADId); console.log('Restauration:', restoration); } // Mettre à jour le statut await conn.query( `UPDATE DemandeConge SET Statut = ?, ValidateurId = ?, ValidateurADId = ?, DateValidation = GETDATE(), CommentaireValidation = ? WHERE Id = ?`, [newStatus, validator_id, validator_id, comment || '', request_id] ); // Créer une notification const notifTitle = action === 'approve' ? 'Demande approuvée ✅' : 'Demande refusée ❌'; let notifMessage = `Votre demande a été ${action === 'approve' ? 'approuvée' : 'refusée'}`; if (comment) notifMessage += ` (Commentaire: ${comment})`; const notifType = action === 'approve' ? 'Success' : 'Error'; await conn.query( `INSERT INTO Notifications (CollaborateurADId, Titre, Message, Type, DemandeCongeId, DateCreation, lu) VALUES (?, ?, ?, ?, ?, GETDATE(), 0)`, [request.CollaborateurADId, notifTitle, notifMessage, notifType, request_id] ); await conn.commit(); // Envoyer email via Microsoft Graph console.log('\n📧 === TENTATIVE ENVOI EMAIL ==='); console.log('1. Récupération token...'); const accessToken = await getGraphToken(); console.log('2. Token obtenu ?', accessToken ? 'OUI' : 'NON'); if (accessToken && request.collaborateur_email) { const fromEmail = 'gtanoreply@ensup.eu'; const collaborateurNom = `${request.prenom} ${request.nom}`; const validateurNom = `${validator[0].prenom} ${validator[0].nom}`; console.log('3. Préparation email professionnel...'); console.log(' De:', fromEmail); console.log(' À:', request.collaborateur_email); console.log(' Collaborateur:', collaborateurNom); console.log(' Validateur:', validateurNom); const dateDebut = new Date(request.DateDebut).toLocaleDateString('fr-FR'); const dateFin = new Date(request.DateFin).toLocaleDateString('fr-FR'); const datesPeriode = dateDebut === dateFin ? dateDebut : `du ${dateDebut} au ${dateFin}`; const subject = action === 'approve' ? '✅ Votre demande de congé a été approuvée' : '❌ Votre demande de congé a été refusée'; const emailHtml = `

${subject}

Bonjour ${collaborateurNom},

Votre demande de ${request.TypeConge} pour ${request.NombreJours} jour(s) ${datesPeriode} a été ${action === 'approve' ? 'approuvée' : 'refusée'} par ${validateurNom}.

${comment ? `

Commentaire: ${comment}

` : ''}

Vous pouvez consulter les détails dans l'application GTA.


Ceci est un email automatique, merci de ne pas y répondre.

`; const emailSent = await sendMailGraph(accessToken, fromEmail, request.collaborateur_email, subject, emailHtml); console.log('4. Email envoyé ?', emailSent ? 'OUI ✅' : 'NON ❌'); } res.json({ success: true, message: `Demande ${action === 'approve' ? 'approuvée' : 'refusée'} avec succès` }); } catch (error) { await conn.rollback(); console.error('❌ Erreur validateRequest:', error); res.status(500).json({ success: false, message: error.message }); } finally { conn.release(); } }); app.get('/api/testRestoration', async (req, res) => { const conn = await pool.getConnection(); try { const { demande_id, collab_id } = req.query; if (!demande_id || !collab_id) { return res.json({ success: false, message: 'Paramètres manquants: demande_id et collab_id requis' }); } // 1. Voir les déductions enregistrées const [deductions] = await conn.query( `SELECT dd.*, tc.Nom as TypeNom FROM DeductionDetails dd JOIN TypeConge tc ON dd.TypeCongeId = tc.Id WHERE dd.DemandeCongeId = ?`, [demande_id] ); // 2. Voir l'état actuel des compteurs const [compteurs] = await conn.query( `SELECT cc.*, tc.Nom as TypeNom FROM CompteurConges cc JOIN TypeConge tc ON cc.TypeCongeId = tc.Id WHERE cc.CollaborateurADId = ? ORDER BY tc.Nom, cc.Annee DESC`, [collab_id] ); // 3. Calculer ce que devrait être la restauration const planRestauration = deductions.map(d => ({ type: d.TypeNom, annee: d.Annee, typeDeduction: d.TypeDeduction, joursARestorer: d.JoursUtilises, action: d.TypeDeduction === 'Reporté N-1' ? 'Ajouter au SoldeReporte ET au Solde' : 'Ajouter au Solde uniquement' })); conn.release(); res.json({ success: true, demande_id: demande_id, collaborateur_id: collab_id, deductions_enregistrees: deductions, compteurs_actuels: compteurs, plan_restauration: planRestauration, instructions: planRestauration.length === 0 ? "❌ Aucune déduction trouvée - La demande a été créée avant l'installation du tracking" : "✅ Déductions trouvées - La restauration devrait fonctionner" }); } catch (error) { conn.release(); res.status(500).json({ success: false, error: error.message }); } }); function normalizeRole(role) { if (!role) return null; const roleLower = role.toLowerCase(); // Normaliser les variantes féminines et masculines if (roleLower === 'collaboratrice') return 'collaborateur'; if (roleLower === 'validatrice') return 'validateur'; if (roleLower === 'directrice de campus') return 'directeur de campus'; if (roleLower === 'apprentie') return 'apprenti'; return roleLower; } app.get('/api/getSocietesByCampus', async (req, res) => { try { const { campusId } = req.query; const conn = await pool.getConnection(); const [societes] = await conn.query(` SELECT DISTINCT s.Id, s.Nom FROM SocieteCampus sc JOIN Societe s ON sc.SocieteId = s.Id WHERE sc.CampusId = ? ORDER BY CASE WHEN s.Nom LIKE '%SOLUTION%' THEN 1 ELSE 2 END, s.Nom `, [campusId]); conn.release(); res.json({ success: true, societes: societes }); } catch (error) { console.error('Erreur getSocietesByCampus:', error); res.status(500).json({ success: false, message: error.message }); } }); // ⭐ NOUVELLE ROUTE HELPER : Récupérer les campus d'une société app.get('/api/getCampusBySociete', async (req, res) => { try { const { societeId } = req.query; const conn = await pool.getConnection(); const [campus] = await conn.query(` SELECT DISTINCT c.Id, c.Nom, sc.Principal FROM SocieteCampus sc JOIN Campus c ON sc.CampusId = c.Id WHERE sc.SocieteId = ? ORDER BY sc.Principal DESC, -- Principal en premier c.Nom `, [societeId]); conn.release(); res.json({ success: true, campus: campus, isMultiCampus: campus.length > 1 }); } catch (error) { console.error('Erreur getCampusBySociete:', error); res.status(500).json({ success: false, message: error.message }); } }); // ======================================== // ROUTE getTeamLeaves COMPLÈTE // ======================================== app.get('/api/getTeamLeaves', async (req, res) => { try { let { user_id: userIdParam, role: roleParam, selectedCampus, selectedSociete, selectedService } = req.query; console.log(`🔍 Paramètres reçus: user_id=${userIdParam}, role=${roleParam}, selectedCampus=${selectedCampus}`); if (!userIdParam) { return res.json({ success: false, message: 'ID utilisateur manquant' }); } const isUUID = userIdParam.length > 10 && userIdParam.includes('-'); console.log(`📝 Type ID détecté: ${isUUID ? 'UUID' : 'Numérique'}`); const userRequest = pool.request(); userRequest.input('userIdParam', userIdParam); const userQuery = ` SELECT ca.id, ca.ServiceId, ca.CampusId, ca.SocieteId, ca.email, s.Nom AS serviceNom, c.Nom AS campusNom, so.Nom AS societeNom FROM CollaborateurAD ca LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN Campus c ON ca.CampusId = c.Id LEFT JOIN Societe so ON ca.SocieteId = so.Id WHERE ${isUUID ? 'ca.entraUserId' : 'ca.id'} = @userIdParam `; const userResult = await userRequest.query(userQuery); if (!userResult.recordset || userResult.recordset.length === 0) { return res.json({ success: false, message: 'Collaborateur non trouvé' }); } const userInfo = userResult.recordset[0]; const serviceId = userInfo.ServiceId; const campusId = userInfo.CampusId; const societeId = userInfo.SocieteId; const userEmail = userInfo.email; const campusNom = userInfo.campusNom; const serviceNom = userInfo.serviceNom; const societeNom = userInfo.societeNom; function normalizeRole(role) { if (!role) return null; const roleLower = role.toLowerCase(); if (roleLower === 'collaboratrice') return 'collaborateur'; if (roleLower === 'validatrice') return 'validateur'; if (roleLower === 'directrice de campus') return 'directeur de campus'; if (roleLower === 'apprentie') return 'apprenti'; return roleLower; } const roleOriginal = roleParam?.toLowerCase(); const role = normalizeRole(roleOriginal); console.log(`👤 Utilisateur trouvé:`); console.log(` - ID: ${userInfo.id}`); console.log(` - Email: ${userEmail}`); console.log(` - ServiceId: ${serviceId}`); console.log(` - CampusId: ${campusId}`); console.log(` - CampusNom: ${campusNom}`); console.log(` - ServiceNom: ${serviceNom}`); console.log(` - SocieteId: ${societeId}`); console.log(` - Role normalisé: ${role}`); const filters = {}; // ======================================== // CAS 1: PRESIDENT, ADMIN, RH, DIRECTEUR DE CAMPUS // ⚠️ SANS VALIDATEUR - C'est la correction principale ! // ======================================== if (role === 'president' || role === 'admin' || role === 'rh' || role === 'directeur de campus') { console.log("CAS 1: President/Admin/RH/Directeur de Campus - Vue globale"); console.log(` Filtres reçus: Société=${selectedSociete}, Campus=${selectedCampus}, Service=${selectedService}`); // 1️⃣ SOCIÉTÉS const societesRequest = pool.request(); const societesResult = await societesRequest.query(` SELECT DISTINCT Nom FROM Societe ORDER BY Nom `); filters.societes = societesResult.recordset.map(s => s.Nom); console.log('📊 Sociétés disponibles:', filters.societes); // 2️⃣ CAMPUS let campusRequest = pool.request(); let campusQuery; if (selectedSociete && selectedSociete !== 'all') { campusQuery = ` SELECT DISTINCT c.Nom FROM Campus c JOIN CollaborateurAD ca ON ca.CampusId = c.Id JOIN Societe so ON ca.SocieteId = so.Id WHERE so.Nom = @selectedSociete AND (ca.actif = 1 OR ca.actif IS NULL) ORDER BY c.Nom `; campusRequest.input('selectedSociete', selectedSociete); } else { campusQuery = ` SELECT DISTINCT Nom FROM Campus ORDER BY Nom `; } const campusResult = await campusRequest.query(campusQuery); filters.campus = campusResult.recordset.map(c => c.Nom); console.log('📊 Campus disponibles:', filters.campus); if (role === 'directeur de campus') { filters.defaultCampus = campusNom; console.log('🏢 Campus par défaut pour directeur:', campusNom); } // 3️⃣ SERVICES let servicesQuery = ` SELECT DISTINCT s.Nom FROM Services s JOIN CollaborateurAD ca ON ca.ServiceId = s.Id `; let servicesConditions = ['(ca.actif = 1 OR ca.actif IS NULL)']; let servicesRequest = pool.request(); if (selectedSociete && selectedSociete !== 'all') { servicesQuery += '\nJOIN Societe so ON ca.SocieteId = so.Id'; servicesConditions.push('so.Nom = @selectedSociete'); servicesRequest.input('selectedSociete', selectedSociete); } if (selectedCampus && selectedCampus !== 'all') { servicesQuery += '\nJOIN Campus c ON ca.CampusId = c.Id'; servicesConditions.push('c.Nom = @selectedCampus'); servicesRequest.input('selectedCampus', selectedCampus); } servicesQuery += `\nWHERE ${servicesConditions.join(' AND ')}\nORDER BY s.Nom`; const servicesResult = await servicesRequest.query(servicesQuery); filters.services = servicesResult.recordset.map(s => s.Nom); // ⭐ LISTE DES EMPLOYÉS let employeesQuery = ` SELECT CONCAT(ca.prenom, ' ', ca.nom) AS fullname, c.Nom AS campusnom, so.Nom AS societenom, s.Nom AS servicenom FROM CollaborateurAD ca JOIN Services s ON ca.ServiceId = s.Id JOIN Campus c ON ca.CampusId = c.Id JOIN Societe so ON ca.SocieteId = so.Id WHERE (ca.actif = 1 OR ca.actif IS NULL) `; let employeesConditions = []; let employeesRequest = pool.request(); if (selectedSociete && selectedSociete !== 'all') { employeesConditions.push('so.Nom = @selectedSociete'); employeesRequest.input('selectedSociete', selectedSociete); } if (selectedCampus && selectedCampus !== 'all') { employeesConditions.push('c.Nom = @selectedCampus'); employeesRequest.input('selectedCampus', selectedCampus); } else if (role === 'directeur de campus' && campusNom) { employeesConditions.push('c.Nom = @campusNom'); employeesRequest.input('campusNom', campusNom); } if (selectedService && selectedService !== 'all') { employeesConditions.push('s.Nom = @selectedService'); employeesRequest.input('selectedService', selectedService); } if (employeesConditions.length > 0) { employeesQuery += ` AND ${employeesConditions.join(' AND ')}`; } employeesQuery += ` ORDER BY so.Nom, c.Nom, ca.prenom, ca.nom`; const employeesResult = await employeesRequest.query(employeesQuery); filters.employees = employeesResult.recordset.map(e => ({ name: e.fullname, campus: e.campusnom, societe: e.societenom, service: e.servicenom })); console.log(`👥 Employés trouvés:`, filters.employees.length); // QUERY DES CONGÉS let whereConditions = [`dc.Statut IN ('Validé', 'Validée', 'Valide', 'En attente')`]; let queryRequest = pool.request(); if (selectedSociete && selectedSociete !== 'all') { whereConditions.push('so.Nom = @selectedSociete'); queryRequest.input('selectedSociete', selectedSociete); } if (selectedCampus && selectedCampus !== 'all') { whereConditions.push('c.Nom = @selectedCampus'); queryRequest.input('selectedCampus', selectedCampus); } else if (role === 'directeur de campus' && campusNom) { whereConditions.push('c.Nom = @campusNom'); queryRequest.input('campusNom', campusNom); } if (selectedService && selectedService !== 'all') { whereConditions.push('s.Nom = @selectedService'); queryRequest.input('selectedService', selectedService); } const query = ` SELECT CONVERT(VARCHAR(10), dc.DateDebut, 23) AS startdate, CONVERT(VARCHAR(10), dc.DateFin, 23) AS enddate, CONCAT(ca.prenom, ' ', ca.nom) AS employeename, ( SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom) FROM (SELECT DISTINCT tc2.Nom FROM DemandeCongeType dct2 JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id WHERE dct2.DemandeCongeId = dc.Id) AS DistinctTypes ) AS type, CONCAT( '[', STRING_AGG( CONCAT( '{"type":"', tc.Nom, '","jours":', dct.NombreJours, ',"periode":"', COALESCE(dct.PeriodeJournee, 'Journée entière'), '"}' ), ',' ), ']' ) AS detailsconges, MAX(tc.CouleurHex) AS color, dc.Statut AS statut, s.Nom AS servicenom, c.Nom AS campusnom, so.Nom AS societenom, dc.NombreJours AS nombrejoursouvres 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 JOIN Services s ON ca.ServiceId = s.Id JOIN Campus c ON ca.CampusId = c.Id JOIN Societe so ON ca.SocieteId = so.Id WHERE ${whereConditions.join(' AND ')} GROUP BY dc.Id, dc.DateDebut, dc.DateFin, ca.prenom, ca.nom, dc.Statut, s.Nom, c.Nom, so.Nom, dc.NombreJours ORDER BY c.Nom, dc.DateDebut ASC `; console.log(`🔍 Query finale WHERE:`, whereConditions.join(' AND ')); const leavesResult = await queryRequest.query(query); const formattedLeaves = leavesResult.recordset.map(leave => ({ ...leave })); console.log(`✅ ${formattedLeaves.length} congés trouvés`); return res.json({ success: true, role: role, leaves: formattedLeaves, filters: filters }); } // ======================================== // CAS 2: VALIDATEUR - Vue équipe via HierarchieValidationAD // ⚠️ C'est ici que le validateur doit tomber ! // ======================================== else if (role === 'validateur') { console.log("CAS 2: Validateur - Vue de l'équipe via HierarchieValidationAD"); console.log(`📍 Validateur: Service=${serviceNom}, Campus=${campusNom}, Société=${societeNom}`); const isFirstLoad = !selectedCampus && !selectedService && !selectedSociete; if (isFirstLoad) { console.log('🎯 Premier chargement validateur : initialisation avec valeurs par défaut'); selectedCampus = campusNom; selectedService = serviceNom; selectedSociete = societeNom; } console.log(`📍 Filtres appliqués: Société=${selectedSociete}, Campus=${selectedCampus}, Service=${selectedService}`); // Sociétés disponibles const societesRequest = pool.request(); const societesResult = await societesRequest.query(` SELECT DISTINCT so.Nom FROM Societe so JOIN CollaborateurAD ca ON ca.SocieteId = so.Id WHERE (ca.actif = 1 OR ca.actif IS NULL) ORDER BY so.Nom `); filters.societes = societesResult.recordset.map(s => s.Nom); // Campus disponibles let campusQuery = ` SELECT DISTINCT c.Nom FROM Campus c JOIN CollaborateurAD ca ON ca.CampusId = c.Id WHERE (ca.actif = 1 OR ca.actif IS NULL) `; let campusRequest = pool.request(); if (selectedSociete && selectedSociete !== 'all') { campusQuery += ` AND ca.SocieteId = (SELECT Id FROM Societe WHERE Nom = @selectedSociete)`; campusRequest.input('selectedSociete', selectedSociete); } campusQuery += ` ORDER BY c.Nom`; const campusResult = await campusRequest.query(campusQuery); filters.campus = campusResult.recordset.map(c => c.Nom); // Services disponibles let servicesQuery = ` SELECT DISTINCT s.Nom FROM Services s JOIN CollaborateurAD ca ON ca.ServiceId = s.Id WHERE (ca.actif = 1 OR ca.actif IS NULL) `; let servicesRequest = pool.request(); if (selectedSociete && selectedSociete !== 'all') { servicesQuery += ` AND ca.SocieteId = (SELECT Id FROM Societe WHERE Nom = @selectedSociete)`; servicesRequest.input('selectedSociete', selectedSociete); } if (selectedCampus && selectedCampus !== 'all') { servicesQuery += ` AND ca.CampusId = (SELECT Id FROM Campus WHERE Nom = @selectedCampus)`; servicesRequest.input('selectedCampus', selectedCampus); } servicesQuery += ` ORDER BY s.Nom`; const servicesResult = await servicesRequest.query(servicesQuery); filters.services = servicesResult.recordset.map(s => s.Nom); filters.defaultCampus = campusNom; filters.defaultService = serviceNom; filters.defaultSociete = societeNom; // ⭐ LISTE DES EMPLOYÉS - UNIQUEMENT CEUX DE L'ÉQUIPE DU VALIDATEUR let employeesQuery = ` SELECT CONCAT(ca.prenom, ' ', ca.nom) AS fullname, c.Nom AS campusnom, so.Nom AS societenom, s.Nom AS servicenom FROM CollaborateurAD ca JOIN Services s ON ca.ServiceId = s.Id JOIN Campus c ON ca.CampusId = c.Id JOIN Societe so ON ca.SocieteId = so.Id JOIN HierarchieValidationAD h ON ca.id = h.CollaborateurId WHERE h.SuperieurId = @userId AND (ca.actif = 1 OR ca.actif IS NULL) `; let employeesRequest = pool.request(); employeesRequest.input('userId', userInfo.id); let employeesConditions = []; if (selectedSociete && selectedSociete !== 'all') { employeesConditions.push('so.Nom = @selectedSociete'); employeesRequest.input('selectedSociete', selectedSociete); } if (selectedCampus && selectedCampus !== 'all') { employeesConditions.push('c.Nom = @selectedCampus'); employeesRequest.input('selectedCampus', selectedCampus); } if (selectedService && selectedService !== 'all') { employeesConditions.push('s.Nom = @selectedService'); employeesRequest.input('selectedService', selectedService); } if (employeesConditions.length > 0) { employeesQuery += ` AND ${employeesConditions.join(' AND ')}`; } employeesQuery += ` ORDER BY s.Nom, ca.prenom, ca.nom`; const employeesResult = await employeesRequest.query(employeesQuery); filters.employees = employeesResult.recordset.map(emp => ({ name: emp.fullname, campus: emp.campusnom, societe: emp.societenom, service: emp.servicenom })); console.log(`👥 Équipe du validateur: ${filters.employees.length} personnes`); // ⭐ QUERY DES CONGÉS - UNIQUEMENT LES COLLABORATEURS DU VALIDATEUR let queryConditions = `WHERE dc.Statut IN ('Validé', 'Validée', 'Valide', 'En attente') AND ca.id IN ( SELECT CollaborateurId FROM HierarchieValidationAD WHERE SuperieurId = @userId )`; let queryRequest = pool.request(); queryRequest.input('userId', userInfo.id); let congesConditions = []; if (selectedSociete && selectedSociete !== 'all') { congesConditions.push('so.Nom = @selectedSociete'); queryRequest.input('selectedSociete', selectedSociete); } if (selectedCampus && selectedCampus !== 'all') { congesConditions.push('c.Nom = @selectedCampus'); queryRequest.input('selectedCampus', selectedCampus); } if (selectedService && selectedService !== 'all') { congesConditions.push('s.Nom = @selectedService'); queryRequest.input('selectedService', selectedService); } if (congesConditions.length > 0) { queryConditions += ` AND ${congesConditions.join(' AND ')}`; } const query = ` SELECT CONVERT(VARCHAR(10), dc.DateDebut, 23) AS startdate, CONVERT(VARCHAR(10), dc.DateFin, 23) AS enddate, CONCAT(ca.prenom, ' ', ca.nom) AS employeename, ( SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom) FROM (SELECT DISTINCT tc2.Nom FROM DemandeCongeType dct2 JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id WHERE dct2.DemandeCongeId = dc.Id) AS DistinctTypes ) AS type, CONCAT( '[', STRING_AGG( CONCAT( '{"type":"', tc.Nom, '","jours":', dct.NombreJours, ',"periode":"', COALESCE(dct.PeriodeJournee, 'Journée entière'), '"}' ), ',' ), ']' ) AS detailsconges, MAX(tc.CouleurHex) AS color, dc.Statut AS statut, s.Nom AS servicenom, c.Nom AS campusnom, so.Nom AS societenom, dc.NombreJours AS nombrejoursouvres 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 JOIN Services s ON ca.ServiceId = s.Id JOIN Campus c ON ca.CampusId = c.Id JOIN Societe so ON ca.SocieteId = so.Id ${queryConditions} GROUP BY dc.Id, dc.DateDebut, dc.DateFin, ca.prenom, ca.nom, dc.Statut, s.Nom, c.Nom, so.Nom, dc.NombreJours ORDER BY s.Nom, dc.DateDebut ASC `; console.log(`🔍 Query WHERE final validateur:`, queryConditions); const leavesResult = await queryRequest.query(query); const formattedLeaves = leavesResult.recordset.map(leave => ({ ...leave })); console.log(`✅ ${formattedLeaves.length} congés trouvés pour l'équipe du validateur`); return res.json({ success: true, role: role, leaves: formattedLeaves, filters: filters }); } // ======================================== // CAS 3: COLLABORATEUR / APPRENTI // ======================================== else if (role === 'collaborateur' || role === 'apprenti') { console.log("CAS 3: Collaborateur/Apprenti avec filtres avancés"); console.log(`📍 Filtres reçus du frontend: Société=${selectedSociete}, Campus=${selectedCampus}, Service=${selectedService}`); const isFirstLoad = !selectedCampus && !selectedService && !selectedSociete; if (isFirstLoad) { console.log('🎯 Premier chargement : initialisation avec service par défaut'); selectedCampus = campusNom; selectedService = serviceNom; selectedSociete = societeNom; } console.log(`📍 Filtres appliqués finaux: Société=${selectedSociete}, Campus=${selectedCampus}, Service=${selectedService}`); // Sociétés disponibles const societesRequest = pool.request(); const societesResult = await societesRequest.query(` SELECT DISTINCT so.Nom FROM Societe so JOIN CollaborateurAD ca ON ca.SocieteId = so.Id WHERE (ca.actif = 1 OR ca.actif IS NULL) ORDER BY so.Nom `); filters.societes = societesResult.recordset.map(s => s.Nom); // Campus disponibles let campusQuery = ` SELECT DISTINCT c.Nom FROM Campus c JOIN CollaborateurAD ca ON ca.CampusId = c.Id WHERE (ca.actif = 1 OR ca.actif IS NULL) `; let campusRequest = pool.request(); if (selectedSociete && selectedSociete !== 'all') { campusQuery += ` AND ca.SocieteId = (SELECT Id FROM Societe WHERE Nom = @selectedSociete)`; campusRequest.input('selectedSociete', selectedSociete); } campusQuery += ` ORDER BY c.Nom`; const campusResult = await campusRequest.query(campusQuery); filters.campus = campusResult.recordset.map(c => c.Nom); // Services disponibles let servicesQuery = ` SELECT DISTINCT s.Nom FROM Services s JOIN CollaborateurAD ca ON ca.ServiceId = s.Id WHERE (ca.actif = 1 OR ca.actif IS NULL) `; let servicesRequest = pool.request(); if (selectedSociete && selectedSociete !== 'all') { servicesQuery += ` AND ca.SocieteId = (SELECT Id FROM Societe WHERE Nom = @selectedSociete)`; servicesRequest.input('selectedSociete', selectedSociete); } if (selectedCampus && selectedCampus !== 'all') { servicesQuery += ` AND ca.CampusId = (SELECT Id FROM Campus WHERE Nom = @selectedCampus)`; servicesRequest.input('selectedCampus', selectedCampus); } servicesQuery += ` ORDER BY s.Nom`; const servicesResult = await servicesRequest.query(servicesQuery); filters.services = servicesResult.recordset.map(s => s.Nom); filters.defaultCampus = campusNom; filters.defaultService = serviceNom; filters.defaultSociete = societeNom; // ⭐ LISTE DES EMPLOYÉS let employeesQuery = ` SELECT CONCAT(ca.prenom, ' ', ca.nom) AS fullname, c.Nom AS campusnom, so.Nom AS societenom, s.Nom AS servicenom FROM CollaborateurAD ca JOIN Services s ON ca.ServiceId = s.Id JOIN Campus c ON ca.CampusId = c.Id JOIN Societe so ON ca.SocieteId = so.Id WHERE (ca.actif = 1 OR ca.actif IS NULL) `; let employeesRequest = pool.request(); let employeesConditions = []; if (selectedSociete && selectedSociete !== 'all') { employeesConditions.push('so.Nom = @selectedSociete'); employeesRequest.input('selectedSociete', selectedSociete); } if (selectedCampus && selectedCampus !== 'all') { employeesConditions.push('c.Nom = @selectedCampus'); employeesRequest.input('selectedCampus', selectedCampus); } if (selectedService && selectedService !== 'all') { employeesConditions.push('s.Nom = @selectedService'); employeesRequest.input('selectedService', selectedService); } if (employeesConditions.length > 0) { employeesQuery += ` AND ${employeesConditions.join(' AND ')}`; } employeesQuery += ` ORDER BY s.Nom, ca.prenom, ca.nom`; const employeesResult = await employeesRequest.query(employeesQuery); filters.employees = employeesResult.recordset.map(emp => ({ name: emp.fullname, campus: emp.campusnom, societe: emp.societenom, service: emp.servicenom })); console.log(`👥 Employés trouvés: ${filters.employees.length}`); // QUERY DES CONGÉS let queryConditions = `WHERE dc.Statut IN ('Validé', 'Validée', 'Valide', 'En attente')`; let queryRequest = pool.request(); let congesConditions = []; if (selectedSociete && selectedSociete !== 'all') { congesConditions.push('so.Nom = @selectedSociete'); queryRequest.input('selectedSociete', selectedSociete); } if (selectedCampus && selectedCampus !== 'all') { congesConditions.push('c.Nom = @selectedCampus'); queryRequest.input('selectedCampus', selectedCampus); } if (selectedService && selectedService !== 'all') { congesConditions.push('s.Nom = @selectedService'); queryRequest.input('selectedService', selectedService); } if (congesConditions.length > 0) { queryConditions += ` AND ${congesConditions.join(' AND ')}`; } const query = ` SELECT CONVERT(VARCHAR(10), dc.DateDebut, 23) AS startdate, CONVERT(VARCHAR(10), dc.DateFin, 23) AS enddate, CONCAT(ca.prenom, ' ', ca.nom) AS employeename, ( SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom) FROM (SELECT DISTINCT tc2.Nom FROM DemandeCongeType dct2 JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id WHERE dct2.DemandeCongeId = dc.Id) AS DistinctTypes ) AS type, CONCAT( '[', STRING_AGG( CONCAT( '{"type":"', tc.Nom, '","jours":', dct.NombreJours, ',"periode":"', COALESCE(dct.PeriodeJournee, 'Journée entière'), '"}' ), ',' ), ']' ) AS detailsconges, MAX(tc.CouleurHex) AS color, dc.Statut AS statut, s.Nom AS servicenom, c.Nom AS campusnom, so.Nom AS societenom, dc.NombreJours AS nombrejoursouvres 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 JOIN Services s ON ca.ServiceId = s.Id JOIN Campus c ON ca.CampusId = c.Id JOIN Societe so ON ca.SocieteId = so.Id ${queryConditions} GROUP BY dc.Id, dc.DateDebut, dc.DateFin, ca.prenom, ca.nom, dc.Statut, s.Nom, c.Nom, so.Nom, dc.NombreJours ORDER BY s.Nom, dc.DateDebut ASC `; console.log(`🔍 Query WHERE final:`, queryConditions); const leavesResult = await queryRequest.query(query); const formattedLeaves = leavesResult.recordset.map(leave => ({ ...leave })); console.log(`✅ ${formattedLeaves.length} congés trouvés`); return res.json({ success: true, role: role, leaves: formattedLeaves, filters: filters }); } // ======================================== // CAS 4: AUTRES RÔLES (Fallback) // ======================================== else { console.log("CAS 4: Autres rôles - Fallback service/campus"); if (!serviceId) { return res.json({ success: false, message: 'ServiceId manquant' }); } const checkServiceRequest = pool.request(); checkServiceRequest.input('serviceId', serviceId); const checkServiceResult = await checkServiceRequest.query(`SELECT Nom FROM Services WHERE Id = @serviceId`); const serviceNomCheck = checkServiceResult.recordset.length > 0 ? checkServiceResult.recordset[0].Nom : "Inconnu"; const isAdminFinancier = serviceNomCheck === "Administratif & Financier"; if (isAdminFinancier) { // Service multi-campus const employeesRequest = pool.request(); employeesRequest.input('serviceId', serviceId); const employeesResult = await employeesRequest.query(` SELECT CONCAT(ca.prenom, ' ', ca.nom) AS fullname, c.Nom AS campusnom, so.Nom AS societenom, s.Nom AS servicenom FROM CollaborateurAD ca JOIN Services s ON ca.ServiceId = s.Id JOIN Campus c ON ca.CampusId = c.Id JOIN Societe so ON ca.SocieteId = so.Id WHERE ca.ServiceId = @serviceId AND (ca.actif = 1 OR ca.actif IS NULL) ORDER BY ca.prenom, ca.nom `); filters.employees = employeesResult.recordset.map(e => ({ name: e.fullname, campus: e.campusnom, societe: e.societenom, service: e.servicenom })); const queryRequest = pool.request(); queryRequest.input('serviceId', serviceId); const query = ` SELECT CONVERT(VARCHAR(10), dc.DateDebut, 23) AS startdate, CONVERT(VARCHAR(10), dc.DateFin, 23) AS enddate, CONCAT(ca.prenom, ' ', ca.nom) AS employeename, ( SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom) FROM (SELECT DISTINCT tc2.Nom FROM DemandeCongeType dct2 JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id WHERE dct2.DemandeCongeId = dc.Id) AS DistinctTypes ) AS type, CONCAT( '[', STRING_AGG( CONCAT( '{"type":"', tc.Nom, '","jours":', dct.NombreJours, ',"periode":"', COALESCE(dct.PeriodeJournee, 'Journée entière'), '"}' ), ',' ), ']' ) AS detailsconges, MAX(tc.CouleurHex) AS color, dc.Statut AS statut, s.Nom AS servicenom, c.Nom AS campusnom, so.Nom AS societenom, dc.NombreJours AS nombrejoursouvres 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 JOIN Services s ON ca.ServiceId = s.Id JOIN Campus c ON ca.CampusId = c.Id JOIN Societe so ON ca.SocieteId = so.Id WHERE dc.Statut IN ('Validé', 'Validée', 'Valide', 'En attente') AND ca.ServiceId = @serviceId GROUP BY dc.Id, dc.DateDebut, dc.DateFin, ca.prenom, ca.nom, dc.Statut, s.Nom, c.Nom, so.Nom, dc.NombreJours ORDER BY c.Nom, dc.DateDebut ASC `; const leavesResult = await queryRequest.query(query); const formattedLeaves = leavesResult.recordset.map(leave => ({ ...leave })); console.log(`✅ ${formattedLeaves.length} congés trouvés`); return res.json({ success: true, role: role, leaves: formattedLeaves, filters: filters }); } else { // Service + Campus const employeesRequest = pool.request(); employeesRequest.input('serviceId', serviceId); employeesRequest.input('campusId', campusId); const employeesResult = await employeesRequest.query(` SELECT CONCAT(ca.prenom, ' ', ca.nom) AS fullname, c.Nom AS campusnom, so.Nom AS societenom, s.Nom AS servicenom FROM CollaborateurAD ca JOIN Services s ON ca.ServiceId = s.Id JOIN Campus c ON ca.CampusId = c.Id JOIN Societe so ON ca.SocieteId = so.Id WHERE ca.ServiceId = @serviceId AND ca.CampusId = @campusId AND (ca.actif = 1 OR ca.actif IS NULL) ORDER BY ca.prenom, ca.nom `); filters.employees = employeesResult.recordset.map(e => ({ name: e.fullname, campus: e.campusnom, societe: e.societenom, service: e.servicenom })); const queryRequest = pool.request(); queryRequest.input('serviceId', serviceId); queryRequest.input('campusId', campusId); const query = ` SELECT CONVERT(VARCHAR(10), dc.DateDebut, 23) AS startdate, CONVERT(VARCHAR(10), dc.DateFin, 23) AS enddate, CONCAT(ca.prenom, ' ', ca.nom) AS employeename, ( SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom) FROM (SELECT DISTINCT tc2.Nom FROM DemandeCongeType dct2 JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id WHERE dct2.DemandeCongeId = dc.Id) AS DistinctTypes ) AS type, CONCAT( '[', STRING_AGG( CONCAT( '{"type":"', tc.Nom, '","jours":', dct.NombreJours, ',"periode":"', COALESCE(dct.PeriodeJournee, 'Journée entière'), '"}' ), ',' ), ']' ) AS detailsconges, MAX(tc.CouleurHex) AS color, dc.Statut AS statut, s.Nom AS servicenom, c.Nom AS campusnom, so.Nom AS societenom, dc.NombreJours AS nombrejoursouvres 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 JOIN Services s ON ca.ServiceId = s.Id JOIN Campus c ON ca.CampusId = c.Id JOIN Societe so ON ca.SocieteId = so.Id WHERE dc.Statut IN ('Validé', 'Validée', 'Valide', 'En attente') AND ca.ServiceId = @serviceId AND ca.CampusId = @campusId GROUP BY dc.Id, dc.DateDebut, dc.DateFin, ca.prenom, ca.nom, dc.Statut, s.Nom, c.Nom, so.Nom, dc.NombreJours ORDER BY c.Nom, dc.DateDebut ASC `; const leavesResult = await queryRequest.query(query); const formattedLeaves = leavesResult.recordset.map(leave => ({ ...leave })); console.log(`✅ ${formattedLeaves.length} congés trouvés`); return res.json({ success: true, role: role, leaves: formattedLeaves, filters: filters }); } } } catch (error) { console.error("❌ Erreur getTeamLeaves:", error); res.status(500).json({ success: false, message: "Erreur serveur", error: error.message }); } }); // ======================================== // SYNCHRONISATION ENTRA ID CORRIGÉE // À remplacer dans server.js à partir de la ligne ~3700 // ======================================== app.post('/api/initial-sync', async (req, res) => { let errorCount = 0; const syncResults = { processed: 0, inserted: 0, updated: 0, deactivated: 0, errors: [] }; try { console.log('\n🔄 === DÉBUT SYNCHRONISATION ENTRA ID ==='); // 1️⃣ Obtenir le token Admin const accessToken = await getGraphToken(); if (!accessToken) { return res.json({ success: false, message: '❌ Impossible d\'obtenir le token Microsoft Graph' }); } console.log('✅ Token Microsoft Graph obtenu'); // ============================================================================= // SCÉNARIO 1 : Synchronisation unitaire (Un seul utilisateur spécifique) // ============================================================================= if (req.body.userPrincipalName || req.body.mail) { const userEmail = (req.body.mail || req.body.userPrincipalName).toLowerCase().trim(); const entraUserId = req.body.id; console.log(`\n🔄 Synchronisation utilisateur unique : ${userEmail}`); // ⭐ VALIDATION : Email requis if (!userEmail || userEmail === '') { return res.json({ success: false, message: '❌ Email utilisateur manquant ou invalide' }); } // ⭐ VALIDATION : Format email const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(userEmail)) { return res.json({ success: false, message: `❌ Format d'email invalide : ${userEmail}` }); } const conn = await pool.getConnection(); try { await conn.beginTransaction(); // Vérifier si l'utilisateur existe déjà const [existing] = await conn.query( 'SELECT id, email, Actif FROM CollaborateurAD WHERE LOWER(email) = ?', [userEmail] ); if (existing.length > 0) { // MISE À JOUR await conn.query(` UPDATE CollaborateurAD SET entraUserId = ?, prenom = ?, nom = ?, service = ?, description = ?, Actif = 1 WHERE LOWER(email) = ? `, [ entraUserId || existing[0].entraUserId, req.body.givenName || 'Prénom', req.body.surname || 'Nom', req.body.department || '', req.body.jobTitle || null, userEmail ]); console.log(` ✅ Utilisateur mis à jour : ${userEmail}`); syncResults.updated++; } else { // INSERTION await conn.query(` INSERT INTO CollaborateurAD (entraUserId, prenom, nom, email, service, description, role, SocieteId, Actif, DateEntree, TypeContrat) VALUES (?, ?, ?, ?, ?, ?, 'Collaborateur', 1, 1, GETDATE(), '37h') `, [ entraUserId, req.body.givenName || 'Prénom', req.body.surname || 'Nom', userEmail, req.body.department || '', req.body.jobTitle || null ]); console.log(` ✅ Nouvel utilisateur créé : ${userEmail}`); syncResults.inserted++; } // Récupération des données fraîches const [userRows] = await conn.query(` SELECT ca.id as localUserId, ca.entraUserId, ca.prenom, ca.nom, ca.email, ca.role, s.Nom as service, ca.TypeContrat as typeContrat, ca.DateEntree as dateEntree, ca.description, ca.CampusId, ca.SocieteId, so.Nom as societe_nom FROM CollaborateurAD ca LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN Societe so ON ca.SocieteId = so.Id WHERE LOWER(ca.email) = ? `, [userEmail]); await conn.commit(); if (userRows.length === 0) { await conn.rollback(); throw new Error('Utilisateur synchronisé mais introuvable en base'); } const userData = userRows[0]; console.log(`✅ Synchronisation réussie : ${userData.email}`); return res.json({ success: true, message: 'Utilisateur synchronisé avec succès', localUserId: userData.localUserId, role: userData.role, service: userData.service, typeContrat: userData.typeContrat, dateEntree: userData.dateEntree, societeId: userData.SocieteId, user: userData }); } catch (syncError) { await conn.rollback(); console.error('❌ Erreur sync unitaire:', syncError); return res.json({ success: false, message: `❌ Erreur synchronisation: ${syncError.message}` }); } finally { conn.release(); } } // ============================================================================= // SCÉNARIO 2 : Full Sync (Tous les membres du groupe Azure) // ============================================================================= console.log('\n🔄 === FULL SYNC - Tous les membres du groupe ==='); // A. Récupérer le nom du groupe const groupResponse = await axios.get( `https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`, { headers: { Authorization: `Bearer ${accessToken}` } } ); const groupName = groupResponse.data.displayName; console.log(`📋 Groupe : ${groupName}`); // B. Récupérer TOUS les membres avec pagination let allAzureMembers = []; let nextLink = `https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}/members?$select=id,givenName,surname,mail,department,jobTitle,accountEnabled&$top=999`; console.log('📥 Récupération des membres...'); while (nextLink) { const membersResponse = await axios.get(nextLink, { headers: { Authorization: `Bearer ${accessToken}` } }); allAzureMembers = allAzureMembers.concat(membersResponse.data.value); nextLink = membersResponse.data['@odata.nextLink']; if (nextLink) { console.log(` 📄 ${allAzureMembers.length} membres récupérés... (suite)`); } } console.log(`✅ ${allAzureMembers.length} membres trouvés dans Entra ID`); // C. Filtrer et valider les emails const validMembers = allAzureMembers.filter(m => { // Ignorer si pas d'email if (!m.mail || m.mail.trim() === '') { console.log(` ⚠️ Ignoré (pas d'email) : ${m.givenName} ${m.surname} (${m.id})`); return false; } // Ignorer si compte désactivé if (m.accountEnabled === false) { console.log(` ⚠️ Ignoré (compte désactivé) : ${m.mail}`); return false; } // Valider format email const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(m.mail)) { console.log(` ⚠️ Ignoré (format email invalide) : ${m.mail}`); return false; } return true; }); console.log(`✅ ${validMembers.length} membres valides à synchroniser`); const conn = await pool.getConnection(); try { await conn.beginTransaction(); // D. Construire la liste des emails valides const azureEmails = new Set(); validMembers.forEach(m => { azureEmails.add(m.mail.toLowerCase().trim()); }); console.log('\n📝 Traitement des utilisateurs...'); // E. Traitement de chaque membre valide for (const m of validMembers) { try { const emailClean = m.mail.toLowerCase().trim(); syncResults.processed++; // Vérifier si l'utilisateur existe déjà const [existing] = await conn.query( 'SELECT id, email, entraUserId, Actif FROM CollaborateurAD WHERE LOWER(email) = ?', [emailClean] ); if (existing.length > 0) { // MISE À JOUR si changements await conn.query(` UPDATE CollaborateurAD SET entraUserId = ?, prenom = ?, nom = ?, service = ?, description = ?, Actif = 1 WHERE LOWER(email) = ? `, [ m.id, m.givenName || existing[0].prenom || 'Prénom', m.surname || existing[0].nom || 'Nom', m.department || '', m.jobTitle || null, emailClean ]); syncResults.updated++; console.log(` ✓ Mis à jour : ${emailClean}`); } else { // INSERTION nouveau await conn.query(` INSERT INTO CollaborateurAD (entraUserId, prenom, nom, email, service, description, role, SocieteId, Actif, DateEntree, TypeContrat) VALUES (?, ?, ?, ?, ?, ?, 'Collaborateur', 1, 1, GETDATE(), '37h') `, [ m.id, m.givenName || 'Prénom', m.surname || 'Nom', emailClean, m.department || '', m.jobTitle || null ]); syncResults.inserted++; console.log(` ✓ Créé : ${emailClean}`); } } catch (userError) { errorCount++; syncResults.errors.push({ email: m.mail, error: userError.message }); console.error(` ❌ Erreur ${m.mail}:`, userError.message); // Continuer avec les autres } } // F. ⭐ DÉSACTIVATION SÉCURISÉE console.log('\n🔍 Désactivation des comptes obsolètes...'); if (azureEmails.size > 0) { const activeEmailsArray = Array.from(azureEmails); const placeholders = activeEmailsArray.map(() => '?').join(','); // ⭐ REQUÊTE PROTÉGÉE : Ne désactive que les comptes qui : // 1. Ont un email valide // 2. Ne sont pas dans Entra ID // 3. Ne sont pas RH/Admin/President // 4. Sont actuellement actifs const [resultDeactivate] = await conn.query(` UPDATE CollaborateurAD SET Actif = 0 WHERE Email IS NOT NULL AND Email != '' AND Email NOT LIKE '%@noemail.local' AND LOWER(Email) NOT IN (${placeholders}) AND Actif = 1 AND role NOT IN ('RH', 'Admin', 'President') `, activeEmailsArray); syncResults.deactivated = resultDeactivate.affectedRows; console.log(` ✓ ${syncResults.deactivated} compte(s) désactivé(s)`); } await conn.commit(); // G. Logging final console.log('\n📊 === RÉSUMÉ SYNCHRONISATION ==='); console.log(` Groupe Azure: ${groupName}`); console.log(` Total membres Entra: ${allAzureMembers.length}`); console.log(` Membres valides: ${validMembers.length}`); console.log(` Traités: ${syncResults.processed}`); console.log(` Créés: ${syncResults.inserted}`); console.log(` Mis à jour: ${syncResults.updated}`); console.log(` Désactivés: ${syncResults.deactivated}`); console.log(` Erreurs: ${errorCount}`); res.json({ success: true, message: 'Synchronisation globale terminée', groupe_sync: groupName, stats: { total_azure: allAzureMembers.length, membres_valides: validMembers.length, processed: syncResults.processed, inserted: syncResults.inserted, updated: syncResults.updated, deactivated: syncResults.deactivated, errors: errorCount, error_details: syncResults.errors.length > 0 ? syncResults.errors : undefined } }); } catch (error) { await conn.rollback(); throw error; } finally { conn.release(); } } catch (error) { console.error('\n❌ === ERREUR CRITIQUE SYNCHRONISATION ==='); console.error('Message:', error.message); console.error('Stack:', error.stack); res.status(500).json({ success: false, message: 'Erreur lors de la synchronisation', error: error.message, stats: syncResults }); } }); '' // ======================================== // NOUVELLES ROUTES ADMINISTRATION RTT // ======================================== app.get('/api/getAllCollaborateurs', async (req, res) => { try { const [collaborateurs] = await pool.query(` SELECT ca.id, ca.prenom, ca.nom, ca.email, ca.role, ca.TypeContrat, ca.DateEntree, s.Nom as service, ca.CampusId, ca.SocieteId, so.Nom as societe_nom FROM CollaborateurAD ca LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN Societe so ON ca.SocieteId = so.Id WHERE (ca.actif = 1 OR ca.actif IS NULL) ORDER BY ca.nom, ca.prenom `); res.json({ success: true, collaborateurs: collaborateurs, total: collaborateurs.length }); } catch (error) { console.error('Erreur getAllCollaborateurs:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); app.post('/api/updateTypeContrat', async (req, res) => { try { const { collaborateur_id, type_contrat } = req.body; if (!collaborateur_id || !type_contrat) { return res.json({ success: false, message: 'Données manquantes' }); } const typesValides = ['37h', 'forfait_jour', 'temps_partiel']; if (!typesValides.includes(type_contrat)) { return res.json({ success: false, message: 'Type de contrat invalide' }); } const [collab] = await pool.query( 'SELECT prenom, nom, CampusId FROM CollaborateurAD WHERE id = ?', [collaborateur_id] ); if (collab.length === 0) { return res.json({ success: false, message: 'Collaborateur non trouvé' }); } await pool.query( 'UPDATE CollaborateurAD SET TypeContrat = ? WHERE id = ?', [type_contrat, collaborateur_id] ); res.json({ success: true, message: 'Type de contrat mis à jour', nom: `${collab[0].prenom} ${collab[0].nom}`, nouveau_type: type_contrat }); } catch (error) { console.error('Erreur updateTypeContrat:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); app.get('/api/getConfigurationRTT', async (req, res) => { try { const annee = parseInt(req.query.annee || new Date().getFullYear()); const [configs] = await pool.query( `SELECT Annee, TypeContrat, JoursAnnuels, AcquisitionMensuelle, Description FROM ConfigurationRTT WHERE Annee = ? ORDER BY TypeContrat`, [annee] ); res.json({ success: true, configs }); } catch (error) { res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } }); app.post('/api/updateConfigurationRTT', async (req, res) => { try { const { annee, typeContrat, joursAnnuels } = req.body; if (!annee || !typeContrat || !joursAnnuels) { return res.json({ success: false, message: 'Données manquantes' }); } const acquisitionMensuelle = joursAnnuels / 12; await pool.query( `INSERT INTO ConfigurationRTT (Annee, TypeContrat, JoursAnnuels, AcquisitionMensuelle) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE JoursAnnuels = ?, AcquisitionMensuelle = ?`, [annee, typeContrat, joursAnnuels, acquisitionMensuelle, joursAnnuels, acquisitionMensuelle] ); res.json({ success: true, message: 'Configuration mise à jour' }); } catch (error) { res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } }); app.post('/api/updateRequest', upload.array('medicalDocuments', 5), async (req, res) => { let connection; try { console.log('\n📥 === MODIFICATION DEMANDE ==='); console.log('Body reçu:', req.body); console.log('Fichiers reçus:', req.files?.length || 0); const { requestId, leaveType, startDate, endDate, reason, businessDays, userId, userEmail, userName, accessToken } = req.body; // ⭐ PARSER LA RÉPARTITION (CRITIQUE) let Repartition; try { Repartition = JSON.parse(req.body.Repartition || '[]'); console.log('📊 Répartition parsée:', JSON.stringify(Repartition, null, 2)); } catch (parseError) { console.error('❌ Erreur parsing Repartition:', parseError); if (req.files) { req.files.forEach(file => { if (fs.existsSync(file.path)) { fs.unlinkSync(file.path); } }); } return res.status(400).json({ success: false, message: 'Erreur de format de la répartition' }); } // Validation if (!requestId || !leaveType || !startDate || !endDate || !businessDays || !userId) { if (req.files) { req.files.forEach(file => { if (fs.existsSync(file.path)) { fs.unlinkSync(file.path); } }); } return res.status(400).json({ success: false, message: '❌ Données manquantes' }); } connection = await pool.getConnection(); await connection.beginTransaction(); const uploadedFiles = req.files || []; console.log(`Demande ID: ${requestId}, User ID: ${userId}`); // 1️⃣ RÉCUPÉRER LA DEMANDE ORIGINALE const [originalRequest] = await connection.query( 'SELECT * FROM DemandeConge WHERE Id = ? AND CollaborateurADId = ?', [requestId, userId] ); if (originalRequest.length === 0) { await connection.rollback(); if (req.files) { req.files.forEach(file => { if (fs.existsSync(file.path)) { fs.unlinkSync(file.path); } }); } return res.status(404).json({ success: false, message: '❌ Demande introuvable ou non autorisée' }); } const original = originalRequest[0]; const oldStatus = original.Statut; console.log(`📋 Demande originale: Statut=${oldStatus}`); // ⭐ VALIDATION : Si arrêt maladie, il faut au moins 1 fichier const hasABS = Repartition.some(r => r.TypeConge === 'ABS'); if (hasABS && uploadedFiles.length === 0) { await connection.rollback(); return res.json({ success: false, message: 'Un justificatif médical est obligatoire pour un arrêt maladie' }); } // ⭐ VALIDATION DE LA RÉPARTITION console.log('📊 Validation répartition...'); console.log('Nombre de jours total:', businessDays); console.log('Répartition reçue:', JSON.stringify(Repartition, null, 2)); // Ne compter que CP, RTT ET RÉCUP dans la répartition const sommeRepartition = Repartition.reduce((sum, r) => { if (r.TypeConge === 'CP' || r.TypeConge === 'RTT' || r.TypeConge === 'Récup') { return sum + parseFloat(r.NombreJours || 0); } return sum; }, 0); console.log('Somme répartition CP+RTT+Récup:', sommeRepartition.toFixed(2)); // Validation : La somme doit correspondre au total const hasCountableLeave = Repartition.some(r => r.TypeConge === 'CP' || r.TypeConge === 'RTT' || r.TypeConge === 'Récup' ); if (hasCountableLeave && Math.abs(sommeRepartition - businessDays) > 0.01) { console.error('❌ ERREUR : Répartition incohérente !'); if (req.files) { req.files.forEach(file => { if (fs.existsSync(file.path)) { fs.unlinkSync(file.path); } }); } await connection.rollback(); return res.json({ success: false, message: `Erreur de répartition : la somme (${sommeRepartition.toFixed(2)}j) ne correspond pas au total (${businessDays}j)` }); } console.log('✅ Validation répartition OK'); // 2️⃣ REMBOURSER L'ANCIENNE DEMANDE (via DeductionDetails) let restorationStats = { count: 0, details: [] }; if (oldStatus !== 'Refusée' && oldStatus !== 'Annulée' && original.TypeCongeId !== 3) { console.log(`🔄 Remboursement de l'ancienne demande...`); const [oldDeductions] = await connection.query( 'SELECT * FROM DeductionDetails WHERE DemandeCongeId = ?', [requestId] ); if (oldDeductions.length > 0) { for (const d of oldDeductions) { const [compteur] = await connection.query( 'SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?', [userId, d.TypeCongeId, d.Annee] ); if (compteur.length > 0) { const newSolde = parseFloat(compteur[0].Solde) + parseFloat(d.JoursUtilises); await connection.query( 'UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?', [newSolde, compteur[0].Id] ); restorationStats.count++; restorationStats.details.push(`${d.JoursUtilises}j rendus (Type ${d.TypeCongeId}, Année ${d.Annee})`); console.log(` ✅ Remboursé ${d.JoursUtilises}j au compteur TypeId=${d.TypeCongeId}, Annee=${d.Annee}`); } } // Supprimer les anciennes déductions await connection.query('DELETE FROM DeductionDetails WHERE DemandeCongeId = ?', [requestId]); console.log(` 🧹 ${oldDeductions.length} déduction(s) supprimée(s)`); } } // 3️⃣ METTRE À JOUR LA DEMANDE (⭐ SQL SERVER : GETDATE + FORMAT) console.log('📝 Mise à jour de la demande...'); // Si elle était validée, on la repasse en "En attente" const newStatus = (oldStatus === 'Validée' || oldStatus === 'Validé') ? 'En attente' : oldStatus; await connection.query( `UPDATE DemandeConge SET TypeCongeId = ?, DateDebut = ?, DateFin = ?, Commentaire = ?, NombreJours = ?, Statut = ?, DateValidation = GETDATE(), CommentaireValidation = COALESCE(CommentaireValidation, '') + CHAR(10) + '[Modifiée le ' + FORMAT(GETDATE(), 'dd/MM/yyyy à HH:mm', 'fr-FR') + ']' WHERE Id = ?`, [leaveType, startDate, endDate, reason || '', businessDays, newStatus, requestId] ); console.log(`✅ Demande ${requestId} modifiée - Statut: ${newStatus}`); // 4️⃣ SUPPRIMER L'ANCIENNE RÉPARTITION DANS DemandeCongeType await connection.query('DELETE FROM DemandeCongeType WHERE DemandeCongeId = ?', [requestId]); // 5️⃣ Sauvegarder la nouvelle répartition AVEC GÉNÉRATION MANUELLE D'ID console.log('\n📊 Sauvegarde de la nouvelle répartition en base...'); for (const rep of Repartition) { const code = rep.TypeConge; const name = code === 'CP' ? 'Congé payé' : code === 'RTT' ? 'RTT' : code === 'ABS' ? 'Congé maladie' : code === 'Formation' ? 'Formation' : code === 'Récup' ? 'Récupération' : code; const [typeRow] = await connection.query( 'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [name] ); if (typeRow.length > 0) { // ⭐ GÉNÉRER L'ID MANUELLEMENT const demandeCongeTypeId = await getNextId(connection, 'DemandeCongeType'); await connection.query(` INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee) VALUES (?, ?, ?, ?, ?) `, [ demandeCongeTypeId, requestId, typeRow[0].Id, rep.NombreJours, rep.PeriodeJournee || 'Journée entière' ]); console.log(` ✓ ${name}: ${rep.NombreJours}j (${rep.PeriodeJournee || 'Journée entière'})`); } } // 6️⃣ CALCULER ET APPLIQUER LA NOUVELLE DÉDUCTION let newRepartition = []; const currentYear = new Date().getFullYear(); const previousYear = currentYear - 1; const isFormationOnly = Repartition.length === 1 && Repartition[0].TypeConge === 'Formation'; if (!isFormationOnly) { console.log('📉 Déduction des compteurs...'); for (const rep of Repartition) { if (rep.TypeConge === 'ABS' || rep.TypeConge === 'Formation') { console.log(` ⏩ ${rep.TypeConge} ignoré (pas de déduction)`); continue; } // ⭐ TRAITEMENT SPÉCIAL POUR RÉCUP if (rep.TypeConge === 'Récup') { const [recupType] = await connection.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Récupération']); if (recupType.length > 0) { await connection.query(` UPDATE CompteurConges SET Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END, DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [rep.NombreJours, rep.NombreJours, userId, recupType[0].Id, currentYear]); await connection.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Récup Posée', ?) `, [requestId, recupType[0].Id, currentYear, rep.NombreJours]); newRepartition.push({ typeCongeId: recupType[0].Id, annee: currentYear, jours: rep.NombreJours, typeDeduction: 'Récup Posée' }); console.log(` ✓ Récup: ${rep.NombreJours}j déduits`); } continue; } // ⭐ CP et RTT : déduction normale const name = rep.TypeConge === 'CP' ? 'Congé payé' : 'RTT'; const [typeRow] = await connection.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [name]); if (typeRow.length > 0) { let joursRestants = parseFloat(rep.NombreJours); // A. Essayer N-1 (CP uniquement) if (rep.TypeConge === 'CP') { const [compteurN1] = await connection.query( 'SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?', [userId, typeRow[0].Id, previousYear] ); if (compteurN1.length > 0 && compteurN1[0].Solde > 0) { const disponibleN1 = parseFloat(compteurN1[0].Solde); const aPrendreN1 = Math.min(disponibleN1, joursRestants); await connection.query( 'UPDATE CompteurConges SET Solde = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ?', [aPrendreN1, compteurN1[0].Id] ); await connection.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Année N-1', ?) `, [requestId, typeRow[0].Id, previousYear, aPrendreN1]); newRepartition.push({ typeCongeId: typeRow[0].Id, annee: previousYear, jours: aPrendreN1, typeDeduction: 'Année N-1' }); joursRestants -= aPrendreN1; console.log(` ✓ ${name} N-1: ${aPrendreN1}j déduits (reste: ${joursRestants}j)`); } } // B. Essayer N if (joursRestants > 0) { const [compteurN] = await connection.query( 'SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?', [userId, typeRow[0].Id, currentYear] ); if (compteurN.length > 0) { const disponibleN = parseFloat(compteurN[0].Solde); const aPrendreN = Math.min(disponibleN, joursRestants); await connection.query( 'UPDATE CompteurConges SET Solde = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ?', [aPrendreN, compteurN[0].Id] ); await connection.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Année N', ?) `, [requestId, typeRow[0].Id, currentYear, aPrendreN]); newRepartition.push({ typeCongeId: typeRow[0].Id, annee: currentYear, jours: aPrendreN, typeDeduction: 'Année N' }); joursRestants -= aPrendreN; console.log(` ✓ ${name} N: ${aPrendreN}j déduits (reste: ${joursRestants}j)`); } } // C. Si il reste des jours → Anticipé if (joursRestants > 0) { const [compteurN] = await connection.query( 'SELECT Id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?', [userId, typeRow[0].Id, currentYear] ); if (compteurN.length > 0) { await connection.query( 'UPDATE CompteurConges SET Solde = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ?', [joursRestants, compteurN[0].Id] ); await connection.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'N Anticip', ?) `, [requestId, typeRow[0].Id, currentYear, joursRestants]); newRepartition.push({ typeCongeId: typeRow[0].Id, annee: currentYear, jours: joursRestants, typeDeduction: 'N Anticip' }); console.log(` ⚠️ ${name} Anticipé: ${joursRestants}j`); } } } } } await connection.commit(); console.log(`✅ Demande ${requestId} modifiée avec succès`); // 7️⃣ ENVOI DES EMAILS (Asynchrone) const graphToken = await getGraphToken(); if (graphToken) { const [managerInfo] = await connection.query( `SELECT m.Email, m.Prenom, m.Nom FROM CollaborateurAD c JOIN HierarchieValidationAD h ON c.id = h.CollaborateurId JOIN CollaborateurAD m ON h.SuperieurId = m.id WHERE c.id = ?`, [userId] ); if (managerInfo.length > 0) { const manager = managerInfo[0]; // Email au manager sendMailGraph( graphToken, 'gtanoreply@ensup.eu', manager.Email, '🔄 Modification de demande de congé', `

🔄 Modification de demande

Bonjour ${manager.Prenom},

${userName} a modifié sa demande de congé :

Type : ${getLeaveTypeName(leaveType)}
Dates : du ${formatDateFR(startDate)} au ${formatDateFR(endDate)}
Durée : ${businessDays} jour(s)
${restorationStats.count > 0 ? `

✅ Les compteurs ont été automatiquement recalculés (${restorationStats.count} opération(s)).

` : ''}

Merci de valider ou refuser cette demande dans l'application.

` ).catch(err => console.error('❌ Erreur email manager:', err)); // Email de confirmation au collaborateur sendMailGraph( graphToken, 'gtanoreply@ensup.eu', userEmail, '✅ Confirmation de modification', `

✅ Demande modifiée

Bonjour ${userName.split(' ')[0]},

Votre demande de congé a bien été modifiée :

Type : ${getLeaveTypeName(leaveType)}
Dates : du ${formatDateFR(startDate)} au ${formatDateFR(endDate)}
Durée : ${businessDays} jour(s)

Elle est maintenant en attente de validation.

` ).catch(err => console.error('❌ Erreur email collaborateur:', err)); } } res.json({ success: true, message: '✅ Demande modifiée avec succès', newStatus: newStatus, restoration: restorationStats, repartition: newRepartition }); } catch (error) { if (connection) { await connection.rollback(); } console.error('❌ Erreur updateRequest:', error); res.status(500).json({ success: false, message: error.message || 'Erreur lors de la modification' }); } finally { if (connection) { connection.release(); } } }); // ⭐ Fonction helper pour calculer la répartition CP function calculateCPRepartition(joursNecessaires, soldeN1, soldeN) { const repartition = []; let reste = joursNecessaires; // D'abord utiliser N-1 if (reste > 0 && soldeN1 > 0) { const joursN1 = Math.min(reste, soldeN1); repartition.push({ type: 'CP', annee: 'N-1', jours: joursN1 }); reste -= joursN1; } // Puis utiliser N if (reste > 0 && soldeN > 0) { const joursN = Math.min(reste, soldeN); repartition.push({ type: 'CP', annee: 'N', jours: joursN }); reste -= joursN; } return repartition; } // ⭐ Fonction helper pour obtenir le champ de compteur function getCounterField(type, annee) { if (type === 'CP' && annee === 'N-1') return 'SoldeCP_N1'; if (type === 'CP' && annee === 'N') return 'SoldeCP_N'; if (type === 'RTT' && annee === 'N') return 'SoldeRTT_N'; return null; } // ⭐ Fonction helper pour le nom du type de congé function getLeaveTypeName(typeId) { const types = { 1: 'Congé payé', 2: 'RTT', 3: 'Arrêt maladie', 4: 'Formation', 5: 'Récupération' }; return types[typeId] || 'Inconnu'; } // ⭐ Fonction helper pour formater les dates function formatDateFR(dateStr) { const date = new Date(dateStr); return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }); } /** * Route pour SUPPRIMER une demande de congé * POST /deleteRequest */ app.post('/api/deleteRequest', async (req, res) => { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const { requestId, userId, userEmail, userName } = req.body; if (!requestId || !userId) { await conn.rollback(); conn.release(); return res.status(400).json({ success: false, message: 'Paramètres manquants (requestId ou userId)' }); } console.log('\n🗑️ === ANNULATION DEMANDE ==='); console.log(`Demande ID: ${requestId}, User ID: ${userId}`); // 1️⃣ Vérifier que la demande existe const [existingRequest] = await conn.query( `SELECT d.Id, d.DateDebut, d.DateFin, d.Statut, d.NombreJours, d.CollaborateurADId FROM DemandeConge d WHERE d.Id = ? AND d.CollaborateurADId = ?`, [requestId, userId] ); if (existingRequest.length === 0) { await conn.rollback(); conn.release(); return res.status(404).json({ success: false, message: 'Demande introuvable ou non autorisée' }); } const request = existingRequest[0]; const requestStatus = request.Statut; // ⭐ CORRECTION 1 : Déclarer `aujourdhui` et `dateDebut` AVANT de les utiliser const aujourdhui = new Date(); aujourdhui.setHours(0, 0, 0, 0); const dateDebut = request.DateDebut ? new Date(request.DateDebut) : null; if (dateDebut) { dateDebut.setHours(0, 0, 0, 0); } if (!dateDebut || isNaN(dateDebut.getTime())) { console.warn('⚠️ Date invalide, on autorise l\'annulation'); // Ne pas bloquer l'annulation si la date est invalide } else { dateDebut.setHours(0, 0, 0, 0); console.log(`📋 Demande: Statut=${requestStatus}, Date début=${dateDebut.toLocaleDateString('fr-FR')}`); // ❌ BLOQUER SI DATE DÉJÀ PASSÉE if (dateDebut <= aujourdhui && requestStatus === 'Validée') { await conn.rollback(); conn.release(); return res.status(400).json({ success: false, message: '❌ Impossible d\'annuler : la date de début est déjà passée ou c\'est aujourd\'hui', dateDebut: dateDebut.toISOString().split('T')[0] }); } } // 2️⃣ RÉCUPÉRER LA RÉPARTITION (pour l'email) const [repartition] = await conn.query(` SELECT dct.*, tc.Nom as TypeNom FROM DemandeCongeType dct JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dct.DemandeCongeId = ? ORDER BY tc.Nom `, [requestId]); // 3️⃣ RESTAURER LES COMPTEURS via DeductionDetails let restorationStats = { count: 0, details: [] }; if (requestStatus !== 'Refusée' && requestStatus !== 'Annulée') { console.log(`🔄 Restauration des compteurs...`); const [deductions] = await conn.query( 'SELECT * FROM DeductionDetails WHERE DemandeCongeId = ?', [requestId] ); if (deductions.length > 0) { for (const d of deductions) { const [compteur] = await conn.query( 'SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?', [userId, d.TypeCongeId, d.Annee] ); if (compteur.length > 0) { const c = compteur[0]; const newSolde = parseFloat(c.Solde) + parseFloat(d.JoursUtilises); await conn.query( 'UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?', [newSolde, c.Id] ); restorationStats.count++; restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: d.JoursUtilises }); console.log(` ✅ Remboursé ${d.JoursUtilises}j au compteur TypeId=${d.TypeCongeId} Année=${d.Annee}`); } else { console.warn(`⚠️ Compteur introuvable (Type ${d.TypeCongeId}, Année ${d.Annee})`); } } // Supprimer les déductions await conn.query('DELETE FROM DeductionDetails WHERE DemandeCongeId = ?', [requestId]); console.log(` 🧹 ${deductions.length} ligne(s) DeductionDetails supprimée(s)`); } else { console.log(' ℹ️ Aucune déduction à rembourser'); } } // 4️⃣ METTRE À JOUR LE STATUT await conn.query( `UPDATE DemandeConge SET Statut = 'Annulée', DateValidation = GETDATE(), CommentaireValidation = COALESCE(CommentaireValidation, '') + CHAR(10) + '[Annulée par le collaborateur le ' + FORMAT(GETDATE(), 'dd/MM/yyyy à HH:mm', 'fr-FR') + ']' WHERE Id = ?`, [requestId] ); console.log(`✅ Demande ${requestId} marquée comme Annulée`); await conn.commit(); conn.release(); // 5️⃣ ENVOI DES EMAILS let emailsSent = { collaborateur: false, manager: false }; const graphToken = await getGraphToken(); if (graphToken) { const [collabInfo] = await conn.query( 'SELECT email, prenom, nom FROM CollaborateurAD WHERE id = ?', [userId] ); const collabEmail = collabInfo.length > 0 ? collabInfo[0].email : userEmail; const collabName = collabInfo.length > 0 ? `${collabInfo[0].prenom} ${collabInfo[0].nom}` : userName; // ⭐ CORRECTION : Formater correctement les dates pour l'email const dateDebutFormatted = request.DateDebut ? new Date(request.DateDebut).toLocaleDateString('fr-FR') : 'Date inconnue'; const dateFinFormatted = request.DateFin ? new Date(request.DateFin).toLocaleDateString('fr-FR') : 'Date inconnue'; const datesPeriode = dateDebutFormatted === dateFinFormatted ? dateDebutFormatted : `du ${dateDebutFormatted} au ${dateFinFormatted}`; const repartitionText = repartition.map(r => ` ${r.TypeNom} : ${r.NombreJours}j ${r.PeriodeJournee !== 'Journée entière' ? `(${r.PeriodeJournee})` : ''} ` ).join(''); // 📧 EMAIL AU COLLABORATEUR if (collabEmail) { try { const subjectCollab = '✅ Confirmation d\'annulation de votre demande'; const bodyCollab = `

✅ Demande annulée

Bonjour ${collabName},

Votre demande de congé a bien été annulée.

📋 Demande annulée

${repartitionText}
Période : ${datesPeriode}
Durée totale : ${request.NombreJours} jour(s)
Répartition :
${restorationStats.count > 0 ? `

Vos compteurs ont été restaurés
${restorationStats.count} opération(s) de remboursement effectuée(s).

` : ''}

📧 Cet email est envoyé automatiquement, merci de ne pas y répondre.

`; await sendMailGraph(graphToken, 'gtanoreply@ensup.eu', collabEmail, subjectCollab, bodyCollab); emailsSent.collaborateur = true; console.log('✅ Email envoyé au collaborateur'); } catch (emailError) { console.error('❌ Erreur email collaborateur:', emailError.message); } } // 📧 EMAIL AU MANAGER const [hierarchie] = await conn.query( `SELECT h.SuperieurId, m.email as managerEmail, m.prenom as managerPrenom, m.nom as managerNom FROM HierarchieValidationAD h LEFT JOIN CollaborateurAD m ON h.SuperieurId = m.id WHERE h.CollaborateurId = ?`, [userId] ); const managerEmail = hierarchie[0]?.managerEmail; const managerName = hierarchie[0] ? `${hierarchie[0].managerPrenom} ${hierarchie[0].managerNom}` : 'Manager'; if (managerEmail && requestStatus !== 'Refusée' && requestStatus !== 'Annulée') { try { const isValidated = requestStatus === 'Validée' || requestStatus === 'Validé'; const subjectManager = isValidated ? `🗑️ Annulation de congé validé - ${collabName}` : `🗑️ Annulation de demande - ${collabName}`; const bodyManager = `

🗑️ Annulation de ${isValidated ? 'congé' : 'demande'}

Bonjour ${managerName},

${collabName} a annulé ${isValidated ? 'son congé validé' : 'sa demande de congé'}.

📋 Demande annulée

${repartitionText}
Statut initial : ${requestStatus}
Période : ${datesPeriode}
Durée totale : ${request.NombreJours} jour(s)
Répartition :
${restorationStats.count > 0 ? `

✅ Les compteurs ont été automatiquement restaurés (${restorationStats.count} opération(s)).

` : ''}

📧 Cet email est envoyé automatiquement, merci de ne pas y répondre.

`; await sendMailGraph(graphToken, 'gtanoreply@ensup.eu', managerEmail, subjectManager, bodyManager); emailsSent.manager = true; console.log('✅ Email envoyé au manager'); } catch (emailError) { console.error('❌ Erreur email manager:', emailError.message); } } } res.json({ success: true, message: 'Demande annulée avec succès', restoration: restorationStats, emailsSent: emailsSent }); } catch (error) { await conn.rollback(); console.error('❌ Erreur deleteRequest:', error); res.status(500).json({ success: false, message: 'Erreur lors de l\'annulation', error: error.message }); } finally { if (conn) conn.release(); } }); app.get('/api/exportCompteurs', async (req, res) => { try { const dateRef = req.query.dateRef || new Date().toISOString().split('T')[0]; const conn = await pool.getConnection(); const [collaborateurs] = await pool.query(` SELECT ca.id, ca.prenom, ca.nom, ca.email, ca.role, ca.TypeContrat, ca.DateEntree, s.Nom as service, ca.CampusId, ca.SocieteId, so.Nom as societe_nom FROM CollaborateurAD ca LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN Societe so ON ca.SocieteId = so.Id WHERE (ca.actif = 1 OR ca.actif IS NULL) ORDER BY ca.nom, ca.prenom `); const rapport = []; for (const collab of collaborateurs) { const dateEntree = collab.DateEntree; const dateReference = new Date(dateRef); const acquisCP = calculerAcquisitionCP_Smart(dateReference, dateEntree); let acquisRTT = 0; if (collab.role !== 'Apprenti') { const rttData = await calculerAcquisitionRTT_Smart(conn, collab.id, dateReference); acquisRTT = rttData.acquisition; } const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']); const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']); let soldeCP = 0; let soldeRTT = 0; if (cpType.length > 0) { const [compteurCP] = await conn.query( 'SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?', [collab.id, cpType[0].Id, dateReference.getFullYear()] ); soldeCP = compteurCP.length > 0 ? parseFloat(compteurCP[0].Solde) : 0; } if (rttType.length > 0 && collab.role !== 'Apprenti') { const [compteurRTT] = await conn.query( 'SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?', [collab.id, rttType[0].Id, dateReference.getFullYear()] ); soldeRTT = compteurRTT.length > 0 ? parseFloat(compteurRTT[0].Solde) : 0; } rapport.push({ id: collab.id, prenom: collab.prenom, nom: collab.nom, email: collab.email, role: collab.role, service: collab.service, societe_id: collab.SocieteId, societe_nom: collab.societe_nom, type_contrat: collab.TypeContrat, date_entree: dateEntree ? formatDateWithoutUTC(dateEntree) : null, cp_acquis: parseFloat(acquisCP.toFixed(2)), cp_solde: parseFloat(soldeCP.toFixed(2)), rtt_acquis: parseFloat(acquisRTT.toFixed(2)), rtt_solde: parseFloat(soldeRTT.toFixed(2)), date_reference: dateRef }); } conn.release(); res.json({ success: true, date_reference: dateRef, total_collaborateurs: rapport.length, rapport: rapport }); } catch (error) { console.error('Erreur exportCompteurs:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); function isInPeriodeAnticipation(dateDebut, typeConge) { const date = new Date(dateDebut); const year = date.getFullYear(); const month = date.getMonth() + 1; // 1-12 if (typeConge === 'CP') { // CP : 01/06 année N → 31/05 année N+1 // Période anticipation : du 01/06 de l'année suivante return month >= 6; // Si >= juin, c'est pour l'exercice N+1 } else if (typeConge === 'RTT') { // RTT : 01/01 année N → 31/12 année N // Pas d'anticipation possible car année civile return month >= 1 && month <= 12; } return false; } function getAnneeCompteur(dateDebut, typeConge) { const date = new Date(dateDebut); const year = date.getFullYear(); const month = date.getMonth() + 1; if (typeConge === 'CP') { // Si date entre 01/06 et 31/12 → année N // Si date entre 01/01 et 31/05 → année N-1 (exercice précédent) return month >= 6 ? year : year - 1; } else { // RTT : toujours année civile return year; } } /** * Vérifie la disponibilité des soldes pour une demande * Retourne : { available: boolean, details: {}, useN1: boolean } */ async function checkSoldesDisponiblesMixte(conn, collaborateurId, repartition, dateDebut, isApprenti) { const today = new Date(); today.setHours(0, 0, 0, 0); const currentYear = today.getFullYear(); const dateDemandeObj = new Date(dateDebut); dateDemandeObj.setHours(0, 0, 0, 0); const demandeYear = dateDemandeObj.getFullYear(); const demandeMonth = dateDemandeObj.getMonth() + 1; console.log('\n🔍 === CHECK SOLDES MIXTE (AVEC ANTICIPATION) ==='); console.log('📅 Date AUJOURD\'HUI:', today.toISOString().split('T')[0]); console.log('📅 Date DEMANDE:', dateDebut); console.log('📅 Année demande:', demandeYear, '/ Mois:', demandeMonth); console.log('📅 Année actuelle:', currentYear); let totalDisponible = 0; let totalNecessaire = 0; const details = {}; for (const rep of repartition) { const typeCode = rep.TypeConge; const joursNecessaires = parseFloat(rep.NombreJours || 0); // Ignorer ABS et Formation if (typeCode === 'ABS' || typeCode === 'Formation') { continue; } totalNecessaire += joursNecessaires; if (typeCode === 'CP') { // ⭐ RÉCUPÉRER LES INFOS COLLABORATEUR const [collabInfo] = await conn.query( `SELECT DateEntree FROM CollaborateurAD WHERE id = ?`, [collaborateurId] ); const dateEntree = collabInfo[0]?.DateEntree; // ⭐ CALCULER L'ACQUISITION JUSQU'À LA DATE DEMANDÉE const acquisALaDate = calculerAcquisitionCP_Smart(dateDemandeObj, dateEntree); console.log('💰 Acquisition CP à la date', dateDebut, ':', acquisALaDate.toFixed(2), 'j'); // ⭐ RÉCUPÉRER LE REPORTÉ N-1 const previousYear = currentYear - 1; const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']); const cpTypeId = cpType[0].Id; const [compteurN1] = await conn.query(` SELECT SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, cpTypeId, previousYear]); const reporteN1 = compteurN1.length > 0 ? parseFloat(compteurN1[0].SoldeReporte || 0) : 0; // ⭐ RÉCUPÉRER CE QUI A DÉJÀ ÉTÉ POSÉ (toutes demandes validées ou en attente) const [totalPose] = await conn.query(` SELECT COALESCE(SUM(dct.NombreJours), 0) as totalPose FROM DemandeConge dc JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dc.CollaborateurADId = ? AND tc.Nom = 'Congé payé' AND dc.Statut IN ('Validée', 'En attente') AND dc.DateDebut <= ? `, [collaborateurId, dateDebut]); const dejaPose = parseFloat(totalPose[0].totalPose || 0); // ⭐ SOLDE RÉEL = Reporté N-1 + Acquisition - Déjà posé const soldeReel = reporteN1 + acquisALaDate - dejaPose; console.log('💰 Soldes CP détaillés:', { reporteN1: reporteN1.toFixed(2), acquisALaDate: acquisALaDate.toFixed(2), dejaPose: dejaPose.toFixed(2), soldeReel: soldeReel.toFixed(2) }); details.CP = { reporteN1: reporteN1, acquisALaDate: acquisALaDate, dejaPose: dejaPose, soldeReel: soldeReel, necessaire: joursNecessaires }; totalDisponible += Math.max(0, soldeReel); if (soldeReel < joursNecessaires) { return { available: false, message: `Solde CP insuffisant (${Math.max(0, soldeReel).toFixed(2)}j disponibles avec anticipation, ${joursNecessaires}j demandés)`, details, manque: joursNecessaires - soldeReel }; } } else if (typeCode === 'RTT') { if (isApprenti) { return { available: false, message: 'Les apprentis ne peuvent pas poser de RTT', details }; } // ⭐ CALCUL RTT (utiliser la fonction existante) const rttData = await calculerAcquisitionRTT_Smart(conn, collaborateurId, dateDemandeObj); const acquisALaDate = rttData.acquisition; console.log('💰 Acquisition RTT à la date', dateDebut, ':', acquisALaDate.toFixed(2), 'j'); // ⭐ RÉCUPÉRER CE QUI A DÉJÀ ÉTÉ POSÉ const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']); const rttTypeId = rttType[0].Id; const [totalPose] = await conn.query(` SELECT COALESCE(SUM(dct.NombreJours), 0) as totalPose FROM DemandeConge dc JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dc.CollaborateurADId = ? AND tc.Nom = 'RTT' AND dc.Statut IN ('Validée', 'En attente') AND dc.DateDebut <= ? `, [collaborateurId, dateDebut]); const dejaPose = parseFloat(totalPose[0].totalPose || 0); // ⭐ SOLDE RÉEL = Acquisition - Déjà posé const soldeReel = acquisALaDate - dejaPose; console.log('💰 Soldes RTT détaillés:', { acquisALaDate: acquisALaDate.toFixed(2), dejaPose: dejaPose.toFixed(2), soldeReel: soldeReel.toFixed(2) }); details.RTT = { acquisALaDate: acquisALaDate, dejaPose: dejaPose, soldeReel: soldeReel, necessaire: joursNecessaires }; totalDisponible += Math.max(0, soldeReel); if (soldeReel < joursNecessaires) { return { available: false, message: `Solde RTT insuffisant (${Math.max(0, soldeReel).toFixed(2)}j disponibles avec anticipation, ${joursNecessaires}j demandés)`, details, manque: joursNecessaires - soldeReel }; } } else if (typeCode === 'Récup') { const [recupType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Récupération']); if (recupType.length === 0) continue; const recupTypeId = recupType[0].Id; const [compteur] = await conn.query( `SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, recupTypeId, currentYear] ); const soldeRecup = compteur.length > 0 ? parseFloat(compteur[0].Solde || 0) : 0; console.log('💰 Solde Récup:', soldeRecup.toFixed(2), 'j'); details.Recup = { soldeN: soldeRecup, total: soldeRecup, necessaire: joursNecessaires }; totalDisponible += Math.min(joursNecessaires, soldeRecup); if (soldeRecup < joursNecessaires) { return { available: false, message: `Solde Récupération insuffisant (${soldeRecup.toFixed(2)}j disponibles, ${joursNecessaires}j demandés)`, details, manque: joursNecessaires - soldeRecup }; } } } console.log('\n✅ Check final:', { totalDisponible: totalDisponible.toFixed(2), totalNecessaire: totalNecessaire.toFixed(2), available: totalDisponible >= totalNecessaire }); return { available: totalDisponible >= totalNecessaire, details, totalDisponible, totalNecessaire }; } // ======================================== // FONCTIONS HELPER // ======================================== async function getSoldesCP(conn, collaborateurId, dateEntree, includeN1Anticipe = false) { const currentYear = new Date().getFullYear(); const previousYear = currentYear - 1; console.log(`\n📊 getSoldesCP - includeN1Anticipe: ${includeN1Anticipe}`); const [cpType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = 'Congé payé' LIMIT 1`); const typeCongeId = cpType[0].Id; // N-1 (reporté) const [compteursN1] = await conn.query( `SELECT Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, previousYear] ); const soldeN1 = compteursN1.length > 0 ? parseFloat(compteursN1[0].SoldeReporte || 0) : 0; // N (actuel) const [compteursN] = await conn.query( `SELECT Solde, SoldeReporte, Total FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, currentYear] ); const soldeN = compteursN.length > 0 ? parseFloat(compteursN[0].Solde || 0) - parseFloat(compteursN[0].SoldeReporte || 0) : 0; const totalAcquisN = compteursN.length > 0 ? parseFloat(compteursN[0].Total || 0) : 0; // Anticipation N const finExerciceN = new Date(currentYear + 1, 4, 31); // 31 mai N+1 const acquisTotaleN = calculerAcquisitionCP_Smart(finExerciceN, dateEntree); const soldeAnticipeN = Math.max(0, acquisTotaleN - totalAcquisN); console.log(' N-1:', soldeN1); console.log(' N:', soldeN); console.log(' Anticipé N:', soldeAnticipeN); // ⭐ Anticipation N+1 (si demandé) let soldeAnticipeN1 = 0; if (includeN1Anticipe) { const debutExerciceN1 = new Date(currentYear + 1, 5, 1); // 01 juin N+1 const finExerciceN1 = new Date(currentYear + 2, 4, 31); // 31 mai N+2 let dateCalculN1 = debutExerciceN1; if (dateEntree && new Date(dateEntree) > debutExerciceN1) { dateCalculN1 = new Date(dateEntree); } const acquisTotaleN1 = calculerAcquisitionCP_Smart(finExerciceN1, dateCalculN1); soldeAnticipeN1 = acquisTotaleN1; console.log(' Anticipé N+1:', soldeAnticipeN1); } return { soldeN1, soldeN, soldeAnticipeN, soldeAnticipeN1 }; } async function getSoldesRTT(conn, collaborateurId, typeContrat, dateEntree) { const currentYear = new Date().getFullYear(); const rttType = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = 'RTT' LIMIT 1`); const typeCongeId = rttType[0].Id; const compteursN = await conn.query( `SELECT Solde, Total FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, currentYear] ); const soldeN = compteursN.length > 0 ? parseFloat(compteursN[0].Solde || 0) : 0; const totalAcquisN = compteursN.length > 0 ? parseFloat(compteursN[0].Total || 0) : 0; // Calcul anticipation N const finAnneeN = new Date(currentYear, 11, 31); // 31 déc N const rttDataTotalN = await calculerAcquisitionRTT_Smart(conn, collaborateurId, finAnneeN); const soldeAnticipeN = Math.max(0, rttDataTotalN.acquisition - totalAcquisN); return { soldeN, soldeAnticipeN }; } async function getSoldesRecup(conn, collaborateurId) { const currentYear = new Date().getFullYear(); const recupType = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = 'Récupération' LIMIT 1`); if (recupType.length === 0) return 0; const typeCongeId = recupType[0].Id; const compteur = await conn.query( `SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, currentYear] ); return compteur.length > 0 ? parseFloat(compteur[0].Solde || 0) : 0; } app.get('/api/getAnticipationDisponible', async (req, res) => { try { const { userid, dateDebut } = req.query; if (!userid || !dateDebut) { return res.json({ success: false, message: 'Paramètres manquants' }); } const conn = await pool.getConnection(); const [collabInfo] = await conn.query( `SELECT DateEntree, TypeContrat, role FROM CollaborateurAD WHERE id = ?`, [userid] ); const dateEntree = collabInfo.DateEntree; const isApprenti = collabInfo.role === 'Apprenti'; // Déterminer si c'est une demande N+1 const dateDemandeObj = new Date(dateDebut); const currentYear = new Date().getFullYear(); const demandeYear = dateDemandeObj.getFullYear(); const demandeMonth = dateDemandeObj.getMonth() + 1; const isN1 = (demandeYear === currentYear + 1 && demandeMonth >= 6) || (demandeYear === currentYear + 2 && demandeMonth <= 5); // Calculer les soldes avec anticipation const soldesCP = await getSoldesCP(conn, userid, dateEntree, isN1); const soldesRTT = isApprenti ? { soldeN: 0, soldeAnticipeN: 0 } : await getSoldesRTT(conn, userid, collabInfo.TypeContrat, dateEntree); const soldesRecup = await getSoldesRecup(conn, userid); conn.release(); res.json({ success: true, isN1Request: isN1, CP: { actuel: soldesCP.soldeN1 + soldesCP.soldeN, anticipeN: soldesCP.soldeAnticipeN, anticipeN1: isN1 ? soldesCP.soldeAnticipeN1 : 0, total: soldesCP.soldeN1 + soldesCP.soldeN + soldesCP.soldeAnticipeN + (isN1 ? soldesCP.soldeAnticipeN1 : 0) }, RTT: { actuel: soldesRTT.soldeN, anticipeN: soldesRTT.soldeAnticipeN, total: soldesRTT.soldeN + soldesRTT.soldeAnticipeN }, Recup: { actuel: soldesRecup, total: soldesRecup } }); } catch (error) { console.error('Erreur getAnticipationDisponible:', error); res.status(500).json({ success: false, message: error.message }); } }); async function deductLeaveBalanceWithN1(conn, collaborateurId, typeCongeId, nombreJours, demandeCongeId, dateDebut) { const currentYear = new Date().getFullYear(); const previousYear = currentYear - 1; const nextYear = currentYear + 1; let joursRestants = nombreJours; const deductions = []; const dateDemandeObj = new Date(dateDebut); const demandeYear = dateDemandeObj.getFullYear(); const demandeMonth = dateDemandeObj.getMonth() + 1; // Déterminer le type de congé const [typeRow] = await conn.query('SELECT Nom FROM TypeConge WHERE Id = ?', [typeCongeId]); const typeNom = typeRow[0].Nom; const isCP = typeNom === 'Congé payé'; // Vérifier si demande pour N+1 let useN1 = false; if (isCP) { useN1 = (demandeYear === nextYear && demandeMonth >= 6) || (demandeYear === nextYear + 1 && demandeMonth <= 5); } else { useN1 = demandeYear === nextYear; } console.log(`\n💰 Déduction ${typeNom}: ${nombreJours}j (useN1: ${useN1})`); if (useN1) { // ORDRE N+1 : N+1 anticipé → N anticipé → N actuel → N-1 // 1. N+1 Anticipé (priorité absolue) const compteurN1Anticipe = await conn.query( `SELECT Id, SoldeAnticipe FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, nextYear] ); if (compteurN1Anticipe.length === 0 && joursRestants > 0) { // Créer le compteur N+1 si inexistant await conn.query( `INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, SoldeAnticipe, IsAnticipe) VALUES (?, ?, ?, 0, 0, 0, 0, 0)`, [collaborateurId, typeCongeId, nextYear] ); } // Récupérer à nouveau après création const compteurN1A = await conn.query( `SELECT Id, SoldeAnticipe FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, nextYear] ); if (compteurN1A.length > 0) { const aDeduire = Math.min(joursRestants, joursRestants); // Tous les jours restants if (aDeduire > 0) { await conn.query( `UPDATE CompteurConges SET SoldeAnticipe = SoldeAnticipe + ?, IsAnticipe = 1 WHERE Id = ?`, [aDeduire, compteurN1A[0].Id] ); await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'N+1 Anticipé', ?)`, [demandeCongeId, typeCongeId, nextYear, aDeduire] ); deductions.push({ annee: nextYear, type: 'N+1 Anticipé', joursUtilises: aDeduire }); joursRestants -= aDeduire; console.log(`✓ N+1 Anticipé: ${aDeduire}j - reste: ${joursRestants}j`); } } // 2. N anticipé if (joursRestants > 0) { const [compteurN_Anticipe] = await conn.query(` SELECT Id, SoldeAnticipe FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, currentYear]); if (compteurN_Anticipe.length > 0) { const soldeNA = parseFloat(compteurN_Anticipe[0].SoldeAnticipe || 0); const aDeduire = Math.min(soldeNA, joursRestants); if (aDeduire > 0) { await conn.query(` UPDATE CompteurConges SET SoldeAnticipe = CASE WHEN (SoldeAnticipe - ?) < 0 THEN 0 ELSE (SoldeAnticipe - ?) END WHERE Id = ? `, [aDeduire, compteurN_Anticipe[0].Id]); await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'N Anticipé', ?) `, [demandeCongeId, typeCongeId, currentYear, aDeduire]); deductions.push({ annee: currentYear, type: 'N Anticipé', joursUtilises: aDeduire, soldeAvant: soldeNA }); joursRestants -= aDeduire; console.log(` ✓ N Anticipé: ${aDeduire}j (reste: ${joursRestants}j)`); } } } // 3. N actuel if (joursRestants > 0) { const [compteurN] = await conn.query(` SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, currentYear]); if (compteurN.length > 0) { const soldeN = parseFloat(compteurN[0].Solde) - parseFloat(compteurN[0].SoldeReporte || 0); const aDeduire = Math.min(soldeN, joursRestants); if (aDeduire > 0) { await conn.query(` UPDATE CompteurConges SET Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END WHERE Id = ? `, [aDeduire, compteurN[0].Id]); await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Année N', ?) `, [demandeCongeId, typeCongeId, currentYear, aDeduire]); deductions.push({ annee: currentYear, type: 'Année N', joursUtilises: aDeduire, soldeAvant: soldeN }); joursRestants -= aDeduire; console.log(` ✓ Année N: ${aDeduire}j (reste: ${joursRestants}j)`); } } } // 4. N-1 reporté if (joursRestants > 0) { const [compteurN1] = await conn.query(` SELECT Id, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, previousYear]); if (compteurN1.length > 0) { const soldeN1 = parseFloat(compteurN1[0].SoldeReporte || 0); const aDeduire = Math.min(soldeN1, joursRestants); if (aDeduire > 0) { await conn.query(` UPDATE CompteurConges SET SoldeReporte = CASE WHEN (SoldeReporte - ?) < 0 THEN 0 ELSE (SoldeReporte - ?) END, Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END WHERE Id = ? `, [aDeduire, aDeduire, compteurN1[0].Id]); await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Reporté N-1', ?) `, [demandeCongeId, typeCongeId, previousYear, aDeduire]); deductions.push({ annee: previousYear, type: 'Reporté N-1', joursUtilises: aDeduire, soldeAvant: soldeN1 }); joursRestants -= aDeduire; console.log(` ✓ Reporté N-1: ${aDeduire}j (reste: ${joursRestants}j)`); } } } } else { // ORDRE NORMAL : N-1 → N → N anticipé // 1. Reporté N-1 const [compteurN1] = await conn.query(` SELECT Id, SoldeReporte, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, previousYear]); if (compteurN1.length > 0 && compteurN1[0].SoldeReporte > 0) { const soldeN1 = parseFloat(compteurN1[0].SoldeReporte); const aDeduire = Math.min(soldeN1, joursRestants); if (aDeduire > 0) { await conn.query(` UPDATE CompteurConges SET SoldeReporte = CASE WHEN (SoldeReporte - ?) < 0 THEN 0 ELSE (SoldeReporte - ?) END, Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END WHERE Id = ? `, [aDeduire, aDeduire, compteurN1[0].Id]); await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Reporté N-1', ?) `, [demandeCongeId, typeCongeId, previousYear, aDeduire]); deductions.push({ annee: previousYear, type: 'Reporté N-1', joursUtilises: aDeduire, soldeAvant: soldeN1 }); joursRestants -= aDeduire; console.log(` ✓ Reporté N-1: ${aDeduire}j (reste: ${joursRestants}j)`); } } // 2. Année N if (joursRestants > 0) { const [compteurN] = await conn.query(` SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, currentYear]); if (compteurN.length > 0) { const soldeN = parseFloat(compteurN[0].Solde) - parseFloat(compteurN[0].SoldeReporte || 0); const aDeduire = Math.min(soldeN, joursRestants); if (aDeduire > 0) { await conn.query(` UPDATE CompteurConges SET Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END WHERE Id = ? `, [aDeduire, compteurN[0].Id]); await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Année N', ?) `, [demandeCongeId, typeCongeId, currentYear, aDeduire]); deductions.push({ annee: currentYear, type: 'Année N', joursUtilises: aDeduire, soldeAvant: soldeN }); joursRestants -= aDeduire; console.log(` ✓ Année N: ${aDeduire}j (reste: ${joursRestants}j)`); } } } // 3. N anticipé if (joursRestants > 0) { const [compteurN_Anticipe] = await conn.query(` SELECT Id, SoldeAnticipe FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, currentYear]); if (compteurN_Anticipe.length > 0) { const soldeNA = parseFloat(compteurN_Anticipe[0].SoldeAnticipe || 0); const aDeduire = Math.min(soldeNA, joursRestants); if (aDeduire > 0) { await conn.query(` UPDATE CompteurConges SET SoldeAnticipe = CASE WHEN (SoldeAnticipe - ?) < 0 THEN 0 ELSE (SoldeAnticipe - ?) END WHERE Id = ? `, [aDeduire, compteurN_Anticipe[0].Id]); await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'N Anticipé', ?) `, [demandeCongeId, typeCongeId, currentYear, aDeduire]); deductions.push({ annee: currentYear, type: 'N Anticipé', joursUtilises: aDeduire, soldeAvant: soldeNA }); joursRestants -= aDeduire; console.log(` ✓ N Anticipé: ${aDeduire}j (reste: ${joursRestants}j)`); } } } } return { success: joursRestants === 0, joursDeduitsTotal: nombreJours - joursRestants, joursNonDeduits: joursRestants, details: deductions, useN1: useN1 }; } /** * Met à jour les soldes anticipés pour un collaborateur * Appelée après chaque mise à jour de compteur ou soumission de demande */ /** * Met à jour les soldes anticipés pour un collaborateur * Appelée après chaque mise à jour de compteur ou soumission de demande */ async function updateSoldeAnticipe(conn, collaborateurId) { const today = new Date(); today.setHours(0, 0, 0, 0); const currentYear = today.getFullYear(); console.log(`🔄 Mise à jour soldes anticipés pour collaborateur ${collaborateurId}`); const collab = await conn.query(` SELECT DateEntree, TypeContrat, role FROM CollaborateurAD WHERE id = ? `, [collaborateurId]); if (collab.length === 0) { console.log(`❌ Collaborateur non trouvé`); return; } const dateEntree = collab[0].DateEntree; const typeContrat = collab[0].TypeContrat || '37h'; const isApprenti = collab[0].role === 'Apprenti'; // ======================================== // CP ANTICIPÉ // ======================================== const cpType = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['Congé payé']); if (cpType.length > 0) { const cpAnticipe = calculerAcquisitionCPAnticipee(today, dateEntree); // Vérifier si le compteur existe const compteurCP = await conn.query(` SELECT Id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, cpType[0].Id, currentYear]); if (compteurCP.length > 0) { await conn.query(` UPDATE CompteurConges SET SoldeAnticipe = ?, DerniereMiseAJour = GETDATE() WHERE Id = ? `, [cpAnticipe, compteurCP[0].Id]); } else { // Créer le compteur s'il n'existe pas const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree); await conn.query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, SoldeAnticipe, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, ?, GETDATE()) `, [collaborateurId, cpType[0].Id, currentYear, acquisCP, acquisCP, cpAnticipe]); } console.log(` ✓ CP Anticipé: ${cpAnticipe.toFixed(2)}j`); } // ======================================== // RTT ANTICIPÉ // ======================================== if (!isApprenti) { const rttType = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['RTT']); if (rttType.length > 0) { const rttAnticipe = await calculerAcquisitionRTTAnticipee(conn, collaborateurId, today); // Vérifier si le compteur existe const compteurRTT = await conn.query(` SELECT Id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttType[0].Id, currentYear]); if (compteurRTT.length > 0) { await conn.query(` UPDATE CompteurConges SET SoldeAnticipe = ?, DerniereMiseAJour = GETDATE() WHERE Id = ? `, [rttAnticipe, compteurRTT[0].Id]); } else { // Créer le compteur s'il n'existe pas const rttData = await calculerAcquisitionRTT_Smart(conn, collaborateurId, today); await conn.query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, SoldeAnticipe, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, ?, GETDATE()) `, [collaborateurId, rttType[0].Id, currentYear, rttData.acquisition, rttData.acquisition, rttAnticipe]); } console.log(` ✓ RTT Anticipé: ${rttAnticipe.toFixed(2)}j`); } } console.log(` ✅ Soldes anticipés mis à jour`); } /** * GET /getSoldesAnticipes * Retourne les soldes actuels ET anticipés pour un collaborateur */ app.get('/api/getSoldesAnticipes', async (req, res) => { try { const userIdParam = req.query.user_id; const dateRefParam = req.query.date_reference; if (!userIdParam) { return res.json({ success: false, message: 'ID utilisateur manquant' }); } const conn = await pool.getConnection(); // Déterminer l'ID const isUUID = userIdParam.length > 10 && userIdParam.includes('-'); const userQuery = ` SELECT ca.id, ca.prenom, ca.nom, ca.DateEntree, ca.TypeContrat, ca.role FROM CollaborateurAD ca WHERE ${isUUID ? 'ca.entraUserId' : 'ca.id'} = ? AND (ca.Actif = 1 OR ca.Actif IS NULL) `; const [userInfo] = await conn.query(userQuery, [userIdParam]); if (userInfo.length === 0) { conn.release(); return res.json({ success: false, message: 'Utilisateur non trouvé' }); } const user = userInfo[0]; const userId = user.id; const dateEntree = user.DateEntree; const typeContrat = user.TypeContrat || '37h'; const isApprenti = user.role === 'Apprenti'; const dateReference = dateRefParam ? new Date(dateRefParam) : new Date(); dateReference.setHours(0, 0, 0, 0); const currentYear = dateReference.getFullYear(); // ===== CP ===== const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']); let cpData = { acquis: 0, solde: 0, anticipe: 0, totalDisponible: 0 }; if (cpType.length > 0) { // Acquisition actuelle const acquisCP = calculerAcquisitionCP_Smart(dateReference, dateEntree); // Anticipé const anticipeCP = calculerAcquisitionCPAnticipee(dateReference, dateEntree); // Solde en base (avec consommations déduites) const [compteurCP] = await conn.query(` SELECT Solde, SoldeReporte, SoldeAnticipe FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [userId, cpType[0].Id, currentYear]); // Reporté N-1 const [compteurCPN1] = await conn.query(` SELECT Solde as SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [userId, cpType[0].Id, currentYear - 1]); const soldeN1 = compteurCPN1.length > 0 ? parseFloat(compteurCPN1[0].SoldeReporte || 0) : 0; const soldeN = compteurCP.length > 0 ? parseFloat(compteurCP[0].Solde || 0) : acquisCP; cpData = { acquis: parseFloat(acquisCP.toFixed(2)), soldeN1: parseFloat(soldeN1.toFixed(2)), soldeN: parseFloat((soldeN - soldeN1).toFixed(2)), soldeTotal: parseFloat(soldeN.toFixed(2)), anticipe: parseFloat(anticipeCP.toFixed(2)), totalDisponible: parseFloat((soldeN + anticipeCP).toFixed(2)) }; } // ===== RTT ===== let rttData = { acquis: 0, solde: 0, anticipe: 0, totalDisponible: 0, isApprenti: isApprenti }; if (!isApprenti) { const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']); if (rttType.length > 0) { // Acquisition actuelle const rttCalc = await calculerAcquisitionRTT_Smart(conn, userId, dateReference); const acquisRTT = rttCalc.acquisition; // Anticipé const anticipeRTT = await calculerAcquisitionRTTAnticipee(conn, userId, dateReference); // Solde en base const [compteurRTT] = await conn.query(` SELECT Solde, SoldeAnticipe FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [userId, rttType[0].Id, currentYear]); const soldeRTT = compteurRTT.length > 0 ? parseFloat(compteurRTT[0].Solde || 0) : acquisRTT; rttData = { acquis: parseFloat(acquisRTT.toFixed(2)), solde: parseFloat(soldeRTT.toFixed(2)), anticipe: parseFloat(anticipeRTT.toFixed(2)), totalDisponible: parseFloat((soldeRTT + anticipeRTT).toFixed(2)), config: rttCalc.config, typeContrat: typeContrat }; } } // ===== RÉCUP ===== const [recupType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Récupération']); let recupData = { solde: 0 }; if (recupType.length > 0) { const [compteurRecup] = await conn.query(` SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [userId, recupType[0].Id, currentYear]); recupData.solde = compteurRecup.length > 0 ? parseFloat(compteurRecup[0].Solde || 0) : 0; } conn.release(); res.json({ success: true, user: { id: user.id, nom: `${user.prenom} ${user.nom}`, typeContrat: typeContrat, dateEntree: dateEntree ? formatDateWithoutUTC(dateEntree) : null }, dateReference: dateReference.toISOString().split('T')[0], cp: cpData, rtt: rttData, recup: recupData, totalGeneral: { disponibleActuel: parseFloat((cpData.soldeTotal + rttData.solde + recupData.solde).toFixed(2)), disponibleAvecAnticipe: parseFloat((cpData.totalDisponible + rttData.totalDisponible + recupData.solde).toFixed(2)) } }); } catch (error) { console.error('Erreur getSoldesAnticipes:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); /** * GET /getCongesAnticipes * Calcule les congés anticipés disponibles pour un collaborateur */ app.get('/api/getCongesAnticipes', async (req, res) => { try { const userIdParam = req.query.user_id; if (!userIdParam) { return res.json({ success: false, message: 'ID utilisateur manquant' }); } const conn = await pool.getConnection(); // Déterminer l'ID (UUID ou numérique) const isUUID = userIdParam.length > 10 && userIdParam.includes('-'); const userQuery = ` SELECT ca.id, ca.prenom, ca.nom, ca.email, ca.DateEntree, ca.TypeContrat, ca.role, ca.CampusId FROM CollaborateurAD ca WHERE ${isUUID ? 'ca.entraUserId' : 'ca.id'} = ? AND (ca.Actif = 1 OR ca.Actif IS NULL) `; const [userInfo] = await conn.query(userQuery, [userIdParam]); if (userInfo.length === 0) { conn.release(); return res.json({ success: false, message: 'Utilisateur non trouvé' }); } const user = userInfo[0]; const userId = user.id; const dateEntree = user.DateEntree; const typeContrat = user.TypeContrat || '37h'; const today = new Date(); const currentYear = today.getFullYear(); const finAnnee = new Date(currentYear, 11, 31); // 31 décembre // ======================================== // CALCUL CP (Congés Payés) // ======================================== // Acquisition actuelle const acquisActuelleCP = calculerAcquisitionCP_Smart(today, dateEntree); // Acquisition prévue à la fin de l'exercice (31 mai N+1) const finExerciceCP = new Date(currentYear + 1, 4, 31); // 31 mai N+1 const acquisTotaleCP = calculerAcquisitionCP_Smart(finExerciceCP, dateEntree); // Récupérer le solde actuel const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']); let soldeActuelCP = 0; let dejaPrisCP = 0; if (cpType.length > 0) { const [compteurCP] = await conn.query(` SELECT Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [userId, cpType[0].Id, currentYear]); if (compteurCP.length > 0) { const total = parseFloat(compteurCP[0].Total || 0); soldeActuelCP = parseFloat(compteurCP[0].Solde || 0); dejaPrisCP = total - (soldeActuelCP - parseFloat(compteurCP[0].SoldeReporte || 0)); } } // Calculer le potentiel anticipé pour CP const acquisRestanteCP = acquisTotaleCP - acquisActuelleCP; const anticipePossibleCP = Math.max(0, acquisRestanteCP); const limiteAnticipeCP = Math.min(anticipePossibleCP, 25 - dejaPrisCP); // ======================================== // CALCUL RTT // ======================================== let anticipePossibleRTT = 0; let limiteAnticipeRTT = 0; let soldeActuelRTT = 0; let dejaPrisRTT = 0; let acquisActuelleRTT = 0; let acquisTotaleRTT = 0; if (user.role !== 'Apprenti') { // Acquisition actuelle const rttDataActuel = await calculerAcquisitionRTT_Smart(conn, userId, today); acquisActuelleRTT = rttDataActuel.acquisition; // Acquisition prévue à la fin de l'année const rttDataTotal = await calculerAcquisitionRTT_Smart(conn, userId, finAnnee); acquisTotaleRTT = rttDataTotal.acquisition; // Récupérer le solde actuel const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']); if (rttType.length > 0) { const [compteurRTT] = await conn.query(` SELECT Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [userId, rttType[0].Id, currentYear]); if (compteurRTT.length > 0) { const total = parseFloat(compteurRTT[0].Total || 0); soldeActuelRTT = parseFloat(compteurRTT[0].Solde || 0); dejaPrisRTT = total - soldeActuelRTT; } } // Calculer le potentiel anticipé pour RTT const acquisRestanteRTT = acquisTotaleRTT - acquisActuelleRTT; anticipePossibleRTT = Math.max(0, acquisRestanteRTT); const maxRTT = typeContrat === 'forfait_jour' ? 12 : 10; limiteAnticipeRTT = Math.min(anticipePossibleRTT, maxRTT - dejaPrisRTT); } conn.release(); res.json({ success: true, user: { id: user.id, nom: `${user.prenom} ${user.nom}`, email: user.email, typeContrat: typeContrat, dateEntree: dateEntree ? formatDateWithoutUTC(dateEntree) : null }, dateReference: today.toISOString().split('T')[0], congesPayes: { acquisActuelle: parseFloat(acquisActuelleCP.toFixed(2)), acquisTotalePrevu: parseFloat(acquisTotaleCP.toFixed(2)), acquisRestante: parseFloat((acquisTotaleCP - acquisActuelleCP).toFixed(2)), soldeActuel: parseFloat(soldeActuelCP.toFixed(2)), dejaPris: parseFloat(dejaPrisCP.toFixed(2)), anticipePossible: parseFloat(anticipePossibleCP.toFixed(2)), limiteAnticipe: parseFloat(limiteAnticipeCP.toFixed(2)), totalDisponible: parseFloat((soldeActuelCP + limiteAnticipeCP).toFixed(2)), message: limiteAnticipeCP > 0 ? `Vous pouvez poser jusqu'à ${limiteAnticipeCP.toFixed(1)} jours de CP en anticipé` : "Vous avez atteint la limite d'anticipation pour les CP" }, rtt: user.role !== 'Apprenti' ? { acquisActuelle: parseFloat(acquisActuelleRTT.toFixed(2)), acquisTotalePrevu: parseFloat(acquisTotaleRTT.toFixed(2)), acquisRestante: parseFloat((acquisTotaleRTT - acquisActuelleRTT).toFixed(2)), soldeActuel: parseFloat(soldeActuelRTT.toFixed(2)), dejaPris: parseFloat(dejaPrisRTT.toFixed(2)), anticipePossible: parseFloat(anticipePossibleRTT.toFixed(2)), limiteAnticipe: parseFloat(limiteAnticipeRTT.toFixed(2)), totalDisponible: parseFloat((soldeActuelRTT + limiteAnticipeRTT).toFixed(2)), message: limiteAnticipeRTT > 0 ? `Vous pouvez poser jusqu'à ${limiteAnticipeRTT.toFixed(1)} jours de RTT en anticipé` : "Vous avez atteint la limite d'anticipation pour les RTT" } : null, regles: { cpMaxAnnuel: 25, rttMaxAnnuel: typeContrat === 'forfait_jour' ? 12 : 10, description: "Les congés anticipés sont basés sur l'acquisition prévue jusqu'à la fin de l'exercice/année" } }); } catch (error) { console.error('Erreur getCongesAnticipes:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); /** * Calcule l'acquisition CP ANTICIPÉE (ce qui reste à acquérir jusqu'à fin d'exercice) */ function calculerAcquisitionCPAnticipee(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️⃣ Déterminer la fin de l'exercice CP (31/05) let finExercice; if (mois >= 6) { finExercice = new Date(annee + 1, 4, 31); // 31/05/N+1 } else { finExercice = new Date(annee, 4, 31); // 31/05/N } finExercice.setHours(0, 0, 0, 0); // 2️⃣ Calculer l'acquisition actuelle const acquisActuelle = calculerAcquisitionCP_Smart(d, dateEntree); // 3️⃣ Calculer l'acquisition totale à fin d'exercice const acquisTotaleFinExercice = calculerAcquisitionCP_Smart(finExercice, dateEntree); // 4️⃣ Anticipée = Totale - Actuelle (plafonnée à 25) const acquisAnticipee = Math.min(25, acquisTotaleFinExercice) - acquisActuelle; return Math.max(0, Math.round(acquisAnticipee * 100) / 100); } /** * Calcule l'acquisition RTT ANTICIPÉE (ce qui reste à acquérir jusqu'à fin d'année) */ async function calculerAcquisitionRTTAnticipee(conn, collaborateurId, dateReference = new Date()) { const d = new Date(dateReference); d.setHours(0, 0, 0, 0); const annee = d.getFullYear(); // 1️⃣ Récupérer les infos du collaborateur const [collabInfo] = await conn.query( `SELECT TypeContrat, DateEntree, role FROM CollaborateurAD WHERE id = ?`, [collaborateurId] ); if (collabInfo.length === 0) { return 0; } const typeContrat = collabInfo[0].TypeContrat || '37h'; const isApprenti = collabInfo[0].role === 'Apprenti'; // 2️⃣ Apprentis = pas de RTT if (isApprenti) { return 0; } // 3️⃣ Récupérer la configuration RTT const config = await getConfigurationRTT(conn, annee, typeContrat); // 4️⃣ Calculer l'acquisition actuelle const rttActuel = await calculerAcquisitionRTT_Smart(conn, collaborateurId, d); const acquisActuelle = rttActuel.acquisition; // 5️⃣ Calculer l'acquisition totale à fin d'année (31/12) const finAnnee = new Date(annee, 11, 31); finAnnee.setHours(0, 0, 0, 0); const rttFinAnnee = await calculerAcquisitionRTT_Smart(conn, collaborateurId, finAnnee); const acquisTotaleFinAnnee = rttFinAnnee.acquisition; // 6️⃣ Anticipée = Totale - Actuelle (plafonnée au max annuel) const acquisAnticipee = Math.min(config.joursAnnuels, acquisTotaleFinAnnee) - acquisActuelle; return Math.max(0, Math.round(acquisAnticipee * 100) / 100); } app.get('/api/getStatistiquesCompteurs', async (req, res) => { try { const conn = await pool.getConnection(); const currentYear = new Date().getFullYear(); const [totalCollabs] = await conn.query( 'SELECT COUNT(*) as total FROM CollaborateurAD WHERE actif = 1 OR actif IS NULL' ); const [statsTypeContrat] = await conn.query(` SELECT TypeContrat, COUNT(*) as nombre, GROUP_CONCAT(CONCAT(prenom, ' ', nom) SEPARATOR ', ') as noms FROM CollaborateurAD WHERE actif = 1 OR actif IS NULL GROUP BY TypeContrat `); const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']); let statsCP = { total_acquis: 0, total_solde: 0, moyenne_utilisation: 0 }; if (cpType.length > 0) { const [cpStats] = await conn.query(` SELECT SUM(Total) as total_acquis, SUM(Solde) as total_solde, AVG(CASE WHEN Total > 0 THEN ((Total - Solde) / Total) * 100 ELSE 0 END) as moyenne_utilisation FROM CompteurConges WHERE TypeCongeId = ? AND Annee = ? `, [cpType[0].Id, currentYear]); if (cpStats.length > 0) { statsCP = { total_acquis: parseFloat((cpStats[0].total_acquis || 0).toFixed(2)), total_solde: parseFloat((cpStats[0].total_solde || 0).toFixed(2)), moyenne_utilisation: parseFloat((cpStats[0].moyenne_utilisation || 0).toFixed(1)) }; } } const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']); let statsRTT = { total_acquis: 0, total_solde: 0, moyenne_utilisation: 0 }; if (rttType.length > 0) { const [rttStats] = await conn.query(` SELECT SUM(Total) as total_acquis, SUM(Solde) as total_solde, AVG(CASE WHEN Total > 0 THEN ((Total - Solde) / Total) * 100 ELSE 0 END) as moyenne_utilisation FROM CompteurConges WHERE TypeCongeId = ? AND Annee = ? `, [rttType[0].Id, currentYear]); if (rttStats.length > 0) { statsRTT = { total_acquis: parseFloat((rttStats[0].total_acquis || 0).toFixed(2)), total_solde: parseFloat((rttStats[0].total_solde || 0).toFixed(2)), moyenne_utilisation: parseFloat((rttStats[0].moyenne_utilisation || 0).toFixed(1)) }; } } conn.release(); res.json({ success: true, annee: currentYear, statistiques: { collaborateurs: { total: totalCollabs[0].total, par_type_contrat: statsTypeContrat }, conges_payes: statsCP, rtt: statsRTT } }); } catch (error) { console.error('Erreur getStatistiquesCompteurs:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); async function hasCompteRenduAccess(userId) { try { const conn = await pool.getConnection(); const [user] = await conn.query(` SELECT TypeContrat, role FROM CollaborateurAD WHERE id = ? `, [userId]); conn.release(); if (!user.length) return false; const userInfo = user[0]; // Accès si : // 1. TypeContrat = 'forfait_jour' // 2. role = 'Directeur Campus' ou 'Directrice Campus' // 3. role = 'RH' ou 'Admin' return ( userInfo.TypeContrat === 'forfait_jour' || userInfo.role === 'Directeur Campus' || userInfo.role === 'Directrice Campus' || userInfo.role === 'RH' || userInfo.role === 'Admin' ); } catch (error) { console.error('Erreur vérification accès:', error); return false; } } // Récupérer les jours du mois // GET - Récupérer les données du compte-rendu app.get('/api/getCompteRenduActivites', async (req, res) => { const { user_id, annee, mois } = req.query; try { // Vérifier l'accès const hasAccess = await hasCompteRenduAccess(user_id); if (!hasAccess) { return res.json({ success: false, message: 'Accès réservé aux collaborateurs en forfait jour et aux directeurs de campus' }); } const conn = await pool.getConnection(); const [jours] = await conn.query(` SELECT id, CollaborateurADId, Annee, Mois, DATE_FORMAT(JourDate, '%Y-%m-%d') as JourDate, JourTravaille, ReposQuotidienRespect, ReposHebdomadaireRespect, CommentaireRepos, Verrouille, DateSaisie, SaisiePar FROM CompteRenduActivites WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ? ORDER BY JourDate `, [user_id, annee, mois]); console.log('🔍 Backend - Jours trouvés:', jours.length); if (jours.length > 0) { console.log('📅 Premier jour:', jours[0].JourDate, 'Type:', typeof jours[0].JourDate); } const [mensuel] = await conn.query(` SELECT * FROM CompteRenduMensuel WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ? `, [user_id, annee, mois]); conn.release(); res.json({ success: true, jours: jours, mensuel: mensuel[0] || null }); } catch (error) { console.error('Erreur getCompteRenduActivites:', error); res.status(500).json({ success: false, message: error.message }); } }); // POST - Sauvegarder un jour avec AUTO-VERROUILLAGE app.post('/api/saveCompteRenduJour', async (req, res) => { const { user_id, date, jour_travaille, repos_quotidien, repos_hebdo, commentaire, rh_override } = req.body; try { const conn = await pool.getConnection(); await conn.beginTransaction(); const dateJour = new Date(date); const aujourdhui = new Date(); aujourdhui.setHours(0, 0, 0, 0); dateJour.setHours(0, 0, 0, 0); // Bloquer saisie du jour actuel (il faut attendre le lendemain) if (dateJour >= aujourdhui) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Vous ne pouvez pas saisir le jour actuel. Veuillez attendre demain.' }); } const annee = dateJour.getFullYear(); const mois = dateJour.getMonth() + 1; // Vérifier si le JOUR est déjà verrouillé (pas le mois entier) const [jourExistant] = await conn.query( 'SELECT Id, Verrouille FROM CompteRenduActivites WHERE CollaborateurADId = ? AND JourDate = ?', [user_id, date] ); if (jourExistant.length > 0 && jourExistant[0].Verrouille && !rh_override) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Ce jour est verrouillé - Contactez les RH pour modification' }); } // Vérifier commentaire obligatoire if (!repos_quotidien || !repos_hebdo) { if (!commentaire || commentaire.trim() === '') { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Commentaire obligatoire en cas de non-respect des repos' }); } } // ⭐ FIX: Utiliser IF EXISTS pattern au lieu de ON DUPLICATE KEY UPDATE if (jourExistant.length > 0) { // UPDATE await conn.query(` UPDATE CompteRenduActivites SET JourTravaille = ?, ReposQuotidienRespect = ?, ReposHebdomadaireRespect = ?, CommentaireRepos = ?, SaisiePar = ?, Verrouille = 1 WHERE CollaborateurADId = ? AND JourDate = ? `, [jour_travaille, repos_quotidien, repos_hebdo, commentaire, user_id, user_id, date]); } else { // INSERT await conn.query(` INSERT INTO CompteRenduActivites (CollaborateurADId, Annee, Mois, JourDate, JourTravaille, ReposQuotidienRespect, ReposHebdomadaireRespect, CommentaireRepos, DateSaisie, SaisiePar, Verrouille) VALUES (?, ?, ?, ?, ?, ?, ?, ?, GETDATE(), ?, 1) `, [user_id, annee, mois, date, jour_travaille, repos_quotidien, repos_hebdo, commentaire, user_id]); } // Mettre à jour les statistiques mensuelles (SANS verrouiller le mois) const [stats] = await conn.query(` SELECT COUNT(*) as nbJours, SUM(CASE WHEN ReposQuotidienRespect = 0 THEN 1 ELSE 0 END) as nbNonRespectQuotidien, SUM(CASE WHEN ReposHebdomadaireRespect = 0 THEN 1 ELSE 0 END) as nbNonRespectHebdo FROM CompteRenduActivites WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ? AND JourTravaille = 1 `, [user_id, annee, mois]); // ⭐ FIX: Vérifier si le mensuel existe const [mensuelExistant] = await conn.query(` SELECT Id FROM CompteRenduMensuel WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ? `, [user_id, annee, mois]); if (mensuelExistant.length > 0) { await conn.query(` UPDATE CompteRenduMensuel SET NbJoursTravailles = ?, NbJoursNonRespectsReposQuotidien = ?, NbJoursNonRespectsReposHebdo = ?, DateValidation = GETDATE() WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ? `, [stats[0].nbJours, stats[0].nbNonRespectQuotidien, stats[0].nbNonRespectHebdo, user_id, annee, mois]); } else { await conn.query(` INSERT INTO CompteRenduMensuel (CollaborateurADId, Annee, Mois, NbJoursTravailles, NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo, Statut, DateValidation) VALUES (?, ?, ?, ?, ?, ?, 'En cours', GETDATE()) `, [user_id, annee, mois, stats[0].nbJours, stats[0].nbNonRespectQuotidien, stats[0].nbNonRespectHebdo]); } await conn.commit(); conn.release(); res.json({ success: true, message: 'Jour enregistré et verrouillé', verrouille: true }); } catch (error) { console.error('❌ Erreur saveCompteRenduJour:', error); res.status(500).json({ success: false, message: error.message }); } }); // POST - Saisie en masse avec AUTO-VERROUILLAGE app.post('/api/saveCompteRenduMasse', async (req, res) => { const { user_id, annee, mois, jours, rh_override } = req.body; try { const conn = await pool.getConnection(); await conn.beginTransaction(); let count = 0; let blocked = 0; for (const jour of jours) { const dateJour = new Date(jour.date); const aujourdhui = new Date(); aujourdhui.setHours(0, 0, 0, 0); dateJour.setHours(0, 0, 0, 0); // Bloquer le jour actuel if (dateJour >= aujourdhui) { blocked++; continue; } // Vérifier si déjà verrouillé const [jourExistant] = await conn.query( 'SELECT Id, Verrouille FROM CompteRenduActivites WHERE CollaborateurADId = ? AND JourDate = ?', [user_id, jour.date] ); if (jourExistant.length > 0 && jourExistant[0].Verrouille && !rh_override) { blocked++; continue; } // ⭐ FIX: Utiliser IF EXISTS pattern au lieu de ON DUPLICATE KEY UPDATE if (jourExistant.length > 0) { // UPDATE await conn.query(` UPDATE CompteRenduActivites SET JourTravaille = ?, ReposQuotidienRespect = ?, ReposHebdomadaireRespect = ?, CommentaireRepos = ?, SaisiePar = ?, Verrouille = 1 WHERE CollaborateurADId = ? AND JourDate = ? `, [jour.jour_travaille, jour.repos_quotidien, jour.repos_hebdo, jour.commentaire || null, user_id, user_id, jour.date]); } else { // INSERT await conn.query(` INSERT INTO CompteRenduActivites (CollaborateurADId, Annee, Mois, JourDate, JourTravaille, ReposQuotidienRespect, ReposHebdomadaireRespect, CommentaireRepos, DateSaisie, SaisiePar, Verrouille) VALUES (?, ?, ?, ?, ?, ?, ?, ?, GETDATE(), ?, 1) `, [user_id, annee, mois, jour.date, jour.jour_travaille, jour.repos_quotidien, jour.repos_hebdo, jour.commentaire || null, user_id]); } count++; } // Mettre à jour statistiques mensuelles const [stats] = await conn.query(` SELECT COUNT(*) as nbJours, SUM(CASE WHEN ReposQuotidienRespect = 0 THEN 1 ELSE 0 END) as nbNonRespectQuotidien, SUM(CASE WHEN ReposHebdomadaireRespect = 0 THEN 1 ELSE 0 END) as nbNonRespectHebdo FROM CompteRenduActivites WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ? AND JourTravaille = 1 `, [user_id, annee, mois]); // ⭐ FIX: Vérifier si le mensuel existe const [mensuelExistant] = await conn.query(` SELECT Id FROM CompteRenduMensuel WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ? `, [user_id, annee, mois]); if (mensuelExistant.length > 0) { await conn.query(` UPDATE CompteRenduMensuel SET NbJoursTravailles = ?, NbJoursNonRespectsReposQuotidien = ?, NbJoursNonRespectsReposHebdo = ?, DateValidation = GETDATE() WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ? `, [stats[0].nbJours, stats[0].nbNonRespectQuotidien, stats[0].nbNonRespectHebdo, user_id, annee, mois]); } else { await conn.query(` INSERT INTO CompteRenduMensuel (CollaborateurADId, Annee, Mois, NbJoursTravailles, NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo, Statut, DateValidation) VALUES (?, ?, ?, ?, ?, ?, 'En cours', GETDATE()) `, [user_id, annee, mois, stats[0].nbJours, stats[0].nbNonRespectQuotidien, stats[0].nbNonRespectHebdo]); } await conn.commit(); conn.release(); res.json({ success: true, count: count, blocked: blocked, message: `${count} jours enregistrés${blocked > 0 ? `, ${blocked} ignorés (jour actuel ou déjà verrouillés)` : ''}` }); } catch (error) { console.error('❌ Erreur saisie masse:', error); res.status(500).json({ success: false, message: error.message }); } }); app.post('/api/deverrouillerJour', async (req, res) => { const { user_id, date, rh_user_id } = req.body; try { const conn = await pool.getConnection(); const [rhUser] = await conn.query( 'SELECT role FROM CollaborateurAD WHERE id = ?', [rh_user_id] ); if (!rhUser.length || (rhUser[0].role !== 'RH' && rhUser[0].role !== 'Admin')) { conn.release(); return res.json({ success: false, message: 'Action réservée aux RH' }); } await conn.query(` UPDATE CompteRenduActivites SET Verrouille = FALSE WHERE CollaborateurADId = ? AND JourDate = ? `, [user_id, date]); conn.release(); res.json({ success: true }); } catch (error) { console.error('❌ Erreur déverrouillage jour:', error); res.status(500).json({ success: false, message: error.message }); } }); // POST - Verrouiller (RH uniquement) app.post('/api/verrouillerCompteRendu', async (req, res) => { const { user_id, annee, mois, rh_user_id } = req.body; try { const conn = await pool.getConnection(); // Vérifier que l'utilisateur est RH const [rhUser] = await conn.query( 'SELECT role FROM CollaborateurAD WHERE id = ?', [rh_user_id] ); if (!rhUser.length || (rhUser[0].role !== 'RH' && rhUser[0].role !== 'Admin')) { conn.release(); return res.json({ success: false, message: 'Action réservée aux RH' }); } await conn.query(` UPDATE CompteRenduMensuel SET Verrouille = TRUE, DateModification = GETDATE() WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ? `, [user_id, annee, mois]); conn.release(); res.json({ success: true }); } catch (error) { console.error('Erreur verrouillage:', error); res.status(500).json({ success: false, message: error.message }); } }); // POST - Déverrouiller (RH uniquement) app.post('/api/deverrouillerCompteRendu', async (req, res) => { const { user_id, annee, mois, rh_user_id } = req.body; try { const conn = await pool.getConnection(); // Vérifier que l'utilisateur est RH const [rhUser] = await conn.query( 'SELECT role FROM CollaborateurAD WHERE id = ?', [rh_user_id] ); if (!rhUser.length || (rhUser[0].role !== 'RH' && rhUser[0].role !== 'Admin')) { conn.release(); return res.json({ success: false, message: 'Action réservée aux RH' }); } await conn.query(` UPDATE CompteRenduMensuel SET Verrouille = FALSE, DateDeverrouillage = GETDATE(), DeverrouillePar = ? WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ? `, [rh_user_id, user_id, annee, mois]); conn.release(); res.json({ success: true }); } catch (error) { console.error('Erreur déverrouillage:', error); res.status(500).json({ success: false, message: error.message }); } }); // GET - Stats annuelles app.get('/api/getStatsAnnuelles', async (req, res) => { const { user_id, annee } = req.query; try { const conn = await pool.getConnection(); const [stats] = await conn.query(` SELECT SUM(NbJoursTravailles) as totalJoursTravailles, SUM(NbJoursNonRespectsReposQuotidien) as totalNonRespectQuotidien, SUM(NbJoursNonRespectsReposHebdo) as totalNonRespectHebdo FROM CompteRenduMensuel WHERE CollaborateurADId = ? AND Annee = ? `, [user_id, annee]); conn.release(); res.json({ success: true, stats: stats[0] || { totalJoursTravailles: 0, totalNonRespectQuotidien: 0, totalNonRespectHebdo: 0 } }); } catch (error) { console.error('Erreur stats:', error); res.status(500).json({ success: false, message: error.message }); } }); // GET - Export PDF (RH uniquement) app.get('/api/exportCompteRenduPDF', async (req, res) => { const { user_id, annee, mois } = req.query; try { const conn = await pool.getConnection(); // Récupérer les données du collaborateur const [collab] = await conn.query( 'SELECT prenom, nom, email FROM CollaborateurAD WHERE id = ?', [user_id] ); // Récupérer les jours du mois const [jours] = await conn.query(` SELECT * FROM CompteRenduActivites WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ? ORDER BY JourDate `, [user_id, annee, mois]); // Récupérer le mensuel const [mensuel] = await conn.query(` SELECT * FROM CompteRenduMensuel WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ? `, [user_id, annee, mois]); conn.release(); // TODO: Générer le PDF avec une bibliothèque comme pdfkit ou puppeteer // Pour l'instant, retourner les données JSON res.json({ success: true, collaborateur: collab[0], jours: jours, mensuel: mensuel[0], message: 'Export PDF à implémenter' }); } catch (error) { console.error('Erreur export PDF:', error); res.status(500).json({ success: false, message: error.message }); } }); app.post('/api/addUserFromEntra', async (req, res) => { try { const { email } = req.body; if (!email) { return res.json({ success: false, message: 'Email requis' }); } console.log(`\n🔍 Recherche utilisateur Entra ID: ${email}`); // 1️⃣ Obtenir le token const accessToken = await getGraphToken(); if (!accessToken) { return res.json({ success: false, message: 'Impossible d\'obtenir le token Microsoft' }); } // 2️⃣ Rechercher l'utilisateur dans Entra ID const searchUrl = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(email)}?$select=id,givenName,surname,mail,userPrincipalName,department,jobTitle,accountEnabled`; let userData; try { const response = await axios.get(searchUrl, { headers: { Authorization: `Bearer ${accessToken}` } }); userData = response.data; } catch (error) { return res.json({ success: false, message: `Utilisateur ${email} non trouvé dans Entra ID` }); } // 3️⃣ Vérifier si compte actif if (userData.accountEnabled === false) { return res.json({ success: false, message: 'Ce compte est désactivé dans Entra ID' }); } // 4️⃣ Vérifier s'il existe déjà en base const conn = await pool.getConnection(); const [existing] = await conn.query( 'SELECT id, email FROM CollaborateurAD WHERE LOWER(email) = ?', [email.toLowerCase()] ); if (existing.length > 0) { conn.release(); return res.json({ success: false, message: 'Cet utilisateur existe déjà en base', userId: existing[0].id }); } // 5️⃣ Insérer l'utilisateur (SANS DateEntree pour éviter GETDATE()) await conn.query(` INSERT INTO CollaborateurAD (entraUserId, prenom, nom, email, service, description, role, SocieteId, Actif, TypeContrat) VALUES (?, ?, ?, ?, ?, ?, 'Collaborateur', 1, 1, '37h') `, [ userData.id, userData.givenName || 'Prénom', userData.surname || 'Nom', email.toLowerCase(), userData.department || '', userData.jobTitle || '' ]); // 6️⃣ Récupérer l'utilisateur créé const [newUser] = await conn.query( 'SELECT id, prenom, nom, email FROM CollaborateurAD WHERE LOWER(email) = ?', [email.toLowerCase()] ); conn.release(); console.log(`✅ Utilisateur créé: ${newUser[0].prenom} ${newUser[0].nom} (ID: ${newUser[0].id})`); res.json({ success: true, message: 'Utilisateur ajouté avec succès', user: newUser[0] }); } catch (error) { console.error('❌ Erreur addUserFromEntra:', error); res.status(500).json({ success: false, message: error.message }); } }); async function syncEntraIdUsers() { const syncResults = { processed: 0, inserted: 0, updated: 0, deactivated: 0, errors: [] }; try { console.log('\n🔄 === DÉBUT SYNCHRONISATION ENTRA ID ==='); // 1️⃣ Obtenir le token const accessToken = await getGraphToken(); if (!accessToken) { console.error('❌ Impossible d\'obtenir le token'); return syncResults; } console.log('✅ Token obtenu'); // 2️⃣ Récupérer le groupe const groupResponse = await axios.get( `https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`, { headers: { Authorization: `Bearer ${accessToken}` } } ); const groupName = groupResponse.data.displayName; console.log(`📋 Groupe : ${groupName}`); // 3️⃣ Récupérer tous les membres avec pagination let allAzureMembers = []; let nextLink = `https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}/members?$select=id,givenName,surname,mail,department,jobTitle,officeLocation,accountEnabled&$top=999`; console.log('📥 Récupération des membres...'); while (nextLink) { const membersResponse = await axios.get(nextLink, { headers: { Authorization: `Bearer ${accessToken}` } }); allAzureMembers = allAzureMembers.concat(membersResponse.data.value); nextLink = membersResponse.data['@odata.nextLink']; if (nextLink) { console.log(` 📄 ${allAzureMembers.length} membres récupérés...`); } } console.log(`✅ ${allAzureMembers.length} membres trouvés`); // 4️⃣ Filtrer les membres valides const validMembers = allAzureMembers.filter(m => { if (!m.mail || m.mail.trim() === '') return false; if (m.accountEnabled === false) return false; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(m.mail); }); console.log(`✅ ${validMembers.length} membres valides`); // 5️⃣ Traitement avec transaction const transaction = new sql.Transaction(pool); await transaction.begin(); try { const azureEmails = new Set(); validMembers.forEach(m => { azureEmails.add(m.mail.toLowerCase().trim()); }); console.log('\n📝 Traitement des utilisateurs...'); // 6️⃣ Pour chaque membre for (const m of validMembers) { try { const emailClean = m.mail.toLowerCase().trim(); syncResults.processed++; // ✅ CORRIGÉ : Utiliser entraUserId et Actif const request = new sql.Request(transaction); request.input('email', sql.NVarChar, emailClean); const result = await request.query(` SELECT id, email, entraUserId, Actif FROM CollaborateurAD WHERE LOWER(email) = LOWER(@email) `); if (result.recordset.length > 0) { // ✅ MISE À JOUR avec bonnes colonnes const updateRequest = new sql.Request(transaction); updateRequest.input('entraUserId', sql.NVarChar, m.id); updateRequest.input('prenom', sql.NVarChar, m.givenName || ''); updateRequest.input('nom', sql.NVarChar, m.surname || ''); updateRequest.input('service', sql.NVarChar, m.department || ''); updateRequest.input('description', sql.NVarChar, m.jobTitle || ''); updateRequest.input('email', sql.NVarChar, emailClean); await updateRequest.query(` UPDATE CollaborateurAD SET entraUserId = @entraUserId, prenom = @prenom, nom = @nom, service = @service, description = @description, Actif = 1 WHERE LOWER(email) = LOWER(@email) `); syncResults.updated++; console.log(` ✓ Mis à jour : ${emailClean}`); } else { // ✅ INSERTION avec bonnes colonnes const insertRequest = new sql.Request(transaction); insertRequest.input('entraUserId', sql.NVarChar, m.id); insertRequest.input('prenom', sql.NVarChar, m.givenName || ''); insertRequest.input('nom', sql.NVarChar, m.surname || ''); insertRequest.input('email', sql.NVarChar, emailClean); insertRequest.input('service', sql.NVarChar, m.department || ''); insertRequest.input('description', sql.NVarChar, m.jobTitle || ''); await insertRequest.query(` INSERT INTO CollaborateurAD (entraUserId, prenom, nom, email, service, description, role, SocieteId, Actif, TypeContrat) VALUES (@entraUserId, @prenom, @nom, @email, @service, @description, 'Collaborateur', 1, 1, '37h') `); syncResults.inserted++; console.log(` ✓ Créé : ${emailClean}`); } } catch (userError) { syncResults.errors.push({ email: m.mail, error: userError.message }); console.error(` ❌ Erreur ${m.mail}:`, userError.message); } } // 7️⃣ ✅ DÉSACTIVATION avec bonne colonne (Actif, pas dateMiseAJour) console.log('\n🔍 Désactivation des comptes obsolètes...'); if (azureEmails.size > 0) { const activeEmailsList = Array.from(azureEmails).map(e => `'${e}'`).join(','); const deactivateRequest = new sql.Request(transaction); const deactivateResult = await deactivateRequest.query(` UPDATE CollaborateurAD SET Actif = 0 WHERE email IS NOT NULL AND email != '' AND LOWER(email) NOT IN (${activeEmailsList}) AND Actif = 1 `); syncResults.deactivated = deactivateResult.rowsAffected[0]; console.log(` ✓ ${syncResults.deactivated} compte(s) désactivé(s)`); } await transaction.commit(); console.log('\n📊 === RÉSUMÉ ==='); console.log(` Groupe: ${groupName}`); console.log(` Total Entra: ${allAzureMembers.length}`); console.log(` Valides: ${validMembers.length}`); console.log(` Traités: ${syncResults.processed}`); console.log(` Créés: ${syncResults.inserted}`); console.log(` Mis à jour: ${syncResults.updated}`); console.log(` Désactivés: ${syncResults.deactivated}`); console.log(` Erreurs: ${syncResults.errors.length}`); } catch (error) { await transaction.rollback(); throw error; } } catch (error) { console.error('\n❌ ERREUR SYNCHRONISATION:', error.message); } return syncResults; } app.post('/api/sync-all', async (req, res) => { try { console.log('🚀 Sync complète manuelle...'); const results = await syncEntraIdUsers(); res.json({ success: true, message: 'Sync terminée', stats: results }); } catch (error) { res.status(500).json({ success: false, message: error.message }); } }); // Route diagnostic app.get('/api/diagnostic-sync', async (req, res) => { try { const totalDB = await pool.query( 'SELECT COUNT(*) as total, SUM(CASE WHEN actif = 1 THEN 1 ELSE 0 END) as actifs FROM CollaborateurAD', [] ); const sansEmail = await pool.query( 'SELECT COUNT(*) as total FROM CollaborateurAD WHERE email IS NULL OR email = \'\'', [] ); const derniers = await pool.query( 'SELECT TOP 10 id, prenom, nom, email, CollaborateurADId, actif FROM CollaborateurAD ORDER BY id DESC', [] ); // Test Entra let entraStatus = { connected: false }; try { const token = await getGraphToken(); if (token) { const groupResponse = await axios.get( `https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`, { headers: { Authorization: `Bearer ${token}` } } ); entraStatus = { connected: true, groupName: groupResponse.data.displayName }; } } catch (err) { entraStatus.error = err.message; } res.json({ success: true, database: { total: totalDB[0]?.total || 0, actifs: totalDB[0]?.actifs || 0, sansEmail: sansEmail[0]?.total || 0 }, entraId: entraStatus, derniers_utilisateurs: derniers }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // GET - Compteur de demandes en attente pour le badge app.get('/api/getPendingRequestsCount', async (req, res) => { try { const userId = req.query.user_id; const userRole = req.query.role; const userEmail = req.query.email; if (!userId) { return res.json({ success: false, message: 'ID utilisateur manquant' }); } const conn = await pool.getConnection(); let count = 0; // Normaliser le rôle const role = normalizeRole(userRole); console.log(`🔍 Comptage demandes pour: ${userEmail}, Role: ${role}`); if (role === 'rh' || role === 'admin' || role === 'president' || role === 'directeur de campus') { // Pour RH/Admin/President/Directeur : toutes les demandes en attente de leur périmètre const [userInfo] = await conn.query( 'SELECT ServiceId, CampusId, SocieteId FROM CollaborateurAD WHERE id = ?', [userId] ); if (userInfo.length === 0) { conn.release(); return res.json({ success: false, message: 'Utilisateur non trouvé' }); } const serviceId = userInfo[0].ServiceId; const campusId = userInfo[0].CampusId; // ⭐ Vérifier accès transversal const accesTransversal = getUserAccesTransversal(userEmail); if (accesTransversal) { // Accès service multi-campus const [result] = await conn.query(` SELECT COUNT(DISTINCT dc.Id) as count FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id JOIN Services s ON ca.ServiceId = s.Id WHERE dc.Statut = 'En attente' AND s.Nom = ? `, [accesTransversal.serviceNom]); count = result[0].count; } else if (role === 'directeur de campus') { // Directeur de campus : toutes les demandes de son campus const [result] = await conn.query(` SELECT COUNT(DISTINCT dc.Id) as count FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id WHERE dc.Statut = 'En attente' AND ca.CampusId = ? `, [campusId]); count = result[0].count; } else { // RH : son service sur son campus uniquement const [result] = await conn.query(` SELECT COUNT(DISTINCT dc.Id) as count FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id WHERE dc.Statut = 'En attente' AND ca.ServiceId = ? AND ca.CampusId = ? `, [serviceId, campusId]); count = result[0].count; } } else if (role === 'validateur' || role === 'validatrice') { // Pour validateurs : demandes de leur service const [userInfo] = await conn.query( 'SELECT ServiceId, CampusId FROM CollaborateurAD WHERE id = ?', [userId] ); if (userInfo.length === 0) { conn.release(); return res.json({ success: false, message: 'Utilisateur non trouvé' }); } const serviceId = userInfo[0].ServiceId; const campusId = userInfo[0].CampusId; const [result] = await conn.query(` SELECT COUNT(DISTINCT dc.Id) as count FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id WHERE dc.Statut = 'En attente' AND ca.ServiceId = ? AND ca.CampusId = ? AND ca.id != ? `, [serviceId, campusId, userId]); count = result[0].count; } conn.release(); console.log(`✅ Nombre de demandes en attente: ${count}`); res.json({ success: true, count: count, role: role }); } catch (error) { console.error('❌ Erreur getPendingRequestsCount:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); app.listen(PORT, "0.0.0.0", async () => { console.log("✅ =========================================="); console.log(" SERVEUR PRINCIPAL DÉMARRÉ"); console.log(" Port:", PORT); console.log(` Base: ${dbConfig.database}@${dbConfig.server}`); console.log("=========================================="); // ⚡ Synchronisation Entra ID au démarrage (après 5 secondes) setTimeout(async () => { console.log("🚀 Lancement synchronisation Entra ID..."); await syncEntraIdUsers(); }, 5000); });