// ============================================================================ // 🚀 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 = 3000; 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.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; } // ============================================================ // 📧 WORKER : scrute RappelsCRAPending toutes les 60 secondes // et envoie les emails via Microsoft Graph // ============================================================ // Template email unifiĂ© (quotidien ou hebdomadaire) function buildRappelCRAHtml(prenom, joursArray, type, moisLabel = null) { const nb = joursArray.length; function grouperEnPlages(jours) { if (!jours || jours.length === 0) return []; const sorted = [...jours].sort(); const plages = []; let debut = sorted[0]; let fin = sorted[0]; for (let i = 1; i < sorted.length; i++) { const prev = new Date(sorted[i - 1] + 'T00:00:00'); const curr = new Date(sorted[i] + 'T00:00:00'); const diffJ = (curr - prev) / 86400000; if (diffJ <= 3) { fin = sorted[i]; } else { plages.push({ debut, fin }); debut = sorted[i]; fin = sorted[i]; } } plages.push({ debut, fin }); return plages; } function formatPlage(debut, fin) { const opts = { weekday: 'long', day: 'numeric', month: 'long' }; const d = new Date(debut + 'T00:00:00').toLocaleDateString('fr-FR', opts); const f = new Date(fin + 'T00:00:00').toLocaleDateString('fr-FR', opts); return debut === fin ? d : `Du ${d} au ${f}`; } const plages = grouperEnPlages(joursArray); const nbPlages = plages.length; const plagesHtml = plages.slice(0, 8).map(({ debut, fin }) => { const label = formatPlage(debut, fin); const nbJoursPlage = joursArray.filter(j => j >= debut && j <= fin).length; const badge = nbJoursPlage > 1 ? `${nbJoursPlage}j` : ''; return `
  • 📅 ${label}${badge}
  • `; }).join(''); const surplus = nbPlages > 8 ? `

    
 et ${nbPlages - 8} autre${nbPlages - 8 > 1 ? 's' : ''} période${nbPlages - 8 > 1 ? 's' : ''}.

    ` : ''; // ✅ Config selon le type de rappel const cfg = type === 'quotidien' ? { couleur: '#f59e0b', emoji: '📅', titre: `${nb} jour${nb > 1 ? 's' : ''} non saisi${nb > 1 ? 's' : ''} dans votre CRA`, intro: `Vous avez ${nb} jour${nb > 1 ? 's' : ''} ouvrable${nb > 1 ? 's' : ''} non saisi${nb > 1 ? 's' : ''} dans votre Compte-Rendu d'ActivitĂ©s.` } : type === 'mensuel' ? { couleur: '#8b5cf6', emoji: '📋', titre: `CRA de ${moisLabel} non saisi`, intro: `Votre Compte-Rendu d'ActivitĂ©s du mois de ${moisLabel} n'a pas encore Ă©tĂ© saisi. Il contient ${nb} jour${nb > 1 ? 's' : ''} ouvrable${nb > 1 ? 's' : ''} Ă  renseigner.` } : { couleur: '#3b82f6', emoji: '🔔', titre: `${nb} jour${nb > 1 ? 's' : ''} non saisi${nb > 1 ? 's' : ''} dans votre CRA`, intro: `Vous avez ${nb} jour${nb > 1 ? 's' : ''} ouvrable${nb > 1 ? 's' : ''} non saisi${nb > 1 ? 's' : ''} dans votre Compte-Rendu d'ActivitĂ©s.` }; return `

    ${cfg.emoji} ${cfg.titre}

    Compte-Rendu d'ActivitĂ©s — Forfait jour

    Bonjour ${prenom},

    ${cfg.intro}

    📋 PĂ©riode${nbPlages > 1 ? 's' : ''} Ă  saisir (${nb} jour${nb > 1 ? 's' : ''} au total)

      ${plagesHtml}
    ${surplus}
    ✏ AccĂ©der Ă  mon CRA

    Email automatique — GTA ENSUP · Ne pas rĂ©pondre

    `; } // Worker principal async function traiterRappelsCRAPending() { let rappels; try { const [rows] = await pool.query(` SELECT TOP 20 Id, CollabId, Email, Prenom, NomComplet, TypeRappel, JoursJson, NbJours, Annee, Mois FROM RappelsCRAPending WHERE Statut = 'pending' ORDER BY DateCreation ASC `); rappels = rows; } catch (err) { console.error('❌ Worker CRA — lecture table:', err.message); return; } if (!rappels || rappels.length === 0) return; console.log(`📬 Worker CRA : ${rappels.length} rappel(s) Ă  traiter`); const accessToken = await getGraphToken(); if (!accessToken) { console.error('❌ Worker CRA — token Graph indisponible'); return; } // Tableau des mois en français const moisNoms = [ 'janvier', 'fĂ©vrier', 'mars', 'avril', 'mai', 'juin', 'juillet', 'aoĂ»t', 'septembre', 'octobre', 'novembre', 'dĂ©cembre' ]; for (const rappel of rappels) { try { const joursArray = JSON.parse(rappel.JoursJson).map(j => j.d); // ✅ Label mois pour le type mensuel const moisLabel = (rappel.Mois && rappel.Annee) ? `${moisNoms[rappel.Mois - 1]} ${rappel.Annee}` : null; // ✅ Sujet selon le type const sujet = rappel.TypeRappel === 'quotidien' ? `📅 Rappel CRA — hier non saisi` : rappel.TypeRappel === 'mensuel' ? `📋 Rappel CRA — ${moisLabel} non saisi` : `🔔 Rappel CRA — ${rappel.NbJours} jour${rappel.NbJours > 1 ? 's' : ''} non saisi${rappel.NbJours > 1 ? 's' : ''}`; // ✅ HTML avec moisLabel passĂ© en paramĂštre const html = buildRappelCRAHtml( rappel.Prenom, joursArray, rappel.TypeRappel, moisLabel ); const envoye = await sendMailGraph( accessToken, 'gtanoreply@ensup.eu', rappel.Email, sujet, html ); await pool.query(` UPDATE RappelsCRAPending SET Statut = ?, DateTraitement = GETDATE() WHERE Id = ? `, [envoye ? 'traite' : 'erreur', rappel.Id]); if (envoye) { console.log(` ✅ ${rappel.NomComplet} (${rappel.TypeRappel}) → ${rappel.Email}`); } else { console.error(` ❌ Échec envoi → ${rappel.Email}`); } await new Promise(r => setTimeout(r, 400)); } catch (err) { console.error(` ❌ Erreur traitement rappel ID ${rappel.Id}:`, err.message); await pool.query(` UPDATE RappelsCRAPending SET Statut = 'erreur', DateTraitement = GETDATE() WHERE Id = ? `, [rappel.Id]); } } } // DĂ©marrer le worker (60 secondes entre chaque passage) const workerCRA = setInterval(traiterRappelsCRAPending, 60 * 1000); // Nettoyage optionnel des lignes traitĂ©es > 30 jours (Ă©vite que la table grossisse) setInterval(async () => { try { await pool.query(` DELETE FROM RappelsCRAPending WHERE Statut IN ('traite', 'erreur') AND DateCreation < DATEADD(DAY, -30, GETDATE()) `); } catch (e) { /* silencieux */ } }, 24 * 60 * 60 * 1000); // une fois par jour console.log('⏰ Worker CRA dĂ©marrĂ© — scrute RappelsCRAPending toutes les 60s'); async function calculerAcquisitionRTTSmart(conn, collaborateurId, dateReference) { try { const result = await calculerAcquisitionRTT(conn, collaborateurId, dateReference); return result; // { acquisition, moisTravailles, config, typeContrat } } catch (error) { console.error('❌ calculerAcquisitionRTTSmart erreur:', error.message); return { acquisition: 0, moisTravailles: 0, config: { joursAnnuels: 0, acquisitionMensuelle: 0 }, typeContrat: '37h' }; } } 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; } // Helper Ă  centraliser UNE SEULE FOIS dans server.js function getAnneesCourantes(dateReference = new Date()) { const currentYear = dateReference.getFullYear(); const dateBascule = new Date(`${currentYear}-06-01`); const apresBasculement = dateReference >= dateBascule; return { anneeN: apresBasculement ? currentYear + 1 : currentYear, // ex: 2027 anneeN1: apresBasculement ? currentYear : currentYear - 1, // ex: 2026 (report) apresBasculement }; } 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 || data.dateDebut, // 🔧 AJOUTÉ dateDebut: data.dateDebut || data.date, // 🔧 AJOUTÉ dateFin: data.dateFin || data.date, // 🔧 AJOUTÉ periode: data.periode || data.periodeJournee || 'JournĂ©e entiĂšre', // 🔧 AJOUTÉ periodeJournee: data.periodeJournee || data.periode, // 🔧 AJOUTÉ nombreJours: data.nombreJours, // 🔧 AJOUTÉ 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)); } //Pour CP AnticipĂ©s // ───────────────────────────────────────────────────────────── // HELPER EXERCICE CP // Annee = annĂ©e de dĂ©but de plage : 2025 → exercice 01/06/2025-31/05/2026 // ───────────────────────────────────────────────────────────── function getExerciceCP(date) { const d = new Date(date); const mois = d.getMonth() + 1; // 1-12 const anneeDebut = mois >= 6 ? d.getFullYear() : d.getFullYear() - 1; return { annee: anneeDebut, // INT → JOIN avec CompteurConges.Annee label: `${anneeDebut}-${anneeDebut + 1}` // VARCHAR → audit lisible }; } /** * DĂ©termine le mode (NORMAL ou ANTICIPATION) et les exercices concernĂ©s. * NORMAL : congĂ©s sur le MÊME exercice que la demande → N-1 prioritaire puis N * ANTICIPATION : congĂ©s sur l'exercice SUIVANT → CP N uniquement */ function getInfosDemandeCP(dateDebut, dateDemande = new Date()) { const exerciceDemande = getExerciceCP(dateDemande); const exerciceConges = getExerciceCP(dateDebut); const isAnticipe = exerciceConges.annee > exerciceDemande.annee; return { isAnticipe, mode: isAnticipe ? 'ANTICIPATION' : 'NORMAL', exerciceDemande, // { annee: 2025, label: '2025-2026' } exerciceConges, // { annee: 2026, label: '2026-2027' } anneeN: exerciceDemande.annee, // compteur CP N (toujours utilisĂ©) anneeN1: isAnticipe ? null : exerciceDemande.annee - 1 // CP N-1 seulement en NORMAL }; } 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 currentYearSys = today.getFullYear(); // ── RĂ©cupĂ©rer le TypeCongeId CP avant la dĂ©tection de bascule ── 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Ă©' }); } // ── DĂ©tection dynamique des annĂ©es post-bascule ──────────────── // On lit la base plutĂŽt que la date systĂšme, car la bascule peut // ĂȘtre faite manuellement avant le 01/06. const [check2027] = await conn.query(` SELECT TOP 1 Id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? AND SoldeReporte > 0 `, [userId, cpType[0].Id, currentYearSys + 1]); const basculeFaite = check2027.length > 0; const dateBascule = new Date(`${currentYearSys}-06-01`); const apresBasculement = today >= dateBascule; const currentYear = basculeFaite ? currentYearSys + 1 : (apresBasculement ? currentYearSys + 1 : currentYearSys); const previousYear = currentYear - 1; const anneeN = currentYear; console.log('\n📊 === CALCUL COMPTEURS ==='); console.log('User:', user.prenom, user.nom, '(ID:', userId, ')'); console.log('Date rĂ©fĂ©rence:', today.toLocaleDateString('fr-FR')); console.log(`📅 AnnĂ©es: N=${currentYear}, N-1=${previousYear} (basculeFaite=${basculeFaite})`); // ═══════════════════════════════════════════════════════════ // 1ïžâƒŁ CALCUL AVEC LES FORMULES INTELLIGENTES // ═══════════════════════════════════════════════════════════ const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree); console.log('🧼 CP calculĂ©:', acquisCP.toFixed(2) + 'j'); 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 // ═══════════════════════════════════════════════════════════ 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; } 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)); 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); } // ═══════════════════════════════════════════════════════════ // 4ïžâƒŁ RELIRE LA BASE POUR AFFICHER LES VRAIES VALEURS // ═══════════════════════════════════════════════════════════ console.log('\n📖 Lecture base aprĂšs synchronisation...'); 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: currentYearSys, 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 - 1}-${previousYear}`, 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: Annee=' + previousYear + ' Acquis=' + totalAcquis + 'j, Solde=' + soldeReporte + 'j'); } // ✅ CP N - LECTURE BASE const [cpNData] = await conn.query(` SELECT Total, Solde, SoldeReporte, SoldeAnticipe, IsAnticipe 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 soldeAnticipe = parseFloat(cpNData[0].SoldeAnticipe) || 0; const isAnticipe = (cpNData[0].IsAnticipe ?? 0) === 1; const soldeReel = Math.max(0, soldeBDD - soldeReporteBDD); const pris = Math.max(0, totalAcquis - soldeReel); counters.cpN = { annee: currentYear, exercice: `${currentYear - 1}-${currentYear}`, 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, soldeAnticipe: parseFloat(soldeAnticipe.toFixed(2)), isAnticipe: isAnticipe, anneeAnticipe: anneeN }; counters.totalDisponible.cp += counters.cpN.solde; console.log('✅ CP N BASE: Annee=' + currentYear + ' Acquis=' + totalAcquis + 'j, Solde=' + soldeReel + 'j'); } // ✅ RTT N - LECTURE BASE // ✅ RTT N - LECTURE BASE // Le RTT est annuel (01/01→31/12), il ne bascule pas comme le CP if (rttType.length > 0 && user.role !== 'Apprenti') { const anneeRTT = currentYearSys; // toujours 2026, pas currentYear=2027 const [rttNData] = await conn.query(` SELECT Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [userId, rttTypeId, anneeRTT] ); 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, anneeRTT, typeContrat); counters.rttN = { annee: anneeRTT, 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: Annee=' + anneeRTT + ' Acquis=' + totalAcquis + 'j, Solde=' + soldeBDD + 'j'); } } // ✅ RĂ©cup - LECTURE BASE // ✅ RĂ©cup - LECTURE BASE // La rĂ©cup est annuelle, elle ne bascule pas comme le CP const [recupType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['RĂ©cupĂ©ration']); if (recupType.length > 0) { const anneeRecup = currentYearSys; // toujours 2026, pas currentYear=2027 const [compteurRecup] = await conn.query(` SELECT Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [userId, recupType[0].Id, anneeRecup] ); if (compteurRecup.length > 0) { const acquis = parseFloat(compteurRecup[0].Total || 0); const solde = parseFloat(compteurRecup[0].Solde || 0); const pris = Math.max(0, acquis - solde); counters.recupN = { annee: anneeRecup, 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: Annee=' + anneeRecup + ' Acquis=' + acquis + 'j, Solde=' + solde + 'j'); } } 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 N-1 (Annee=' + previousYear + '):', counters.cpN1?.solde + 'j'); console.log(' CP N (Annee=' + currentYear + '):', counters.cpN?.solde + '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, aDeduireN1, aDeduireN1, compteurN1[0].Id] ); // Sauvegarde du dĂ©tail de la dĂ©duction N-1 await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'ReportĂ© N-1', ?)`, [demandeCongeId, typeCongeId, previousYear, aDeduireN1] ); 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, 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.Id AS DeductionId, dd.TypeCongeId, dd.Annee, dd.TypeDeduction, dd.JoursUtilises, tc.Nom as TypeNom FROM DeductionDetails dd JOIN TypeConge tc ON dd.TypeCongeId = tc.Id WHERE dd.DemandeCongeId = ? 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 { DeductionId, TypeCongeId, Annee, TypeDeduction, JoursUtilises, TypeNom } = deduction; console.log(`\n🔍 Traitement: ${TypeNom} - ${TypeDeduction} - ${JoursUtilises}j (AnnĂ©e: ${Annee})`); // ── RĂ©cup posĂ©e ─────────────────────────────────────────── 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; } // ── AnticipĂ© N+1 ────────────────────────────────────────── // ✅ Plus de SoldeAnticipe → on supprime uniquement la ligne DeductionDetails // Le solde rĂ©el de la ligne CompteurConges Annee=anneeN n'a jamais Ă©tĂ© touchĂ© if (TypeDeduction === 'AnticipĂ© N+1') { console.log(`🔄 Restauration AnticipĂ© N+1: suppression de ${JoursUtilises}j dans DeductionDetails`); await conn.query( `DELETE FROM DeductionDetails WHERE Id = ?`, [DeductionId] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ AnticipĂ© N+1 restaurĂ©: ligne DeductionDetails supprimĂ©e`); continue; } // ── Ancien TypeDeduction 'N AnticipĂ©' (rĂ©trocompatibilitĂ©) ─ // Pour les demandes posĂ©es avant la migration vers 'AnticipĂ© N+1' if (TypeDeduction === 'N AnticipĂ©' || TypeDeduction === 'N+1 AnticipĂ©') { console.log(`🔄 Restauration ${TypeDeduction} (legacy): suppression de ${JoursUtilises}j`); // Si la ligne CompteurConges avait Ă©tĂ© modifiĂ©e (ancienne logique), // on restitue le Solde en base pour ĂȘtre sĂ»r const [compteur] = await conn.query( `SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, TypeCongeId, Annee] ); if (compteur.length > 0) { // Rembourser dans Solde au cas oĂč l'ancienne logique l'avait dĂ©duit await conn.query( `UPDATE CompteurConges SET Solde = Solde + ?, SoldeAnticipe = CASE WHEN COALESCE(SoldeAnticipe, 0) - ? < 0 THEN 0 ELSE COALESCE(SoldeAnticipe, 0) - ? END, IsAnticipe = 0, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [JoursUtilises, JoursUtilises, JoursUtilises, compteur[0].Id] ); } // Supprimer la ligne DeductionDetails dans tous les cas await conn.query( `DELETE FROM DeductionDetails WHERE Id = ?`, [DeductionId] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ ${TypeDeduction} (legacy) restaurĂ©`); continue; } // ── Report N-1 ──────────────────────────────────────────── if (TypeDeduction === 'ReportĂ© N-1' || TypeDeduction === 'Report N-1') { 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 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Ă© N-1 restaurĂ©: ${ancienSolde} → ${nouveauSolde}`); } continue; } // ── AnnĂ©e N (dĂ©duction normale) ─────────────────────────── if (TypeDeduction === 'AnnĂ©e N' || TypeDeduction === 'Anne 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}`); } continue; } // ── AnnĂ©e N-1 (dĂ©duction normale sur exercice prĂ©cĂ©dent) ── if (TypeDeduction === 'AnnĂ©e N-1' || TypeDeduction === 'Anne N-1') { 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-1 restaurĂ©e: ${ancienSolde} → ${nouveauSolde}`); } continue; } // ── Anticip (jours rĂ©els dĂ©duits du solde CP N) ────────────────────── if (TypeDeduction === 'Anticip') { 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 = parseFloat((ancienSolde + parseFloat(JoursUtilises)).toFixed(2)); await conn.query( `UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [nouveauSolde, compteur[0].Id] ); console.log(`✅ Anticip restaurĂ©: Solde ${ancienSolde} → ${nouveauSolde}j`); } await conn.query(`DELETE FROM DeductionDetails WHERE Id = ?`, [DeductionId]); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); continue; } // ── AnticipFutur (tracĂ© uniquement, solde non touchĂ©) ──────────────── if (TypeDeduction === 'AnticipFutur') { await conn.query(`DELETE FROM DeductionDetails WHERE Id = ?`, [DeductionId]); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ AnticipFutur supprimĂ©: ${JoursUtilises}j tracĂ© retirĂ© (aucun solde Ă  remettre)`); continue; } // ── AnticipRTT (solde RTT N rĂ©el dĂ©duit) → remettre ───────────────── if (TypeDeduction === 'AnticipRTT') { const [compteur] = await conn.query( `SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, TypeCongeId, Annee] ); if (compteur.length > 0) { const nouveauSolde = parseFloat((parseFloat(compteur[0].Solde) + parseFloat(JoursUtilises)).toFixed(2)); await conn.query( `UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [nouveauSolde, compteur[0].Id] ); console.log(`✅ AnticipRTT restaurĂ©: Solde +${JoursUtilises}j → ${nouveauSolde}j`); } await conn.query(`DELETE FROM DeductionDetails WHERE Id = ?`, [DeductionId]); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); continue; } // ── AnticipFuturRTT (tracĂ© uniquement, solde non touchĂ©) ──────────── if (TypeDeduction === 'AnticipFuturRTT') { await conn.query(`DELETE FROM DeductionDetails WHERE Id = ?`, [DeductionId]); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ AnticipFuturRTT supprimĂ©: ${JoursUtilises}j tracĂ© retirĂ© (aucun solde Ă  remettre)`); continue; } // ── AnticipNp1RTT (dĂ©duit du Solde compteur RTT N+1) → remettre ───── if (TypeDeduction === 'AnticipNp1RTT') { const [compteur] = await conn.query( `SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, TypeCongeId, Annee] ); if (compteur.length > 0) { const nouveauSolde = parseFloat((parseFloat(compteur[0].Solde) + parseFloat(JoursUtilises)).toFixed(2)); await conn.query( `UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [nouveauSolde, compteur[0].Id] ); console.log(`✅ AnticipNp1RTT restaurĂ©: Solde RTT N+1 +${JoursUtilises}j → ${nouveauSolde}j`); } await conn.query(`DELETE FROM DeductionDetails WHERE Id = ?`, [DeductionId]); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); continue; } // ── TypeDeduction non reconnu → log warning ─────────────── console.warn(`⚠ TypeDeduction non gĂ©rĂ©: "${TypeDeduction}" — ignorĂ©`); } // ✅ Suppression de updateSoldeAnticipe — plus nĂ©cessaire sans SoldeAnticipe 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) return res.json({ success: false, message: 'ID invalide' }); const result = await pool.request() .input('collabId', sql.Int, id) .query(` SELECT dc.Id, dc.DateDebut, dc.DateFin, dc.NombreJours AS days, dc.Statut AS status, dc.DateDemande, STRING_AGG(tc.Nom, ', ') WITHIN GROUP (ORDER BY tc.Nom) AS type, CASE WHEN dc.DateDebut = dc.DateFin THEN FORMAT(dc.DateDebut, 'dd/MM/yyyy') ELSE FORMAT(dc.DateDebut, 'dd/MM/yyyy') + ' - ' + FORMAT(dc.DateFin, 'dd/MM/yyyy') END AS datedisplay FROM DemandeConge dc LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dc.CollaborateurADId = @collabId GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.NombreJours, dc.Statut, dc.DateDemande ORDER BY dc.DateDemande DESC `); return res.json({ success: true, requests: result.recordset }); } catch (error) { console.error('Erreur getEmployeRequest', error); return 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; let collaborateurId; 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 }); } collaborateurId = userLookup.recordset[0].id; console.log(`✅ CollaborateurADId trouvĂ©: ${collaborateurId}`); mainRequest.input('collaborateurId', collaborateurId); whereClause = 'dc.CollaborateurADId = @collaborateurId'; } else { collaborateurId = parseInt(userId); mainRequest.input('userId', collaborateurId); whereClause = '(dc.EmployeeId = @userId OR dc.CollaborateurADId = @userId)'; } // ✅ REQUÊTE CORRIGÉE - AJOUT de PeriodeJournee et CouleurHex const result = await mainRequest.query(` SELECT dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.CommentaireValidation, dc.Validateur, dc.DocumentJoint, dc.NombreJours, ( 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, -- ⭐ AJOUT: RĂ©cupĂ©rer les couleurs des types de congĂ© ( SELECT STRING_AGG(tc3.CouleurHex, ',') WITHIN GROUP (ORDER BY tc3.Nom) FROM ( SELECT DISTINCT tc2.CouleurHex, tc2.Nom FROM DemandeCongeType dct2 JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id WHERE dct2.DemandeCongeId = dc.Id ) AS tc3 ) AS Couleurs, -- ⭐ AJOUT: RĂ©cupĂ©rer la premiĂšre couleur (principale) ( SELECT TOP 1 tc2.CouleurHex FROM DemandeCongeType dct2 JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id WHERE dct2.DemandeCongeId = dc.Id ORDER BY dct2.NombreJours DESC ) AS CouleurPrincipale, -- ⭐ AJOUT: RĂ©cupĂ©rer la pĂ©riode de journĂ©e ( SELECT TOP 1 COALESCE(dct2.PeriodeJournee, 'JournĂ©e entiĂšre') FROM DemandeCongeType dct2 WHERE dct2.DemandeCongeId = dc.Id ) AS PeriodeJournee, -- ⭐ AJOUT: DĂ©tails JSON des congĂ©s (type, jours, pĂ©riode, couleur) ( SELECT CONCAT( '[', STRING_AGG( CONCAT( '{"type":"', tc2.Nom, '","jours":', dct2.NombreJours, ',"periode":"', COALESCE(dct2.PeriodeJournee, 'JournĂ©e entiĂšre'), '","couleur":"', COALESCE(tc2.CouleurHex, '#6b7280'), '"}' ), ',' ), ']' ) FROM DemandeCongeType dct2 JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id WHERE dct2.DemandeCongeId = dc.Id ) AS DetailsCongesJSON 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)}`; } // ⭐ Parser les dĂ©tails JSON si disponible let detailsConges = []; if (row.DetailsCongesJSON) { try { detailsConges = JSON.parse(row.DetailsCongesJSON); } catch (e) { console.warn('Erreur parsing DetailsCongesJSON:', e); } } return { id: row.Id, type: row.TypeConges || 'Non dĂ©fini', startDate: row.DateDebut, endDate: row.DateFin, dateDisplay, days: row.NombreJoursTotal || row.NombreJours || workingDays, status: row.Statut, reason: row.Commentaire || 'Aucun commentaire', submittedAt: row.DateDemande, submittedDisplay: formatDate(row.DateDemande), validator: row.Validateur || null, validationComment: row.CommentaireValidation || null, fileUrl, // ⭐ NOUVEAUX CHAMPS POUR LE CALENDRIER couleur: row.CouleurPrincipale || '#6b7280', couleurs: row.Couleurs ? row.Couleurs.split(',') : ['#6b7280'], periodeJournee: row.PeriodeJournee || 'JournĂ©e entiĂšre', detailsConges: detailsConges }; }); 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 { // ✅ CORRIGÉ : manager_id (cohĂ©rent avec frontend) const managerId = req.query.manager_id; if (!managerId) return res.json({ success: false, message: 'ParamĂštre manager_id manquant' }); const request = pool.request(); request.input('managerId', managerId); // RĂ©cupĂ©rer les infos du manager const managerResult = await request.query(` SELECT ServiceId, CampusId, role FROM CollaborateurAD WHERE id = @managerId `); if (managerResult.recordset.length === 0) { return res.json({ success: false, message: 'Manager non trouvĂ©' }); } const managerRow = managerResult.recordset[0]; const serviceId = managerRow.ServiceId; const campusId = managerRow.CampusId; const role = normalizeRole(managerRow.role); console.log(`🔍 getAllTeamRequests - Manager: ${managerId}, Role: ${role}`); let rows; // VALIDATEURS ET DIRECTEURS : Service + Campus (vue complĂšte) if (role === 'validateur' || role === 'directeur de campus') { const queryRequest = pool.request(); queryRequest.input('serviceId', serviceId); queryRequest.input('campusId', campusId); queryRequest.input('managerId', managerId); const result = await queryRequest.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, STRING_AGG(tc.Nom, ', ') as type FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE ca.ServiceId = @serviceId AND ca.CampusId = @campusId AND ca.id != @managerId GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.DocumentJoint, dc.CollaborateurADId, ca.Prenom, ca.Nom, ca.Email ORDER BY dc.DateDemande DESC `); rows = result.recordset; console.log(` ✅ ${rows.length} demande(s) historiques (service/campus)`); } // RH : Service + Campus // RH : Toutes les demandes (tous services et campus) else if (role === 'rh') { const queryRequest = pool.request(); queryRequest.input('managerId', managerId); const result = await queryRequest.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, STRING_AGG(tc.Nom, ', ') as type FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE ca.id != @managerId GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.DocumentJoint, dc.CollaborateurADId, ca.Prenom, ca.Nom, ca.Email ORDER BY dc.DateDemande DESC `); rows = result.recordset; console.log(` ✅ ${rows.length} demande(s) historiques (RH - toutes)`); } // ADMIN/PRESIDENT : Toutes else { const result = await pool.request().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, STRING_AGG(tc.Nom, ', ') as type FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId JOIN TypeConge tc ON dct.TypeCongeId = tc.Id GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.DocumentJoint, dc.CollaborateurADId, ca.Prenom, ca.Nom, ca.Email ORDER BY dc.DateDemande DESC `); rows = result.recordset; console.log(` ✅ ${rows.length} demande(s) historiques (Admin)`); } 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 || 'Non dĂ©fini', 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) { console.error('❌ Erreur getAllTeamRequests:', error); res.status(500).json({ success: false, message: 'Erreur DB', error: error.message }); } }); app.get('/api/getPendingRequests', async (req, res) => { try { // ✅ CORRIGÉ : manager_id const managerId = req.query.manager_id; if (!managerId) return res.json({ success: false, message: 'ID manager manquant' }); const request = pool.request(); request.input('managerId', managerId); const managerResult = await request.query(` SELECT ServiceId, CampusId, role FROM CollaborateurAD WHERE id = @managerId `); if (managerResult.recordset.length === 0) { return res.json({ success: false, message: 'Manager non trouvĂ©' }); } const managerRow = managerResult.recordset[0]; const serviceId = managerRow.ServiceId; const campusId = managerRow.CampusId; const role = normalizeRole(managerRow.role); console.log(`🔍 getPendingRequests - Manager: ${managerId}, Role: ${role}`); let rows; // VALIDATEURS, DIRECTEURS ET PRÉSIDENTS : Leurs collaborateurs directs (hiĂ©rarchie) if (role === 'validateur' || role === 'directeur de campus' || role === 'president') { const queryRequest = pool.request(); queryRequest.input('managerId', managerId); const result = await queryRequest.query(` SELECT dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.CollaborateurADId, CONCAT(ca.prenom, ' ', ca.nom) as employee_name, ca.email as employee_email, STRING_AGG(tc.Nom, ', ') as types, SUM(dct.NombreJours) as NombreJoursTotal FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id JOIN HierarchieValidationAD hv ON ca.id = hv.CollaborateurId LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE hv.SuperieurId = @managerId AND dc.Statut = 'En attente' GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.CollaborateurADId, ca.prenom, ca.nom, ca.email ORDER BY dc.DateDemande ASC `); rows = result.recordset; console.log(` ✅ ${rows.length} demande(s) Ă  valider (hiĂ©rarchie - ${role})`); } // RH : Service + Campus else if (role === 'rh') { const queryRequest = pool.request(); queryRequest.input('serviceId', serviceId); queryRequest.input('campusId', campusId); queryRequest.input('managerId', managerId); const result = await queryRequest.query(` SELECT dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.CollaborateurADId, CONCAT(ca.prenom, ' ', ca.nom) as employee_name, ca.email as employee_email, STRING_AGG(tc.Nom, ', ') as types, SUM(dct.NombreJours) as NombreJoursTotal FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE ca.ServiceId = @serviceId AND ca.CampusId = @campusId AND dc.Statut = 'En attente' AND ca.id != @managerId GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.CollaborateurADId, ca.prenom, ca.nom, ca.email ORDER BY dc.DateDemande ASC `); rows = result.recordset; console.log(` ✅ ${rows.length} demande(s) Ă  valider (RH)`); } // ADMIN : Toutes les demandes en attente else if (role === 'admin') { const result = await pool.request().query(` SELECT dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.CollaborateurADId, CONCAT(ca.prenom, ' ', ca.nom) as employee_name, ca.email as employee_email, STRING_AGG(tc.Nom, ', ') as types, SUM(dct.NombreJours) as NombreJoursTotal FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dc.Statut = 'En attente' GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.CollaborateurADId, ca.prenom, ca.nom, ca.email ORDER BY dc.DateDemande ASC `); rows = result.recordset; console.log(` ✅ ${rows.length} demande(s) Ă  valider (Admin)`); } else { return res.json({ success: false, message: 'RĂŽle non autorisĂ© pour la validation' }); } const requests = rows.map(row => ({ id: row.Id, employee_id: row.CollaborateurADId, employee_name: row.employee_name, employee_email: row.employee_email, type: row.types || 'Non dĂ©fini', start_date: row.DateDebut, end_date: row.DateFin, date_display: row.DateDebut === row.DateFin ? formatDate(row.DateDebut) : `${formatDate(row.DateDebut)} - ${formatDate(row.DateFin)}`, days: row.NombreJoursTotal || getWorkingDays(row.DateDebut, row.DateFin), status: row.Statut, reason: row.Commentaire || '', submitted_at: row.DateDemande, submitted_display: formatDate(row.DateDemande) })); 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, DateDebut, DateFin) 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 }); } }); /** * 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) // ======================================== // ============================================================================ // 🔄 FONCTION DE DÉDUCTION AVEC ORDRE CORRIGÉ : N-1 → N → ANTICIPÉ // ============================================================================ // ============================================================================ // 🔄 FONCTION DE DÉDUCTION CORRIGÉE POUR SQL SERVER // Ordre : N-1 → N → ANTICIPÉ // ============================================================================ // ============================================================================ // 🔄 FONCTION DE DÉDUCTION CORRIGÉE POUR SQL SERVER (avec syntaxe ?) // Ordre : N-1 → N → ANTICIPÉ // ============================================================================ // FONCTION CORRIGÉE : deductLeaveBalanceWithAnticipation // Ordre de dĂ©duction : N-1 → N → N AnticipĂ© // FONCTION CORRIGÉE pour SQL Server // FONCTION CORRIGÉE pour votre wrapper MySQL // FONCTION CORRIGÉE POUR SQL SERVER - BASÉE SUR VOTRE ANCIEN CODE MYSQL // ✅ VERSION SQL SERVER - CONVERSION DE VOTRE VERSION MYSQL QUI FONCTIONNE async function deductLeaveBalanceWithAnticipation(conn, collaborateurId, typeCongeId, nombreJours, demandeCongeId, dateDebut) { const today = new Date(); const currentYear = today.getFullYear(); const previousYear = currentYear - 1; let joursRestants = nombreJours; const deductions = []; console.log("📊 DÉDUCTION AVEC PRIORITÉ N-1"); console.log(`Jours Ă  dĂ©duire: ${nombreJours}j`); // ======================================== // 🔍 VĂ©rifier si c'est un CP (pour gĂ©rer N-1) // ======================================== const [typeInfo] = await conn.query( `SELECT Nom FROM TypeConge WHERE Id = ?`, [typeCongeId] ); const isCP = typeInfo.length > 0 && typeInfo[0].Nom === 'CongĂ© payĂ©'; // ======================================== // 1ïžâƒŁ DĂ©duire du REPORT N-1 (CP uniquement) // ======================================== if (isCP) { const [compteurN1] = await conn.query( `SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?`, [collaborateurId, typeCongeId, previousYear] ); if (compteurN1.length > 0) { const soldeN1 = parseFloat(compteurN1[0].Solde || 0); const aDeduireN1 = Math.min(soldeN1, joursRestants); if (aDeduireN1 > 0) { await conn.query( `UPDATE CompteurConges SET Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END, SoldeReporte = CASE WHEN SoldeReporte - ? < 0 THEN 0 ELSE SoldeReporte - ? END, DerniereMiseAJour = GETDATE() WHERE Id=?`, [aDeduireN1, aDeduireN1, aDeduireN1, aDeduireN1, compteurN1[0].Id] ); await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'AnnĂ©e N-1', ?)`, [demandeCongeId, typeCongeId, previousYear, 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.toFixed(2)}j)`); } else { console.log(`⚠ Aucun solde N-1 disponible`); } } else { console.log(`⚠ Pas de compteur N-1 trouvĂ©`); } } // ======================================== // 2ïžâƒŁ DĂ©duire du SOLDE N (acquis 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 soldeTotal = parseFloat(compteurN[0].Solde || 0); const soldeReporte = parseFloat(compteurN[0].SoldeReporte || 0); // ⭐ CORRECTION : Le solde N est le solde total MOINS le report // Car le Solde contient dĂ©jĂ  le SoldeReporte const soldeNAcquis = isCP ? Math.max(0, soldeTotal - soldeReporte) : soldeTotal; const aDeduireN = Math.min(soldeNAcquis, joursRestants); if (aDeduireN > 0) { await conn.query( `UPDATE CompteurConges SET Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END, DerniereMiseAJour = GETDATE() WHERE Id=?`, [aDeduireN, aDeduireN, compteurN[0].Id] ); 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 N", joursUtilises: aDeduireN }); joursRestants -= aDeduireN; console.log(`✅ AnnĂ©e N: ${aDeduireN.toFixed(2)}j dĂ©duits (reste ${joursRestants.toFixed(2)}j)`); } } } if (joursRestants > 0) { console.log(`❌ ERREUR: ${joursRestants.toFixed(2)}j non couverts`); } console.log(`✅ DĂ©duction terminĂ©e - Total dĂ©duit: ${(nombreJours - joursRestants).toFixed(2)}j`); return { success: joursRestants === 0, joursDeduitsTotal: nombreJours - joursRestants, joursNonDeduits: joursRestants, details: deductions }; } /** * Calcule l'acquisition CP projetĂ©e Ă  une date donnĂ©e * RĂšgle : 2.5j/mois travaillĂ© depuis la date d'entrĂ©e (ou juin N-1 si dĂ©jĂ  lĂ ) * Plafond exercice : 25j (juin N → mai N+1) */ function calculerAcquisitionCPSmart(dateReference, dateEntree) { if (!dateEntree) return 0; const ref = new Date(dateReference); const entree = new Date(dateEntree); // DĂ©but de l'exercice en cours (1er juin de l'annĂ©e N-1 ou N selon mois) const moisRef = ref.getMonth() + 1; // 1-12 const anneeRef = ref.getFullYear(); const debutExercice = moisRef >= 6 ? new Date(anneeRef, 5, 1) // 1er juin anneeRef : new Date(anneeRef - 1, 5, 1); // 1er juin anneeRef-1 // Le dĂ©compte commence au plus tard entre entrĂ©e et dĂ©but exercice const dateDepart = entree > debutExercice ? entree : debutExercice; if (ref <= dateDepart) return 0; // Nombre de mois complets travaillĂ©s entre dateDepart et dateReference let mois = 0; const d = new Date(dateDepart); while (true) { const next = new Date(d.getFullYear(), d.getMonth() + 1, d.getDate()); if (next > ref) break; mois++; d.setMonth(d.getMonth() + 1); } // 2.5j par mois, plafonnĂ© Ă  25j return Math.min(25, mois * 2.5); } /** * VĂ©rifie si le collaborateur a assez de CP pour la demande. * Respecte les rĂšgles N-1 prioritaire (NORMAL) / N seulement (ANTICIPATION). */ // ✅ checkLeaveBalanceCP CORRIGÉE // ========================================== // FONCTION CORRIGÉE - deductLeaveBalanceCP // ========================================== async function deductLeaveBalanceCP(conn, collaborateurId, cpTypeId, nombreJours, demandeId, dateDebut) { const today = new Date(); const currentYear = today.getFullYear(); const dateBascule = new Date(currentYear, 5, 1); // 01/06/currentYear dateBascule.setHours(0, 0, 0, 0); const apresBasculement = today >= dateBascule; // Exercice en cours (Annee = annĂ©e de FIN) const anneeExCourant = apresBasculement ? currentYear + 1 : currentYear; const anneeExPrecedent = anneeExCourant - 1; // Exercice CP du congĂ© demandĂ© const dateDebutConge = new Date(dateDebut); const moisConge = dateDebutConge.getMonth() + 1; const anneeConge = dateDebutConge.getFullYear(); const anneeExConge = moisConge >= 6 ? anneeConge + 1 : anneeConge; // Cas anticipĂ© : le congĂ© est sur l'exercice SUIVANT const isAnticipe = anneeExConge > anneeExCourant; console.log(` CP — exerciceCourant=${anneeExCourant} | exerciceConge=${anneeExConge} | anticipĂ©=${isAnticipe}`); let joursRestants = nombreJours; const deductions = []; if (!isAnticipe) { // ── CAS NORMAL ────────────────────────────────────────────────── // PrioritĂ© 1 : CP N-1 const [compteurN1] = await conn.query( `SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, cpTypeId, anneeExPrecedent] ); if (compteurN1.length > 0) { const soldeN1 = Math.max(0, parseFloat(compteurN1[0].Solde) || 0); console.log(` 💰 CP N-1 (Annee=${anneeExPrecedent}): ${soldeN1.toFixed(2)}j`); const aDeduireN1 = Math.min(soldeN1, joursRestants); if (aDeduireN1 > 0) { await conn.query( `UPDATE CompteurConges SET Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [aDeduireN1, aDeduireN1, compteurN1[0].Id] ); await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Report N-1', ?)`, [demandeId, cpTypeId, anneeExPrecedent, aDeduireN1] ); deductions.push({ annee: anneeExPrecedent, type: 'Report N-1', joursUtilises: aDeduireN1 }); joursRestants -= aDeduireN1; console.log(` ✅ CP N-1 dĂ©duit: ${aDeduireN1}j (reste ${joursRestants}j)`); } } else { console.log(` â„č Pas de compteur CP N-1 (Annee=${anneeExPrecedent})`); } // PrioritĂ© 2 : CP N courant if (joursRestants > 0) { const [compteurN] = await conn.query( `SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, cpTypeId, anneeExCourant] ); if (compteurN.length > 0) { const soldeTotal = parseFloat(compteurN[0].Solde) || 0; const soldeReporte = parseFloat(compteurN[0].SoldeReporte) || 0; const soldeN = Math.max(0, soldeTotal - soldeReporte); console.log(` 💰 CP N (Annee=${anneeExCourant}): ${soldeTotal}j - ${soldeReporte}j reportĂ© = ${soldeN.toFixed(2)}j`); 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, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [aDeduireN, aDeduireN, compteurN[0].Id] ); await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'AnnĂ©e N', ?)`, [demandeId, cpTypeId, anneeExCourant, aDeduireN] ); deductions.push({ annee: anneeExCourant, type: 'AnnĂ©e N', joursUtilises: aDeduireN }); joursRestants -= aDeduireN; console.log(` ✅ CP N dĂ©duit: ${aDeduireN}j (reste ${joursRestants}j)`); } } else { console.log(` ⚠ Pas de compteur CP N (Annee=${anneeExCourant})`); } } return { success: joursRestants === 0, mode: 'NORMAL', joursDeduitsTotal: nombreJours - joursRestants, joursNonDeduits: joursRestants, details: deductions }; } else { // ── CAS ANTICIPÉ ───────────────────────────────────────────────── const TAUX_CP = 25 / 360; const anneeN_anticipe = anneeExCourant; // CORRECTION 2+3 : Lire Solde, SoldeReporte ET Total dans la mĂȘme requĂȘte const [cNRows] = await conn.query( `SELECT Id, Solde, COALESCE(SoldeReporte, 0) AS SoldeReporte, COALESCE(Total, 0) AS Total FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, cpTypeId, anneeN_anticipe] ); // CORRECTION 2 : Solde N = Solde brut - SoldeReporte const soldeNBrut = cNRows.length > 0 ? Math.max(0, parseFloat(cNRows[0].Solde || 0)) : 0; const soldeNReporte = cNRows.length > 0 ? parseFloat(cNRows[0].SoldeReporte || 0) : 0; const soldeNActuel = Math.max(0, soldeNBrut - soldeNReporte); // CORRECTION 3 : Plafond calculĂ© avec Total acquis (pas Solde) const totalDejaAcquisN = cNRows.length > 0 ? parseFloat(cNRows[0].Total || 0) : 0; // Projection jusqu'au 31/05/anneeN_anticipe // Projection jusqu'Ă  la DATE DU CONGÉ (pas la fin d'exercice) const dateDebutCongeObj = new Date(dateDebut); dateDebutCongeObj.setHours(0, 0, 0, 0); const joursJusquAuConge = Math.max(0, Math.floor((dateDebutCongeObj - today) / (1000 * 60 * 60 * 24))); const plafondRestantN = Math.max(0, 25 - totalDejaAcquisN); const acquisJusquAuConge = parseFloat(Math.min(plafondRestantN, joursJusquAuConge * TAUX_CP).toFixed(2)); const soldeNProj = parseFloat(Math.min(25, soldeNActuel + acquisJusquAuConge).toFixed(2)); console.log(` 📊 Projection CP N jusqu'au ${dateDebut}: soldeActuel=${soldeNActuel}j, plafond=${plafondRestantN}j, jours d'ici le congĂ©=${joursJusquAuConge}j, acquis d'ici=${acquisJusquAuConge}j, solde projetĂ©=${soldeNProj}j`); // DĂ©jĂ  consommĂ© sur Annee=anneeN_anticipe (Anticip + AnticipFutur des demandes prĂ©cĂ©dentes) const dejaConsoNRows = 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 IN ('Anticip', 'AnticipFutur') AND dc.Statut NOT IN ('RefusĂ©', 'RefusĂ©e', 'AnnulĂ©', 'AnnulĂ©e') AND dc.DateDebut <= ?`, [collaborateurId, cpTypeId, anneeN_anticipe, dateDebut] ); const dejaConsoN = parseFloat(dejaConsoNRows[0]?.total || 0); // ← CETTE LIGNE const dispoN = parseFloat(Math.max(0, soldeNProj - dejaConsoN).toFixed(2)); console.log(` 💰 CP N (${anneeN_anticipe}): actuel=${soldeNActuel.toFixed(2)}j + acquis d'ici=${acquisJusquAuConge.toFixed(2)}j = projetĂ© ${soldeNProj.toFixed(2)}j - consommĂ© anticipĂ© ${dejaConsoN.toFixed(2)}j = dispo ${dispoN.toFixed(2)}j`); // CP N+1 prorata depuis 01/06/anneeN_anticipe jusqu'Ă  dateDebutConge const debutNp1 = new Date(anneeN_anticipe, 5, 1); // 01/06/anneeN_anticipe debutNp1.setHours(0, 0, 0, 0); const diffJoursNp1 = Math.max(0, Math.floor((dateDebutConge - debutNp1) / (1000 * 60 * 60 * 24))); const acquisNp1 = parseFloat(Math.min(25, diffJoursNp1 * TAUX_CP).toFixed(2)); const dejaConsoNp1Rows = 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 dc.Statut NOT IN ('RefusĂ©', 'RefusĂ©e', 'AnnulĂ©', 'AnnulĂ©e') AND dc.DateDebut <= ?`, [collaborateurId, cpTypeId, anneeExConge, dateDebut] ); const dejaConsoNp1 = parseFloat(dejaConsoNp1Rows[0]?.total || 0); const dispoNp1 = parseFloat(Math.max(0, acquisNp1 - dejaConsoNp1).toFixed(2)); console.log(` 💰 CP N+1 (${anneeExConge}): ${diffJoursNp1}j × ${TAUX_CP.toFixed(4)} = ${acquisNp1}j - consommĂ© ${dejaConsoNp1}j = dispo ${dispoNp1}j`); const totalDispo = parseFloat((dispoN + dispoNp1).toFixed(2)); console.log(` 📊 Total anticipĂ©: N=${dispoN.toFixed(2)}j + N+1=${dispoNp1.toFixed(2)}j = ${totalDispo.toFixed(2)}j | demandĂ©s=${nombreJours}j`); if (totalDispo < nombreJours) { console.warn(` ❌ AnticipĂ© bloquĂ© — ${totalDispo.toFixed(2)}j dispo < ${nombreJours}j demandĂ©s`); return { success: false, mode: 'ANTICIPÉ', joursDeduitsTotal: 0, joursNonDeduits: nombreJours, soldeNProj, dejaConsoN, dispoN, acquisNp1, dejaConsoNp1, dispoNp1, totalDispo, details: [], message: `Solde CP anticipĂ© insuffisant : ${totalDispo.toFixed(2)}j disponibles (N: ${dispoN.toFixed(2)}j + N+1: ${dispoNp1.toFixed(2)}j), ${nombreJours}j demandĂ©s` }; } // ── DĂ©duction ──────────────────────────────────────────────────── let joursRestants = nombreJours; // 1ïžâƒŁ DĂ©duire du solde rĂ©el actuel (ce qui est vraiment en base) if (joursRestants > 0 && soldeNActuel > 0 && cNRows.length > 0) { const aDeduire = parseFloat(Math.min(soldeNActuel, joursRestants).toFixed(2)); await conn.query( `UPDATE CompteurConges SET Solde = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [aDeduire, cNRows[0].Id] ); await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Anticip', ?)`, [demandeId, cpTypeId, anneeN_anticipe, aDeduire] ); deductions.push({ annee: anneeN_anticipe, type: 'Anticip', joursUtilises: aDeduire }); joursRestants = parseFloat((joursRestants - aDeduire).toFixed(2)); console.log(` ✅ CP N (${anneeN_anticipe}) Anticip: ${aDeduire.toFixed(2)}j sur solde rĂ©el (reste futur: ${joursRestants.toFixed(2)}j)`); } // 2ïžâƒŁ Jours restants dans CP N (acquis futurs jusqu'au 31/05) → AnticipFutur if (joursRestants > 0 && dispoN > 0) { const aTracerN = parseFloat(Math.min(dispoN, joursRestants).toFixed(2)); await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'AnticipFutur', ?)`, [demandeId, cpTypeId, anneeN_anticipe, aTracerN] ); deductions.push({ annee: anneeN_anticipe, type: 'AnticipFutur', joursUtilises: aTracerN }); joursRestants = parseFloat((joursRestants - aTracerN).toFixed(2)); console.log(` ✅ CP N (${anneeN_anticipe}) AnticipFutur: ${aTracerN.toFixed(2)}j tracĂ© — reste: ${joursRestants.toFixed(2)}j`); } // 3ïžâƒŁ Jours restants = prorata CP N+1 → AnticipNp1 if (joursRestants > 0 && dispoNp1 > 0) { const aTracerNp1 = parseFloat(Math.min(dispoNp1, joursRestants).toFixed(2)); const [cNp1Rows] = await conn.query( `SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, cpTypeId, anneeExConge] ); if (cNp1Rows.length === 0) { const [newIdRows] = await conn.query(`SELECT ISNULL(MAX(Id), 0) + 1 AS nextId FROM CompteurConges`); const newId = (newIdRows[0]?.nextId) || 1; await conn.query( `INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, 0, ?, 0, GETDATE())`, [newId, collaborateurId, cpTypeId, anneeExConge, -aTracerNp1] ); console.log(` ➕ CompteurConges Annee=${anneeExConge} créé Solde=${-aTracerNp1}j`); } else { await conn.query( `UPDATE CompteurConges SET Solde = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [aTracerNp1, cNp1Rows[0].Id] ); console.log(` ✏ CompteurConges Annee=${anneeExConge} Solde - ${aTracerNp1}j`); } await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'AnticipNp1', ?)`, [demandeId, cpTypeId, anneeExConge, aTracerNp1] ); deductions.push({ annee: anneeExConge, type: 'AnticipNp1', joursUtilises: aTracerNp1 }); joursRestants = parseFloat((joursRestants - aTracerNp1).toFixed(2)); console.log(` ✅ CP N+1 (${anneeExConge}) AnticipNp1: ${aTracerNp1.toFixed(2)}j tracĂ©`); } return { success: true, mode: 'ANTICIPÉ', joursDeduitsTotal: nombreJours, joursNonDeduits: 0, soldeNProj, dispoN, details: deductions }; } } // ========================================== // FONCTION CORRIGÉE - checkLeaveBalanceCP // ========================================== async function checkLeaveBalanceCP(conn, collaborateurId, joursNecessaires, dateDebut) { const today = new Date(); today.setHours(0, 0, 0, 0); const currentYear = today.getFullYear(); const userIdInt = parseInt(collaborateurId, 10); const dateDebutObj = new Date(dateDebut); dateDebutObj.setHours(0, 0, 0, 0); const TAUX_CP = 25 / 360; const dateBascule = new Date(currentYear, 5, 1); dateBascule.setHours(0, 0, 0, 0); const apresBasculement = today >= dateBascule; const anneeExCourant = apresBasculement ? currentYear + 1 : currentYear; const anneeExPrecedent = anneeExCourant - 1; const finExerciceCourant = new Date(anneeExCourant, 4, 31); finExerciceCourant.setHours(23, 59, 59, 0); const isAnticipe = dateDebutObj > finExerciceCourant; const moisDebut = dateDebutObj.getMonth() + 1; const anneeDebut = dateDebutObj.getFullYear(); const anneeExConge = moisDebut >= 6 ? anneeDebut + 1 : anneeDebut; console.log(`\n🔍 CHECK CP BALANCE`); console.log(` Collaborateur: ${userIdInt}`); console.log(` Jours nĂ©cessaires: ${joursNecessaires}j`); console.log(` Exercice courant: ${anneeExCourant} | Fin exercice: ${finExerciceCourant.toLocaleDateString('fr-FR')}`); console.log(` Date congĂ©: ${dateDebut} | Mode: ${isAnticipe ? 'ANTICIPÉ 🔼' : 'NORMAL'}`); const [cpTypeRows] = await conn.query( `SELECT TOP 1 Id FROM TypeConge WHERE Nom = ?`, ['CongĂ© payĂ©'] ); if (cpTypeRows.length === 0) throw new Error('Type CP introuvable'); const cpTypeId = cpTypeRows[0].Id; // ── Lire solde ET total acquis ───────────────────────────────────────── const [cNRows] = await conn.query( `SELECT TOP 1 COALESCE(Solde, 0) AS Solde, COALESCE(SoldeReporte, 0) AS SoldeReporte, COALESCE(Total, 0) AS Total FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? ORDER BY Id DESC`, [userIdInt, cpTypeId, anneeExCourant] ); const soldeNActuel = cNRows.length > 0 ? Math.max(0, parseFloat(cNRows[0].Solde) - parseFloat(cNRows[0].SoldeReporte)) : 0; const totalDejaAcquis = cNRows.length > 0 ? parseFloat(cNRows[0].Total) : 0; // ── Projection CP N jusqu'Ă  la date du congĂ© ────────────────────────── const joursJusquAuDebut = Math.max(0, Math.floor((dateDebutObj - today) / (1000 * 60 * 60 * 24))); const acquisDiciDebut = parseFloat( Math.min(25 - totalDejaAcquis, joursJusquAuDebut * TAUX_CP).toFixed(2) ); const soldeNProjecte = parseFloat((soldeNActuel + acquisDiciDebut).toFixed(2)); // ── DĂ©jĂ  consommĂ© Anticip+AnticipFutur sur anneeExCourant ───────────── const dejaConsoNRows = 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 IN ('Anticip', 'AnticipFutur') AND dc.Statut != 'RefusĂ©' AND dc.DateDebut <= ? -- ✅ AJOUT `, [userIdInt, cpTypeId, anneeExCourant, dateDebut]); const dejaConsoN = parseFloat(dejaConsoNRows[0]?.total || 0); const soldeNNet = parseFloat(Math.max(0, soldeNProjecte - dejaConsoN).toFixed(2)); console.log(` CP N (${anneeExCourant}): actuel=${soldeNActuel}j + ${joursJusquAuDebut}j × ${TAUX_CP.toFixed(4)} = +${acquisDiciDebut}j → projetĂ© ${soldeNProjecte}j - consommĂ© ${dejaConsoN}j = net ${soldeNNet}j`); if (isAnticipe) { // ── MODE ANTICIPÉ : CP N net + CP N+1 prorata jusqu'Ă  dateDebutObj ── const debutNp1 = new Date(anneeExCourant, 5, 1); debutNp1.setHours(0, 0, 0, 0); const diffJoursNp1 = Math.max(0, Math.floor((dateDebutObj - debutNp1) / (1000 * 60 * 60 * 24))); const acquisNp1 = parseFloat(Math.min(25, diffJoursNp1 * TAUX_CP).toFixed(2)); const dejaConsoNp1Rows = 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 dc.Statut != 'RefusĂ©' AND dc.DateDebut <= ? -- ✅ AJOUT `, [userIdInt, cpTypeId, anneeExConge, dateDebut]); const dejaConsoNp1 = parseFloat(dejaConsoNp1Rows[0]?.total || 0); const dispoNp1 = parseFloat(Math.max(0, acquisNp1 - dejaConsoNp1).toFixed(2)); const soldeTotalDisponible = parseFloat((soldeNNet + dispoNp1).toFixed(2)); const suffisant = soldeTotalDisponible >= joursNecessaires; const deficit = suffisant ? 0 : parseFloat((joursNecessaires - soldeTotalDisponible).toFixed(2)); console.log(` CP N+1 (${anneeExConge}): ${diffJoursNp1}j × ${TAUX_CP.toFixed(4)} = ${acquisNp1}j - consommĂ© ${dejaConsoNp1}j = dispo ${dispoNp1}j`); console.log(` 📊 Total anticipĂ©: ${soldeTotalDisponible}j | besoin=${joursNecessaires}j`); console.log(` ${suffisant ? '✅ AUTORISÉ' : '❌ BLOQUÉ'}`); return { valide: suffisant, mode: 'ANTICIPATION', isAnticipe: true, anneeN: anneeExCourant, anneeNp1: anneeExConge, soldeNProjecte, dejaConsoN, soldeNNet, acquisNp1, dejaConsoNp1, dispoNp1, soldeTotalDisponible, deficit, message: suffisant ? undefined : `Solde CP insuffisant (⏩ AnticipĂ©) Maximum disponible : ${soldeTotalDisponible.toFixed(2)}j — DemandĂ© : ${joursNecessaires}j — DĂ©ficit : ${deficit.toFixed(2)}j` }; } else { // ── MODE NORMAL : N-1 + N projetĂ© ───────────────────────────────── const [cN1Rows] = await conn.query( `SELECT TOP 1 COALESCE(Solde, 0) AS Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? ORDER BY Id DESC`, [userIdInt, cpTypeId, anneeExPrecedent] ); const soldeN1 = cN1Rows.length > 0 ? Math.max(0, parseFloat(cN1Rows[0].Solde)) : 0; const soldeTotalDisponible = parseFloat((soldeN1 + soldeNNet).toFixed(2)); const suffisant = soldeTotalDisponible >= joursNecessaires; const deficit = suffisant ? 0 : parseFloat((joursNecessaires - soldeTotalDisponible).toFixed(2)); console.log(` CP N-1 (${anneeExPrecedent}): ${soldeN1}j`); console.log(` Total disponible: N-1=${soldeN1}j + N net=${soldeNNet}j = ${soldeTotalDisponible}j`); console.log(` ${suffisant ? '✅ AUTORISÉ' : '❌ BLOQUÉ'} — besoin=${joursNecessaires}j`); return { valide: suffisant, mode: 'NORMAL', isAnticipe: false, soldeN1, soldeNActuel, soldeNProjecte, soldeNNet, soldeTotalDisponible, deficit, message: suffisant ? undefined : `Solde CP insuffisant. Maximum disponible : ${soldeTotalDisponible.toFixed(2)}j — DemandĂ© : ${joursNecessaires}j — DĂ©ficit : ${deficit.toFixed(2)}j` }; } } // ============================================================================ // 🔍 FONCTION DE VÉRIFICATION DES SOLDES (sans anticipation) // ============================================================================ async function checkLeaveBalanceWithAnticipation(conn, collaborateurId, repartition, dateDebut) { const today = new Date(); today.setHours(0, 0, 0, 0); const currentYear = today.getFullYear(); const previousYear = currentYear - 1; console.log('\n🔍 === CHECK SOLDES (ANTICIPATION DÉSACTIVÉE) ==='); 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 === 'RĂ©cup' ? 'RĂ©cupĂ©ration' : 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; // ==================================== // CP - SANS ANTICIPATION // ==================================== if (typeCode === 'CP') { // Solde N-1 (reportĂ©) const [compteurN1] = await conn.query(` SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, previousYear]); const soldeN1 = compteurN1.length > 0 ? parseFloat(compteurN1[0].Solde || 0) : 0; // Solde N (actuel) const [compteurN] = await conn.query(` SELECT Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, currentYear]); const soldeNTotal = compteurN.length > 0 ? parseFloat(compteurN[0].Solde || 0) : 0; const soldeReporte = compteurN.length > 0 ? parseFloat(compteurN[0].SoldeReporte || 0) : 0; const soldeN = Math.max(0, soldeNTotal - soldeReporte); // ⭐ TOTAL SANS ANTICIPATION const soldeTotal = soldeN1 + soldeN; console.log(`💰 CP: N-1=${soldeN1.toFixed(2)}j + N=${soldeN.toFixed(2)}j = ${soldeTotal.toFixed(2)}j`); const suffisant = soldeTotal >= joursNecessaires; verification.push({ type: typeName, joursNecessaires, soldeN1, soldeN, soldeTotal, anticipeDisponible: 0, suffisant, deficit: Math.max(0, joursNecessaires - soldeTotal) }); if (!suffisant) { return { valide: false, message: `Solde CP insuffisant : ${soldeTotal.toFixed(2)}j disponibles, ${joursNecessaires}j demandĂ©s`, details: verification, insuffisants: verification.filter(v => !v.suffisant) }; } } // ==================================== // RTT - SANS ANTICIPATION // ==================================== else if (typeCode === 'RTT') { const [compteurN] = await conn.query(` SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, currentYear]); const soldeN = compteurN.length > 0 ? parseFloat(compteurN[0].Solde || 0) : 0; console.log(`💰 RTT: ${soldeN.toFixed(2)}j disponibles`); const suffisant = soldeN >= joursNecessaires; verification.push({ type: typeName, joursNecessaires, soldeN, soldeTotal: soldeN, anticipeDisponible: 0, suffisant, deficit: Math.max(0, joursNecessaires - soldeN) }); if (!suffisant) { return { valide: false, message: `Solde RTT insuffisant : ${soldeN.toFixed(2)}j disponibles, ${joursNecessaires}j demandĂ©s`, details: verification, insuffisants: verification.filter(v => !v.suffisant) }; } } // ==================================== // RÉCUP // ==================================== 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 [compteur] = await conn.query(` SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, recupType[0].Id, currentYear]); const soldeRecup = compteur.length > 0 ? parseFloat(compteur[0].Solde || 0) : 0; const suffisant = soldeRecup >= joursNecessaires; verification.push({ type: 'RĂ©cupĂ©ration', joursNecessaires, soldeTotal: soldeRecup, suffisant, deficit: Math.max(0, joursNecessaires - soldeRecup) }); if (!suffisant) { return { valide: false, message: `Solde RĂ©cupĂ©ration insuffisant : ${soldeRecup.toFixed(2)}j disponibles, ${joursNecessaires}j demandĂ©s`, details: verification, insuffisants: verification.filter(v => !v.suffisant) }; } } } return { valide: true, details: verification, insuffisants: [] }; } // ======================================== // 🔧 FONCTION HELPER - GÉNÉRATION D'ID // ======================================== // ✅ VERSION CORRIGÉE // ✅ getNextId — NE PREND PLUS conn EN PARAMÈTRE // ✅ getNextId — pool.request() direct, sans conn async function getNextId(connection, tableName) { const request = pool.request(); const result = await request.query( `SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM [${tableName}]` ); const id = result.recordset[0].NextId; console.log(`🔑 getNextId(${tableName}) = ${id}`); return id; } // ========================================== // CODE CORRIGÉ - submitLeaveRequest // ========================================== // ✅ Helper Ă  placer UNE FOIS en haut du fichier (aprĂšs la dĂ©finition du pool) function extractValue(row, ...keys) { for (const key of keys) { const val = row[key] ?? row[key.toLowerCase()] ?? row[key.toUpperCase()]; if (val !== undefined && val !== null) { if (typeof val === 'object') return Object.values(val)[0] ?? null; return val; } } for (const val of Object.values(row)) { if (val !== null && val !== undefined && typeof val !== 'object') return val; } return null; } // ✅ Helper Ă  ajouter UNE FOIS en haut du fichier function getRows(result) { // GĂšre : result, result.recordset, result[0], result.recordset[0] if (!result) return []; if (Array.isArray(result)) { // Si c'est un tableau de tableaux [[{...}]] if (result.length > 0 && Array.isArray(result[0])) return result[0]; return result; } if (result.recordset) { const rs = result.recordset; if (Array.isArray(rs) && rs.length > 0 && Array.isArray(rs[0])) return rs[0]; return rs; } return []; } // ════════════════════════════════════════════════════════════════════════ // POST /api/submitLeaveRequest // ════════════════════════════════════════════════════════════════════════ app.post('/api/submitLeaveRequest', uploadMedical.array('medicalDocuments', 5), async (req, res) => { const conn = await pool.getConnection(); try { await conn.beginTransaction(); console.log('🚀 Transaction dĂ©marrĂ©e'); const today = new Date(); const currentYear = today.getFullYear(); // ── CP : basculement 1er juin ────────────────────────────────── const dateBasculeCP = new Date(`${currentYear}-06-01`); const apresBasc = today >= dateBasculeCP; const anneeN = apresBasc ? currentYear + 1 : currentYear; const anneeN1 = apresBasc ? currentYear : currentYear - 1; const uploadedFiles = req.files || []; console.log('📎 Fichiers mĂ©dicaux reçus:', uploadedFiles.length); 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; let Repartition; try { Repartition = JSON.parse(req.body.Repartition); } catch (e) { console.error('❌ Erreur parsing Repartition:', e); Repartition = []; } // ── Helpers ─────────────────────────────────────────────────── const toTypeName = (code) => ({ 'CP': 'CongĂ© payĂ©', 'RTT': 'RTT', 'ABS': 'CongĂ© maladie', 'Formation': 'Formation', 'RĂ©cup': 'RĂ©cupĂ©ration' }[code] || code); const getRows = (result) => { if (!result) return []; if (Array.isArray(result)) { if (result.length > 0 && Array.isArray(result[0])) return result[0]; return result; } if (result.recordset) { const rs = result.recordset; if (Array.isArray(rs) && rs.length > 0 && Array.isArray(rs[0])) return rs[0]; return rs; } return []; }; const readNum = (row, key) => { if (!row) return null; const v = row[key] ?? row[key.toLowerCase()] ?? row[key.toUpperCase()]; if (v === undefined || v === null) return null; const n = Number(v); return isNaN(n) ? null : n; }; // ── Validation PeriodeJournee ───────────────────────────────── const validPeriodes = ['JournĂ©e entiĂšre', 'Matin', 'AprĂšs-midi']; for (const rep of Repartition) { const periodeDebut = rep.PeriodeJourneeDebut || 'JournĂ©e entiĂšre'; const periodeFin = rep.PeriodeJourneeFin || 'JournĂ©e entiĂšre'; if (!validPeriodes.includes(periodeDebut)) { await conn.rollback(); conn.release(); return res.json({ success: false, message: `PeriodeJourneeDebut invalide: ${periodeDebut}` }); } if (!validPeriodes.includes(periodeFin)) { await conn.rollback(); conn.release(); return res.json({ success: false, message: `PeriodeJourneeFin invalide: ${periodeFin}` }); } // VĂ©rification cohĂ©rence demi-journĂ©e (mĂȘme jour) const isDemiJournee = periodeDebut === periodeFin && periodeDebut !== 'JournĂ©e entiĂšre'; if (isDemiJournee && parseFloat(rep.NombreJours) !== 0.5) { await conn.rollback(); conn.release(); return res.json({ success: false, message: `Demi-journĂ©e doit valoir 0.5j (reçu: ${rep.NombreJours}j)` }); } } if (!Repartition || Repartition.length === 0) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'RĂ©partition manquante' }); } const totalJoursDemandes = Repartition.reduce((sum, r) => sum + parseFloat(r.NombreJours || 0), 0); if (totalJoursDemandes > 25) { await conn.rollback(); conn.release(); return res.json({ success: false, message: `Vous ne pouvez pas poser plus de 25 jours ouvrĂ©s par demande (demandĂ© : ${totalJoursDemandes}j)` }); } // ── ÉTAPE 1 : Trouver le collaborateur ──────────────────────── console.log('🔍 ÉTAPE 1 - Recherche collaborateur:', Email); const collabADRaw = await conn.query( `SELECT TOP 1 CAST(id AS INT) AS collab_id FROM CollaborateurAD WHERE email = ?`, [Email] ); const collabAD = getRows(collabADRaw); const isAD = collabAD.length > 0; const collaborateurId = isAD ? readNum(collabAD[0], 'collab_id') : null; console.log('đŸ‘€ CollaborateurAD trouvĂ©:', isAD, '| ID:', collaborateurId); if (!isAD || !collaborateurId) { await conn.rollback(); conn.release(); return res.json({ success: false, message: `Collaborateur introuvable: ${Email}` }); } // ── Trouver le validateur ───────────────────────────────────── let validateurId = null; let validateurADId = null; const hierRaw = await conn.query( `SELECT TOP 1 CAST(SuperieurId AS INT) AS sup_id FROM HierarchieValidationAD WHERE CollaborateurId = ?`, [collaborateurId] ); const hier = getRows(hierRaw); if (hier.length > 0) validateurADId = readNum(hier[0], 'sup_id'); console.log('👔 ValidateurADId:', validateurADId); const isFormationOnly = Repartition.every(r => r.TypeConge === 'Formation'); const statutDemande = isFormationOnly ? 'ValidĂ©' : (statut || 'En attente'); console.log('📋 isFormationOnly:', isFormationOnly, '| Statut:', statutDemande); // ── TypeCongeId CSV ─────────────────────────────────────────── const typeIds = []; for (const rep of Repartition) { const name = toTypeName(rep.TypeConge); const tRaw = await conn.query( `SELECT TOP 1 CAST(Id AS INT) AS type_id FROM TypeConge WHERE Nom = ?`, [name] ); const tRow = getRows(tRaw); if (tRow.length > 0) { const tid = readNum(tRow[0], 'type_id'); if (tid && !typeIds.includes(tid)) typeIds.push(tid); } } const typeCongeIdCsv = typeIds.join(','); console.log('đŸ· TypeCongeIdCsv:', typeCongeIdCsv); if (!typeCongeIdCsv) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Type de congĂ© introuvable en base' }); } // ── ÉTAPE 2 : INSERT DemandeConge ───────────────────────────── console.log('📝 ÉTAPE 2 - CrĂ©ation DemandeConge'); const nextDcRaw = await conn.query( `SELECT CAST(ISNULL(MAX(Id), 0) + 1 AS INT) AS next_id FROM DemandeConge` ); const demandeId = readNum(getRows(nextDcRaw)[0], 'next_id'); console.log('🆔 Prochain ID DemandeConge:', demandeId); await conn.query( `INSERT INTO DemandeConge (Id, CollaborateurADId, DateDebut, DateFin, TypeCongeId, Statut, DateDemande, Commentaire, Validateur, NombreJours, ValidateurId, ValidateurADId) VALUES (?, ?, ?, ?, ?, ?, GETDATE(), ?, ?, ?, ?, ?)`, [demandeId, collaborateurId, DateDebut, DateFin, typeCongeIdCsv, statutDemande, Commentaire, '', NombreJours, validateurId, validateurADId] ); console.log('✅ Demande créée ID:', demandeId, '| Statut:', statutDemande); // ── ÉTAPE 3 : Fichiers mĂ©dicaux ─────────────────────────────── if (uploadedFiles.length > 0) { console.log('📎 ÉTAPE 3 - Sauvegarde fichiers mĂ©dicaux'); for (const file of uploadedFiles) { const nextDocRaw = await conn.query( `SELECT CAST(ISNULL(MAX(Id), 0) + 1 AS INT) AS next_id FROM DocumentsMedicaux` ); const docId = readNum(getRows(nextDocRaw)[0], 'next_id'); await conn.query( `INSERT INTO DocumentsMedicaux (Id, DemandeCongeId, NomFichier, CheminFichier, TypeMime, TailleFichier, DateUpload) VALUES (?, ?, ?, ?, ?, ?, GETDATE())`, [docId, demandeId, file.originalname, file.path, file.mimetype, file.size] ); console.log('📄 Fichier sauvegardĂ©:', file.originalname); } } // ── ÉTAPE 4 : DemandeCongeType ──────────────────────────────── // ✅ CORRECTION : on stocke PeriodeJournee (dĂ©but) ET PeriodeJourneeFin sĂ©parĂ©ment // PrĂ©requis SQL : ALTER TABLE DemandeCongeType ADD PeriodeJourneeFin NVARCHAR(50) NULL; console.log('📋 ÉTAPE 4 - Sauvegarde rĂ©partition'); for (const rep of Repartition) { const name = toTypeName(rep.TypeConge); const typeRowRaw = await conn.query( `SELECT TOP 1 CAST(Id AS INT) AS type_id FROM TypeConge WHERE Nom = ?`, [name] ); const typeRow = getRows(typeRowRaw); if (typeRow.length > 0) { const typeCongeId = readNum(typeRow[0], 'type_id'); const periodeDebut = rep.PeriodeJourneeDebut || 'JournĂ©e entiĂšre'; const periodeFin = rep.PeriodeJourneeFin || 'JournĂ©e entiĂšre'; const segmentDateDebut = rep.DateDebut ? new Date(rep.DateDebut).toISOString().split('T')[0] : DateDebut; const segmentDateFin = rep.DateFin ? new Date(rep.DateFin).toISOString().split('T')[0] : DateFin; const nextDctRaw = await conn.query( `SELECT CAST(ISNULL(MAX(Id), 0) + 1 AS INT) AS next_id FROM DemandeCongeType` ); const dctId = readNum(getRows(nextDctRaw)[0], 'next_id'); await conn.query( `INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee, PeriodeJourneeFin, DateDebut, DateFin) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [dctId, demandeId, typeCongeId, rep.NombreJours, periodeDebut, periodeFin, segmentDateDebut, segmentDateFin] ); console.log(`✅ ${name} → ${rep.NombreJours}j | ${segmentDateDebut} → ${segmentDateFin} | dĂ©but:${periodeDebut} fin:${periodeFin}`); } else { console.warn(`⚠ TypeConge introuvable: ${name}`); } } // ── ÉTAPE 5 : DĂ©duction des compteurs ───────────────────────── console.log('💰 ÉTAPE 5 - DĂ©duction compteurs'); if (isAD && collaborateurId && !isFormationOnly) { for (const rep of Repartition) { if (rep.TypeConge === 'ABS' || rep.TypeConge === 'Formation') { console.log(`⏭ Skip dĂ©duction: ${rep.TypeConge}`); continue; } // ── RĂ©cup ───────────────────────────────────────────── if (rep.TypeConge === 'RĂ©cup') { const recupRaw = await conn.query( `SELECT TOP 1 CAST(Id AS INT) AS type_id FROM TypeConge WHERE Nom = ?`, ['RĂ©cupĂ©ration'] ); const recupRow = getRows(recupRaw); if (!recupRow.length) throw new Error('Type RĂ©cupĂ©ration introuvable'); const recupTypeId = readNum(recupRow[0], 'type_id'); 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, recupTypeId, currentYear] ); await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'RĂ©cup PosĂ©', ?)`, [demandeId, recupTypeId, currentYear, rep.NombreJours] ); console.log(`✅ RĂ©cup dĂ©duit: ${rep.NombreJours}j`); continue; } // ── CP ──────────────────────────────────────────────── if (rep.TypeConge === 'CP') { const cpRaw = await conn.query( `SELECT TOP 1 CAST(Id AS INT) AS type_id FROM TypeConge WHERE Nom = ?`, ['CongĂ© payĂ©'] ); const cpRow = getRows(cpRaw); if (!cpRow.length) throw new Error('Type CongĂ© payĂ© introuvable'); const cpTypeId = readNum(cpRow[0], 'type_id'); console.log('🔄 Appel deductLeaveBalanceCP...'); const result = await deductLeaveBalanceCP( conn, collaborateurId, cpTypeId, parseFloat(rep.NombreJours), demandeId, DateDebut ); console.log('📊 RĂ©sultat dĂ©duction CP:', result); if (!result.success) { if (result.mode === 'ANTICIPÉ POST-BASCULEMENT') { console.warn(`⚠ AnticipĂ© post-basculement — ${result.joursNonDeduits.toFixed(2)}j non couverts`); await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'AnticipĂ© TolĂ©rĂ©', ?)`, [demandeId, cpTypeId, anneeN, result.joursNonDeduits] ); } else { throw new Error(`DĂ©duction CP Ă©chouĂ©e — ${result.joursNonDeduits.toFixed(2)}j non couverts`); } } console.log(`✅ CP dĂ©duits: ${result.joursDeduitsTotal.toFixed(2)}j | Mode: ${result.mode}`); continue; } // ── RTT ─────────────────────────────────────────────── if (rep.TypeConge === 'RTT') { const rttRaw = await conn.query( `SELECT TOP 1 CAST(Id AS INT) AS type_id FROM TypeConge WHERE Nom = ?`, ['RTT'] ); const rttRow = getRows(rttRaw); if (!rttRow.length) throw new Error('Type RTT introuvable'); const rttTypeId = readNum(rttRow[0], 'type_id'); const anneeRTT = new Date(DateDebut).getFullYear(); const isAnticipeRTT = anneeRTT > currentYear; console.log(` RTT — anneeRTT=${anneeRTT} (${isAnticipeRTT ? 'anticipĂ©' : 'normal'})`); if (!isAnticipeRTT) { const existingRTT = await conn.query( `SELECT TOP 1 Id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, rttTypeId, anneeRTT] ); if (!getRows(existingRTT).length) { const nextRttIdRaw = await conn.query( `SELECT CAST(ISNULL(MAX(Id), 0) + 1 AS INT) AS next_id FROM CompteurConges` ); const nextRttId = readNum(getRows(nextRttIdRaw)[0], 'next_id'); const conn2 = await pool.getConnection(); let acquisRTT = 0; try { const finEx = new Date(anneeRTT, 11, 31); const rttData = await calculerAcquisitionRTTSmart(conn2, collaborateurId, finEx); acquisRTT = rttData.acquisition; } finally { conn2.release(); } await conn.query( `INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, 0, GETDATE())`, [nextRttId, collaborateurId, rttTypeId, anneeRTT, acquisRTT] ); console.log(` ✅ Compteur RTT ${anneeRTT} créé (Total=${acquisRTT.toFixed(2)}j)`); } 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, rttTypeId, anneeRTT] ); await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'AnnĂ©e N', ?)`, [demandeId, rttTypeId, anneeRTT, rep.NombreJours] ); console.log(`✅ RTT normal dĂ©duit: ${rep.NombreJours}j sur Annee=${anneeRTT}`); } else { const anneeRTTCourant = currentYear; const nombreJoursRTT = parseFloat(rep.NombreJours); const conn2rtt = await pool.getConnection(); let joursAnnuelsRTTN = 10; try { const cfgN = await getConfigurationRTT(conn2rtt, anneeRTTCourant, typeContrat); joursAnnuelsRTTN = cfgN.joursAnnuels; } finally { conn2rtt.release(); } const tauxRTTN = joursAnnuelsRTTN / 360; const conn3rtt = await pool.getConnection(); let joursAnnuelsRTTNp1 = 10; try { const cfgNp1 = await getConfigurationRTT(conn3rtt, anneeRTT, typeContrat); joursAnnuelsRTTNp1 = cfgNp1.joursAnnuels; } finally { conn3rtt.release(); } const tauxRTTNp1 = joursAnnuelsRTTNp1 / 360; const [cRTTNRows] = await conn.query( `SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, rttTypeId, anneeRTTCourant] ); const soldeRTTNActuel = cRTTNRows.length > 0 ? Math.max(0, parseFloat(cRTTNRows[0].Solde || 0)) : 0; const finExRTTN = new Date(anneeRTTCourant, 11, 31); finExRTTN.setHours(23, 59, 59, 0); const joursRestantsRTTN = Math.max(0, Math.floor((finExRTTN - today) / (1000 * 60 * 60 * 24))); const acquisRestantsRTTN = parseFloat(Math.min(joursAnnuelsRTTN - soldeRTTNActuel, joursRestantsRTTN * tauxRTTN).toFixed(2)); const soldeRTTNProj = parseFloat(Math.min(joursAnnuelsRTTN, soldeRTTNActuel + acquisRestantsRTTN).toFixed(2)); const [dejaConsoRTTNRows] = 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 IN ('AnticipRTT', 'AnticipFuturRTT') AND dc.Statut != 'RefusĂ©'`, [collaborateurId, rttTypeId, anneeRTTCourant] ); const dejaConsoRTTN = parseFloat(dejaConsoRTTNRows[0]?.total || 0); const dispoRTTN = parseFloat(Math.max(0, soldeRTTNProj - dejaConsoRTTN).toFixed(2)); const dateDebutCongeRTT = new Date(DateDebut); dateDebutCongeRTT.setHours(0, 0, 0, 0); const debutNp1RTT = new Date(anneeRTT, 0, 1); debutNp1RTT.setHours(0, 0, 0, 0); const diffJoursRTTNp1 = Math.max(0, Math.floor((dateDebutCongeRTT - debutNp1RTT) / (1000 * 60 * 60 * 24))); const acquisRTTNp1 = parseFloat(Math.min(joursAnnuelsRTTNp1, diffJoursRTTNp1 * tauxRTTNp1).toFixed(2)); const [dejaConsoRTTNp1Rows] = 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 dc.Statut != 'RefusĂ©'`, [collaborateurId, rttTypeId, anneeRTT] ); const dejaConsoRTTNp1 = parseFloat(dejaConsoRTTNp1Rows[0]?.total || 0); const dispoRTTNp1 = parseFloat(Math.max(0, acquisRTTNp1 - dejaConsoRTTNp1).toFixed(2)); const totalDispoRTT = parseFloat((dispoRTTN + dispoRTTNp1).toFixed(2)); console.log(` 💰 RTT N (${anneeRTTCourant}): actuel=${soldeRTTNActuel.toFixed(2)}j + restants=${acquisRestantsRTTN.toFixed(2)}j = projetĂ© ${soldeRTTNProj.toFixed(2)}j - consommĂ© ${dejaConsoRTTN.toFixed(2)}j = dispo ${dispoRTTN.toFixed(2)}j`); console.log(` 💰 RTT N+1 (${anneeRTT}): ${diffJoursRTTNp1}j × ${tauxRTTNp1.toFixed(4)} = ${acquisRTTNp1.toFixed(2)}j - consommĂ© ${dejaConsoRTTNp1.toFixed(2)}j = dispo ${dispoRTTNp1.toFixed(2)}j`); console.log(` 📊 RTT total anticipĂ©: ${totalDispoRTT.toFixed(2)}j | demandĂ©s=${nombreJoursRTT}j`); if (totalDispoRTT < nombreJoursRTT) { throw new Error(`Solde RTT anticipĂ© insuffisant : ${totalDispoRTT.toFixed(2)}j disponibles (N: ${dispoRTTN.toFixed(2)}j + N+1: ${dispoRTTNp1.toFixed(2)}j), ${nombreJoursRTT}j demandĂ©s`); } let joursRestantsRTT2 = nombreJoursRTT; if (joursRestantsRTT2 > 0 && soldeRTTNActuel > 0 && cRTTNRows.length > 0) { const aDeduireRTT = parseFloat(Math.min(soldeRTTNActuel, joursRestantsRTT2).toFixed(2)); await conn.query( `UPDATE CompteurConges SET Solde = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [aDeduireRTT, cRTTNRows[0].Id] ); await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'AnticipRTT', ?)`, [demandeId, rttTypeId, anneeRTTCourant, aDeduireRTT] ); joursRestantsRTT2 = parseFloat((joursRestantsRTT2 - aDeduireRTT).toFixed(2)); console.log(` ✅ RTT AnticipRTT: ${aDeduireRTT.toFixed(2)}j dĂ©duit → reste: ${joursRestantsRTT2.toFixed(2)}j`); } if (joursRestantsRTT2 > 0 && dispoRTTN > 0) { const aTracerRTTN = parseFloat(Math.min(dispoRTTN, joursRestantsRTT2).toFixed(2)); await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'AnticipFuturRTT', ?)`, [demandeId, rttTypeId, anneeRTTCourant, aTracerRTTN] ); joursRestantsRTT2 = parseFloat((joursRestantsRTT2 - aTracerRTTN).toFixed(2)); console.log(` ✅ RTT AnticipFuturRTT: ${aTracerRTTN.toFixed(2)}j tracĂ© → reste: ${joursRestantsRTT2.toFixed(2)}j`); } if (joursRestantsRTT2 > 0 && dispoRTTNp1 > 0) { const aTracerRTTNp1 = parseFloat(Math.min(dispoRTTNp1, joursRestantsRTT2).toFixed(2)); const [cRTTNp1Rows] = await conn.query( `SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, rttTypeId, anneeRTT] ); if (cRTTNp1Rows.length === 0) { const [newIdRTTRows] = await conn.query(`SELECT ISNULL(MAX(Id), 0) + 1 AS nextId FROM CompteurConges`); const newIdRTT = newIdRTTRows[0]?.nextId || 1; await conn.query( `INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, 0, ?, 0, GETDATE())`, [newIdRTT, collaborateurId, rttTypeId, anneeRTT, -aTracerRTTNp1] ); console.log(` ➕ CompteurConges RTT Annee=${anneeRTT} créé Solde=${-aTracerRTTNp1}j`); } else { await conn.query( `UPDATE CompteurConges SET Solde = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [aTracerRTTNp1, cRTTNp1Rows[0].Id] ); console.log(` ✏ CompteurConges RTT Annee=${anneeRTT} Solde - ${aTracerRTTNp1}j`); } await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'AnticipNp1RTT', ?)`, [demandeId, rttTypeId, anneeRTT, aTracerRTTNp1] ); joursRestantsRTT2 = parseFloat((joursRestantsRTT2 - aTracerRTTNp1).toFixed(2)); console.log(` ✅ RTT AnticipNp1RTT: ${aTracerRTTNp1.toFixed(2)}j tracĂ© sur N+1`); } if (joursRestantsRTT2 > 0.01) { throw new Error(`DĂ©duction RTT anticipĂ©e incomplĂšte: ${joursRestantsRTT2.toFixed(2)}j non couverts`); } console.log(`✅ RTT anticipĂ© dĂ©duit: ${nombreJoursRTT}j | AnticipRTT+AnticipFuturRTT+AnticipNp1RTT`); } continue; } } console.log('✅ DĂ©ductions compteurs terminĂ©es'); } // ── ÉTAPE 6 : Notifications ─────────────────────────────────── console.log('🔔 ÉTAPE 6 - Notifications'); const dateDebutFmt = new Date(DateDebut).toLocaleDateString('fr-FR'); const dateFinFmt = new Date(DateFin).toLocaleDateString('fr-FR'); const datesPeriode = dateDebutFmt === dateFinFmt ? dateDebutFmt : `du ${dateDebutFmt} au ${dateFinFmt}`; if (isFormationOnly && collaborateurId) { const maxNotifRaw = await conn.query( `SELECT CAST(ISNULL(MAX(Id), 0) + 1 AS INT) AS next_id FROM Notifications` ); const notifId = readNum(getRows(maxNotifRaw)[0], 'next_id'); await conn.query( `INSERT INTO Notifications (Id, CollaborateurADId, Type, Titre, Message, DemandeCongeId, DateCreation, Lu) VALUES (?, ?, 'Success', 'Formation validĂ©e automatiquement', ?, ?, GETDATE(), 0)`, [notifId, collaborateurId, `Votre pĂ©riode de formation ${datesPeriode} a Ă©tĂ© validĂ©e automatiquement.`, demandeId] ); console.log('🔔 Notification formation créée, ID:', notifId); } // ── ÉTAPE 7 : RĂ©cupĂ©rer les managers ───────────────────────── console.log('👔 ÉTAPE 7 - RĂ©cupĂ©ration managers'); const managersRaw = await conn.query( `SELECT c.email AS manager_email FROM HierarchieValidationAD hv JOIN CollaborateurAD c ON hv.SuperieurId = c.id WHERE hv.CollaborateurId = ?`, [collaborateurId] ); const managers = getRows(managersRaw) .map(r => r?.manager_email ?? r?.MANAGER_EMAIL) .filter(Boolean); console.log('đŸ‘„ Managers trouvĂ©s:', managers); // ── COMMIT ──────────────────────────────────────────────────── await conn.commit(); console.log('✅ Transaction validĂ©e avec succĂšs'); // ── ÉTAPE 8 : SSE ───────────────────────────────────────────── if (isFormationOnly && collaborateurId) { notifyCollabClients({ type: 'demande-validated', demandeId, statut: 'ValidĂ©', timestamp: new Date().toISOString() }, collaborateurId); } // ── ÉTAPE 9 : Emails (aprĂšs commit) ─────────────────────────── console.log('📧 Envoi des emails...'); const accessToken = await getGraphToken(); if (accessToken) { const fromEmail = 'gtanoreply@ensup.eu'; const typesConges = Repartition.map(r => `${toTypeName(r.TypeConge)} (${r.NombreJours}j)` ).join(', '); if (isFormationOnly) { try { await sendMailGraph(accessToken, fromEmail, Email, 'Formation enregistrĂ©e et validĂ©e', `

    ✅ Formation validĂ©e

    Bonjour ${Nom},

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

    Période : ${datesPeriode}

    Durée : ${NombreJours} jours

    ` ); console.log('📧 Email formation envoyĂ©'); } catch (e) { console.error('❌ Email formation:', e.message); } } else { try { await sendMailGraph(accessToken, fromEmail, Email, 'Confirmation de rĂ©ception de votre demande de congĂ©', `

    📋 Demande enregistrĂ©e

    Bonjour ${Nom},

    Votre demande a bien été enregistrée et est en attente de validation.

    Type : ${typesConges}

    Période : ${datesPeriode}

    Durée : ${NombreJours} jours

    ` ); console.log('📧 Email confirmation envoyĂ©'); } catch (e) { console.error('❌ Email confirmation:', e.message); } } for (const managerEmail of managers) { const validationUrl = `https://mygta.ensup-adm.net/manager?demandeId=${demandeId}`; try { await sendMailGraph(accessToken, fromEmail, managerEmail, `Nouvelle demande de congĂ© — ${Nom}`, `

    🔔 Nouvelle demande de congĂ©

    ${Nom} a soumis une nouvelle demande de congé.

    ${Commentaire ? `` : ''}
    Type ${typesConges}
    Période ${datesPeriode}
    Durée ${NombreJours} jours
    Commentaire ${Commentaire}
    Valider ou refuser la demande
    ` ); console.log('📧 Email manager envoyĂ©:', managerEmail); } catch (e) { console.error('❌ Email manager:', managerEmail, e.message); } } } else { console.log('⚠ Pas de token Graph — emails non envoyĂ©s'); } res.json({ success: true, message: isFormationOnly ? 'Formation enregistrĂ©e et validĂ©e automatiquement' : 'Demande soumise avec succĂšs', requestid: demandeId, managers, autovalidated: isFormationOnly, filesuploaded: uploadedFiles.length }); } catch (error) { console.error('❌ ERREUR GLOBALE'); console.error('Message:', error.message); console.error('Stack:', error.stack); try { await conn.rollback(); console.log('🔄 Rollback effectuĂ©'); } catch (_) { } if (req.files) { req.files.forEach(f => { try { if (fs.existsSync(f.path)) fs.unlinkSync(f.path); } catch (_) { } console.log('🗑 Fichier supprimĂ©:', f.originalname); }); } res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } finally { conn.release(); console.log('🔓 Connexion libĂ©rĂ©e'); } }); /** * AppelĂ©e depuis le frontend dĂšs que l'utilisateur saisit les dates. * Retourne les soldes projetĂ©s + le mode (NORMAL/ANTICIPATION) avant soumission. */ /** * ROUTE : /api/soldes-previsionnel * * Calcule le solde prĂ©visionnel CP en fonction de la date de dĂ©but du congĂ© * selon les rĂšgles mĂ©tier : * - CongĂ©s normaux (mĂȘme exercice) : N-1 puis N * - CongĂ©s anticipĂ©s (exercice suivant) : uniquement N */ // ────────────────────────────────────────────────────────────────────────────── // ROUTE /api/simulation-solde-cp // Retourne le solde CP projetĂ© Ă  une date donnĂ©e, en tenant compte : // - des acquisitions journaliĂšres (0.0694j/j) // - des demandes dĂ©jĂ  posĂ©es (Anticip + AnticipFutur sur CP N) // - du basculement 01/06 // - du compteur CP N+1 (2027) qui dĂ©marre le 01/06/2026 // Usage : GET /api/simulation-solde-cp?userid=10754&date=2026-09-01 // ────────────────────────────────────────────────────────────────────────────── app.get('/api/simulation-solde-cp', async (req, res) => { const { userid, date } = req.query; if (!userid || !date) return res.json({ success: false, message: 'ParamĂštres requis: userid, date' }); const conn = await pool.getConnection(); try { const today = new Date(); today.setHours(0, 0, 0, 0); const dateSimul = new Date(date); dateSimul.setHours(0, 0, 0, 0); const userIdInt = parseInt(userid, 10); const currentYear = today.getFullYear(); const TAUX_CP = 25 / 360; const dateBascule = new Date(currentYear, 5, 1); dateBascule.setHours(0, 0, 0, 0); const apresBasculementAujourdhui = today >= dateBascule; const anneeN = apresBasculementAujourdhui ? currentYear + 1 : currentYear; const anneeN1 = anneeN - 1; const anneeNp1 = anneeN + 1; // apresBasculementSimul = true si la date du congĂ© est aprĂšs le 31/05/anneeN // = mode anticipĂ© → exercice N-1 terminĂ©, non utilisable const dateBasculeAnneeN = new Date(anneeN, 4, 31); const apresBasculementSimul = dateSimul > dateBasculeAnneeN; const [cpTypeRows] = await conn.query(`SELECT TOP 1 Id FROM TypeConge WHERE Nom = ?`, ['CongĂ© payĂ©']); if (!cpTypeRows.length) throw new Error('Type CP introuvable'); const cpTypeId = cpTypeRows[0].Id; // ── Lire solde CP N-1 ────────────────────────────────────────────────── // Uniquement utile en mode normal (congĂ© avant le 31/05) const [cptN1Rows] = await conn.query( `SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [userIdInt, cpTypeId, anneeN1] ); const soldeN1Dispo = cptN1Rows.length ? Math.max(0, parseFloat(cptN1Rows[0].Solde || 0)) : 0; // ── Lire solde ET total acquis CP N ──────────────────────────────────── const [cptNRows] = await conn.query( `SELECT Solde, Total FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [userIdInt, cpTypeId, anneeN] ); const soldeNActuel = cptNRows.length ? Math.max(0, parseFloat(cptNRows[0].Solde || 0)) : 0; const totalDejaAcquisN = cptNRows.length ? parseFloat(cptNRows[0].Total || 0) : 0; // ── Lire solde CP N+1 ────────────────────────────────────────────────── const [cptNp1Rows] = await conn.query( `SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [userIdInt, cpTypeId, anneeNp1] ); // ── DĂ©jĂ  consommĂ© AnticipFutur sur CP N uniquement ──────────────────── // Les 'Anticip' sont DÉJÀ dĂ©duits du Solde DB → ne pas les resoustraire // Seuls les 'AnticipFutur' sont des engagements tracĂ©s non encore en Solde console.log('anneeN:', anneeN, 'cpTypeId:', cpTypeId, 'userIdInt:', userIdInt); const [dejaConsoNRows] = 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 = 'AnticipFutur' AND dc.Statut NOT IN ('RefusĂ©', 'RefusĂ©e', 'AnnulĂ©', 'AnnulĂ©e')`, [userIdInt, cpTypeId, anneeN] ); const dejaConsoN = parseFloat(dejaConsoNRows[0]?.total || 0); console.log('dejaConsoN result:', dejaConsoNRows); // ── DĂ©jĂ  consommĂ© sur CP N+1 ─────────────────────────────────────────── const [dejaConsoNp1Rows] = 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 dc.Statut NOT IN ('RefusĂ©', 'RefusĂ©e', 'AnnulĂ©', 'AnnulĂ©e')`, [userIdInt, cpTypeId, anneeNp1] ); const dejaConsoNp1 = parseFloat(dejaConsoNp1Rows[0]?.total || 0); // ── Projection CP N jusqu'Ă  dateSimul ───────────────────────────────── // soldeNActuel est dĂ©jĂ  net des Anticip (dĂ©duits du Solde DB) // on projette les acquisitions futures et on soustrait seulement les AnticipFutur // Ligne actuelle — projette jusqu'Ă  dateSimul sans limite const diffJoursSimul = Math.max(0, Math.floor((dateSimul - today) / 86400000)); const acquisDiciSimul = parseFloat( Math.min(25 - totalDejaAcquisN, diffJoursSimul * TAUX_CP).toFixed(2) ); const plafondAbsolu = parseFloat(Math.min(25, soldeNActuel + (25 - totalDejaAcquisN)).toFixed(2)); const soldeNProjecte = parseFloat(Math.min(soldeNActuel + acquisDiciSimul, plafondAbsolu).toFixed(2)); // Dispo rĂ©elle CP N = solde projetĂ© - AnticipFutur dĂ©jĂ  engagĂ©s const soldeNDispo = parseFloat(Math.max(0, soldeNProjecte - dejaConsoN).toFixed(2)); // ── CP N+1 : prorata depuis 01/06/anneeN jusqu'Ă  dateSimul ─────────── let soldeNp1Dispo = 0; let diffJoursNp1 = 0; let acquisNp1 = 0; const debutNp1 = new Date(anneeN, 5, 1); debutNp1.setHours(0, 0, 0, 0); if (dateSimul > debutNp1) { diffJoursNp1 = Math.max(0, Math.floor((dateSimul - debutNp1) / 86400000)); acquisNp1 = parseFloat(Math.min(25, diffJoursNp1 * TAUX_CP).toFixed(2)); soldeNp1Dispo = parseFloat(Math.max(0, acquisNp1 - dejaConsoNp1).toFixed(2)); } // ── Total disponible ─────────────────────────────────────────────────── // Mode anticipĂ© (aprĂšs 31/05/anneeN) : exercice N-1 terminĂ© → N + N+1 seulement // Mode normal (avant 31/05/anneeN) : N-1 + N + N+1 const totalDisponible = apresBasculementSimul ? parseFloat((soldeNDispo + soldeNp1Dispo).toFixed(2)) : parseFloat((soldeN1Dispo + soldeNDispo + soldeNp1Dispo).toFixed(2)); const result = { dateSimulation: date, apresBasculement: apresBasculementSimul, anneeN, anneeN1, anneeNp1, cpN1: { annee: anneeN1, disponible: apresBasculementSimul ? 0 : soldeN1Dispo }, cpN: { annee: anneeN, soldeActuel: soldeNActuel, totalDejaAcquis: totalDejaAcquisN, acquisDici: acquisDiciSimul, soldeProjetĂ©: soldeNProjecte, dejaConsomme: dejaConsoN, disponible: soldeNDispo }, cpNp1: { annee: anneeNp1, debutExercice: `01/06/${anneeN}`, joursDepuisDebut: diffJoursNp1, acquisProrate: acquisNp1, dejaConsomme: dejaConsoNp1, disponible: soldeNp1Dispo }, totalDisponible }; console.log(`📅 Simulation CP userid=${userIdInt} date=${date}:`, result); console.log('apresBasculementSimul:', apresBasculementSimul, 'dateSimul:', dateSimul, 'dateBasculeAnneeN:', dateBasculeAnneeN); res.json({ success: true, ...result }); } catch (err) { console.error('❌ simulation-solde-cp:', err); res.json({ success: false, message: err.message }); } finally { if (conn) conn.release?.(); } }); // ROUTE /api/simulation-solde-rtt // Retourne le solde RTT projetĂ© Ă  une date donnĂ©e : // - Avant basculement 01/01/N+1 : RTT N projetĂ© au 31/12/N - dĂ©jĂ  consommĂ© // - AprĂšs basculement : RTT N figĂ© au 31/12 + RTT N+1 prorata depuis 01/01/N+1 // Usage : GET /api/simulation-solde-rtt?userid=10754&date=2027-03-01 // ────────────────────────────────────────────────────────────────────────────── app.get('/api/simulation-solde-rtt', async (req, res) => { const { userid, date } = req.query; if (!userid || !date) return res.json({ success: false, message: 'ParamĂštres requis: userid, date' }); const conn = await pool.getConnection(); try { const today = new Date(); today.setHours(0, 0, 0, 0); const dateSimul = new Date(date); dateSimul.setHours(0, 0, 0, 0); const userIdInt = parseInt(userid, 10); const currentYear = today.getFullYear(); // Infos collaborateur (TypeContrat pour taux RTT) const [collabRows] = await conn.query( `SELECT TOP 1 TypeContrat, role FROM CollaborateurAD WHERE id = ?`, [userIdInt] ); if (!collabRows.length) throw new Error('Collaborateur introuvable'); const typeContrat = collabRows[0].TypeContrat || '37h'; const isApprenti = collabRows[0].role === 'Apprenti'; if (isApprenti) { return res.json({ success: true, totalDisponible: 0, note: 'Apprenti : pas de RTT' }); } const [rttTypeRows] = await conn.query(`SELECT TOP 1 Id FROM TypeConge WHERE Nom = ?`, ['RTT']); if (!rttTypeRows.length) throw new Error('Type RTT introuvable'); const rttTypeId = rttTypeRows[0].Id; // Exercice RTT courant = annĂ©e civile const anneeRTTN = currentYear; // ex: 2026 const anneeRTTNp1 = currentYear + 1; // ex: 2027 // Basculement : 01/01/anneeRTTNp1 = fin exercice RTT N const dateBasculeRTT = new Date(anneeRTTNp1, 0, 1); // 01/01/2027 dateBasculeRTT.setHours(0, 0, 0, 0); const apresBasculementSimul = dateSimul >= dateBasculeRTT; // Config RTT taux journalier const cfgN = await getConfigurationRTT(conn, anneeRTTN, typeContrat); const tauxRTTN = cfgN.joursAnnuels / 360; const cfgNp1 = await getConfigurationRTT(conn, anneeRTTNp1, typeContrat); const tauxRTTNp1 = cfgNp1.joursAnnuels / 360; // Lire solde RTT N en base const [cptNRows] = await conn.query( `SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [userIdInt, rttTypeId, anneeRTTN] ); const soldeRTTNActuel = cptNRows.length ? Math.max(0, parseFloat(cptNRows[0].Solde || 0)) : 0; // Lire solde RTT N+1 en base (peut ĂȘtre nĂ©gatif si AnticipNp1RTT posĂ©) const [cptNp1Rows] = await conn.query( `SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [userIdInt, rttTypeId, anneeRTTNp1] ); const soldeRTTNp1Actuel = cptNp1Rows.length ? parseFloat(cptNp1Rows[0].Solde || 0) : 0; // DĂ©jĂ  consommĂ© RTT N (AnticipRTT + AnticipFuturRTT) const [dejaConsoNRows] = 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 IN ('AnticipRTT', 'AnticipFuturRTT') AND dc.Statut != 'RefusĂ©'`, [userIdInt, rttTypeId, anneeRTTN] ); const dejaConsoRTTN = parseFloat(dejaConsoNRows[0]?.total || 0); // DĂ©jĂ  consommĂ© RTT N+1 (DeductionDetails anneeNp1) const [dejaConsoNp1Rows] = 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 dc.Statut != 'RefusĂ©'`, [userIdInt, rttTypeId, anneeRTTNp1] ); const dejaConsoRTTNp1 = parseFloat(dejaConsoNp1Rows[0]?.total || 0); let result; if (!apresBasculementSimul) { // ── AVANT basculement (date simulĂ©e < 01/01/N+1) ───────────────────── // RTT N : acquis depuis aujourd'hui jusqu'Ă  dateSimul const diffJoursSimul = Math.max(0, Math.floor((dateSimul - today) / 86400000)); const acquisDici = parseFloat(Math.min(cfgN.joursAnnuels - soldeRTTNActuel, diffJoursSimul * tauxRTTN).toFixed(2)); const soldeRTTNSimul = parseFloat(Math.min(cfgN.joursAnnuels, soldeRTTNActuel + acquisDici).toFixed(2)); const soldeRTTNDispo = parseFloat(Math.max(0, soldeRTTNSimul - dejaConsoRTTN).toFixed(2)); result = { dateSimulation: date, apresBasculement: false, anneeRTTN, anneeRTTNp1, rttN: { annee: anneeRTTN, soldeActuel: soldeRTTNActuel, acquisDici, soldeProjetĂ©: soldeRTTNSimul, dejaConsomme: dejaConsoRTTN, disponible: soldeRTTNDispo }, rttNp1: { annee: anneeRTTNp1, note: `Pas encore commencĂ© (dĂ©marre le 01/01/${anneeRTTNp1})`, disponible: 0 }, totalDisponible: soldeRTTNDispo }; } else { // ── APRÈS basculement (date simulĂ©e >= 01/01/N+1) ──────────────────── // RTT N : solde figĂ© au 31/12/anneeRTTN - dĂ©jĂ  consommĂ© anticipĂ© const finExRTTN = new Date(anneeRTTN, 11, 31); finExRTTN.setHours(0, 0, 0, 0); const joursRestantsAvantBascule = Math.max(0, Math.floor((finExRTTN - today) / 86400000)); const acquisAvantBascule = parseFloat(Math.min(cfgN.joursAnnuels - soldeRTTNActuel, joursRestantsAvantBascule * tauxRTTN).toFixed(2)); const soldeRTTNFinal = parseFloat(Math.min(cfgN.joursAnnuels, soldeRTTNActuel + acquisAvantBascule).toFixed(2)); const soldeRTTNDispo = parseFloat(Math.max(0, soldeRTTNFinal - dejaConsoRTTN).toFixed(2)); // RTT N+1 : prorata depuis 01/01/anneeRTTNp1 jusqu'Ă  dateSimul const debutNp1RTT = new Date(anneeRTTNp1, 0, 1); debutNp1RTT.setHours(0, 0, 0, 0); const diffJoursNp1 = Math.max(0, Math.floor((dateSimul - debutNp1RTT) / 86400000)); const acquisRTTNp1 = parseFloat(Math.min(cfgNp1.joursAnnuels, diffJoursNp1 * tauxRTTNp1).toFixed(2)); const soldeRTTNp1Dispo = parseFloat(Math.max(0, acquisRTTNp1 - dejaConsoRTTNp1).toFixed(2)); result = { dateSimulation: date, apresBasculement: true, anneeRTTN, anneeRTTNp1, rttN: { annee: anneeRTTN, soldeActuel: soldeRTTNActuel, acquisAvantBascule, soldeFinalAu31Dec: soldeRTTNFinal, dejaConsomme: dejaConsoRTTN, disponible: soldeRTTNDispo }, rttNp1: { annee: anneeRTTNp1, debutExercice: `01/01/${anneeRTTNp1}`, joursDepuisDebut: diffJoursNp1, acquisProrate: acquisRTTNp1, dejaConsomme: dejaConsoRTTNp1, disponible: soldeRTTNp1Dispo }, totalDisponible: parseFloat((soldeRTTNDispo + soldeRTTNp1Dispo).toFixed(2)) }; } console.log(`📅 Simulation RTT userid=${userIdInt} date=${date}:`, result); res.json({ success: true, ...result }); } catch (err) { console.error('❌ simulation-solde-rtt:', err); res.json({ success: false, message: err.message }); } finally { if (conn) conn.release?.(); } }); // AppelĂ©e depuis le frontend dĂšs que l'utilisateur saisit les dates. // Retourne les soldes projetĂ©s + le mode NORMAL/ANTICIPATION avant soumission. // ✅ ROUTE /api/soldes-previsionnel AVEC DEBUG app.get('/api/soldes-previsionnel', async (req, res) => { const { userid, dateDebut, joursCP, joursRTT, demandeIdExclure } = req.query; if (!userid || !dateDebut) return res.json({ success: false, message: 'ParamĂštres requis: userid, dateDebut' }); const userIdInt = parseInt(userid, 10); if (isNaN(userIdInt)) return res.json({ success: false, message: 'userid invalide' }); const demandeIdExclureInt = demandeIdExclure ? parseInt(demandeIdExclure, 10) : null; try { const today = new Date(); today.setHours(0, 0, 0, 0); const currentYear = today.getFullYear(); const dateDebutObj = new Date(dateDebut); dateDebutObj.setHours(0, 0, 0, 0); const moisDebut = dateDebutObj.getMonth() + 1; const anneeDebut = dateDebutObj.getFullYear(); const dateBasculeCP = new Date(`${currentYear}-06-01`); const apresBasculement = today >= dateBasculeCP; const anneeN = apresBasculement ? currentYear + 1 : currentYear; const anneeN1 = anneeN - 1; const anneeExConge = moisDebut >= 6 ? anneeDebut + 1 : anneeDebut; const finExerciceCourant = new Date(anneeN, 4, 31); finExerciceCourant.setHours(23, 59, 59, 0); const isAnticipe = dateDebutObj > finExerciceCourant; const exerciceDemande = anneeN; const exerciceConges = anneeExConge - 1; const anneeRTT = dateDebutObj.getFullYear(); const anneeRTTPrev = anneeRTT - 1; const isAnticipeRTT = anneeRTT > currentYear; const TAUX_CP = 25 / 360; console.log('🔍 PRÉVISIONNEL', { userid: userIdInt, dateDebut, currentYear, isAnticipe, exerciceDemande, exerciceConges, apresBasculement, anneeN1, anneeN, anneeRTT, anneeRTTPrev, isAnticipeRTT, demandeIdExclure: demandeIdExclureInt }); // ── Infos collaborateur ──────────────────────────────────────── const rCollabInfo = pool.request(); rCollabInfo.input('userId', userIdInt); const resCollabInfo = await rCollabInfo.query(` SELECT TOP 1 role, DateEntree, TypeContrat FROM CollaborateurAD WHERE id = @userId `); if (!resCollabInfo.recordset.length) throw new Error('Collaborateur introuvable'); const isApprenti = resCollabInfo.recordset[0].role === 'Apprenti'; const dateEntree = resCollabInfo.recordset[0].DateEntree ?? null; const typeContrat = resCollabInfo.recordset[0].TypeContrat ?? '37h'; // ── TypeConge CP ─────────────────────────────────────────────── const r0 = pool.request(); r0.input('nom', 'CongĂ© payĂ©'); const cpTypeResult = await r0.query(`SELECT TOP 1 Id FROM TypeConge WHERE Nom = @nom`); if (!cpTypeResult.recordset.length) throw new Error('Type CP introuvable'); const cpTypeId = cpTypeResult.recordset[0].Id; // ── TypeConge RTT ────────────────────────────────────────────── const rRTT = pool.request(); rRTT.input('nom', 'RTT'); const rttTypeResult = await rRTT.query(`SELECT TOP 1 Id FROM TypeConge WHERE Nom = @nom`); const rttTypeId = rttTypeResult.recordset[0]?.Id ?? null; // ── Helper : lire un compteur CP ─────────────────────────────── const lireCompteurCP = async (annee) => { const r = pool.request(); r.input('userId', userIdInt); r.input('typeId', cpTypeId); r.input('annee', annee); const result = await r.query(` SELECT TOP 1 CAST(COALESCE(Solde, 0) AS FLOAT) AS Solde, CAST(COALESCE(SoldeReporte, 0) AS FLOAT) AS SoldeReporte, CAST(COALESCE(Total, 0) AS FLOAT) AS Total FROM CompteurConges WHERE CollaborateurADId = @userId AND TypeCongeId = @typeId AND Annee = @annee ORDER BY Id DESC `); if (!result.recordset.length) return null; const row = result.recordset[0]; const solde = parseFloat(row.Solde); const reporte = parseFloat(row.SoldeReporte); return { solde, reporte, total: parseFloat(row.Total), dispoN1: Math.max(0, solde), dispoN: Math.max(0, solde - reporte) }; }; // ── Helper : lire un compteur RTT ────────────────────────────── const lireCompteurRTT = async (annee) => { if (!rttTypeId) return null; const r = pool.request(); r.input('userId', userIdInt); r.input('typeId', rttTypeId); r.input('annee', annee); const result = await r.query(` SELECT TOP 1 CAST(COALESCE(Solde, 0) AS FLOAT) AS Solde, CAST(COALESCE(Total, 0) AS FLOAT) AS Total FROM CompteurConges WHERE CollaborateurADId = @userId AND TypeCongeId = @typeId AND Annee = @annee ORDER BY Id DESC `); if (!result.recordset.length) return null; return { solde: parseFloat(result.recordset[0].Solde), total: parseFloat(result.recordset[0].Total) }; }; // ── Helper : crĂ©er un compteur RTT si absent ─────────────────── const creerCompteurRTTSiAbsent = async (annee, acquisProjectee) => { const existant = await lireCompteurRTT(annee); if (existant) return existant; console.log(`🆕 Compteur RTT ${annee} absent — crĂ©ation automatique (Total: ${acquisProjectee.toFixed(2)}j)`); const rNextId = pool.request(); const resNextId = await rNextId.query(`SELECT CAST(ISNULL(MAX(Id), 0) + 1 AS INT) AS nextid FROM CompteurConges`); const nextId = resNextId.recordset[0].nextid; const rInsert = pool.request(); rInsert.input('nextId', nextId); rInsert.input('userId', userIdInt); rInsert.input('typeId', rttTypeId); rInsert.input('annee', annee); rInsert.input('total', acquisProjectee); await rInsert.query(` INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (@nextId, @userId, @typeId, @annee, @total, 0, 0, GETDATE()) `); return await lireCompteurRTT(annee); }; // ── Lire les compteurs CP ────────────────────────────────────── const cptN1 = await lireCompteurCP(anneeN1); const cptN = await lireCompteurCP(anneeN); console.log(`🔍 Compteur CP N-1 (${anneeN1}):`, cptN1); console.log(`🔍 Compteur CP N (${anneeN}):`, cptN); const cpN1Dispo = isAnticipe ? 0 : (cptN1 ? cptN1.dispoN1 : 0); const cpNAcquisAujourdHui = cptN ? cptN.dispoN : 0; const soldeNActuel = cptN ? Math.max(0, parseFloat(cptN.solde || 0)) : 0; // ── Calcul CP selon mode ─────────────────────────────────────── let totalCPDisponible; let cpSoldeADateDebut; let cpNAnticipeDispo; let dispoNp1 = 0; if (!isAnticipe) { // ── MODE NORMAL : projeter jusqu'Ă  dateDebutObj ─────────── const joursJusquAuDebut = Math.max(0, Math.floor((dateDebutObj - today) / 86400000)); const plafondRestant = Math.max(0, 25 - (cptN ? cptN.total : 0)); const acquisDiciDebut = parseFloat(Math.min(plafondRestant, joursJusquAuDebut * TAUX_CP).toFixed(2)); const soldeNProjecte = parseFloat(Math.min(25, soldeNActuel + acquisDiciDebut).toFixed(2)); // DĂ©jĂ  consommĂ© AnticipFutur sur anneeN const rDejaConsoN = pool.request(); rDejaConsoN.input('userId', userIdInt); rDejaConsoN.input('typeId', cpTypeId); rDejaConsoN.input('annee', anneeN); let qN = ` SELECT COALESCE(SUM(dd.JoursUtilises), 0) AS total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = @userId AND dd.TypeCongeId = @typeId AND dd.Annee = @annee AND dd.TypeDeduction = 'AnticipFutur' AND dc.Statut NOT IN ('RefusĂ©', 'RefusĂ©e', 'AnnulĂ©', 'AnnulĂ©e')`; if (demandeIdExclureInt) { rDejaConsoN.input('demandeIdExclure', demandeIdExclureInt); qN += ` AND dc.Id != @demandeIdExclure`; } const resN = await rDejaConsoN.query(qN); const dejaConsoAnticipFutur = parseFloat(resN.recordset[0]?.total ?? 0); const soldeNNet = parseFloat(Math.max(0, soldeNProjecte - dejaConsoAnticipFutur).toFixed(2)); cpSoldeADateDebut = soldeNNet; cpNAnticipeDispo = parseFloat(Math.max(0, soldeNNet - cpNAcquisAujourdHui).toFixed(2)); totalCPDisponible = parseFloat((cpN1Dispo + soldeNNet).toFixed(2)); console.log(`🔍 CP NORMAL: solde=${soldeNActuel}j + acq=${acquisDiciDebut}j = ${soldeNProjecte}j - AnticipFutur=${dejaConsoAnticipFutur}j = ${soldeNNet}j | N-1=${cpN1Dispo}j | Total=${totalCPDisponible}j`); } else { // ── MODE ANTICIPÉ ───────────────────────────────────────── // FIX 1 : CP N se projette toujours jusqu'au 31/05 (jamais jusqu'Ă  dateDebutObj) const finExN = new Date(currentYear, 4, 31); finExN.setHours(23, 59, 59, 0); const debutNp1 = new Date(currentYear, 5, 1); debutNp1.setHours(0, 0, 0, 0); // Jours depuis aujourd'hui jusqu'au 31/05 (borne fixe) const joursJusquauFinExN = today <= finExN ? Math.max(0, Math.floor((finExN - today) / 86400000)) : 0; const plafondRestantN = Math.max(0, 25 - (cptN?.total ?? 0)); const acquisJusquauFinExN = parseFloat( Math.min(plafondRestantN, joursJusquauFinExN * TAUX_CP).toFixed(2) ); const soldeNAFinEx = parseFloat(Math.min(25, soldeNActuel + acquisJusquauFinExN).toFixed(2)); // DĂ©jĂ  consommĂ© via Anticip + AnticipFutur sur anneeN const rDejaConsoN = pool.request(); rDejaConsoN.input('userId', userIdInt); rDejaConsoN.input('typeId', cpTypeId); rDejaConsoN.input('annee', anneeN); let qDejaConsoN = ` SELECT COALESCE(SUM(dd.JoursUtilises), 0) AS total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = @userId AND dd.TypeCongeId = @typeId AND dd.Annee = @annee AND dd.TypeDeduction IN ('Anticip', 'AnticipFutur') AND dc.Statut NOT IN ('RefusĂ©', 'RefusĂ©e', 'AnnulĂ©', 'AnnulĂ©e')`; if (demandeIdExclureInt) { rDejaConsoN.input('demandeIdExclure', demandeIdExclureInt); qDejaConsoN += ` AND dc.Id != @demandeIdExclure`; } const resDejaConsoN = await rDejaConsoN.query(qDejaConsoN); const dejaConsoAnticip = parseFloat(resDejaConsoN.recordset[0]?.total ?? 0); const soldeNDispo = parseFloat(Math.max(0, soldeNAFinEx - dejaConsoAnticip).toFixed(2)); // FIX 2 : CP N+1 — condition >= pour inclure le 1er juin lui-mĂȘme if (dateDebutObj >= debutNp1) { // Nombre de jours depuis le 01/06 jusqu'Ă  dateDebutObj INCLUS const diffJoursNp1 = Math.max(0, Math.floor((dateDebutObj - debutNp1) / 86400000) + 1); const acquisNp1 = parseFloat(Math.min(25, diffJoursNp1 * TAUX_CP).toFixed(2)); const rDejaConsoNp1 = pool.request(); rDejaConsoNp1.input('userId', userIdInt); rDejaConsoNp1.input('typeId', cpTypeId); rDejaConsoNp1.input('annee', anneeExConge); let qNp1 = ` SELECT COALESCE(SUM(dd.JoursUtilises), 0) AS total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = @userId AND dd.TypeCongeId = @typeId AND dd.Annee = @annee AND dc.Statut NOT IN ('RefusĂ©', 'RefusĂ©e', 'AnnulĂ©', 'AnnulĂ©e')`; if (demandeIdExclureInt) { rDejaConsoNp1.input('demandeIdExclure', demandeIdExclureInt); qNp1 += ` AND dc.Id != @demandeIdExclure`; } const resNp1 = await rDejaConsoNp1.query(qNp1); const dejaConsoNp1 = parseFloat(resNp1.recordset[0]?.total ?? 0); dispoNp1 = parseFloat(Math.max(0, acquisNp1 - dejaConsoNp1).toFixed(2)); console.log(`🔍 CP N+1 (${anneeExConge}): ${diffJoursNp1}j × ${TAUX_CP.toFixed(4)} = ${acquisNp1}j - consommĂ© ${dejaConsoNp1}j → dispo ${dispoNp1}j`); } cpSoldeADateDebut = soldeNDispo; cpNAnticipeDispo = dispoNp1; totalCPDisponible = parseFloat((soldeNDispo + dispoNp1).toFixed(2)); console.log(`🔍 CP ANTICIPÉ — N au 31/05: ${soldeNAFinEx}j (acq +${acquisJusquauFinExN}j) - consommĂ© ${dejaConsoAnticip}j = ${soldeNDispo}j | N+1 (01/06→${dateDebut}): ${dispoNp1}j | Total: ${totalCPDisponible}j`); } console.log(`🔍 CP — N-1: ${cpN1Dispo.toFixed(2)}j | Acquis aujourd'hui N: ${cpNAcquisAujourdHui.toFixed(2)}j | Part anticipĂ©e: ${cpNAnticipeDispo.toFixed(2)}j | Total: ${totalCPDisponible.toFixed(2)}j`); // ── RTT ──────────────────────────────────────────────────────── let rttDispo = 0; let acquisRTTADateDebut = 0; let rttNAcquisAujourdHui = 0; let rttAnticipeDispo = 0; let simRTTAcquisUtilise = 0; let simRTTAnticipeUtilise = 0; if (rttTypeId && !isApprenti) { const conn = await pool.getConnection(); try { const rttData = await calculerAcquisitionRTTSmart(conn, userIdInt, dateDebutObj); acquisRTTADateDebut = rttData.acquisition; } finally { conn.release(); } let acquisRTTFinExercice = 0; if (isAnticipeRTT) { const conn2 = await pool.getConnection(); try { const finEx = new Date(anneeRTT, 11, 31); const rttDataFin = await calculerAcquisitionRTTSmart(conn2, userIdInt, finEx); acquisRTTFinExercice = rttDataFin.acquisition; } finally { conn2.release(); } await creerCompteurRTTSiAbsent(anneeRTT, acquisRTTFinExercice); } const cptRTT = await lireCompteurRTT(anneeRTT); rttNAcquisAujourdHui = cptRTT ? cptRTT.solde : 0; const rRTTConso = pool.request(); rRTTConso.input('userId', userIdInt); rRTTConso.input('typeId', rttTypeId); rRTTConso.input('annee', anneeRTT); let queryRTTConso = ` SELECT CAST(COALESCE(SUM(dd.JoursUtilises), 0) AS FLOAT) AS total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = @userId AND dd.TypeCongeId = @typeId AND dd.Annee = @annee AND dc.Statut != 'RefusĂ©' `; if (isAnticipeRTT) { queryRTTConso += ` AND dd.TypeDeduction IN ('AnticipRTT', 'AnticipFuturRTT')`; } if (demandeIdExclureInt) { rRTTConso.input('demandeIdExclure', demandeIdExclureInt); queryRTTConso += ` AND dc.Id != @demandeIdExclure`; } const resRTTConso = await rRTTConso.query(queryRTTConso); const rttConsomme = parseFloat(resRTTConso.recordset[0]?.total ?? 0); if (!isAnticipeRTT) { const rttSoldeADateDebut = Math.max(0, acquisRTTADateDebut - rttConsomme); rttAnticipeDispo = Math.max(0, rttSoldeADateDebut - rttNAcquisAujourdHui); rttDispo = Math.max(0, rttSoldeADateDebut); } else { const connRTTCfg = await pool.getConnection(); let cfgRTTPrev; try { cfgRTTPrev = await getConfigurationRTT(connRTTCfg, anneeRTT, typeContrat); } finally { connRTTCfg.release(); } const tauxRTTNPrev = cfgRTTPrev.joursAnnuels / 360; const finExRTTNPrev = new Date(anneeRTT, 11, 31); finExRTTNPrev.setHours(23, 59, 59, 0); const joursRestantsRTTNPrev = Math.max(0, Math.floor((finExRTTNPrev - today) / 86400000)); const acquisRestantsRTTNPrev = Math.min(cfgRTTPrev.joursAnnuels - rttNAcquisAujourdHui, joursRestantsRTTNPrev * tauxRTTNPrev); const soldeRTTNProjPrev = Math.min(cfgRTTPrev.joursAnnuels, rttNAcquisAujourdHui + acquisRestantsRTTNPrev); const dispoRTTNPrev = parseFloat(Math.max(0, soldeRTTNProjPrev - rttConsomme).toFixed(2)); const anneeRTTConge = anneeRTT + 1; const connRTTCfgNp1 = await pool.getConnection(); let cfgRTTNp1Prev; try { cfgRTTNp1Prev = await getConfigurationRTT(connRTTCfgNp1, anneeRTTConge, typeContrat); } finally { connRTTCfgNp1.release(); } const tauxRTTNp1Prev = cfgRTTNp1Prev.joursAnnuels / 360; const debutRTTNp1Prev = new Date(anneeRTTConge, 0, 1); debutRTTNp1Prev.setHours(0, 0, 0, 0); const diffJoursRTTNp1Prev = Math.max(0, Math.floor((dateDebutObj - debutRTTNp1Prev) / 86400000)); const acquisRTTNp1Prev = parseFloat(Math.min(cfgRTTNp1Prev.joursAnnuels, diffJoursRTTNp1Prev * tauxRTTNp1Prev).toFixed(2)); const rRTTConsoNp1 = pool.request(); rRTTConsoNp1.input('userId', userIdInt); rRTTConsoNp1.input('typeId', rttTypeId); rRTTConsoNp1.input('annee', anneeRTTConge); let qRTTConsoNp1 = ` SELECT CAST(COALESCE(SUM(dd.JoursUtilises), 0) AS FLOAT) AS total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = @userId AND dd.TypeCongeId = @typeId AND dd.Annee = @annee AND dc.Statut != 'RefusĂ©'`; if (demandeIdExclureInt) { rRTTConsoNp1.input('demandeIdExclure', demandeIdExclureInt); qRTTConsoNp1 += ` AND dc.Id != @demandeIdExclure`; } const resRTTConsoNp1 = await rRTTConsoNp1.query(qRTTConsoNp1); const rttConsoNp1Prev = parseFloat(resRTTConsoNp1.recordset[0]?.total ?? 0); const dispoRTTNp1Prev = parseFloat(Math.max(0, acquisRTTNp1Prev - rttConsoNp1Prev).toFixed(2)); rttDispo = parseFloat((dispoRTTNPrev + dispoRTTNp1Prev).toFixed(2)); rttAnticipeDispo = dispoRTTNp1Prev; console.log(`🔍 RTT ANTICIPÉ: N=${dispoRTTNPrev.toFixed(2)}j + N+1=${dispoRTTNp1Prev.toFixed(2)}j = total ${rttDispo.toFixed(2)}j`); } console.log(`🔍 RTT (${anneeRTT}) — Acquis Ă  dateDebut: ${acquisRTTADateDebut.toFixed(2)}j | En base: ${rttNAcquisAujourdHui.toFixed(2)}j | Dispo: ${rttDispo.toFixed(2)}j`); const joursRTTNecessaires = parseFloat(joursRTT) || 0; let resteRTT = joursRTTNecessaires; if (resteRTT > 0 && rttNAcquisAujourdHui > 0) { simRTTAcquisUtilise = Math.max(0, Math.min(rttNAcquisAujourdHui - rttConsomme, resteRTT)); resteRTT -= simRTTAcquisUtilise; } if (resteRTT > 0 && rttAnticipeDispo > 0) { simRTTAnticipeUtilise = Math.min(rttAnticipeDispo, resteRTT); resteRTT -= simRTTAnticipeUtilise; } } // ── Mode label ───────────────────────────────────────────────── let modeLabel = ''; if (!isAnticipe) { modeLabel = apresBasculement ? 'NORMAL POST-BASCULEMENT' : 'NORMAL'; } else if (!apresBasculement) { modeLabel = 'ANTICIPÉ CLASSIQUE'; } else { modeLabel = 'ANTICIPÉ POST-BASCULEMENT'; } // ── VĂ©rification suffisance ──────────────────────────────────── const joursNecessairesCP = parseFloat(joursCP) || 0; const joursNecessairesRTT = parseFloat(joursRTT) || 0; const suffisantCP = joursNecessairesCP <= 0 || totalCPDisponible >= joursNecessairesCP; const suffisantRTT = joursNecessairesRTT <= 0 || rttDispo >= joursNecessairesRTT; const suffisant = suffisantCP && suffisantRTT; const deficitCP = suffisantCP ? 0 : parseFloat((joursNecessairesCP - totalCPDisponible).toFixed(2)); const deficitRTT = suffisantRTT ? 0 : parseFloat((joursNecessairesRTT - rttDispo).toFixed(2)); // ── Simulation prĂ©lĂšvement CP ────────────────────────────────── let simN1Utilise = 0; let simNAcquisUtilise = 0; let simNAnticipeUtilise = 0; let reste = joursNecessairesCP; if (!isAnticipe && reste > 0 && cpN1Dispo > 0) { simN1Utilise = Math.min(cpN1Dispo, reste); reste -= simN1Utilise; } if (reste > 0 && cpNAcquisAujourdHui > 0) { simNAcquisUtilise = Math.min(cpNAcquisAujourdHui, reste); reste -= simNAcquisUtilise; } if (reste > 0 && cpNAnticipeDispo > 0) { simNAnticipeUtilise = Math.min(cpNAnticipeDispo, reste); reste -= simNAnticipeUtilise; } console.log('🔍 PRÉVISIONNEL RÉSULTAT', { modeLabel, cpN1Dispo, cpNAcquisAujourdHui, cpNAnticipeDispo, cpSoldeADateDebut, dispoNp1, totalCPDisponible, rttDispo, suffisant, deficitCP, deficitRTT }); res.json({ success: true, previsionnel: { mode: modeLabel, isAnticipe, apresBasculement, exerciceDemande: `${exerciceDemande}-${exerciceDemande + 1}`, exerciceConges: `${exerciceConges}-${exerciceConges + 1}`, anneeN1, anneeN, // ── CP ───────────────────────────────────────────────── cpN1Reporte: isAnticipe ? 0 : parseFloat(cpN1Dispo.toFixed(2)), cpNSolde: parseFloat(cpNAcquisAujourdHui.toFixed(2)), cpNSoldeALaDate: parseFloat(cpSoldeADateDebut.toFixed(2)), cpNAnticipeAutorise: parseFloat(cpNAnticipeDispo.toFixed(2)), acquisCPADateDebut: parseFloat(cpSoldeADateDebut.toFixed(2)), cpNAcquisAujourdHui: parseFloat(cpNAcquisAujourdHui.toFixed(2)), cpNAnticipeDispo: parseFloat(cpNAnticipeDispo.toFixed(2)), totalCPDisponible, // ── RTT ──────────────────────────────────────────────── anneeRTT, anneeRTTPrev, isAnticipeRTT, acquisRTTADateDebut: parseFloat(acquisRTTADateDebut.toFixed(2)), rttNAcquisAujourdHui: parseFloat(rttNAcquisAujourdHui.toFixed(2)), rttAnticipeDispo: parseFloat(rttAnticipeDispo.toFixed(2)), rttDispo: parseFloat(rttDispo.toFixed(2)), // ── VĂ©rification ─────────────────────────────────────── joursNecessaires: joursNecessairesCP, joursNecessairesCP, joursNecessairesRTT, totalDisponible: totalCPDisponible, suffisant, suffisantCP, suffisantRTT, deficit: deficitCP, deficitCP, deficitRTT, // ── Simulation CP ────────────────────────────────────── simulation: { n1Utilise: parseFloat(simN1Utilise.toFixed(2)), nAcquisUtilise: parseFloat(simNAcquisUtilise.toFixed(2)), nAnticipeUtilise: parseFloat(simNAnticipeUtilise.toFixed(2)), rttAcquisUtilise: parseFloat(simRTTAcquisUtilise.toFixed(2)), rttAnticipeUtilise: parseFloat(simRTTAnticipeUtilise.toFixed(2)) }, message: (() => { const parts = []; if (!isAnticipe && cpN1Dispo > 0) parts.push(`N-1(${anneeN1}): ${cpN1Dispo.toFixed(2)}j`); if (cpNAcquisAujourdHui > 0) parts.push(`N acquis(${anneeN}): ${cpNAcquisAujourdHui.toFixed(2)}j`); if (cpNAnticipeDispo > 0) parts.push(`N anticipĂ©: ${cpNAnticipeDispo.toFixed(2)}j`); if (dispoNp1 > 0) parts.push(`N+1(${anneeExConge}): ${dispoNp1.toFixed(2)}j`); if (rttDispo > 0) parts.push(`RTT(${anneeRTT}): ${rttDispo.toFixed(2)}j`); return `${modeLabel} — ${parts.join(' + ')} → CP total: ${totalCPDisponible.toFixed(2)}j`; })() } }); } catch (error) { console.error('❌ Erreur soldes-previsionnel:', error); res.status(500).json({ success: false, message: error.message }); } }); 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 (AD ou User classique) // ======================================== let validateurId = null; let validateurADId = null; let validatorData = null; const [validatorAD] = await conn.query( 'SELECT TOP 1 Id, prenom, nom, email, CampusId FROM CollaborateurAD WHERE Id = ?', [validator_id] ); if (validatorAD.length > 0) { validateurADId = validator_id; validatorData = validatorAD[0]; console.log(`đŸ‘€ Validateur trouvĂ© dans CollaborateurAD: ${validatorData.prenom} ${validatorData.nom}`); } else { const [validatorUser] = await conn.query( 'SELECT TOP 1 ID, Prenom, Nom, Email FROM Users WHERE ID = ?', [validator_id] ); if (validatorUser.length > 0) { validateurId = validator_id; validatorData = { Id: validatorUser[0].ID, prenom: validatorUser[0].Prenom, nom: validatorUser[0].Nom, email: validatorUser[0].Email }; console.log(`đŸ‘€ Validateur trouvĂ© dans Users: ${validatorData.prenom} ${validatorData.nom}`); } else { throw new Error('Validateur introuvable'); } } // ======================================== // RÉCUPÉRER LA DEMANDE (sans JOIN sur TypeCongeId car c'est un CSV) // ======================================== 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 FROM DemandeConge dc 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]; // ======================================== // RÉCUPÉRER LES TYPES DE CONGÉS // ======================================== let typeCongeNom = 'CongĂ©'; const [congeTypes] = await conn.query( `SELECT tc.Nom, dct.NombreJours FROM DemandeCongeType dct JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dct.DemandeCongeId = ?`, [request_id] ); if (congeTypes.length > 0) { typeCongeNom = congeTypes.map(t => t.Nom).join(' + '); } else if (request.TypeCongeId) { const typeIds = request.TypeCongeId.toString().split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id)); if (typeIds.length > 0) { const [types] = await conn.query( `SELECT Nom FROM TypeConge WHERE Id IN (${typeIds.join(',')})` ); if (types.length > 0) { typeCongeNom = types.map(t => t.Nom).join(' + '); } } } request.TypeConge = typeCongeNom; console.log('\n=== DONNÉES RÉCUPÉRÉES ==='); console.log('request:', JSON.stringify(request, null, 2)); console.log('TypeConge rĂ©solu:', typeCongeNom); console.log('validateurId:', validateurId); console.log('validateurADId:', validateurADId); 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, validateurId, validateurADId, comment || '', request_id] ); console.log(`✅ Demande mise Ă  jour - ValidateurId: ${validateurId}, ValidateurADId: ${validateurADId}`); // ======================================== // CRÉER UNE NOTIFICATION (avec ID manuel) // ======================================== 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'; // GĂ©nĂ©rer l'ID manuellement const [maxNotifId] = await conn.query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM Notifications'); const notifId = maxNotifId[0].NextId; await conn.query( `INSERT INTO Notifications (Id, CollaborateurADId, Titre, Message, Type, DemandeCongeId, DateCreation, lu) VALUES (?, ?, ?, ?, ?, ?, GETDATE(), 0)`, [notifId, request.CollaborateurADId, notifTitle, notifMessage, notifType, request_id] ); console.log(`📬 Notification créée avec ID ${notifId}`); await conn.commit(); // ======================================== // ENVOYER EMAIL VIA MICROSOFT GRAPH // ======================================== console.log('\n📧 === TENTATIVE ENVOI EMAIL ==='); const accessToken = await getGraphToken(); if (accessToken && request.collaborateur_email) { const fromEmail = 'gtanoreply@ensup.eu'; const collaborateurNom = `${request.prenom} ${request.nom}`; const validateurNom = `${validatorData.prenom} ${validatorData.nom}`; 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('📧 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 // ======================================== // ======================================== // ROUTE getTeamLeaves COMPLÈTE ET OPTIMISÉE // ======================================== 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}`); // ✅ Fragment SQL rĂ©utilisable pour le JSON detailsconges (tous les CAS) const detailscongesSQL = ` CONCAT( '[', STRING_AGG( CONCAT( '{"type":"', tc.Nom, '","jours":', dct.NombreJours, ',"periodeDebut":"', COALESCE(dct.PeriodeJournee, 'JournĂ©e entiĂšre'), '","periodeFin":"', COALESCE(dct.PeriodeJourneeFin, dct.PeriodeJournee, 'JournĂ©e entiĂšre'), '","dateDebut":"', CONVERT(VARCHAR(10), dct.DateDebut, 23), '","dateFin":"', CONVERT(VARCHAR(10), dct.DateFin, 23), '"}' ), ',' ), ']' ) AS detailsconges`; const filters = {}; // ======================================== // CAS 1: PRESIDENT, ADMIN, RH, DIRECTEUR DE CAMPUS // ======================================== if (role === 'president' || role === 'admin' || 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, ${detailscongesSQL}, 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 // ======================================== else if (role === 'validateur') { console.log("CAS 2: Validateur - Vue identique au collaborateur"); 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 validateur : initialisation avec valeurs 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, ${detailscongesSQL}, 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 3: COLLABORATEUR / APPRENTI // ======================================== else if (role === 'collaborateur' || role === 'apprenti' || role === 'rh') { 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, ${detailscongesSQL}, 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) { 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, ${detailscongesSQL}, 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 { 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, ${detailscongesSQL}, 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; 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); 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 (!requestId || !leaveType || !startDate || !endDate || !businessDays || !userId) { 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(); const q = async (sqlText, params = {}, usePoolDirectly = false) => { const request = (usePoolDirectly || !connection || typeof connection.request !== 'function') ? pool.request() : connection.request(); for (const [key, value] of Object.entries(params)) request.input(key, value); const result = await request.query(sqlText); return result.recordset || []; }; await connection.beginTransaction(); const uploadedFiles = req.files || []; console.log(`Demande ID: ${requestId}, User ID: ${userId}`); // ── Calcul anneeN dynamique (CP et RTT) ────────────────────── const today = new Date(); const currentYear = today.getFullYear(); const dateBascule = new Date(`${currentYear}-06-01`); const apresBasculement = today >= dateBascule; const anneeN = apresBasculement ? currentYear + 1 : currentYear; const anneeN1 = apresBasculement ? currentYear : currentYear - 1; // TypeContrat du collaborateur (nĂ©cessaire pour taux RTT anticipĂ©) const collabInfoUpdate = await q( 'SELECT TOP 1 TypeContrat FROM CollaborateurAD WHERE id = @userId', { userId } ); const typeContrat = collabInfoUpdate[0]?.TypeContrat || '37h'; // 1ïžâƒŁ RÉCUPÉRER LA DEMANDE ORIGINALE ────────────────────────── const originalRequest = await q( 'SELECT * FROM DemandeConge WHERE Id = @requestId AND CollaborateurADId = @userId', { requestId, userId } ); if (!originalRequest || originalRequest.length === 0) { await connection.rollback(); 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}`); const aujourdhui = new Date(); aujourdhui.setHours(0, 0, 0, 0); const dateDebutOriginale = original.DateDebut ? new Date(original.DateDebut) : null; if (dateDebutOriginale) dateDebutOriginale.setHours(0, 0, 0, 0); if (dateDebutOriginale && dateDebutOriginale <= aujourdhui) { await connection.rollback(); req.files?.forEach(file => { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); }); return res.status(400).json({ success: false, message: '❌ Impossible de modifier : la date de dĂ©but est dĂ©jĂ  passĂ©e ou c\'est aujourd\'hui', dateDebut: dateDebutOriginale.toISOString().split('T')[0] }); } 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' }); } const sommeRepartition = Repartition.reduce((sum, r) => { if (['CP', 'RTT', 'RĂ©cup'].includes(r.TypeConge)) return sum + parseFloat(r.NombreJours || 0); return sum; }, 0); const hasCountableLeave = Repartition.some(r => ['CP', 'RTT', 'RĂ©cup'].includes(r.TypeConge)); if (hasCountableLeave && Math.abs(sommeRepartition - businessDays) > 0.01) { 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)` }); } // 2ïžâƒŁ REMBOURSER L'ANCIENNE DEMANDE ─────────────────────────── // ✅ Plus de SoldeAnticipe — on rembourse uniquement Solde // ✅ Pour 'AnticipĂ© N+1' : juste suppression DeductionDetails (rien n'avait Ă©tĂ© dĂ©duit du Solde) 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 q( 'SELECT * FROM DeductionDetails WHERE DemandeCongeId = @requestId', { requestId } ); if (oldDeductions && oldDeductions.length > 0) { for (const d of oldDeductions) { // ✅ AnticipĂ© TolĂ©rĂ© → skip (jamais dĂ©duit du Solde) if (d.TypeDeduction === 'AnticipĂ© TolĂ©rĂ©') { console.log(` ⏭ SKIP: ${d.JoursUtilises}j "AnticipĂ© TolĂ©rĂ©" (non acquis)`); continue; } // ✅ AnticipĂ© N+1 (nouvelle logique) → rien Ă  rembourser en Solde // La ligne DeductionDetails sera supprimĂ©e en bloc plus bas if (d.TypeDeduction === 'AnticipĂ© N+1') { console.log(` ⏭ SKIP Solde pour AnticipĂ© N+1 (${d.JoursUtilises}j) — sera supprimĂ© avec DELETE`); restorationStats.count++; restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: parseFloat(d.JoursUtilises), isAnticipe: true, typeDeduction: d.TypeDeduction }); continue; } // ✅ AnticipFutur → jamais dĂ©duit du Solde, juste suppression DeductionDetails if (d.TypeDeduction === 'AnticipFutur') { console.log(` ⏭ SKIP Solde pour AnticipFutur (${d.JoursUtilises}j) — tracĂ© uniquement, sera supprimĂ© avec DELETE`); restorationStats.count++; restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: 0, isAnticipe: true, typeDeduction: d.TypeDeduction }); continue; } // ✅ AnticipNp1 (CP) → dĂ©duit du Solde CompteurConges N+1 → remettre if (d.TypeDeduction === 'AnticipNp1') { const compteurNp1 = await q( `SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`, { userId, typeCongeId: d.TypeCongeId, annee: d.Annee } ); if (compteurNp1?.length > 0) { const newSolde = parseFloat((parseFloat(compteurNp1[0].Solde) + parseFloat(d.JoursUtilises)).toFixed(2)); await q( 'UPDATE CompteurConges SET Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id', { solde: newSolde, id: compteurNp1[0].Id } ); console.log(` ✅ AnticipNp1 restaurĂ©: Solde N+1 +${d.JoursUtilises}j → ${newSolde}j`); } restorationStats.count++; restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: parseFloat(d.JoursUtilises), typeDeduction: d.TypeDeduction }); continue; } // ✅ AnticipRTT → solde RTT N rĂ©el dĂ©duit → remettre if (d.TypeDeduction === 'AnticipRTT') { const compteur = await q( `SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`, { userId, typeCongeId: d.TypeCongeId, annee: d.Annee } ); if (compteur?.length > 0) { const newSolde = parseFloat((parseFloat(compteur[0].Solde) + parseFloat(d.JoursUtilises)).toFixed(2)); await q('UPDATE CompteurConges SET Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id', { solde: newSolde, id: compteur[0].Id }); console.log(` ✅ AnticipRTT restaurĂ©: Solde +${d.JoursUtilises}j → ${newSolde}j`); } restorationStats.count++; restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: parseFloat(d.JoursUtilises), typeDeduction: d.TypeDeduction }); continue; } // ✅ AnticipFuturRTT → tracĂ© uniquement, aucun solde Ă  remettre if (d.TypeDeduction === 'AnticipFuturRTT') { console.log(` ⏭ SKIP Solde pour AnticipFuturRTT (${d.JoursUtilises}j) — tracĂ© uniquement`); restorationStats.count++; restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: 0, typeDeduction: d.TypeDeduction }); continue; } // ✅ AnticipNp1RTT (RTT N+1) → dĂ©duit du Solde CompteurConges RTT N+1 → remettre if (d.TypeDeduction === 'AnticipNp1RTT') { const compteur = await q( `SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`, { userId, typeCongeId: d.TypeCongeId, annee: d.Annee } ); if (compteur?.length > 0) { const newSolde = parseFloat((parseFloat(compteur[0].Solde) + parseFloat(d.JoursUtilises)).toFixed(2)); await q('UPDATE CompteurConges SET Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id', { solde: newSolde, id: compteur[0].Id }); console.log(` ✅ AnticipNp1RTT restaurĂ©: Solde RTT N+1 +${d.JoursUtilises}j → ${newSolde}j`); } restorationStats.count++; restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: parseFloat(d.JoursUtilises), typeDeduction: d.TypeDeduction }); continue; } // ✅ Legacy 'N AnticipĂ©' / 'N+1 AnticipĂ©' (ancienne logique) // Le Solde avait Ă©tĂ© dĂ©duit → on le rembourse const isAncienAnticipe = d.TypeDeduction && ( d.TypeDeduction === 'N AnticipĂ©' || d.TypeDeduction === 'N+1 AnticipĂ©' ); const compteur = await q( `SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`, { userId, typeCongeId: d.TypeCongeId, annee: d.Annee } ); if (compteur && compteur.length > 0) { const joursUtilises = parseFloat(d.JoursUtilises); const newSolde = parseFloat(compteur[0].Solde) + joursUtilises; await q( 'UPDATE CompteurConges SET Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id', { solde: newSolde, id: compteur[0].Id } ); console.log(` ✅ RemboursĂ© ${joursUtilises}j (Solde) — Type=${d.TypeDeduction}, AnnĂ©e=${d.Annee}${isAncienAnticipe ? ' [legacy]' : ''}`); restorationStats.count++; restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: joursUtilises, isAnticipe: isAncienAnticipe, typeDeduction: d.TypeDeduction }); } } // Supprime toutes les dĂ©ductions de cette demande en une fois await q( 'DELETE FROM DeductionDetails WHERE DemandeCongeId = @requestId', { requestId } ); console.log(` đŸ§č ${oldDeductions.length} dĂ©duction(s) supprimĂ©e(s)`); } } // 3ïžâƒŁ METTRE À JOUR LA DEMANDE ──────────────────────────────── console.log('📝 Mise Ă  jour de la demande...'); const newStatus = (oldStatus === 'ValidĂ©e' || oldStatus === 'ValidĂ©') ? 'En attente' : oldStatus; await q( `UPDATE DemandeConge SET TypeCongeId = @leaveType, DateDebut = @startDate, DateFin = @endDate, Commentaire = @reason, NombreJours = @businessDays, Statut = @newStatus, DateValidation = GETDATE(), CommentaireValidation = COALESCE(CommentaireValidation, '') + CHAR(10) + '[ModifiĂ©e le ' + FORMAT(GETDATE(), 'dd/MM/yyyy Ă  HH:mm', 'fr-FR') + ']' WHERE Id = @requestId`, { leaveType, startDate, endDate, reason: reason || '', businessDays, newStatus, requestId } ); console.log(`✅ Demande ${requestId} modifiĂ©e - Statut: ${newStatus}`); // 4ïžâƒŁ SUPPRIMER L'ANCIENNE RÉPARTITION ──────────────────────── await q('DELETE FROM DemandeCongeType WHERE DemandeCongeId = @requestId', { requestId }); // 5ïžâƒŁ SAUVEGARDER LA NOUVELLE RÉPARTITION ───────────────────── 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 typeRows = await q('SELECT TOP 1 Id FROM TypeConge WHERE Nom = @nom', { nom: name }); if (!typeRows || typeRows.length === 0) { console.warn(` ⚠ Type introuvable: ${name}`); continue; } const typeCongeId = typeRows[0].Id; const dctId = await getNextId(connection, 'DemandeCongeType'); await q( `INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee, DateDebut, DateFin) VALUES (@id, @demandeCongeId, @typeCongeId, @nbJours, @periode, @dateDebut, @dateFin)`, { id: dctId, demandeCongeId: requestId, typeCongeId, nbJours: rep.NombreJours, periode: rep.PeriodeJournee || 'JournĂ©e entiĂšre', dateDebut: rep.DateDebut || startDate, dateFin: rep.DateFin || endDate } ); console.log(` ✓ ${name}: ${rep.NombreJours}j (${rep.PeriodeJournee || 'JournĂ©e entiĂšre'})`); } // 6ïžâƒŁ CALCULER ET APPLIQUER LA NOUVELLE DÉDUCTION ───────────── let newRepartition = []; 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; } // ── RĂ©cup ───────────────────────────────────────────── if (rep.TypeConge === 'RĂ©cup') { const recupTypeRows = await q('SELECT TOP 1 Id FROM TypeConge WHERE Nom = @nom', { nom: 'RĂ©cupĂ©ration' }); if (!recupTypeRows?.length) { console.warn(' ⚠ Type RĂ©cupĂ©ration introuvable'); continue; } const recupTypeId = recupTypeRows[0].Id; const nbJours = parseFloat(rep.NombreJours || 0); await q( `UPDATE CompteurConges SET Solde = CASE WHEN Solde - @nbJours < 0 THEN 0 ELSE Solde - @nbJours END, DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`, { nbJours, userId, typeCongeId: recupTypeId, annee: currentYear } ); await q( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@requestId, @typeCongeId, @annee, 'RĂ©cup PosĂ©e', @jours)`, { requestId, typeCongeId: recupTypeId, annee: currentYear, jours: nbJours } ); newRepartition.push({ typeCongeId: recupTypeId, annee: currentYear, jours: rep.NombreJours, typeDeduction: 'RĂ©cup PosĂ©e' }); console.log(` ✓ RĂ©cup: ${nbJours}j dĂ©duits`); continue; } // ── CP ──────────────────────────────────────────────── if (rep.TypeConge === 'CP') { const cpTypeRows = await q('SELECT TOP 1 Id FROM TypeConge WHERE Nom = @nom', { nom: 'CongĂ© payĂ©' }); if (!cpTypeRows?.length) { console.warn(' ⚠ Type CongĂ© payĂ© introuvable'); continue; } const cpTypeId = cpTypeRows[0].Id; console.log('🔄 Appel deductLeaveBalanceCP pour modification...'); const result = await deductLeaveBalanceCP( connection, userId, cpTypeId, parseFloat(rep.NombreJours), requestId, startDate ); console.log('📊 RĂ©sultat dĂ©duction CP:', result); if (!result.success) { // ✅ TolĂ©rance uniquement pour anticipĂ© post-bascule // (les jours seront acquis avant la date de dĂ©part) if (result.mode === 'ANTICIPÉ POST-BASCULEMENT') { console.warn(`⚠ AnticipĂ© post-basculement — ${result.joursNonDeduits.toFixed(2)}j non couverts (tolĂ©rĂ©)`); await q( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@requestId, @typeCongeId, @annee, 'AnticipĂ© TolĂ©rĂ©', @jours)`, { requestId, typeCongeId: cpTypeId, annee: anneeN, jours: result.joursNonDeduits } ); } else { throw new Error(`DĂ©duction CP Ă©chouĂ©e — ${result.joursNonDeduits.toFixed(2)}j non couverts`); } } console.log(`✅ CP dĂ©duits: ${result.joursDeduitsTotal.toFixed(2)}j | Mode: ${result.mode}`); continue; } // ── RTT ─────────────────────────────────────────────── // ✅ MĂȘme logique anticipĂ© que le submit : AnticipRTT / AnticipFuturRTT / AnticipNp1RTT if (rep.TypeConge === 'RTT') { const rttTypeRows = await q('SELECT TOP 1 Id FROM TypeConge WHERE Nom = @nom', { nom: 'RTT' }); if (!rttTypeRows?.length) { console.warn(' ⚠ Type RTT introuvable'); continue; } const rttTypeId = rttTypeRows[0].Id; const nbJours = parseFloat(rep.NombreJours || 0); // anneeRTT = annĂ©e civile de la date de dĂ©but du congĂ© const anneeRTT = new Date(startDate).getFullYear(); const isAnticipeRTT = anneeRTT > currentYear; console.log(` RTT — anneeRTT=${anneeRTT} (${isAnticipeRTT ? 'anticipĂ©' : 'normal'})`); if (!isAnticipeRTT) { // ── MODE NORMAL ────────────────────────────────────────── const existingRTT = await q( `SELECT Id FROM CompteurConges WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`, { userId, typeCongeId: rttTypeId, annee: anneeRTT } ); if (!existingRTT?.length) { const nextId = await getNextId(connection, 'CompteurConges'); const conn2 = await pool.getConnection(); let acquisRTT = 0; try { const finEx = new Date(anneeRTT, 11, 31); const rttData = await calculerAcquisitionRTTSmart(conn2, userId, finEx); acquisRTT = rttData.acquisition; } finally { conn2.release(); } await q( `INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (@id, @userId, @typeCongeId, @annee, @total, 0, 0, GETDATE())`, { id: nextId, userId, typeCongeId: rttTypeId, annee: anneeRTT, total: acquisRTT } ); console.log(` ✅ Compteur RTT ${anneeRTT} créé (Total=${acquisRTT.toFixed(2)}j)`); } await q( `UPDATE CompteurConges SET Solde = CASE WHEN Solde - @nbJours < 0 THEN 0 ELSE Solde - @nbJours END, DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`, { nbJours, userId, typeCongeId: rttTypeId, annee: anneeRTT } ); await q( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@requestId, @typeCongeId, @annee, 'AnnĂ©e N', @jours)`, { requestId, typeCongeId: rttTypeId, annee: anneeRTT, jours: nbJours } ); console.log(` ✓ RTT normal: ${nbJours}j dĂ©duits sur Annee=${anneeRTT}`); } else { // ── MODE ANTICIPÉ RTT ───────────────────────────────────── const anneeRTTCourant = currentYear; // Taux RTT N et N+1 const conn2rtt = await pool.getConnection(); let joursAnnuelsRTTN = 10; try { const cfg = await getConfigurationRTT(conn2rtt, anneeRTTCourant, typeContrat || '37h'); joursAnnuelsRTTN = cfg.joursAnnuels; } finally { conn2rtt.release(); } const conn3rtt = await pool.getConnection(); let joursAnnuelsRTTNp1 = 10; try { const cfg = await getConfigurationRTT(conn3rtt, anneeRTT, typeContrat || '37h'); joursAnnuelsRTTNp1 = cfg.joursAnnuels; } finally { conn3rtt.release(); } const tauxRTTN = joursAnnuelsRTTN / 360; const tauxRTTNp1 = joursAnnuelsRTTNp1 / 360; // Solde RTT N rĂ©el const cRTTN = await q( `SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`, { userId, typeCongeId: rttTypeId, annee: anneeRTTCourant } ); const soldeRTTNActuel = cRTTN?.length > 0 ? Math.max(0, parseFloat(cRTTN[0].Solde || 0)) : 0; // Projection RTT N jusqu'au 31/12 const finExRTTN = new Date(anneeRTTCourant, 11, 31); finExRTTN.setHours(23, 59, 59, 0); const joursRestantsRTTN = Math.max(0, Math.floor((finExRTTN - today) / 86400000)); const acquisRestantsRTTN = parseFloat(Math.min(joursAnnuelsRTTN - soldeRTTNActuel, joursRestantsRTTN * tauxRTTN).toFixed(2)); const soldeRTTNProj = parseFloat(Math.min(joursAnnuelsRTTN, soldeRTTNActuel + acquisRestantsRTTN).toFixed(2)); // DĂ©jĂ  consommĂ© RTT N (AnticipRTT + AnticipFuturRTT) const dejaConsoRTTNRows = await q( `SELECT CAST(COALESCE(SUM(dd.JoursUtilises), 0) AS FLOAT) AS total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = @userId AND dd.TypeCongeId = @typeCongeId AND dd.Annee = @annee AND dd.TypeDeduction IN ('AnticipRTT', 'AnticipFuturRTT') AND dc.Statut != 'RefusĂ©'`, { userId, typeCongeId: rttTypeId, annee: anneeRTTCourant } ); const dejaConsoRTTN = parseFloat(dejaConsoRTTNRows[0]?.total || 0); const dispoRTTN = parseFloat(Math.max(0, soldeRTTNProj - dejaConsoRTTN).toFixed(2)); // Prorata RTT N+1 depuis 01/01/anneeRTT const dateDebutCongeRTT = new Date(startDate); dateDebutCongeRTT.setHours(0, 0, 0, 0); const debutNp1RTT = new Date(anneeRTT, 0, 1); debutNp1RTT.setHours(0, 0, 0, 0); const diffJoursRTTNp1 = Math.max(0, Math.floor((dateDebutCongeRTT - debutNp1RTT) / 86400000)); const acquisRTTNp1 = parseFloat(Math.min(joursAnnuelsRTTNp1, diffJoursRTTNp1 * tauxRTTNp1).toFixed(2)); const dejaConsoRTTNp1Rows = await q( `SELECT CAST(COALESCE(SUM(dd.JoursUtilises), 0) AS FLOAT) AS total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = @userId AND dd.TypeCongeId = @typeCongeId AND dd.Annee = @annee AND dc.Statut != 'RefusĂ©'`, { userId, typeCongeId: rttTypeId, annee: anneeRTT } ); const dejaConsoRTTNp1 = parseFloat(dejaConsoRTTNp1Rows[0]?.total || 0); const dispoRTTNp1 = parseFloat(Math.max(0, acquisRTTNp1 - dejaConsoRTTNp1).toFixed(2)); const totalDispoRTT = parseFloat((dispoRTTN + dispoRTTNp1).toFixed(2)); console.log(` 💰 RTT N (${anneeRTTCourant}): projetĂ© ${soldeRTTNProj.toFixed(2)}j - consommĂ© ${dejaConsoRTTN.toFixed(2)}j = dispo ${dispoRTTN.toFixed(2)}j`); console.log(` 💰 RTT N+1 (${anneeRTT}): ${acquisRTTNp1.toFixed(2)}j - consommĂ© ${dejaConsoRTTNp1.toFixed(2)}j = dispo ${dispoRTTNp1.toFixed(2)}j`); console.log(` 📊 RTT total: ${totalDispoRTT.toFixed(2)}j | demandĂ©s=${nbJours}j`); if (totalDispoRTT < nbJours) { throw new Error(`Solde RTT anticipĂ© insuffisant : ${totalDispoRTT.toFixed(2)}j disponibles, ${nbJours}j demandĂ©s`); } let joursRestantsRTT = nbJours; // 1ïžâƒŁ Solde RTT N rĂ©el → AnticipRTT if (joursRestantsRTT > 0 && soldeRTTNActuel > 0 && cRTTN?.length > 0) { const aDeduireRTT = parseFloat(Math.min(soldeRTTNActuel, joursRestantsRTT).toFixed(2)); await q('UPDATE CompteurConges SET Solde = Solde - @jours, DerniereMiseAJour = GETDATE() WHERE Id = @id', { jours: aDeduireRTT, id: cRTTN[0].Id }); await q(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@requestId, @typeCongeId, @annee, 'AnticipRTT', @jours)`, { requestId, typeCongeId: rttTypeId, annee: anneeRTTCourant, jours: aDeduireRTT }); joursRestantsRTT = parseFloat((joursRestantsRTT - aDeduireRTT).toFixed(2)); console.log(` ✅ AnticipRTT: ${aDeduireRTT.toFixed(2)}j → reste ${joursRestantsRTT.toFixed(2)}j`); } // 2ïžâƒŁ Acquisition future RTT N (jusqu'au 31/12) → AnticipFuturRTT if (joursRestantsRTT > 0 && dispoRTTN > 0) { const aTracerRTTN = parseFloat(Math.min(dispoRTTN, joursRestantsRTT).toFixed(2)); await q(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@requestId, @typeCongeId, @annee, 'AnticipFuturRTT', @jours)`, { requestId, typeCongeId: rttTypeId, annee: anneeRTTCourant, jours: aTracerRTTN }); joursRestantsRTT = parseFloat((joursRestantsRTT - aTracerRTTN).toFixed(2)); console.log(` ✅ AnticipFuturRTT: ${aTracerRTTN.toFixed(2)}j tracĂ© → reste ${joursRestantsRTT.toFixed(2)}j`); } // 3ïžâƒŁ Prorata RTT N+1 → AnticipNp1RTT if (joursRestantsRTT > 0 && dispoRTTNp1 > 0) { const aTracerRTTNp1 = parseFloat(Math.min(dispoRTTNp1, joursRestantsRTT).toFixed(2)); const cRTTNp1 = await q(`SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`, { userId, typeCongeId: rttTypeId, annee: anneeRTT }); if (!cRTTNp1?.length) { const nextId = await getNextId(connection, 'CompteurConges'); await q(`INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (@id, @userId, @typeCongeId, @annee, 0, @solde, 0, GETDATE())`, { id: nextId, userId, typeCongeId: rttTypeId, annee: anneeRTT, solde: -aTracerRTTNp1 }); console.log(` ➕ CompteurConges RTT Annee=${anneeRTT} créé Solde=${-aTracerRTTNp1}j`); } else { await q('UPDATE CompteurConges SET Solde = Solde - @jours, DerniereMiseAJour = GETDATE() WHERE Id = @id', { jours: aTracerRTTNp1, id: cRTTNp1[0].Id }); } await q(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@requestId, @typeCongeId, @annee, 'AnticipNp1RTT', @jours)`, { requestId, typeCongeId: rttTypeId, annee: anneeRTT, jours: aTracerRTTNp1 }); joursRestantsRTT = parseFloat((joursRestantsRTT - aTracerRTTNp1).toFixed(2)); console.log(` ✅ AnticipNp1RTT: ${aTracerRTTNp1.toFixed(2)}j tracĂ© sur RTT ${anneeRTT}`); } if (joursRestantsRTT > 0.01) { throw new Error(`DĂ©duction RTT anticipĂ©e incomplĂšte: ${joursRestantsRTT.toFixed(2)}j non couverts`); } console.log(` ✓ RTT anticipĂ©: ${nbJours}j dĂ©duits | AnticipRTT+AnticipFuturRTT+AnticipNp1RTT`); } continue; } } // ✅ updateSoldeAnticipe supprimĂ© — plus nĂ©cessaire console.log('✅ DĂ©ductions appliquĂ©es'); } await connection.commit(); console.log(`✅ Demande ${requestId} modifiĂ©e avec succĂšs`); // 7ïžâƒŁ ENVOI DES EMAILS (aprĂšs commit) ───────────────────────── const graphToken = await getGraphToken(); if (graphToken) { const managerInfo = await q( `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`, { userId }, true // usePoolDirectly aprĂšs commit ); if (managerInfo?.length > 0) { const manager = managerInfo[0]; sendMailGraph(graphToken, 'gtanoreply@ensup.eu', manager.Email, '🔄 Modification de demande de congĂ©', `

    🔄 Modification de demande

    ${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 ? `

    ✅ Compteurs recalculĂ©s (${restorationStats.count} opĂ©ration(s)).

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

    ✅ Demande modifiĂ©e

    Bonjour ${userName.split(' ')[0]}, votre demande 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, 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, d.Commentaire 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; 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'); } else { console.log(`📋 Demande: Statut=${requestStatus}, Date dĂ©but=${dateDebut.toLocaleDateString('fr-FR')}`); 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 ────────────────────────────────── 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) { // ── AnticipĂ© TolĂ©rĂ© → skip ───────────────────────── if (d.TypeDeduction === 'AnticipĂ© TolĂ©rĂ©') { console.log(` ⏭ SKIP: ${d.JoursUtilises}j "AnticipĂ© TolĂ©rĂ©" (non acquis, ne pas rembourser)`); continue; } // ✅ AnticipĂ© N+1 (nouvelle logique) // Le Solde du compteur n'a jamais Ă©tĂ© touchĂ© → DELETE seul suffit if (d.TypeDeduction === 'AnticipĂ© N+1') { console.log(` ⏭ SKIP Solde pour AnticipĂ© N+1 (${d.JoursUtilises}j) — supprimĂ© avec DELETE global`); restorationStats.count++; restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: parseFloat(d.JoursUtilises), isAnticipe: true, typeDeduction: d.TypeDeduction }); continue; } // ✅ AnticipFutur → jamais dĂ©duit du Solde, juste suppression DeductionDetails if (d.TypeDeduction === 'AnticipFutur') { console.log(` ⏭ SKIP Solde pour AnticipFutur (${d.JoursUtilises}j) — tracĂ© uniquement, supprimĂ© avec DELETE`); restorationStats.count++; restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: 0, isAnticipe: true, typeDeduction: d.TypeDeduction }); continue; } // ✅ AnticipNp1 → dĂ©duit du Solde CompteurConges N+1 → remettre if (d.TypeDeduction === 'AnticipNp1') { const [compteurNp1] = await conn.query( 'SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?', [userId, d.TypeCongeId, d.Annee] ); if (compteurNp1?.length > 0) { const newSolde = parseFloat((parseFloat(compteurNp1[0].Solde) + parseFloat(d.JoursUtilises)).toFixed(2)); await conn.query( 'UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?', [newSolde, compteurNp1[0].Id] ); console.log(` ✅ AnticipNp1 restaurĂ©: Solde N+1 +${d.JoursUtilises}j → ${newSolde}j`); } restorationStats.count++; restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: parseFloat(d.JoursUtilises), typeDeduction: d.TypeDeduction }); continue; } // ✅ AnticipFuturRTT → tracĂ© uniquement, jamais dĂ©duit du Solde RTT → DELETE seul // MĂȘme logique qu'AnticipFutur pour les CP if (d.TypeDeduction === 'AnticipFuturRTT') { console.log(` ⏭ SKIP Solde pour AnticipFuturRTT (${d.JoursUtilises}j) — tracĂ© uniquement, supprimĂ© avec DELETE`); restorationStats.count++; restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: 0, isAnticipe: true, typeDeduction: d.TypeDeduction }); continue; } // ✅ AnticipRTT → solde RTT N rĂ©ellement dĂ©duit → remettre MAIS plafonnĂ© au Total acquis aujourd'hui if (d.TypeDeduction === 'AnticipRTT') { const [compteurRTT] = await conn.query( 'SELECT Id, Solde, Total FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?', [userId, d.TypeCongeId, d.Annee] ); if (compteurRTT?.length > 0) { const soldeActuel = parseFloat(compteurRTT[0].Solde) || 0; const totalAcquis = parseFloat(compteurRTT[0].Total) || 0; // 🔑 Plafonner au Total acquis aujourd'hui (pas de surplus non encore acquis) const newSolde = parseFloat( Math.min(soldeActuel + parseFloat(d.JoursUtilises), totalAcquis).toFixed(2) ); await conn.query( 'UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?', [newSolde, compteurRTT[0].Id] ); console.log(` ✅ AnticipRTT restaurĂ©: Solde RTT +${d.JoursUtilises}j → ${newSolde}j (plafonnĂ© Ă  Total=${totalAcquis}j)`); } restorationStats.count++; restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: parseFloat(d.JoursUtilises), isAnticipe: true, typeDeduction: d.TypeDeduction }); continue; } // ✅ AnticipNp1RTT → solde RTT N+1 rĂ©ellement dĂ©duit → remettre MAIS plafonnĂ© au Total acquis aujourd'hui if (d.TypeDeduction === 'AnticipNp1RTT') { const [compteurRTTNp1] = await conn.query( 'SELECT Id, Solde, Total FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?', [userId, d.TypeCongeId, d.Annee] ); if (compteurRTTNp1?.length > 0) { const soldeActuel = parseFloat(compteurRTTNp1[0].Solde) || 0; const totalAcquis = parseFloat(compteurRTTNp1[0].Total) || 0; // 🔑 Plafonner au Total acquis aujourd'hui (idem AnticipRTT) const newSolde = parseFloat( Math.min(soldeActuel + parseFloat(d.JoursUtilises), totalAcquis).toFixed(2) ); await conn.query( 'UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?', [newSolde, compteurRTTNp1[0].Id] ); console.log(` ✅ AnticipNp1RTT restaurĂ©: Solde RTT N+1 +${d.JoursUtilises}j → ${newSolde}j (plafonnĂ© Ă  Total=${totalAcquis}j)`); } restorationStats.count++; restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: parseFloat(d.JoursUtilises), isAnticipe: true, typeDeduction: d.TypeDeduction }); continue; } // ✅ Legacy 'N AnticipĂ©' / 'N+1 AnticipĂ©' (ancienne logique) // Le Solde avait Ă©tĂ© dĂ©duit → on le rembourse const isLegacyAnticipe = ( d.TypeDeduction === 'N AnticipĂ©' || d.TypeDeduction === 'N+1 AnticipĂ©' ); 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 joursUtilises = parseFloat(d.JoursUtilises); const newSolde = parseFloat(compteur[0].Solde) + joursUtilises; await conn.query( 'UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?', [newSolde, compteur[0].Id] ); console.log(` ✅ RemboursĂ© ${joursUtilises}j (Solde) — Type=${d.TypeDeduction}, AnnĂ©e=${d.Annee}${isLegacyAnticipe ? ' [legacy]' : ''}`); restorationStats.count++; restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: joursUtilises, isAnticipe: isLegacyAnticipe, typeDeduction: d.TypeDeduction }); } else { console.warn(`⚠ Compteur introuvable (Type ${d.TypeCongeId}, AnnĂ©e ${d.Annee})`); } } // Supprime toutes les dĂ©ductions de cette demande en une fois 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(); // 5ïžâƒŁ ENVOI DES EMAILS (aprĂšs commit) ───────────────────────── let emailsSent = { collaborateur: false, manager: false }; const graphToken = await getGraphToken(); if (graphToken) { const [collabInfo] = await pool.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; 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 collaborateur if (collabEmail) { try { await sendMailGraph( graphToken, 'gtanoreply@ensup.eu', collabEmail, '✅ Confirmation d\'annulation de votre demande', `

    ✅ 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) effectuée(s).

    ` : ''}

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

    ` ); emailsSent.collaborateur = true; console.log('✅ Email envoyĂ© au collaborateur'); } catch (e) { console.error('❌ Erreur email collaborateur:', e.message); } } // 📧 Email manager const [hierarchie] = await pool.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 couleur = isValidated ? '#ef4444' : '#f59e0b'; const couleurDark = isValidated ? '#991b1b' : '#d97706'; const subjectManager = isValidated ? `đŸ—‘ïž Annulation de congĂ© validĂ© - ${collabName}` : `đŸ—‘ïž Annulation de demande - ${collabName}`; await sendMailGraph( graphToken, 'gtanoreply@ensup.eu', managerEmail, subjectManager, `

    đŸ—‘ïž 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.

    ` ); emailsSent.manager = true; console.log('✅ Email envoyĂ© au manager'); } catch (e) { console.error('❌ Erreur email manager:', e.message); } } } res.json({ success: true, message: 'Demande annulĂ©e avec succĂšs', restoration: restorationStats, 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 }; } /** * 🔍 DĂ©termine si une demande de CP utilise l'anticipation * * @param {Date|string} dateDebut - Date de dĂ©but des congĂ©s * @param {Date|string} dateDemande - Date de la demande (dĂ©faut: aujourd'hui) * @returns {Object} Informations sur les annĂ©es de compteurs Ă  utiliser */ function getAnneesCompteursCP(dateDebut, dateDemande = new Date()) { const debut = new Date(dateDebut); debut.setHours(0, 0, 0, 0); const demande = new Date(dateDemande); demande.setHours(0, 0, 0, 0); const moisDebut = debut.getMonth() + 1; // 1-12 const anneeDebut = debut.getFullYear(); const moisDemande = demande.getMonth() + 1; const anneeDemande = demande.getFullYear(); // ======================================== // 1ïžâƒŁ DĂ©terminer l'exercice de la DEMANDE // ======================================== let exerciceDemande; if (moisDemande >= 6) { // Demande entre juin et dĂ©cembre N → exercice commence en N exerciceDemande = anneeDemande; } else { // Demande entre janvier et mai N → exercice a commencĂ© en N-1 exerciceDemande = anneeDemande - 1; } // ======================================== // 2ïžâƒŁ DĂ©terminer l'exercice des CONGÉS // ======================================== let exerciceConges; if (moisDebut >= 6) { exerciceConges = anneeDebut; } else { exerciceConges = anneeDebut - 1; } // ======================================== // 3ïžâƒŁ Calculer les annĂ©es de compteurs // ======================================== const isAnticipe = exerciceConges > exerciceDemande; if (isAnticipe) { // MODE ANTICIPATION : congĂ©s sur exercice suivant return { mode: 'ANTICIPATION', isAnticipe: true, exerciceDemande: exerciceDemande, exerciceConges: exerciceConges, anneeN: exerciceDemande, // L'annĂ©e N actuelle anneeN1: null, // Pas de N-1 en anticipation description: `Anticipation: CP ${exerciceDemande} pour congĂ©s ${exerciceConges}-${exerciceConges + 1}` }; } else { // MODE NORMAL : congĂ©s sur exercice en cours return { mode: 'NORMAL', isAnticipe: false, exerciceDemande: exerciceDemande, exerciceConges: exerciceConges, anneeN1: exerciceDemande - 1, // Le reportĂ© de l'exercice prĂ©cĂ©dent anneeN: exerciceDemande, // L'exercice en cours description: `Normal: N-1 (${exerciceDemande - 1}) prioritaire puis N (${exerciceDemande})` }; } } /** * 💰 DĂ©duit les CP avec gestion du glissement d'exercice * * RÈGLES MÉTIER : * - MODE NORMAL : N-1 (reportĂ©) prioritaire puis N * - MODE ANTICIPATION : N uniquement (pas de report) * * @param {Connection} conn - Connexion BDD * @param {number} collaborateurId - ID du collaborateur * @param {number} typeCongeId - ID du type de congĂ© CP * @param {number} nombreJours - Nombre de jours Ă  dĂ©duire * @param {number} demandeCongeId - ID de la demande * @param {Date|string} dateDebut - Date de dĂ©but des congĂ©s * @param {Date|string} dateDemande - Date de la demande * @returns {Object} RĂ©sultat de la dĂ©duction async function deductLeaveBalanceCP(conn, collaborateurId, typeCongeId, nombreJours, demandeCongeId, dateDebut, dateDemande = new Date()) { let joursRestants = nombreJours; const deductions = []; console.log(`\n📅 === DÉDUCTION CP ===`); console.log(` Jours Ă  dĂ©duire: ${nombreJours}j`); console.log(` Date congĂ©s: ${dateDebut}`); console.log(` Date demande: ${dateDemande}`); // ⭐ CALCULER LES ANNÉES DE COMPTEURS const infos = getAnneesCompteursCP(dateDebut, dateDemande); console.log(` Mode: ${infos.mode}`); console.log(` ${infos.description}`); if (infos.isAnticipe) { // ======================================== // MODE ANTICIPATION : CP N uniquement // ======================================== console.log(` 🔾 Utilisation CP ${infos.anneeN} uniquement`); const [compteurN] = await conn.query(` SELECT Id, Solde, SoldeReporte, Total FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, infos.anneeN]); if (compteurN.length > 0) { const soldeTotal = parseFloat(compteurN[0].Solde || 0); const soldeReporte = parseFloat(compteurN[0].SoldeReporte || 0); // Solde N = Solde Total - ReportĂ© const soldeN = Math.max(0, soldeTotal - soldeReporte); console.log(` 💰 Solde CP ${infos.anneeN}: ${soldeN.toFixed(2)}j disponible (Total: ${soldeTotal.toFixed(2)}j, ReportĂ©: ${soldeReporte.toFixed(2)}j)`); 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, DerniereMiseAJour = GETDATE() WHERE Id = ? `, [aDeduire, aDeduire, compteurN[0].Id]); await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, ?, ?) `, [demandeCongeId, typeCongeId, infos.anneeN, `CP ${infos.anneeN} (AnticipĂ©)`, aDeduire]); deductions.push({ annee: infos.anneeN, type: `CP ${infos.anneeN} (AnticipĂ©)`, joursUtilises: aDeduire, soldeAvant: soldeN }); joursRestants -= aDeduire; console.log(` ✅ DĂ©duit de CP ${infos.anneeN}: ${aDeduire.toFixed(2)}j (reste: ${joursRestants.toFixed(2)}j)`); } else { console.log(` ⚠ Solde CP ${infos.anneeN} insuffisant`); } } else { console.log(` ⚠ Compteur CP ${infos.anneeN} non trouvĂ©`); } } else { // ======================================== // MODE NORMAL : N-1 prioritaire puis N // ======================================== console.log(` 🔾 PrioritĂ© 1: CP ${infos.anneeN1} (reportĂ©)`); console.log(` 🔾 PrioritĂ© 2: CP ${infos.anneeN} (actuel)`); // 1ïžâƒŁ Utiliser N-1 (reportĂ©) en prioritĂ© const [compteurN1] = await conn.query(` SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, infos.anneeN1]); if (compteurN1.length > 0) { const soldeN1 = parseFloat(compteurN1[0].SoldeReporte || 0); console.log(` 💰 Solde CP ${infos.anneeN1} (reportĂ©): ${soldeN1.toFixed(2)}j`); 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, DerniereMiseAJour = GETDATE() WHERE Id = ? `, [aDeduire, aDeduire, aDeduire, aDeduire, compteurN1[0].Id]); await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, ?, ?) `, [demandeCongeId, typeCongeId, infos.anneeN1, `CP ${infos.anneeN1} (ReportĂ©)`, aDeduire]); deductions.push({ annee: infos.anneeN1, type: `CP ${infos.anneeN1} (ReportĂ©)`, joursUtilises: aDeduire, soldeAvant: soldeN1 }); joursRestants -= aDeduire; console.log(` ✅ DĂ©duit de CP ${infos.anneeN1} (reportĂ©): ${aDeduire.toFixed(2)}j (reste: ${joursRestants.toFixed(2)}j)`); } } else { console.log(` â„č Pas de CP ${infos.anneeN1} reportĂ©`); } // 2ïžâƒŁ Si N-1 insuffisant, utiliser N if (joursRestants > 0) { const [compteurN] = await conn.query(` SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, infos.anneeN]); if (compteurN.length > 0) { const soldeTotal = parseFloat(compteurN[0].Solde || 0); const soldeReporte = parseFloat(compteurN[0].SoldeReporte || 0); const soldeN = Math.max(0, soldeTotal - soldeReporte); console.log(` 💰 Solde CP ${infos.anneeN}: ${soldeN.toFixed(2)}j disponible`); 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, DerniereMiseAJour = GETDATE() WHERE Id = ? `, [aDeduire, aDeduire, compteurN[0].Id]); await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, ?, ?) `, [demandeCongeId, typeCongeId, infos.anneeN, `CP ${infos.anneeN}`, aDeduire]); deductions.push({ annee: infos.anneeN, type: `CP ${infos.anneeN}`, joursUtilises: aDeduire, soldeAvant: soldeN }); joursRestants -= aDeduire; console.log(` ✅ DĂ©duit de CP ${infos.anneeN}: ${aDeduire.toFixed(2)}j (reste: ${joursRestants.toFixed(2)}j)`); } else { console.log(` ⚠ Solde CP ${infos.anneeN} insuffisant`); } } } } console.log(` 📊 RĂ©sultat: ${deductions.length} dĂ©duction(s), ${joursRestants.toFixed(2)}j non couverts`); return { success: joursRestants === 0, joursDeduitsTotal: nombreJours - joursRestants, joursNonDeduits: joursRestants, details: deductions, mode: infos.mode, exerciceDemande: infos.exerciceDemande, exerciceConges: infos.exerciceConges }; }*/ // ======================================== // 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 }); } }); // FONCTION CORRIGÉE : deductLeaveBalanceWithN1 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) || (demandeYear === nextYear - 1 && demandeMonth >= 6); } else { useN1 = (demandeYear === nextYear); } console.log(`DĂ©duction ${typeNom}: ${nombreJours}j - useN1=${useN1}`); if (useN1) { // ===== ORDRE POUR 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) { const soldeN1A = parseFloat(compteurN1Anticipe[0].SoldeAnticipe || 0); const aDeduire = Math.min(soldeN1A, joursRestants); if (aDeduire > 0) { await conn.query( `UPDATE CompteurConges SET SoldeAnticipe = CASE WHEN SoldeAnticipe - ? < 0 THEN 0 ELSE SoldeAnticipe - ? END WHERE Id=?`, [aDeduire, aDeduire, compteurN1Anticipe[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, soldeAvant: soldeN1A }); joursRestants -= aDeduire; console.log(`✅ N+1 Anticip: ${aDeduire}j - reste ${joursRestants}j`); } } // 2. N Anticip if (joursRestants > 0) { const compteurNAnticipe = await conn.query( `SELECT Id, SoldeAnticipe FROM CompteurConges WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?`, [collaborateurId, typeCongeId, currentYear] ); if (compteurNAnticipe.length > 0) { const soldeNA = parseFloat(compteurNAnticipe[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, aDeduire, compteurNAnticipe[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 - 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, aDeduire, compteurN[0].Id] ); await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Anne N', ?)`, [demandeCongeId, typeCongeId, currentYear, aDeduire] ); deductions.push({ annee: currentYear, type: "Anne N", joursUtilises: aDeduire, soldeAvant: soldeN }); joursRestants -= aDeduire; console.log(`✅ Anne N: ${aDeduire}j - reste ${joursRestants}j`); } } } // 4. N-1 report (en dernier pour N+1) 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, 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 POUR N : N-1 d'abord → puis N seulement si N-1 vide → puis N anticip ===== // 1. Report N-1 (PRIORITÉ ABSOLUE) const compteurN1 = await conn.query( `SELECT Id, SoldeReporte, Solde 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, 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. Anne N (SEULEMENT SI N-1 est vide ou insuffisant) 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 - 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, aDeduire, compteurN[0].Id] ); await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Anne N', ?)`, [demandeCongeId, typeCongeId, currentYear, aDeduire] ); deductions.push({ annee: currentYear, type: "Anne N", joursUtilises: aDeduire, soldeAvant: soldeN }); joursRestants -= aDeduire; console.log(`✅ Anne N: ${aDeduire}j - reste ${joursRestants}j`); } } } // 3. N Anticip (uniquement si N-1 ET N sont Ă©puisĂ©s) if (joursRestants > 0) { const compteurNAnticipe = await conn.query( `SELECT Id, SoldeAnticipe FROM CompteurConges WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?`, [collaborateurId, typeCongeId, currentYear] ); if (compteurNAnticipe.length > 0) { const soldeNA = parseFloat(compteurNAnticipe[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, aDeduire, compteurNAnticipe[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 TOP 1 Id FROM TypeConge WHERE Nom = ?`, ['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; } } function genererJoursJpos(jpos, mois, annee) { const jours = []; const dateDebut = new Date(jpos.DateDebut); const dateFin = new Date(jpos.DateFin); const premierJourMois = new Date(annee, mois - 1, 1); const dernierJourMois = new Date(annee, mois, 0); const debutEffectif = dateDebut > premierJourMois ? dateDebut : premierJourMois; const finEffective = dateFin < dernierJourMois ? dateFin : dernierJourMois; for (let d = new Date(debutEffectif); d <= finEffective; d.setDate(d.getDate() + 1)) { if (d.getMonth() + 1 === mois && d.getFullYear() === annee) { const jourNum = d.getDate(); const jourSemaine = d.getDay(); const typeJour = jourSemaine === 0 ? 'Dimanche' : jourSemaine === 6 ? 'Samedi' : 'FĂ©riĂ©'; const labelPeriode = jpos.PeriodeJournee === 'Matin' ? '[Matin] ' : jpos.PeriodeJournee === 'AprĂšs-midi' ? '[AprĂšs-midi] ' : ''; jours.push({ JourDate: jourNum, JourTravaille: 1, CommentaireRepos: `[${jpos.TypeCongeNom}] Jour travaillĂ© (${typeJour}) - ${labelPeriode}${jpos.Commentaire || ''}`.trim(), Source: 'JPOSF', PeriodeJournee: jpos.PeriodeJournee || 'JournĂ©e entiĂšre', saisi_par_rh: true }); } } return jours; } function fusionnerDemiJournees(existing, nouveau) { if (!existing) return nouveau; const periodeExisting = existing.PeriodeJournee || 'JournĂ©e entiĂšre'; const periodeNouveau = nouveau.PeriodeJournee || 'JournĂ©e entiĂšre'; if ((periodeExisting === 'Matin' && periodeNouveau === 'AprĂšs-midi') || (periodeExisting === 'AprĂšs-midi' && periodeNouveau === 'Matin')) { return { ...existing, JourTravaille: 0, CommentaireRepos: `${existing.CommentaireRepos} + ${nouveau.CommentaireRepos}`, PeriodeJournee: 'JournĂ©e entiĂšre' }; } return existing; } async function jourNecessiteSaisieCRA(pool, collaborateurId, dateJour) { const request = new sql.Request(pool); request.input('collaborateurId', sql.Int, collaborateurId); request.input('dateJour', sql.Date, dateJour); // VĂ©rifier s'il y a un congĂ© ce jour-lĂ  via DemandeConge et DemandeCongeType const congeResult = await request.query(` SELECT dct.Id, dct.DemandeCongeId, dct.TypeCongeId, dct.NombreJours, dct.PeriodeJournee, dc.DateDebut, dc.DateFin, dc.Statut, tc.Code AS TypeCongeCode, tc.Libelle AS TypeCongeLibelle FROM DemandeCongeType dct INNER JOIN DemandeConge dc ON dct.DemandeCongeId = dc.Id INNER JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dc.CollaborateurADId = @collaborateurId AND @dateJour BETWEEN dc.DateDebut AND dc.DateFin AND dc.Statut = 'Validee' `); // Si pas de congĂ© validĂ©, c'est un jour potentiellement travaillĂ© if (congeResult.recordset.length === 0) { return { necessiteSaisie: true, typeJournee: 'Journee', valeurJour: 1, raison: 'jour_travaille', congeInfo: null }; } const conge = congeResult.recordset[0]; // VĂ©rifier si c'est un JPOSF if (conge.TypeCongeCode === 'JPOSF') { return { necessiteSaisie: true, typeJournee: 'Journee', valeurJour: 1, raison: 'jposf', congeInfo: { type: conge.TypeCongeLibelle, code: conge.TypeCongeCode } }; } // VĂ©rifier si c'est une demi-journĂ©e de congĂ© via PeriodeJournee // PeriodeJournee peut ĂȘtre : 'Matin', 'Apres-midi', 'Journee' (ou null pour journĂ©e complĂšte) if (conge.PeriodeJournee === 'Matin' || conge.PeriodeJournee === 'Apres-midi') { // Demi-journĂ©e de congĂ© = l'autre demi-journĂ©e est travaillĂ©e const periodeTravaillee = conge.PeriodeJournee === 'Matin' ? 'Apres-midi' : 'Matin'; return { necessiteSaisie: true, typeJournee: periodeTravaillee, valeurJour: 0.5, raison: 'demi_journee_travaillee', congeInfo: { type: conge.TypeCongeLibelle, periodeConge: conge.PeriodeJournee, periodeTravaillee: periodeTravaillee } }; } // On peut aussi vĂ©rifier via NombreJours si c'est 0.5 if (conge.NombreJours === 0.5) { // Demi-journĂ©e mais PeriodeJournee non spĂ©cifiĂ©e, on demande quand mĂȘme la saisie return { necessiteSaisie: true, typeJournee: 'Demi-journee', valeurJour: 0.5, raison: 'demi_journee_travaillee', congeInfo: { type: conge.TypeCongeLibelle, nombreJours: conge.NombreJours } }; } // JournĂ©e complĂšte de congĂ© (sauf JPOSF) = pas de saisie CRA nĂ©cessaire return { necessiteSaisie: false, typeJournee: null, valeurJour: 0, raison: 'journee_conge_complete', congeInfo: { type: conge.TypeCongeLibelle, code: conge.TypeCongeCode } }; } async function jourNecessiteSaisieCRATransaction(transaction, collaborateurId, dateJour) { const request = new sql.Request(transaction); request.input('collaborateurId', sql.Int, collaborateurId); request.input('dateJour', sql.Date, dateJour); const congeResult = await request.query(` SELECT dct.Id, dct.PeriodeJournee, dct.NombreJours, tc.Code AS TypeCongeCode, tc.Libelle AS TypeCongeLibelle FROM DemandeCongeType dct INNER JOIN DemandeConge dc ON dct.DemandeCongeId = dc.Id INNER JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dc.CollaborateurADId = @collaborateurId AND @dateJour BETWEEN dc.DateDebut AND dc.DateFin AND dc.Statut = 'Validee' `); if (congeResult.recordset.length === 0) { return { necessiteSaisie: true, typeJournee: 'Journee', valeurJour: 1, raison: 'jour_travaille', congeInfo: null }; } const conge = congeResult.recordset[0]; if (conge.TypeCongeCode === 'JPOSF') { return { necessiteSaisie: true, typeJournee: 'Journee', valeurJour: 1, raison: 'jposf', congeInfo: { type: conge.TypeCongeLibelle, code: conge.TypeCongeCode } }; } if (conge.PeriodeJournee === 'Matin' || conge.PeriodeJournee === 'Apres-midi') { const periodeTravaillee = conge.PeriodeJournee === 'Matin' ? 'Apres-midi' : 'Matin'; return { necessiteSaisie: true, typeJournee: periodeTravaillee, valeurJour: 0.5, raison: 'demi_journee_travaillee', congeInfo: { type: conge.TypeCongeLibelle, periodeConge: conge.PeriodeJournee, periodeTravaillee: periodeTravaillee } }; } if (conge.NombreJours === 0.5) { return { necessiteSaisie: true, typeJournee: 'Demi-journee', valeurJour: 0.5, raison: 'demi_journee_travaillee', congeInfo: { type: conge.TypeCongeLibelle, nombreJours: conge.NombreJours } }; } return { necessiteSaisie: false, typeJournee: null, valeurJour: 0, raison: 'journee_conge_complete', congeInfo: { type: conge.TypeCongeLibelle, code: conge.TypeCongeCode } }; } app.get('/api/getJoursSaisieObligatoire', async (req, res) => { try { const { collaborateurId, annee, mois, user_id } = req.query; // ✅ Ajouter user_id const parsedCollaborateurId = parseInt(collaborateurId); const parsedAnnee = parseInt(annee); const parsedMois = parseInt(mois); const parsedUserId = parseInt(user_id); // ✅ Parser user_id if (isNaN(parsedCollaborateurId) || isNaN(parsedAnnee) || isNaN(parsedMois)) { return res.status(400).json({ success: false, message: 'ParamĂštres invalides' }); } // ✅ VĂ©rifier que user_id est fourni if (!user_id || isNaN(parsedUserId)) { return res.status(401).json({ success: false, message: 'Authentification requise' }); } // ✅ Utiliser parsedUserId au lieu de req.user.id const hasAccess = await hasCompteRenduAccess(parsedUserId); if (!hasAccess && parsedUserId !== parsedCollaborateurId) { return res.status(403).json({ success: false, message: 'AccĂšs non autorisĂ©' }); } // ❌ SUPPRIMER: const pool = await sql.connect(); // ✅ Le pool global est dĂ©jĂ  disponible // Calculer le premier et dernier jour du mois const premierJour = new Date(parsedAnnee, parsedMois - 1, 1); const dernierJour = new Date(parsedAnnee, parsedMois, 0); const joursObligatoires = []; // Parcourir chaque jour du mois for (let d = new Date(premierJour); d <= dernierJour; d.setDate(d.getDate() + 1)) { const jourSemaine = d.getDay(); // ✅ Utiliser pool.request() au lieu de new sql.Request(pool) const requestJF = pool.request(); requestJF.input('dateJour', sql.Date, new Date(d)); const jourFerieResult = await requestJF.query(` SELECT * FROM JoursFeries WHERE DateJour = @dateJour `); const estJourFerie = jourFerieResult.recordset.length > 0; // VĂ©rifier si c'est un samedi (6) ou dimanche (0) ou jour fĂ©riĂ© if (jourSemaine !== 0 && jourSemaine !== 6 && !estJourFerie) { joursObligatoires.push({ date: formatDateWithoutUTC(new Date(d)), jourSemaine: jourSemaine }); } } res.json({ success: true, data: joursObligatoires }); } catch (error) { console.error('Erreur getJoursSaisieObligatoire:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); 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' }); } // Validation des paramĂštres const parsedUserId = parseInt(user_id); const parsedAnnee = parseInt(annee); const parsedMois = parseInt(mois); if (isNaN(parsedUserId) || isNaN(parsedAnnee) || isNaN(parsedMois)) { return res.json({ success: false, message: 'ParamĂštres invalides' }); } // RequĂȘte pour les jours const joursRequest = new sql.Request(pool); joursRequest.input('userid', sql.Int, parsedUserId); joursRequest.input('annee', sql.Int, parsedAnnee); joursRequest.input('mois', sql.Int, parsedMois); const joursResult = await joursRequest.query(` SELECT Id, CollaborateurADId, Annee, Mois, CONVERT(VARCHAR(10), JourDate, 23) as JourDate, JourTravaille, ReposQuotidienRespect, ReposHebdomadaireRespect, CommentaireRepos, Verrouille, DateSaisie, SaisiePar FROM CompteRenduActivites WHERE CollaborateurADId = @userid AND Annee = @annee AND Mois = @mois ORDER BY JourDate `); console.log('🔍 Backend - Jours trouvĂ©s:', joursResult.recordset.length); // RequĂȘte pour le mensuel const mensuelRequest = new sql.Request(pool); mensuelRequest.input('userid', sql.Int, parsedUserId); mensuelRequest.input('annee', sql.Int, parsedAnnee); mensuelRequest.input('mois', sql.Int, parsedMois); const mensuelResult = await mensuelRequest.query(` SELECT * FROM CompteRenduMensuel WHERE CollaborateurADId = @userid AND Annee = @annee AND Mois = @mois `); res.json({ success: true, jours: joursResult.recordset, mensuel: mensuelResult.recordset[0] || null }); } catch (error) { console.error('❌ Erreur getCompteRenduActivites:', error); res.status(500).json({ success: false, message: error.message }); } }); function genererJoursConge(conge, mois, annee) { const jours = []; const dateDebut = new Date(conge.DateDebut); const dateFin = new Date(conge.DateFin); // Premier et dernier jour du mois demandĂ© const premierJourMois = new Date(annee, mois - 1, 1); const dernierJourMois = new Date(annee, mois, 0); // Ajuster les bornes const debutEffectif = dateDebut > premierJourMois ? dateDebut : premierJourMois; const finEffective = dateFin < dernierJourMois ? dateFin : dernierJourMois; // ItĂ©rer sur chaque jour for (let d = new Date(debutEffectif); d <= finEffective; d.setDate(d.getDate() + 1)) { if (d.getMonth() + 1 === mois && d.getFullYear() === annee) { const jourNum = d.getDate(); const dateStr = d.toISOString().split('T')[0]; const estPremierJour = d.getTime() === dateDebut.getTime(); const estDernierJour = d.getTime() === dateFin.getTime(); const estJourUnique = estPremierJour && estDernierJour; let periodeJournee = 'JournĂ©e entiĂšre'; let jourTravaille = 0; if (estJourUnique) { periodeJournee = conge.PeriodeJournee || 'JournĂ©e entiĂšre'; jourTravaille = (periodeJournee === 'Matin' || periodeJournee === 'AprĂšs-midi') ? 1 : 0; } else if (estPremierJour && conge.PeriodeJournee === 'AprĂšs-midi') { periodeJournee = 'AprĂšs-midi'; jourTravaille = 1; } else if (estDernierJour && conge.PeriodeJournee === 'Matin') { periodeJournee = 'Matin'; jourTravaille = 1; } const labelPeriode = periodeJournee === 'Matin' ? '[CongĂ© Matin] ' : periodeJournee === 'AprĂšs-midi' ? '[CongĂ© AprĂšs-midi] ' : '[CongĂ© JournĂ©e] '; jours.push({ JourDate: jourNum, DateComplete: dateStr, JourTravaille: jourTravaille, CommentaireRepos: `[${conge.TypeCongeNom}] ${labelPeriode}${conge.Commentaire || ''}`.trim(), Source: `CongĂ©-${conge.TypeCongeNom}`, PeriodeJournee: periodeJournee, DemandeCongeId: conge.Id, TypeCongeId: conge.TypeCongeId, TypeCongeNom: conge.TypeCongeNom }); } } return jours; } async function getCongesAvecMultiJours(conn, userId, mois, annee) { // Calculer les dates de dĂ©but et fin de mois pour la requĂȘte const debutMois = new Date(annee, mois - 1, 1); const finMois = new Date(annee, mois, 0); // ExĂ©cuter la requĂȘte avec les nouveaux paramĂštres const [congesRaw] = await conn.query(` SELECT dc.Id, dc.DateDebut, dc.DateFin, dc.CollaborateurADId, tc.Nom as TypeCongeNom, tc.Id as TypeCongeId, dct.PeriodeJournee, dct.NombreJours, dc.Commentaire, dc.Statut 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 NOT IN ('RĂ©cupĂ©ration', 'JPOSF') AND dc.Statut IN ('ValidĂ©e', 'ValidĂ©') AND ( (YEAR(dc.DateDebut) = ? AND MONTH(dc.DateDebut) = ?) OR (YEAR(dc.DateFin) = ? AND MONTH(dc.DateFin) = ?) OR (dc.DateDebut < ? AND dc.DateFin > ?) ) `, [userId, annee, mois, annee, mois, debutMois, finMois]); // GĂ©nĂ©rer tous les jours pour chaque congĂ© const tousLesJours = []; for (const conge of congesRaw) { const joursConge = genererJoursConge(conge, mois, annee); tousLesJours.push(...joursConge); } return tousLesJours; } // ✅ Route GET CRA pour EmployeeDetails (CORRIGÉE - avec statut saisie) app.get('/api/getCra', async (req, res) => { try { const { user_id, month, year } = req.query; if (!user_id || !month || !year) { return res.json({ success: false, message: 'ParamĂštres manquants' }); } const parsedUserId = parseInt(user_id); const parsedMonth = parseInt(month); const parsedYear = parseInt(year); // RĂ©cupĂ©rer les jours du CRA const requestCra = new sql.Request(pool); requestCra.input('userid', sql.Int, parsedUserId); requestCra.input('mois', sql.Int, parsedMonth); requestCra.input('annee', sql.Int, parsedYear); const craResult = await requestCra.query(` SELECT Id, JourDate, JourTravaille, ReposQuotidienRespect, ReposHebdomadaireRespect, CommentaireRepos, Verrouille FROM CompteRenduActivites WHERE CollaborateurADId = @userid AND Mois = @mois AND Annee = @annee ORDER BY JourDate `); // RĂ©cupĂ©rer tous les types de congĂ©s const typesResult = await new sql.Request(pool).query(` SELECT Id, Nom FROM TypeConge WHERE Actif = 1 `); const typesCongeMap = {}; for (const tc of typesResult.recordset) { typesCongeMap[tc.Id] = tc.Nom; } // RĂ©cupĂ©rer les congĂ©s validĂ©s pour ce mois const requestConges = new sql.Request(pool); requestConges.input('userid', sql.Int, parsedUserId); requestConges.input('mois', sql.Int, parsedMonth); requestConges.input('annee', sql.Int, parsedYear); const congesResult = await requestConges.query(` SELECT Id, DateDebut, DateFin, NombreJours, Statut, TypeCongeId FROM DemandeConge WHERE CollaborateurADId = @userid AND Statut = 'ValidĂ©e' AND ( (MONTH(DateDebut) = @mois AND YEAR(DateDebut) = @annee) OR (MONTH(DateFin) = @mois AND YEAR(DateFin) = @annee) OR (DateDebut <= DATEFROMPARTS(@annee, @mois, 1) AND DateFin >= EOMONTH(DATEFROMPARTS(@annee, @mois, 1))) ) `); // RĂ©cupĂ©rer le rĂ©sumĂ© mensuel const requestMensuel = new sql.Request(pool); requestMensuel.input('userid', sql.Int, parsedUserId); requestMensuel.input('mois', sql.Int, parsedMonth); requestMensuel.input('annee', sql.Int, parsedYear); const mensuelResult = await requestMensuel.query(` SELECT NbJoursTravailles, NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo, Statut, Verrouille FROM CompteRenduMensuel WHERE CollaborateurADId = @userid AND Mois = @mois AND Annee = @annee `); const daysInMonth = new Date(parsedYear, parsedMonth, 0).getDate(); const days = []; const extractPeriode = (comm) => { if (!comm) return 'JournĂ©e complĂšte'; const match = comm.match(/\[PERIODE:(.*?)\]/); return match ? match[1] : 'JournĂ©e complĂšte'; }; const getTypeCongeNom = (typeCongeId) => { if (!typeCongeId) return 'CongĂ©'; const firstId = String(typeCongeId).split(',')[0].trim(); const id = parseInt(firstId); return typesCongeMap[id] || 'CongĂ©'; }; const getTypeCra = (typeConge) => { if (!typeConge) return 'leave'; const typeLower = typeConge.toLowerCase(); if (typeLower.includes('rtt') || typeLower.includes('rĂ©cup')) return 'rtt'; if (typeLower.includes('maladie')) return 'sick'; if (typeLower.includes('tĂ©lĂ©travail') || typeLower.includes('remote')) return 'remote'; return 'leave'; }; // CrĂ©er un map des jours CRA saisis const craMap = new Map(); for (const jour of craResult.recordset) { const dayNum = new Date(jour.JourDate).getDate(); const periode = extractPeriode(jour.CommentaireRepos); if (!craMap.has(dayNum)) { craMap.set(dayNum, []); } craMap.get(dayNum).push({ ...jour, periode }); } // CrĂ©er un map des congĂ©s par jour const congesMap = new Map(); for (const conge of congesResult.recordset) { const start = new Date(conge.DateDebut); const end = new Date(conge.DateFin); const typeNom = getTypeCongeNom(conge.TypeCongeId); for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { if (d.getMonth() + 1 === parsedMonth && d.getFullYear() === parsedYear) { const dayNum = d.getDate(); congesMap.set(dayNum, { typeCongeId: conge.TypeCongeId, typeNom: typeNom, typeCra: getTypeCra(typeNom), statut: conge.Statut }); } } } // Jours fĂ©riĂ©s français const joursFeries = getJoursFeries(parsedYear); const feriesSet = new Set(joursFeries .filter(jf => jf.month === parsedMonth) .map(jf => jf.day) ); // Date du jour pour savoir si le jour est passĂ© const today = new Date(); today.setHours(0, 0, 0, 0); // Construire les jours for (let day = 1; day <= daysInMonth; day++) { const date = new Date(parsedYear, parsedMonth - 1, day); const dayOfWeek = date.getDay(); const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; const isFerie = feriesSet.has(day); const isPast = date < today; const isToday = date.getTime() === today.getTime(); let type = null; let label = ''; let value = 0; let saisi = false; // ✅ Nouveau : indique si le jour a Ă©tĂ© saisi if (isWeekend) { type = 'weekend'; label = dayOfWeek === 0 ? 'Dimanche' : 'Samedi'; saisi = true; // Weekend = pas besoin de saisie } else if (isFerie) { type = 'holiday'; label = 'Jour fĂ©riĂ©'; saisi = true; // FĂ©riĂ© = pas besoin de saisie } else if (congesMap.has(day) && congesMap.get(day).typeCra !== 'remote') { const conge = congesMap.get(day); type = conge.typeCra; label = conge.typeNom; saisi = true; // Autres congĂ©s = jour NON travaillĂ© (absence) value = 1; } else if (craMap.has(day)) { // ✅ Jour SAISI dans le CRA saisi = true; const craJours = craMap.get(day); const totalValue = craJours.reduce((sum, j) => { return sum + (j.periode === 'Matin' || j.periode === 'AprĂšs-midi' ? 0.5 : 1); }, 0); if (craJours.some(j => j.JourTravaille)) { type = 'worked'; label = totalValue === 0.5 ? 'Demi-journĂ©e travaillĂ©e' : 'JournĂ©e travaillĂ©e'; value = totalValue; } else { type = 'not_worked'; label = 'Non travaillĂ© (saisi)'; value = 0; } } else { // ✅ Jour NON SAISI saisi = false; if (isPast) { type = 'missing'; // Jour passĂ© non saisi = manquant label = 'Non saisi'; } else { type = 'pending'; // Jour futur = en attente label = 'À saisir'; } } days.push({ day, date: date.toISOString().split('T')[0], type, label, value, isWeekend, isFerie, isPast, isToday, saisi // ✅ Nouveau champ }); } // Calculer le rĂ©sumĂ© avec les nouveaux statuts const joursOuvrables = days.filter(d => !d.isWeekend && !d.isFerie); const joursPasses = joursOuvrables.filter(d => d.isPast || d.isToday); const summary = { worked: days.filter(d => d.type === 'worked').reduce((sum, d) => sum + d.value, 0), leave: days.filter(d => d.type === 'leave').length, rtt: days.filter(d => d.type === 'rtt').length, sick: days.filter(d => d.type === 'sick').length, remote: days.filter(d => d.type === 'remote').length, holiday: days.filter(d => d.type === 'holiday').length, not_worked: days.filter(d => d.type === 'not_worked').length, // ✅ Nouveaux indicateurs missing: days.filter(d => d.type === 'missing').length, // Jours passĂ©s non saisis pending: days.filter(d => d.type === 'pending').length, // Jours futurs Ă  saisir totalOuvrables: joursOuvrables.length, totalSaisis: days.filter(d => d.saisi && !d.isWeekend && !d.isFerie).length, tauxSaisie: joursPasses.length > 0 ? Math.round((joursPasses.filter(d => d.saisi).length / joursPasses.length) * 100) : 100 }; // Utiliser les donnĂ©es mensuelles si disponibles if (mensuelResult.recordset.length > 0) { const mensuel = mensuelResult.recordset[0]; summary.worked = mensuel.NbJoursTravailles || summary.worked; } res.json({ success: true, cra: { month: parsedMonth, year: parsedYear, days, summary, mensuel: mensuelResult.recordset[0] || null } }); } catch (error) { console.error('❌ Erreur getCra:', error); res.status(500).json({ success: false, message: 'Erreur lors de la rĂ©cupĂ©ration du CRA', error: error.message }); } }); // ✅ Fonction helper pour les jours fĂ©riĂ©s français function getJoursFeries(year) { const feries = [ { day: 1, month: 1, label: 'Jour de l\'an' }, { day: 1, month: 5, label: 'FĂȘte du travail' }, { day: 8, month: 5, label: 'Victoire 1945' }, { day: 14, month: 7, label: 'FĂȘte nationale' }, { day: 15, month: 8, label: 'Assomption' }, { day: 1, month: 11, label: 'Toussaint' }, { day: 11, month: 11, label: 'Armistice' }, { day: 25, month: 12, label: 'NoĂ«l' } ]; const a = year % 19; const b = Math.floor(year / 100); const c = year % 100; const d = Math.floor(b / 4); const e = b % 4; const f = Math.floor((b + 8) / 25); const g = Math.floor((b - f + 1) / 3); const h = (19 * a + b - d - g + 15) % 30; const i = Math.floor(c / 4); const k = c % 4; const l = (32 + 2 * e + 2 * i - h - k) % 7; const m = Math.floor((a + 11 * h + 22 * l) / 451); const month = Math.floor((h + l - 7 * m + 114) / 31); const day = ((h + l - 7 * m + 114) % 31) + 1; const paques = new Date(year, month - 1, day); const lundiPaques = new Date(paques); lundiPaques.setDate(lundiPaques.getDate() + 1); feries.push({ day: lundiPaques.getDate(), month: lundiPaques.getMonth() + 1, label: 'Lundi de PĂąques' }); const ascension = new Date(paques); ascension.setDate(ascension.getDate() + 39); feries.push({ day: ascension.getDate(), month: ascension.getMonth() + 1, label: 'Ascension' }); const pentecote = new Date(paques); pentecote.setDate(pentecote.getDate() + 50); feries.push({ day: pentecote.getDate(), month: pentecote.getMonth() + 1, label: 'Lundi de PentecĂŽte' }); return feries; } /** * GET /api/getMesCongesPersonnels * RĂ©cupĂšre les congĂ©s personnels d'un utilisateur (pour le compte-rendu d'activitĂ©s) * UtilisĂ© notamment par les validateurs qui ne voient pas leurs propres congĂ©s via getTeamLeaves */ /** * GET /api/getMesCongesPersonnels * RĂ©cupĂšre les congĂ©s personnels d'un utilisateur (pour le compte-rendu d'activitĂ©s) */ app.get('/api/getMesCongesPersonnels', async (req, res) => { try { const { user_id } = req.query; if (!user_id) { return res.json({ success: false, message: 'ID utilisateur manquant' }); } const isUUID = user_id.length > 10 && user_id.includes('-'); let numericUserId = user_id; if (isUUID) { const userRequest = pool.request(); userRequest.input('entraUserId', user_id); const userResult = await userRequest.query(` SELECT id FROM CollaborateurAD WHERE entraUserId = @entraUserId `); if (userResult.recordset.length === 0) { return res.json({ success: false, message: 'Utilisateur non trouvĂ©' }); } numericUserId = userResult.recordset[0].id; } const queryRequest = pool.request(); queryRequest.input('userId', sql.Int, parseInt(numericUserId)); const query = ` SELECT dc.Id, 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, '","typeId":', tc.Id, ',"jours":', dct.NombreJours, ',"periode":"', COALESCE(dct.PeriodeJournee, 'JournĂ©e entiĂšre'), '"}' ), ',' ), ']' ) AS detailsconges, MAX(tc.CouleurHex) AS color, dc.Statut AS statut, dc.NombreJours AS nombrejoursouvres, CASE WHEN EXISTS ( SELECT 1 FROM DemandeCongeType dct3 JOIN TypeConge tc3 ON dct3.TypeCongeId = tc3.Id WHERE dct3.DemandeCongeId = dc.Id AND tc3.Nom = 'JPOSF' ) AND NOT EXISTS ( SELECT 1 FROM DemandeCongeType dct4 JOIN TypeConge tc4 ON dct4.TypeCongeId = tc4.Id WHERE dct4.DemandeCongeId = dc.Id AND tc4.Nom != 'JPOSF' ) THEN 1 ELSE 0 END AS isJPOSFOnly FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dc.CollaborateurADId = @userId AND dc.Statut IN ('ValidĂ©', 'ValidĂ©e', 'Valide') GROUP BY dc.Id, dc.DateDebut, dc.DateFin, ca.prenom, ca.nom, dc.Statut, dc.NombreJours ORDER BY dc.DateDebut DESC `; const result = await queryRequest.query(query); console.log(`✅ getMesCongesPersonnels: ${result.recordset.length} congĂ©s trouvĂ©s pour user ${numericUserId}`); return res.json({ success: true, conges: result.recordset }); } catch (error) { console.error('❌ Erreur getMesCongesPersonnels:', error); res.status(500).json({ success: false, message: error.message }); } }); /** * Envoie un email au N+1 direct quand les repos quotidiens/hebdomadaires ne sont pas respectĂ©s */ async function notifierN1ReposNonRespecte(pool, collaborateurId, date, commentaire) { // Validation if (!collaborateurId || collaborateurId === 'undefined' || collaborateurId === null) { console.error('❌ collaborateurId NULL dans notification'); return; } const parsedCollabId = parseInt(collaborateurId); if (isNaN(parsedCollabId)) { console.error('❌ collaborateurId non numĂ©rique:', collaborateurId); return; } try { // RĂ©cupĂ©rer info collaborateur const collabRequest = new sql.Request(pool); collabRequest.input('collabId', sql.Int, parsedCollabId); const collabResult = await collabRequest.query(` SELECT prenom, nom, email, ServiceId, CampusId FROM CollaborateurAD WHERE id = @collabId `); if (collabResult.recordset.length === 0) { console.error(`❌ Collaborateur ${parsedCollabId} introuvable`); return; } const collab = collabResult.recordset[0]; const collabName = `${collab.prenom} ${collab.nom}`; console.log(`📧 Notification repos non respectĂ©s - Collaborateur: ${collabName}, Date: ${date}`); // RĂ©cupĂ©rer le manager via hiĂ©rarchie const managerRequest = new sql.Request(pool); managerRequest.input('collabId', sql.Int, parsedCollabId); const managerResult = await managerRequest.query(` SELECT ca.id, ca.email, ca.prenom, ca.nom FROM HierarchieValidationAD hv JOIN CollaborateurAD ca ON hv.SuperieurId = ca.id WHERE hv.CollaborateurId = @collabId `); if (managerResult.recordset.length === 0) { console.error(`❌ Manager introuvable pour ${collabName}`); return; } const manager = managerResult.recordset[0]; const managerName = `${manager.prenom} ${manager.nom}`; const managerEmail = manager.email; console.log(`✅ Manager trouvĂ©: ${managerName} (${managerEmail})`); // Formater la date const dateObj = new Date(date); const dateFormatted = dateObj.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); // Obtenir le token Graph const graphToken = await getGraphToken(); if (!graphToken) { console.error('❌ Impossible d\'obtenir le token Graph'); return; } // Construire l'email const subject = `⚠ Alerte repos non respectĂ©s - ${collabName}`; const bodyHTML = `

    ⚠ Alerte Temps de Repos

    Bonjour ${managerName},

    Un non-respect des temps de repos obligatoires a été déclaré par ${collabName}.

    📅 DĂ©tails du signalement

    Collaborateur : ${collabName}
    Date : ${dateFormatted}
    ${commentaire ? `

    💬 Commentaire :

    ${commentaire}

    ` : ''}

    â„č Rappel : Le Code du travail impose un repos quotidien minimum de 11 heures consĂ©cutives et un repos hebdomadaire de 35 heures consĂ©cutives (24h + 11h).

    Cet email est envoyé automatiquement depuis l'application GTA.

    `; // Envoyer l'email const emailSent = await sendMailGraph(graphToken, 'gtanoreply@ensup.eu', managerEmail, subject, bodyHTML); if (emailSent) { console.log(`✅ Email envoyĂ© au N+1: ${managerEmail}`); // CrĂ©er une notification en base const notifRequest = new sql.Request(pool); const maxNotifIdResult = await notifRequest.query(` SELECT ISNULL(MAX(Id), 0) + 1 AS nextId FROM Notifications `); const notifId = maxNotifIdResult.recordset[0].nextId; const insertNotifRequest = new sql.Request(pool); insertNotifRequest.input('id', sql.Int, notifId); insertNotifRequest.input('collabId', sql.Int, manager.id); insertNotifRequest.input('type', sql.NVarChar(50), 'Warning'); insertNotifRequest.input('titre', sql.NVarChar(200), 'Repos non respectĂ©s'); insertNotifRequest.input('message', sql.NVarChar(sql.MAX), `${collabName} a dĂ©clarĂ© un non-respect des temps de repos le ${dateFormatted}`); await insertNotifRequest.query(` INSERT INTO Notifications (Id, CollaborateurADId, Type, Titre, Message, DateCreation, Lu) VALUES (@id, @collabId, @type, @titre, @message, GETDATE(), 0) `); } else { console.error('❌ Échec envoi email au N+1'); } } catch (error) { console.error('❌ Erreur notification N+1:', error); } } async function recalculerStatsMensuel(transaction, userId, annee, mois) { const statsRequest = new sql.Request(transaction); statsRequest.input('userid', sql.Int, userId); statsRequest.input('annee', sql.Int, annee); statsRequest.input('mois', sql.Int, mois); // RĂ©cupĂ©rer tous les enregistrements pour calculer manuellement const allJours = await statsRequest.query(` SELECT Id, JourTravaille, ReposQuotidienRespect, ReposHebdomadaireRespect, CommentaireRepos FROM CompteRenduActivites WHERE CollaborateurADId = @userid AND Annee = @annee AND Mois = @mois `); let totalJoursSaisis = 0; let joursTravailles = 0; let nonRespectQuotidien = 0; let nonRespectHebdo = 0; for (const jour of allJours.recordset) { const periode = extractPeriodeFromCommentaire(jour.CommentaireRepos); const valeurJour = (periode === 'Matin' || periode === 'AprĂšs-midi') ? 0.5 : 1; totalJoursSaisis += valeurJour; if (jour.JourTravaille) { joursTravailles += valeurJour; } if (!jour.ReposQuotidienRespect) { nonRespectQuotidien++; } if (!jour.ReposHebdomadaireRespect) { nonRespectHebdo++; } } console.log(`📊 Stats calculĂ©es: ${totalJoursSaisis}j saisis, ${joursTravailles}j travaillĂ©s`); // VĂ©rifier si un enregistrement mensuel existe dĂ©jĂ  const checkRequest = new sql.Request(transaction); checkRequest.input('userid', sql.Int, userId); checkRequest.input('annee', sql.Int, annee); checkRequest.input('mois', sql.Int, mois); const existing = await checkRequest.query(` SELECT Id FROM CompteRenduMensuel WHERE CollaborateurADId = @userid AND Annee = @annee AND Mois = @mois `); if (existing.recordset.length > 0) { // UPDATE const updateRequest = new sql.Request(transaction); updateRequest.input('mensuelId', sql.Int, existing.recordset[0].Id); updateRequest.input('totalJours', sql.Float, joursTravailles); updateRequest.input('nonRespectQuotidien', sql.Int, nonRespectQuotidien); updateRequest.input('nonRespectHebdo', sql.Int, nonRespectHebdo); await updateRequest.query(` UPDATE CompteRenduMensuel SET NbJoursTravailles = @totalJours, NbJoursNonRespectsReposQuotidien = @nonRespectQuotidien, NbJoursNonRespectsReposHebdo = @nonRespectHebdo, DateModification = GETDATE() WHERE Id = @mensuelId `); console.log(`✅ Stats mensuelles mises Ă  jour`); } else { // INSERT - GĂ©nĂ©rer l'ID manuellement const newIdRequest = new sql.Request(transaction); const maxIdResult = await newIdRequest.query(` SELECT ISNULL(MAX(Id), 0) + 1 AS nextId FROM CompteRenduMensuel `); const mensuelId = maxIdResult.recordset[0].nextId; const insertRequest = new sql.Request(transaction); insertRequest.input('mensuelId', sql.Int, mensuelId); insertRequest.input('userid', sql.Int, userId); insertRequest.input('annee', sql.Int, annee); insertRequest.input('mois', sql.Int, mois); insertRequest.input('totalJours', sql.Float, joursTravailles); insertRequest.input('nonRespectQuotidien', sql.Int, nonRespectQuotidien); insertRequest.input('nonRespectHebdo', sql.Int, nonRespectHebdo); await insertRequest.query(` INSERT INTO CompteRenduMensuel ( Id, CollaborateurADId, Annee, Mois, NbJoursTravailles, NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo, Statut, Verrouille, DateCreation, DateModification ) VALUES ( @mensuelId, @userid, @annee, @mois, @totalJours, @nonRespectQuotidien, @nonRespectHebdo, 'Brouillon', 0, GETDATE(), GETDATE() ) `); console.log(`✅ Stats mensuelles créées`); } } function extractPeriodeFromCommentaire(commentaire) { if (!commentaire) return 'JournĂ©e complĂšte'; const match = commentaire.match(/\[PERIODE:(.*?)\]/); return match ? match[1] : 'JournĂ©e complĂšte'; } // ✅ FONCTION HELPER : Extraire le commentaire sans le prĂ©fixe pĂ©riode function extractCommentaireSansPeriode(commentaire) { if (!commentaire) return null; return commentaire.replace(/\[PERIODE:.*?\]\s*/, '').trim() || null; } app.post('/api/saveCompteRenduJour', async (req, res) => { const { user_id, date, jour_travaille, repos_quotidien, repos_hebdo, commentaire, rh_override, periode } = req.body; const transaction = new sql.Transaction(pool); try { await transaction.begin(); const parsedUserId = parseInt(user_id); if (isNaN(parsedUserId)) { await transaction.rollback(); return res.json({ success: false, message: 'ID utilisateur invalide' }); } 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 && !rh_override) { await transaction.rollback(); 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Ă© const checkRequest = new sql.Request(transaction); checkRequest.input('userid', sql.Int, parsedUserId); checkRequest.input('date', sql.Date, dateJour); const existingResult = await checkRequest.query(` SELECT Verrouille FROM CompteRenduActivites WHERE CollaborateurADId = @userid AND JourDate = @date `); if (existingResult.recordset.length > 0 && existingResult.recordset[0].Verrouille && !rh_override) { await transaction.rollback(); return res.json({ success: false, message: 'Ce jour est verrouillĂ© - Contactez les RH pour modification' }); } // VĂ©rifier commentaire obligatoire si non-respect des repos if ((!repos_quotidien || !repos_hebdo) && (!commentaire || commentaire.trim() === '')) { await transaction.rollback(); return res.json({ success: false, message: 'Commentaire obligatoire en cas de non-respect des repos' }); } // Construire le commentaire avec la pĂ©riode let commentaireComplet = ''; if (periode && periode !== 'JournĂ©e complĂšte') { commentaireComplet = `[PERIODE:${periode}] `; } if (commentaire) { commentaireComplet += commentaire; } // VĂ©rifier si l'enregistrement existe dĂ©jĂ  if (existingResult.recordset.length > 0) { // UPDATE const updateRequest = new sql.Request(transaction); updateRequest.input('userid', sql.Int, parsedUserId); updateRequest.input('date', sql.Date, dateJour); updateRequest.input('jourTravaille', sql.Bit, jour_travaille ? 1 : 0); updateRequest.input('reposQuotidien', sql.Bit, repos_quotidien ? 1 : 0); updateRequest.input('reposHebdo', sql.Bit, repos_hebdo ? 1 : 0); updateRequest.input('commentaire', sql.NVarChar(sql.MAX), commentaireComplet || null); updateRequest.input('saisiePar', sql.Int, parsedUserId); await updateRequest.query(` UPDATE CompteRenduActivites SET JourTravaille = @jourTravaille, ReposQuotidienRespect = @reposQuotidien, ReposHebdomadaireRespect = @reposHebdo, CommentaireRepos = @commentaire, SaisiePar = @saisiePar, DateModification = GETDATE(), Verrouille = 1 WHERE CollaborateurADId = @userid AND JourDate = @date `); } else { // INSERT - GĂ©nĂ©rer l'ID manuellement const maxIdRequest = new sql.Request(transaction); const maxIdResult = await maxIdRequest.query(` SELECT ISNULL(MAX(Id), 0) + 1 AS nextId FROM CompteRenduActivites `); const newId = maxIdResult.recordset[0].nextId; const insertRequest = new sql.Request(transaction); insertRequest.input('id', sql.Int, newId); insertRequest.input('userid', sql.Int, parsedUserId); insertRequest.input('annee', sql.Int, annee); insertRequest.input('mois', sql.Int, mois); insertRequest.input('date', sql.Date, dateJour); insertRequest.input('jourTravaille', sql.Bit, jour_travaille ? 1 : 0); insertRequest.input('reposQuotidien', sql.Bit, repos_quotidien ? 1 : 0); insertRequest.input('reposHebdo', sql.Bit, repos_hebdo ? 1 : 0); insertRequest.input('commentaire', sql.NVarChar(sql.MAX), commentaireComplet || null); insertRequest.input('saisiePar', sql.Int, parsedUserId); await insertRequest.query(` INSERT INTO CompteRenduActivites (Id, CollaborateurADId, Annee, Mois, JourDate, JourTravaille, ReposQuotidienRespect, ReposHebdomadaireRespect, CommentaireRepos, DateCreation, DateModification, SaisiePar, Verrouille) VALUES (@id, @userid, @annee, @mois, @date, @jourTravaille, @reposQuotidien, @reposHebdo, @commentaire, GETDATE(), GETDATE(), @saisiePar, 1) `); } // Recalculer les stats mensuelles await recalculerStatsMensuel(transaction, parsedUserId, annee, mois); // Notification N+1 si repos non respectĂ©s if (!repos_quotidien || !repos_hebdo) { // Appel asynchrone (ne pas bloquer la rĂ©ponse) setImmediate(() => { notifierN1ReposNonRespecte(pool, parsedUserId, date, commentaire) .catch(err => console.error('Erreur notification N+1:', err)); }); } await transaction.commit(); res.json({ success: true, message: 'Jour enregistrĂ© et verrouillĂ©', verrouille: true }); } catch (error) { await transaction.rollback(); console.error('❌ Erreur saveCompteRenduJour:', error); res.status(500).json({ success: false, message: error.message }); } }); /** * POST /api/saveCompteRenduMasse * Sauvegarde plusieurs jours de compte-rendu en une fois */ app.post('/api/saveCompteRenduMasse', async (req, res) => { const { user_id, annee, mois, jours, rh_override } = req.body; const transaction = new sql.Transaction(pool); try { await transaction.begin(); const parsedUserId = parseInt(user_id); const parsedAnnee = parseInt(annee); const parsedMois = parseInt(mois); if (isNaN(parsedUserId) || isNaN(parsedAnnee) || isNaN(parsedMois)) { await transaction.rollback(); return res.json({ success: false, message: 'ParamĂštres invalides' }); } let count = 0; let blocked = 0; const notificationsRepos = []; const aujourdhui = new Date(); aujourdhui.setHours(0, 0, 0, 0); for (const jour of jours) { const dateJour = new Date(jour.date); dateJour.setHours(0, 0, 0, 0); // Bloquer le jour actuel (sauf override RH) if (dateJour >= aujourdhui && !rh_override) { blocked++; continue; } // VĂ©rifier si dĂ©jĂ  verrouillĂ© const checkRequest = new sql.Request(transaction); checkRequest.input('userid', sql.Int, parsedUserId); checkRequest.input('date', sql.Date, dateJour); const existingResult = await checkRequest.query(` SELECT Id, Verrouille FROM CompteRenduActivites WHERE CollaborateurADId = @userid AND JourDate = @date `); if (existingResult.recordset.length > 0 && existingResult.recordset[0].Verrouille && !rh_override) { blocked++; continue; } // Construire le commentaire avec la pĂ©riode let commentaireComplet = ''; if (jour.periode && jour.periode !== 'JournĂ©e complĂšte') { commentaireComplet = `[PERIODE:${jour.periode}] `; } if (jour.commentaire) { commentaireComplet += jour.commentaire; } if (existingResult.recordset.length > 0) { // UPDATE const updateRequest = new sql.Request(transaction); updateRequest.input('userid', sql.Int, parsedUserId); updateRequest.input('date', sql.Date, dateJour); updateRequest.input('jourTravaille', sql.Bit, jour.jour_travaille ? 1 : 0); updateRequest.input('reposQuotidien', sql.Bit, jour.repos_quotidien ? 1 : 0); updateRequest.input('reposHebdo', sql.Bit, jour.repos_hebdo ? 1 : 0); updateRequest.input('commentaire', sql.NVarChar(sql.MAX), commentaireComplet || null); updateRequest.input('saisiePar', sql.Int, parsedUserId); await updateRequest.query(` UPDATE CompteRenduActivites SET JourTravaille = @jourTravaille, ReposQuotidienRespect = @reposQuotidien, ReposHebdomadaireRespect = @reposHebdo, CommentaireRepos = @commentaire, SaisiePar = @saisiePar, DateModification = GETDATE(), Verrouille = 1 WHERE CollaborateurADId = @userid AND JourDate = @date `); } else { // INSERT - GĂ©nĂ©rer l'ID manuellement const maxIdRequest = new sql.Request(transaction); const maxIdResult = await maxIdRequest.query(` SELECT ISNULL(MAX(Id), 0) + 1 AS nextId FROM CompteRenduActivites `); const newId = maxIdResult.recordset[0].nextId; const insertRequest = new sql.Request(transaction); insertRequest.input('id', sql.Int, newId); insertRequest.input('userid', sql.Int, parsedUserId); insertRequest.input('annee', sql.Int, parsedAnnee); insertRequest.input('mois', sql.Int, parsedMois); insertRequest.input('date', sql.Date, dateJour); insertRequest.input('jourTravaille', sql.Bit, jour.jour_travaille ? 1 : 0); insertRequest.input('reposQuotidien', sql.Bit, jour.repos_quotidien ? 1 : 0); insertRequest.input('reposHebdo', sql.Bit, jour.repos_hebdo ? 1 : 0); insertRequest.input('commentaire', sql.NVarChar(sql.MAX), commentaireComplet || null); insertRequest.input('saisiePar', sql.Int, parsedUserId); await insertRequest.query(` INSERT INTO CompteRenduActivites (Id, CollaborateurADId, Annee, Mois, JourDate, JourTravaille, ReposQuotidienRespect, ReposHebdomadaireRespect, CommentaireRepos, DateCreation, DateModification, SaisiePar, Verrouille) VALUES (@id, @userid, @annee, @mois, @date, @jourTravaille, @reposQuotidien, @reposHebdo, @commentaire, GETDATE(), GETDATE(), @saisiePar, 1) `); } count++; // Collecter les jours avec repos non respectĂ©s pour notification if (!jour.repos_quotidien || !jour.repos_hebdo) { notificationsRepos.push({ date: jour.date, commentaire: jour.commentaire }); } } // Recalculer les stats mensuelles await recalculerStatsMensuel(transaction, parsedUserId, parsedAnnee, parsedMois); await transaction.commit(); // Envoyer les notifications N+1 (asynchrone) if (notificationsRepos.length > 0) { setImmediate(() => { for (const notif of notificationsRepos) { notifierN1ReposNonRespecte(pool, parsedUserId, notif.date, notif.commentaire) .catch(err => console.error('Erreur notification N+1:', err)); } }); } 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) { await transaction.rollback(); console.error('❌ Erreur saveCompteRenduMasse:', error); res.status(500).json({ success: false, message: error.message }); } }); app.get('/api/getCompteRenduMois/:userId/:annee/:mois', async (req, res) => { try { const { userId, annee, mois } = req.params; const parsedUserId = parseInt(userId); const request = new sql.Request(pool); request.input('userid', sql.Int, parsedUserId); request.input('annee', sql.Int, parseInt(annee)); request.input('mois', sql.Int, parseInt(mois)); const result = await request.query(` SELECT Id, JourDate, JourTravaille, ReposQuotidienRespect, ReposHebdomadaireRespect, CommentaireRepos, Verrouille, DateCreation, DateModification FROM CompteRenduActivites WHERE CollaborateurADId = @userid AND Annee = @annee AND Mois = @mois ORDER BY JourDate `); // Transformer les rĂ©sultats pour extraire la pĂ©riode const joursAvecPeriode = result.recordset.map(jour => { const periode = extractPeriodeFromCommentaire(jour.CommentaireRepos); const commentaireSansPeriode = extractCommentaireSansPeriode(jour.CommentaireRepos); return { ...jour, PeriodeJournee: periode, Commentaire: commentaireSansPeriode, ValeurJour: (periode === 'Matin' || periode === 'AprĂšs-midi') ? 0.5 : 1 }; }); res.json({ success: true, jours: joursAvecPeriode }); } catch (error) { console.error('❌ Erreur rĂ©cupĂ©ration mois:', error); res.status(500).json({ success: false, message: 'Erreur lors de la rĂ©cupĂ©ration', error: 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 parsedUserId = parseInt(user_id); const parsedAnnee = parseInt(annee); if (isNaN(parsedUserId) || isNaN(parsedAnnee)) { return res.json({ success: false, message: 'ParamĂštres invalides' }); } const request = new sql.Request(pool); request.input('userid', sql.Int, parsedUserId); request.input('annee', sql.Int, parsedAnnee); const result = await request.query(` SELECT ISNULL(SUM(NbJoursTravailles), 0) as totalJoursTravailles, ISNULL(SUM(NbJoursNonRespectsReposQuotidien), 0) as totalNonRespectQuotidien, ISNULL(SUM(NbJoursNonRespectsReposHebdo), 0) as totalNonRespectHebdo FROM CompteRenduMensuel WHERE CollaborateurADId = @userid AND Annee = @annee `); res.json({ success: true, stats: result.recordset[0] || { totalJoursTravailles: 0, totalNonRespectQuotidien: 0, totalNonRespectHebdo: 0 } }); } catch (error) { console.error('❌ Erreur getStatsAnnuelles:', 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 }); } }); // ============================================ // 🔄 BASCULE CP 31 MAI — ROUTE MANUELLE // ============================================ app.post('/api/admin/basculement-cp-2026', async (req, res) => { const { secret } = req.body; if (secret !== 'ENSUP_BASCULE_2026') { return res.status(403).json({ message: 'Non autorisĂ©' }); } const conn = await pool.getConnection(); const rapport = []; try { await conn.beginTransaction(); // RĂ©cupĂ©rer le TypeCongeId CP const [cpType] = await conn.query( `SELECT TOP 1 Id FROM TypeConge WHERE Nom = 'CongĂ© payĂ©'` ); if (!cpType.length) { throw new Error('Type CongĂ© payĂ© introuvable'); } const cpTypeId = cpType[0].Id; console.log(`\n🔄 === BASCULE CP 2026→2027 ===`); console.log(` TypeCongeId CP: ${cpTypeId}`); // RĂ©cupĂ©rer tous les collaborateurs actifs const [collabs] = await conn.query(` SELECT id, prenom, nom FROM CollaborateurAD WHERE Actif = 1 OR Actif IS NULL ORDER BY nom, prenom `); console.log(` Collaborateurs Ă  traiter: ${collabs.length}`); for (const collab of collabs) { const userId = collab.id; const nom = `${collab.prenom} ${collab.nom}`; // ───────────────────────────────────── // ÉTAPE A : ClĂŽturer CP 2025 // Les jours restants sont perdus // ───────────────────────────────────── const [cpt2025] = await conn.query(` SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = 2025 `, [userId, cpTypeId]); if (cpt2025.length > 0) { const solde2025 = parseFloat(cpt2025[0].Solde || 0); if (solde2025 > 0) { await conn.query(` UPDATE CompteurConges SET Solde = 0, DerniereMiseAJour = GETDATE() WHERE Id = ? `, [cpt2025[0].Id]); rapport.push({ collaborateur: nom, etape: 'A - ClĂŽture 2025', detail: `${solde2025.toFixed(2)}j perdus (non reportables)` }); console.log(` [A] ${nom}: ${solde2025.toFixed(2)}j perdus (2025)`); } } // ───────────────────────────────────── // ÉTAPE B : Calculer le report depuis 2026 // Solde 2026 → devient SoldeReporte dans 2027 // Si nĂ©gatif → on reporte 0 (jamais un nĂ©gatif) // ───────────────────────────────────── const [cpt2026] = await conn.query(` SELECT Id, Solde, SoldeReporte, Total FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = 2026 `, [userId, cpTypeId]); let montantReport = 0; if (cpt2026.length > 0) { const solde2026 = parseFloat(cpt2026[0].Solde || 0); // Report = solde restant, minimum 0, maximum 25 montantReport = Math.max(0, Math.min(solde2026, 25)); console.log(` [B] ${nom}: Solde 2026=${solde2026.toFixed(2)}j → Report=${montantReport.toFixed(2)}j`); } // ───────────────────────────────────── // ÉTAPE C : Mettre Ă  jour le compteur 2027 // 2027 devient le nouveau CP N // ───────────────────────────────────── const [cpt2027] = await conn.query(` SELECT Id, Solde, SoldeReporte, Total FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = 2027 `, [userId, cpTypeId]); if (cpt2027.length > 0) { // Compteur 2027 existe (créé par les anticipĂ©s) const soldeActuel2027 = parseFloat(cpt2027[0].Solde || 0); const nouveauSolde = parseFloat((soldeActuel2027 + montantReport).toFixed(2)); await conn.query(` UPDATE CompteurConges SET Solde = ?, SoldeReporte = ?, DerniereMiseAJour = GETDATE() WHERE Id = ? `, [nouveauSolde, montantReport, cpt2027[0].Id]); rapport.push({ collaborateur: nom, etape: 'C - Bascule 2027', detail: `SoldeAvant=${soldeActuel2027.toFixed(2)}j + Report=${montantReport.toFixed(2)}j = SoldeApres=${nouveauSolde.toFixed(2)}j` }); console.log(` [C] ${nom}: 2027 ${soldeActuel2027.toFixed(2)}j + ${montantReport.toFixed(2)}j report = ${nouveauSolde.toFixed(2)}j`); } else if (montantReport > 0) { // Compteur 2027 n'existe pas encore → le crĂ©er const [nextIdRow] = await conn.query( `SELECT ISNULL(MAX(Id), 0) + 1 AS nextId FROM CompteurConges` ); const nextId = nextIdRow[0].nextId; await conn.query(` INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, 2027, 0, ?, ?, GETDATE()) `, [nextId, userId, cpTypeId, montantReport, montantReport]); rapport.push({ collaborateur: nom, etape: 'C - CrĂ©ation 2027', detail: `Compteur créé avec Report=${montantReport.toFixed(2)}j` }); console.log(` [C] ${nom}: Compteur 2027 créé avec ${montantReport.toFixed(2)}j report`); } } await conn.commit(); console.log(`\n✅ Bascule terminĂ©e: ${rapport.length} opĂ©rations`); res.json({ success: true, message: `Bascule CP 2026→2027 effectuĂ©e — ${rapport.length} opĂ©rations`, total_collaborateurs: collabs.length, rapport }); } catch (error) { await conn.rollback(); console.error('❌ Erreur bascule CP:', error); res.status(500).json({ success: false, message: error.message }); } finally { conn.release(); } }); // ============================================ // 🔍 VÉRIFICATION AVANT ET APRÈS BASCULE // ============================================ app.get('/api/admin/verif-bascule-cp', async (req, res) => { try { const [result] = await pool.query(` SELECT ca.prenom + ' ' + ca.nom AS collaborateur, cc.Annee, CAST(cc.Total AS DECIMAL(10,2)) AS Total, CAST(cc.Solde AS DECIMAL(10,2)) AS Solde, CAST(cc.SoldeReporte AS DECIMAL(10,2)) AS SoldeReporte, CASE WHEN cc.Solde < 0 THEN 'NEGATIF' WHEN cc.Solde = 0 AND cc.Total = 0 THEN 'VIDE' ELSE 'OK' END AS etat FROM CompteurConges cc JOIN CollaborateurAD ca ON cc.CollaborateurADId = ca.id JOIN TypeConge tc ON cc.TypeCongeId = tc.Id WHERE tc.Nom = 'CongĂ© payĂ©' AND cc.Annee IN (2025, 2026, 2027) ORDER BY CASE WHEN cc.Solde < 0 THEN 0 ELSE 1 END, ca.nom, cc.Annee `); // Statistiques rapides const negatifs = result.filter(r => r.etat === 'NEGATIF'); const annee2025 = result.filter(r => r.Annee === 2025); const annee2026 = result.filter(r => r.Annee === 2026); const annee2027 = result.filter(r => r.Annee === 2027); res.json({ success: true, stats: { total_lignes: result.length, negatifs: negatifs.length, annee_2025: annee2025.length, annee_2026: annee2026.length, annee_2027: annee2027.length }, attention: negatifs, data: result }); } catch (error) { res.status(500).json({ success: false, message: error.message }); } }); app.get('/test-bascule', (req, res) => { res.sendFile(path.join(__dirname, 'test-bascule.html')); }); 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); });