Files
GTA/project/public/Backend/server.js
2026-01-12 12:16:53 +01:00

10578 lines
417 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// 🚀 GTA - GESTION DES TEMPS ET ABSENCES
// ============================================================================
// Serveur Backend Node.js avec SQL Server
// Port: 3004
// Base de données: GTA (SQL Server)
// ============================================================================
import express from 'express';
import sql from 'mssql';
import cors from 'cors';
import axios from 'axios';
import multer from 'multer';
import path from 'path';
import { fileURLToPath } from 'url';
import cron from 'node-cron';
import crypto from 'crypto';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
import WebhookManager from './webhook-utils.js';
import { WEBHOOKS, EVENTS } from './webhook-config.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = 3004;
const webhookManager = new WebhookManager(WEBHOOKS.SECRET_KEY);
const sseClientsCollab = new Set();
process.on('uncaughtException', (err) => {
console.error('💥 ERREUR CRITIQUE NON CATCHÉE:', err);
console.error('Stack:', err.stack);
// On ne crash pas pour pouvoir déboguer
});
process.on('unhandledRejection', (reason, promise) => {
console.error('💥 PROMESSE REJETÉE NON GÉRÉE:', reason);
console.error('Promise:', promise);
});
app.use(cors({
origin: ['http://localhost:3013', 'http://localhost:80', 'https://mygta-dev.ensup-adm.net'],
credentials: true
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const dbConfig = {
server: '192.168.0.3', // ⭐ 'server' au lieu de 'host'
user: 'gta_app',
password: 'GTA2025!Secure',
database: 'GTA',
port: 1433, // ⭐ Nombre, pas string
options: {
encrypt: true, // ⭐ Pas de SSL en réseau local
trustServerCertificate: true,
enableArithAbort: true,
connectTimeout: 60000,
requestTimeout: 60000
},
pool: {
max: 10,
min: 0,
idleTimeoutMillis: 30000
}
};
function nowFR() {
const d = new Date();
d.setHours(d.getHours() + 2);
return d.toISOString().slice(0, 19).replace('T', ' ');
}
function formatDateWithoutUTC(date) {
if (!date) return null;
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
function formatDateToFrenchTime(date) {
if (!date) return null;
// Créer un objet Date et le convertir en heure française (Europe/Paris)
const d = new Date(date);
// Formater en ISO avec le fuseau horaire français
return d.toLocaleString('fr-FR', {
timeZone: 'Europe/Paris',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
}
// Ou plus simple, pour avoir un format ISO compatible avec le frontend
function formatDateToFrenchISO(date) {
if (!date) return null;
const d = new Date(date);
// Convertir en heure française
const frenchDate = new Date(d.toLocaleString('en-US', { timeZone: 'Europe/Paris' }));
return frenchDate.toISOString();
}
/**
* Récupère le dernier arrêté validé/clôturé
*/
async function getDernierArrete(conn) {
const [arretes] = await conn.query(`
SELECT TOP 1 * FROM ArreteComptable
WHERE Statut IN ('Validé', 'Clôturé')
ORDER BY DateArrete DESC
`);
return arretes.length > 0 ? arretes[0] : null;
}
/**
* Vérifie si une date est avant le dernier arrêté
*/
async function estAvantArrete(conn, date) {
const dernierArrete = await getDernierArrete(conn);
if (!dernierArrete) {
return false; // Pas d'arrêté = toutes les dates sont autorisées
}
const dateTest = new Date(date);
const dateArrete = new Date(dernierArrete.DateArrete);
return dateTest <= dateArrete;
}
async function getSoldeFige(conn, collaborateurId, typeCongeId, annee) {
const dernierArrete = await getDernierArrete(conn);
if (!dernierArrete) {
return null; // Pas d'arrêté
}
const [soldes] = await conn.query(`
SELECT TOP 1 * FROM SoldesFiges
WHERE ArreteId = ?
AND CollaborateurADId = ?
AND TypeCongeId = ?
AND Annee = ?
`, [dernierArrete.Id, collaborateurId, typeCongeId, annee]);
return soldes.length > 0 ? soldes[0] : null;
}
async function calculerAcquisitionDepuisArrete(conn, collaborateurId, typeConge, dateReference = new Date()) {
const dernierArrete = await getDernierArrete(conn);
const anneeRef = dateReference.getFullYear();
// Déterminer le type de congé
const [typeRow] = await conn.query(
'SELECT TOP 1 Id FROM TypeConge WHERE Nom = ?',
[typeConge === 'CP' ? 'Congé payé' : 'RTT']
);
if (typeRow.length === 0) {
throw new Error(`Type de congé ${typeConge} non trouvé`);
}
const typeCongeId = typeRow[0].Id;
const [collab] = await conn.query('SELECT role FROM CollaborateurAD WHERE id = ?', [collaborateurId]);
const isApprenti = collab.length > 0 && collab[0].role === 'Apprenti';
if (typeConge === 'RTT' && isApprenti) {
return 0; // ⭐ Les apprentis n'ont pas de RTT
}
// Si pas d'arrêté, calcul normal depuis le début
if (!dernierArrete) {
if (typeConge === 'CP') {
const [collab] = await conn.query('SELECT DateEntree, CampusId FROM CollaborateurAD WHERE id = ?', [collaborateurId]);
const dateEntree = collab.length > 0 ? collab[0].DateEntree : null;
return calculerAcquisitionCP(dateReference, dateEntree);
} else {
const rttData = await calculerAcquisitionRTT(conn, collaborateurId, dateReference);
return rttData.acquisition;
}
}
const dateArrete = new Date(dernierArrete.DateArrete);
// Si la date de référence est AVANT l'arrêté, utiliser le solde figé
if (dateReference <= dateArrete) {
const soldeFige = await getSoldeFige(conn, collaborateurId, typeCongeId, anneeRef);
return soldeFige ? soldeFige.TotalAcquis : 0;
}
// Si la date est APRÈS l'arrêté, partir du solde figé + calcul depuis l'arrêté
const soldeFige = await getSoldeFige(conn, collaborateurId, typeCongeId, anneeRef);
const acquisFigee = soldeFige ? soldeFige.TotalAcquis : 0;
// Calculer l'acquisition DEPUIS l'arrêté
let acquisDepuisArrete = 0;
if (typeConge === 'CP') {
const moisDepuisArrete = getMoisTravaillesCP(dateReference, dateArrete);
acquisDepuisArrete = moisDepuisArrete * (25 / 12);
} else {
const [collab] = await conn.query('SELECT TypeContrat, CampusId FROM CollaborateurAD WHERE id = ?', [collaborateurId]);
const typeContrat = collab.length > 0 && collab[0].TypeContrat ? collab[0].TypeContrat : '37h';
const config = await getConfigurationRTT(conn, anneeRef, typeContrat);
const moisDepuisArrete = getMoisTravaillesRTT(dateReference, dateArrete);
acquisDepuisArrete = moisDepuisArrete * config.acquisitionMensuelle;
}
return acquisFigee + acquisDepuisArrete;
}
const pool = new sql.ConnectionPool(dbConfig);
// ⭐ CONNEXION AU DÉMARRAGE
pool.connect()
.then(() => {
console.log('✅ ==========================================');
console.log(' CONNECTÉ À SQL SERVER');
console.log(` Base: ${dbConfig.database}@${dbConfig.host}`);
console.log('==========================================');
})
.catch(err => {
console.error('❌ ==========================================');
console.error(' ERREUR CONNEXION SQL SERVER');
console.error(' Message:', err.message);
console.error('==========================================');
});
// ========================================
// 🔧 MONKEY-PATCH MSSQL POUR SUPPORTER "?"
// ========================================
// MONKEY-PATCH MSSQL POUR SUPPORTER ? ET LIMIT
const originalRequest = sql.Request;
sql.Request = function (...args) {
const request = new originalRequest(...args);
const originalQuery = request.query.bind(request);
request.query = async function (queryText, ...queryArgs) {
let convertedQuery = queryText; // 🔥 AJOUTÉ ICI
try {
if (!queryArgs || queryArgs.length === 0 || !Array.isArray(queryArgs[0])) {
return await originalQuery(queryText);
}
const params = queryArgs[0];
params.forEach((value, index) => {
request.input(`param${index}`, value);
});
let paramIndex = 0;
convertedQuery = convertedQuery.replace(/\?/g, () => `@param${paramIndex++}`);
// CONVERSION GETDATE() → GETDATE()
convertedQuery = convertedQuery.replace(/NOW\(\)/gi, 'GETDATE()');
// CONVERSION LEAST() → CASE WHEN
while (convertedQuery.match(/LEAST\s*\(/i)) {
convertedQuery = convertedQuery.replace(
/LEAST\s*\(\s*([^,]+?)\s*,\s*([^)]+?)\s*\)/i,
'(CASE WHEN $1 < $2 THEN $1 ELSE $2 END)'
);
}
// LIMIT → TOP conversion
convertedQuery = convertedQuery.replace(
/LIMIT\s+(\d+)\s+OFFSET\s+(\d+)/gi,
'OFFSET $2 ROWS FETCH NEXT $1 ROWS ONLY'
);
const simpleLimitMatch = convertedQuery.match(/LIMIT\s+(\d+)(?!\s+OFFSET)/i);
if (simpleLimitMatch) {
const limitValue = simpleLimitMatch[1];
convertedQuery = convertedQuery.replace(/LIMIT\s+\d+(?!\s+OFFSET)/i, '');
convertedQuery = convertedQuery.replace(
/SELECT(\s+DISTINCT)?/i,
`SELECT$1 TOP ${limitValue}`
);
}
return await originalQuery(convertedQuery);
} catch (error) {
console.error('❌ Erreur query SQL:', error.message);
console.error('Query originale:', queryText.substring(0, 300));
console.error('Query convertie:', convertedQuery?.substring(0, 300));
throw error;
}
};
return request;
};
console.log('✅ Driver mssql patché: support des ?, LIMIT, GETDATE() et LEAST() activé');
// ========================================
// ⭐ WRAPPER POUR COMPATIBILITÉ MYSQL
// ========================================
/**
* Simule pool.getConnection() de MySQL
* Retourne un objet avec query(), beginTransaction(), commit(), rollback(), release()
*/
// ⭐ WRAPPER POUR COMPATIBILITÉ MYSQL
// ⭐ WRAPPER POUR COMPATIBILITÉ MYSQL
pool.getConnection = async function () {
if (!pool.connected) {
await pool.connect();
}
let transaction = null;
return {
query: async function (queryText, params = []) {
// ⭐ FIX: Déclarer parameterizedQuery EN DEHORS du try block
let parameterizedQuery = queryText;
try {
const request = transaction ? new sql.Request(transaction) : pool.request();
// Ajouter les paramètres (@param0, @param1, ...)
params.forEach((value, index) => {
request.input(`param${index}`, value);
});
// Remplacer ? par @param0, @param1, etc.
let paramIndex = 0;
parameterizedQuery = parameterizedQuery.replace(/\?/g, () => `@param${paramIndex++}`);
// ⭐⭐⭐ CONVERSION LIMIT → TOP (VERSION CORRIGÉE) ⭐⭐⭐
// 1. Gérer LIMIT avec OFFSET
parameterizedQuery = parameterizedQuery.replace(
/LIMIT\s+(\d+)\s+OFFSET\s+(\d+)/gi,
'OFFSET $2 ROWS FETCH NEXT $1 ROWS ONLY'
);
// 2. Marquer tous les LIMIT (même sans espace avant)
parameterizedQuery = parameterizedQuery.replace(
/\s*LIMIT\s+(\d+)(?!\s+OFFSET)/gi,
' __LIMIT__$1__'
);
// 3. Injecter TOP après SELECT
let limitValue = null;
const limitMatch = parameterizedQuery.match(/__LIMIT__(\d+)__/);
if (limitMatch) {
limitValue = limitMatch[1];
parameterizedQuery = parameterizedQuery.replace(/__LIMIT__\d+__/g, '');
}
if (limitValue) {
parameterizedQuery = parameterizedQuery.replace(
/(SELECT\s+(?:DISTINCT\s+)?)/i,
`$1TOP ${limitValue} `
);
}
// ⭐ FIX: Convertir TRUE/FALSE en 1/0 pour SQL Server
parameterizedQuery = parameterizedQuery.replace(/\bTRUE\b/gi, '1');
parameterizedQuery = parameterizedQuery.replace(/\bFALSE\b/gi, '0');
const result = await request.query(parameterizedQuery);
return [result.recordset || []];
} catch (error) {
console.error('❌ Erreur query SQL:', error.message);
console.error('Query originale:', queryText);
console.error('Query convertie:', parameterizedQuery?.substring(0, 500));
throw error;
}
},
beginTransaction: async function () {
transaction = new sql.Transaction(pool);
await transaction.begin();
},
commit: async function () {
if (transaction) {
await transaction.commit();
transaction = null;
}
},
rollback: async function () {
if (transaction) {
await transaction.rollback();
transaction = null;
}
},
release: function () {
console.log('🔄 Connection released (no-op avec mssql)');
}
};
};
// ⭐ pool.query() direct (sans transaction)
// ⭐ pool.query() direct (sans transaction)
pool.query = async function (queryText, params = []) {
if (!pool.connected) {
await pool.connect();
}
// ⭐ FIX: Déclarer parameterizedQuery EN DEHORS du try/catch implicite
let parameterizedQuery = queryText;
const request = pool.request();
params.forEach((value, index) => {
request.input(`param${index}`, value);
});
let paramIndex = 0;
parameterizedQuery = parameterizedQuery.replace(/\?/g, () => `@param${paramIndex++}`);
// ⭐⭐⭐ CONVERSION LIMIT → TOP (VERSION CORRIGÉE) ⭐⭐⭐
// 1. Gérer LIMIT avec OFFSET
parameterizedQuery = parameterizedQuery.replace(
/LIMIT\s+(\d+)\s+OFFSET\s+(\d+)/gi,
'OFFSET $2 ROWS FETCH NEXT $1 ROWS ONLY'
);
// 2. Marquer tous les LIMIT (même sans espace avant)
parameterizedQuery = parameterizedQuery.replace(
/\s*LIMIT\s+(\d+)(?!\s+OFFSET)/gi,
' __LIMIT__$1__'
);
// 3. Injecter TOP après SELECT
let limitValue = null;
const limitMatch = parameterizedQuery.match(/__LIMIT__(\d+)__/);
if (limitMatch) {
limitValue = limitMatch[1];
parameterizedQuery = parameterizedQuery.replace(/__LIMIT__\d+__/g, '');
}
if (limitValue) {
parameterizedQuery = parameterizedQuery.replace(
/(SELECT\s+(?:DISTINCT\s+)?)/i,
`$1TOP ${limitValue} `
);
}
// ⭐ FIX: Convertir TRUE/FALSE en 1/0 pour SQL Server
parameterizedQuery = parameterizedQuery.replace(/\bTRUE\b/gi, '1');
parameterizedQuery = parameterizedQuery.replace(/\bFALSE\b/gi, '0');
const result = await request.query(parameterizedQuery);
return [result.recordset || []];
};
const AZURE_CONFIG = {
tenantId: '9840a2a0-6ae1-4688-b03d-d2ec291be0f9',
clientId: '4bb4cc24-bac3-427c-b02c-5d14fc67b561',
clientSecret: 'gvf8Q~545Bafn8yYsgjW~QG_P1lpzaRe6gJNgb2t',
groupId: 'c1ea877c-6bca-4f47-bfad-f223640813a0'
};
const storage = multer.diskStorage({
destination: './uploads/',
filename: (req, file, cb) => {
cb(null, Date.GETDATE() + path.extname(file.originalname));
}
});
const upload = multer({ storage });
const medicalStorage = multer.diskStorage({
destination: './uploads/medical/',
filename: (req, file, cb) => {
const uniqueSuffix = Date.GETDATE() + '-' + Math.round(Math.random() * 1E9);
cb(null, 'medical-' + uniqueSuffix + path.extname(file.originalname));
}
});
const ACCES_TRANSVERSAUX = {
'sloisil@ensup.eu': {
typeAcces: 'service_multi_campus',
serviceNom: 'Pédagogie',
description: 'Sandrine - Vue complète Pédagogie (tous campus)'
},
'mbouteiller@ensup.eu': {
typeAcces: 'service_multi_campus',
serviceNom: 'Admissions',
description: 'Morgane - Vue complète Admissions (tous campus)'
},
'vnoel@ensup.eu': {
typeAcces: 'service_multi_campus',
serviceNom: 'Relations Entreprises',
description: 'Viviane - Vue complète Relations Entreprises (tous campus)'
},
'vpierrel@ensup.eu': {
typeAcces: 'service_multi_campus', // ✅ CORRIGÉ - même type que les autres
serviceNom: 'Administratif & Financier',
description: 'Virginie - Vue complète Administratif & Financier (tous campus)'
}
};
function getUserAccesTransversal(userEmail) {
const acces = ACCES_TRANSVERSAUX[userEmail?.toLowerCase()] || null;
if (acces) {
console.log(`🌐 Accès transversal: ${acces.description}`);
}
return acces;
}
const uploadMedical = multer({
storage: medicalStorage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Type de fichier non autorisé'));
}
}
});
import fs from 'fs';
if (!fs.existsSync('./uploads/medical')) {
fs.mkdirSync('./uploads/medical', { recursive: true });
}
app.get('/api/events/collaborateur', (req, res) => {
const userId = req.query.user_id;
if (!userId) {
return res.status(401).json({ error: 'user_id requis' });
}
console.log('🔔 Nouvelle connexion SSE collaborateur:', userId);
// ⭐ HEADERS CRITIQUES POUR SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.setHeader('Access-Control-Allow-Origin', '*');
// ⭐ FLUSH IMMÉDIATEMENT POUR ÉTABLIR LA CONNEXION
res.flushHeaders();
const sendEvent = (data) => {
try {
if (res.writableEnded) {
console.log('⚠️ Tentative d\'envoi sur connexion fermée');
return false;
}
res.write(`data: ${JSON.stringify(data)}\n\n`);
return true;
} catch (error) {
console.error('❌ Erreur envoi SSE:', error);
return false;
}
};
const client = {
userId: parseInt(userId),
send: sendEvent,
res: res // ⭐ Garder référence pour vérifier l'état
};
sseClientsCollab.add(client);
console.log(`📊 Clients SSE collaborateurs connectés: ${sseClientsCollab.size}`);
// ⭐ ÉVÉNEMENT DE CONNEXION
sendEvent({
type: 'connected',
message: 'Connexion établie',
timestamp: new Date().toISOString()
});
// ⭐ HEARTBEAT AVEC VÉRIFICATION
const heartbeat = setInterval(() => {
const success = sendEvent({
type: 'heartbeat',
timestamp: new Date().toISOString()
});
if (!success) {
console.log('💔 Heartbeat échoué, nettoyage...');
clearInterval(heartbeat);
sseClientsCollab.delete(client);
}
}, 30000); // 30 secondes
// ⭐ GESTION PROPRE DE LA DÉCONNEXION
const cleanup = () => {
console.log('🔌 Déconnexion SSE collaborateur:', userId);
clearInterval(heartbeat);
sseClientsCollab.delete(client);
console.log(`📊 Clients SSE collaborateurs connectés: ${sseClientsCollab.size}`);
};
req.on('close', cleanup);
req.on('error', (err) => {
console.error('❌ Erreur SSE connexion:', err.message);
cleanup();
});
// ⭐ TIMEOUT DE SÉCURITÉ (optionnel, mais recommandé)
req.socket.setTimeout(0); // Désactiver timeout pour SSE
});
const notifyCollabClients = (event, targetUserId = null) => {
console.log(
`📢 Notification SSE Collab: ${event.type}`,
targetUserId ? `pour user ${targetUserId}` : 'pour tous'
);
const deadClients = [];
sseClientsCollab.forEach(client => {
// ⭐ FILTRER PAR USER SI NÉCESSAIRE
if (targetUserId && client.userId !== targetUserId) {
return;
}
// ⭐ VÉRIFIER SI LA CONNEXION EST TOUJOURS ACTIVE
if (client.res && client.res.writableEnded) {
console.log(`💀 Client mort détecté: ${client.userId}`);
deadClients.push(client);
return;
}
// ⭐ ENVOYER L'ÉVÉNEMENT
const success = client.send(event);
if (!success) {
deadClients.push(client);
}
});
// ⭐ NETTOYER LES CLIENTS MORTS
deadClients.forEach(client => {
console.log(`🧹 Nettoyage client mort: ${client.userId}`);
sseClientsCollab.delete(client);
});
if (deadClients.length > 0) {
console.log(`📊 Clients SSE après nettoyage: ${sseClientsCollab.size}`);
}
};
const sseClients = new Set();
// 🔌 ROUTE SSE POUR LE CALENDRIER
app.get('/api/sse', (req, res) => {
const userId = req.query.user_id;
if (!userId) {
return res.status(400).json({ error: 'user_id requis' });
}
console.log('🔌 Nouvelle connexion SSE:', userId);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
const sendEvent = (data) => {
try {
res.write(`data: ${JSON.stringify(data)}\n\n`);
} catch (error) {
console.error('❌ Erreur envoi SSE:', error);
}
};
const client = { id: userId, send: sendEvent };
sseClients.add(client);
console.log(`📊 Clients SSE connectés: ${sseClients.size}`);
// Envoyer un heartbeat initial
sendEvent({
type: 'ping',
message: 'Connexion établie',
timestamp: new Date().toISOString()
});
// Heartbeat toutes les 30 secondes
const heartbeat = setInterval(() => {
try {
sendEvent({ type: 'ping', timestamp: new Date().toISOString() });
} catch (error) {
console.error('❌ Erreur heartbeat:', error);
clearInterval(heartbeat);
}
}, 30000);
req.on('close', () => {
console.log('🔌 Déconnexion SSE:', userId);
clearInterval(heartbeat);
sseClients.delete(client);
console.log(`📊 Clients SSE connectés: ${sseClients.size}`);
});
});
// 📢 FONCTION POUR NOTIFIER LES CLIENTS
const notifyClients = (event, userId = null) => {
console.log(`📢 Notification SSE: ${event.type}${userId ? ` pour ${userId}` : ''}`);
sseClients.forEach(client => {
// Si userId est spécifié, envoyer seulement à ce client
if (userId && client.id !== userId) {
return;
}
try {
client.send(event);
} catch (error) {
console.error('❌ Erreur envoi event:', error);
}
});
};
app.post('/api/webhook/receive', async (req, res) => {
try {
const signature = req.headers['x-webhook-signature'];
const payload = req.body;
console.log('\n📥 === WEBHOOK REÇU (COLLABORATEURS) ===');
console.log('Event:', payload.event);
console.log('Data:', JSON.stringify(payload.data, null, 2));
// Vérifier la signature
if (!webhookManager.verifySignature(payload, signature)) {
console.error('❌ Signature webhook invalide');
return res.status(401).json({ error: 'Signature invalide' });
}
const { event, data } = payload;
// Traiter selon le type d'événement
switch (event) {
case EVENTS.COMPTEUR_UPDATED:
console.log('📊 WEBHOOK COMPTEUR_UPDATED REÇU');
console.log('Collaborateur:', data.collaborateurId);
console.log('Type mise à jour:', data.typeUpdate);
console.log('Type congé:', data.typeConge);
console.log('Année:', data.annee);
console.log('Nouveau Total:', data.nouveauTotal + 'j');
console.log('Nouveau Solde:', data.nouveauSolde + 'j');
console.log('Source:', data.source);
// ✅ PAS D'UPDATE EN BASE (car même DB partagée)
// ✅ UNIQUEMENT NOTIFICATION SSE pour rafraîchir l'interface
notifyCollabClients({
type: 'compteur-updated',
collaborateurId: data.collaborateurId,
typeConge: data.typeConge,
annee: data.annee,
typeUpdate: data.typeUpdate,
nouveauTotal: data.nouveauTotal,
nouveauSolde: data.nouveauSolde,
source: data.source,
timestamp: new Date().toISOString()
}, data.collaborateurId);
console.log('✅ Notification SSE envoyée au collaborateur', data.collaborateurId);
break;
case EVENTS.DEMANDE_VALIDATED:
console.log('\n✅ === WEBHOOK DEMANDE_VALIDATED REÇU ===');
console.log(` Demande: ${data.demandeId}`);
console.log(` Statut: ${data.statut}`);
console.log(` Type: ${data.typeConge}`);
console.log(` Couleur: ${data.couleurHex}`);
// Notifier les clients SSE avec TOUTES les infos
notifyClients({
type: 'demande-validated',
demandeId: data.demandeId,
statut: data.statut,
typeConge: data.typeConge,
couleurHex: data.couleurHex || '#d946ef',
date: data.date,
periode: data.periode,
collaborateurId: data.collaborateurId,
timestamp: new Date().toISOString()
}, data.collaborateurId);
// Notifier les RH aussi
notifyClients({
type: 'demande-list-updated',
action: 'validation-collab',
demandeId: data.demandeId,
statut: data.statut,
typeConge: data.typeConge,
couleurHex: data.couleurHex || '#d946ef',
timestamp: new Date().toISOString()
});
console.log(' 📢 Notifications SSE envoyées');
break;
case EVENTS.DEMANDE_UPDATED:
console.log('\n✏ === WEBHOOK DEMANDE_UPDATED REÇU ===');
console.log(` Demande: ${data.demandeId}`);
console.log(` Collaborateur: ${data.collaborateurId}`);
notifyCollabClients({
type: 'demande-updated-rh',
demandeId: data.demandeId,
timestamp: new Date().toISOString()
}, data.collaborateurId);
console.log(' 📢 Notification modification envoyée');
break;
case EVENTS.DEMANDE_DELETED:
console.log('\n🗑 === WEBHOOK DEMANDE_DELETED REÇU ===');
console.log(` Demande: ${data.demandeId}`);
console.log(` Collaborateur: ${data.collaborateurId}`);
notifyCollabClients({
type: 'demande-deleted-rh',
demandeId: data.demandeId,
timestamp: new Date().toISOString()
}, data.collaborateurId);
console.log(' 📢 Notification suppression envoyée');
break;
default:
console.warn(`⚠️ Type d'événement webhook inconnu: ${event}`);
}
res.json({ success: true, message: 'Webhook traité' });
} catch (error) {
console.error('❌ Erreur traitement webhook:', error);
res.status(500).json({ error: error.message });
}
});
function getDateFinMoisPrecedent(referenceDate = new Date()) {
const now = new Date(referenceDate);
now.setHours(0, 0, 0, 0);
return new Date(now.getFullYear(), now.getMonth(), 0);
}
function parseDateYYYYMMDD(s) {
if (!s) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
const [y, m, d] = s.split('-').map(Number);
return new Date(y, m - 1, d);
}
return new Date(s);
}
const LEAVE_RULES = {
CP: {
nom: 'Congé payé',
joursAnnuels: 25,
periodeDebut: { mois: 6, jour: 1 },
periodeFin: { mois: 5, jour: 31 },
acquisitionMensuelle: 25 / 12,
reportable: true,
periodeReport: 'exercice'
},
RTT: {
nom: 'RTT',
joursAnnuels: 10,
periodeDebut: { mois: 1, jour: 1 },
periodeFin: { mois: 12, jour: 31 },
acquisitionMensuelle: 10 / 12,
reportable: false,
periodeReport: null
}
};
// ========================================
// NOUVELLES FONCTIONS POUR RTT VARIABLES
// ========================================
/**
* Récupère la configuration RTT pour une année et un type de contrat donnés
* RÈGLES :
* - 37h : toujours 10 RTT/an (0.8333/mois)
* - Forfait jour 2025 : 10 RTT/an (0.8333/mois)
* - Forfait jour 2026+ : 12 RTT/an (1.0/mois)
*/
async function getConfigurationRTT(conn, annee, typeContrat = '37h') {
try {
// D'abord chercher en base de données
const [config] = await conn.query(
`SELECT JoursAnnuels, AcquisitionMensuelle
FROM ConfigurationRTT
WHERE Annee = ? AND TypeContrat = ?
LIMIT 1`,
[annee, typeContrat]
);
if (config.length > 0) {
return {
joursAnnuels: parseFloat(config[0].JoursAnnuels),
acquisitionMensuelle: parseFloat(config[0].AcquisitionMensuelle)
};
}
// Si pas en base, utiliser les règles par défaut
console.warn(`⚠️ Pas de config RTT en base pour ${annee}/${typeContrat}, utilisation des règles par défaut`);
return getConfigurationRTTDefaut(annee, typeContrat);
} catch (error) {
console.error('Erreur getConfigurationRTT:', error);
return getConfigurationRTTDefaut(annee, typeContrat);
}
}
function getConfigurationRTTDefaut(annee, typeContrat) {
// 37h : toujours 10 RTT/an
if (typeContrat === '37h' || typeContrat === 'temps_partiel') {
return {
joursAnnuels: 10,
acquisitionMensuelle: 10 / 12 // 0.8333
};
}
// Forfait jour : dépend de l'année
if (typeContrat === 'forfait_jour') {
if (annee <= 2025) {
// 2025 et avant : 10 RTT/an
return {
joursAnnuels: 10,
acquisitionMensuelle: 10 / 12 // 0.8333
};
} else {
// 2026 et après : 12 RTT/an
return {
joursAnnuels: 12,
acquisitionMensuelle: 12 / 12 // 1.0
};
}
}
// Par défaut : 10 RTT/an
return {
joursAnnuels: 10,
acquisitionMensuelle: 10 / 12
};
}
/**
* Calcule l'acquisition RTT avec la formule Excel exacte
*/
async function calculerAcquisitionRTT(conn, collaborateurId, dateReference = new Date()) {
const d = new Date(dateReference);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
// 1⃣ Récupérer les infos du collaborateur
const [collabInfo] = await conn.query(
`SELECT TypeContrat, DateEntree, role FROM CollaborateurAD WHERE id = ?`,
[collaborateurId]
);
if (collabInfo.length === 0) {
throw new Error(`Collaborateur ${collaborateurId} non trouvé`);
}
const typeContrat = collabInfo[0].TypeContrat || '37h';
const dateEntree = collabInfo[0].DateEntree;
const isApprenti = collabInfo[0].role === 'Apprenti';
// 2⃣ Apprentis = pas de RTT
if (isApprenti) {
return {
acquisition: 0,
moisTravailles: 0,
config: { joursAnnuels: 0, acquisitionMensuelle: 0 },
typeContrat: typeContrat
};
}
// 3⃣ Récupérer la configuration RTT (avec règles 2025/2026)
const config = await getConfigurationRTT(conn, annee, typeContrat);
console.log(`📊 Config RTT ${annee}/${typeContrat}: ${config.joursAnnuels}j/an (${config.acquisitionMensuelle.toFixed(4)}/mois)`);
// 4⃣ Début d'acquisition = 01/01/N ou date d'entrée si postérieure
let dateDebutAcquis = new Date(annee, 0, 1); // 01/01/N
dateDebutAcquis.setHours(0, 0, 0, 0);
if (dateEntree) {
const entree = new Date(dateEntree);
entree.setHours(0, 0, 0, 0);
if (entree.getFullYear() === annee && entree > dateDebutAcquis) {
dateDebutAcquis = entree;
}
if (entree.getFullYear() > annee) {
return {
acquisition: 0,
moisTravailles: 0,
config: config,
typeContrat: typeContrat
};
}
}
// 5⃣ Calculer avec la formule Excel
const acquisition = calculerAcquisitionFormuleExcel(dateDebutAcquis, d, config.acquisitionMensuelle);
// 6⃣ Calculer les mois travaillés (pour info)
const moisTravailles = config.acquisitionMensuelle > 0
? acquisition / config.acquisitionMensuelle
: 0;
// 7⃣ Plafonner au maximum annuel
const acquisitionFinale = Math.min(acquisition, config.joursAnnuels);
return {
acquisition: Math.round(acquisitionFinale * 100) / 100,
moisTravailles: Math.round(moisTravailles * 100) / 100,
config: config,
typeContrat: typeContrat
};
}
/**
* Calcule l'acquisition avec la formule Excel exacte :
* E1 * ((JOUR(FIN.MOIS(B1;0)) - JOUR(B1) + 1) / JOUR(FIN.MOIS(B1;0))
* + DATEDIF(B1;B2;"m") - 1
* + JOUR(B2) / JOUR(FIN.MOIS(B2;0)))
*/
function calculerAcquisitionFormuleExcel(dateDebut, dateReference, coeffMensuel) {
const b1 = new Date(dateDebut);
const b2 = new Date(dateReference);
b1.setHours(0, 0, 0, 0);
b2.setHours(0, 0, 0, 0);
// Si date référence avant date début
if (b2 < b1) {
return 0;
}
// Si même mois et même année
if (b1.getFullYear() === b2.getFullYear() && b1.getMonth() === b2.getMonth()) {
const joursTotal = new Date(b2.getFullYear(), b2.getMonth() + 1, 0).getDate();
const joursAcquis = b2.getDate() - b1.getDate() + 1;
return Math.round((joursAcquis / joursTotal) * coeffMensuel * 100) / 100;
}
// 1⃣ Fraction du PREMIER mois
const joursFinMoisB1 = new Date(b1.getFullYear(), b1.getMonth() + 1, 0).getDate();
const jourB1 = b1.getDate();
const fractionPremierMois = (joursFinMoisB1 - jourB1 + 1) / joursFinMoisB1;
// 2⃣ Mois COMPLETS entre
const moisComplets = dateDifMonths(b1, b2) - 1;
// 3⃣ Fraction du DERNIER mois
const joursFinMoisB2 = new Date(b2.getFullYear(), b2.getMonth() + 1, 0).getDate();
const jourB2 = b2.getDate();
const fractionDernierMois = jourB2 / joursFinMoisB2;
// 4⃣ Total
const totalMois = fractionPremierMois + Math.max(0, moisComplets) + fractionDernierMois;
const acquisition = totalMois * coeffMensuel;
return Math.round(acquisition * 100) / 100;
}
/**
* Équivalent de DATEDIF(date1, date2, "m") en JavaScript
*/
function dateDifMonths(date1, date2) {
const d1 = new Date(date1);
const d2 = new Date(date2);
let months = (d2.getFullYear() - d1.getFullYear()) * 12;
months += d2.getMonth() - d1.getMonth();
// Si le jour de d2 < jour de d1, on n'a pas encore complété le mois
if (d2.getDate() < d1.getDate()) {
months--;
}
return Math.max(0, months);
}
/**
* Calcule l'acquisition CP avec la formule Excel exacte
*/
function calculerAcquisitionCP(dateReference = new Date(), dateEntree = null) {
const d = new Date(dateReference);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
const mois = d.getMonth() + 1;
// 1⃣ Déterminer le début de l'exercice CP (01/06)
let exerciceDebut;
if (mois >= 6) {
exerciceDebut = new Date(annee, 5, 1); // 01/06/N
} else {
exerciceDebut = new Date(annee - 1, 5, 1); // 01/06/N-1
}
exerciceDebut.setHours(0, 0, 0, 0);
// 2⃣ Ajuster si date d'entrée postérieure
let dateDebutAcquis = new Date(exerciceDebut);
if (dateEntree) {
const entree = new Date(dateEntree);
entree.setHours(0, 0, 0, 0);
if (entree > exerciceDebut) {
dateDebutAcquis = entree;
}
}
// 3⃣ Calculer avec la formule Excel
const coeffCP = 25 / 12; // 2.0833
const acquisition = calculerAcquisitionFormuleExcel(dateDebutAcquis, d, coeffCP);
// 4⃣ Plafonner à 25 jours
return Math.min(acquisition, 25);
}
// ========================================
// CALCUL CP INTELLIGENT (MODE AUTO)
// ========================================
/**
* Calcule l'acquisition CP avec détection automatique du mode
*/
function calculerAcquisitionCP_Smart(dateReference = new Date(), dateEntree = null) {
const d = new Date(dateReference);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
const mois = d.getMonth() + 1;
// 1⃣ Déterminer le début de l'exercice CP (01/06)
let exerciceDebut;
if (mois >= 6) {
exerciceDebut = new Date(annee, 5, 1); // 01/06/N
} else {
exerciceDebut = new Date(annee - 1, 5, 1); // 01/06/N-1
}
exerciceDebut.setHours(0, 0, 0, 0);
// 2⃣ Obtenir le mode de calcul
const modeInfo = getModeCalcul('CP', dateEntree);
const dateDebutAcquis = modeInfo.dateDebut;
console.log(` 📅 ${modeInfo.description}`);
// 3⃣ Calculer avec la formule Excel
const coeffCP = 25 / 12; // 2.0833
const acquisition = calculerAcquisitionFormuleExcel(dateDebutAcquis, d, coeffCP);
// 4⃣ Plafonner à 25 jours
return Math.min(acquisition, 25);
}
// ========================================
// CALCUL RTT INTELLIGENT (MODE AUTO)
// ========================================
/**
* Calcule l'acquisition RTT avec détection automatique du mode
*/
async function calculerAcquisitionRTT_Smart(conn, collaborateurId, dateReference = new Date()) {
const d = new Date(dateReference);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
// 1⃣ Récupérer les infos du collaborateur
const [collabInfo] = await conn.query(
`SELECT TypeContrat, DateEntree, role FROM CollaborateurAD WHERE id = ?`,
[collaborateurId]
);
if (collabInfo.length === 0) {
throw new Error(`Collaborateur ${collaborateurId} non trouvé`);
}
const typeContrat = collabInfo[0].TypeContrat || '37h';
const dateEntree = collabInfo[0].DateEntree;
const isApprenti = collabInfo[0].role === 'Apprenti';
// 2⃣ Apprentis = pas de RTT
if (isApprenti) {
return {
acquisition: 0,
moisTravailles: 0,
config: { joursAnnuels: 0, acquisitionMensuelle: 0 },
typeContrat: typeContrat,
mode: 'APPRENTI'
};
}
// 3⃣ Récupérer la configuration RTT (avec règles 2025/2026)
const config = await getConfigurationRTT(conn, annee, typeContrat);
// 4⃣ Obtenir le mode de calcul
const modeInfo = getModeCalcul('RTT', dateEntree);
const dateDebutAcquis = modeInfo.dateDebut;
console.log(` 📅 ${modeInfo.description}`);
// 5⃣ Calculer avec la formule Excel
const acquisition = calculerAcquisitionFormuleExcel(dateDebutAcquis, d, config.acquisitionMensuelle);
// 6⃣ Calculer les mois travaillés (pour info)
const moisTravailles = config.acquisitionMensuelle > 0
? acquisition / config.acquisitionMensuelle
: 0;
// 7⃣ Plafonner au maximum annuel
const acquisitionFinale = Math.min(acquisition, config.joursAnnuels);
return {
acquisition: Math.round(acquisitionFinale * 100) / 100,
moisTravailles: Math.round(moisTravailles * 100) / 100,
config: config,
typeContrat: typeContrat,
mode: modeInfo.mode
};
}
// ========================================
// FONCTION DE DÉTECTION AUTOMATIQUE DU MODE
// ========================================
/**
* Détermine automatiquement si on doit utiliser le mode transition
* en comparant la date actuelle avec le début de l'exercice
*/
function isInExerciceActuel(typeConge) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const currentYear = today.getFullYear();
const currentMonth = today.getMonth() + 1; // 1-12
if (typeConge === 'CP') {
// Exercice CP : 01/06/N → 31/05/N+1
// Déterminer le début de l'exercice ACTUEL
let debutExercice;
if (currentMonth >= 6) {
// On est entre juin et décembre → exercice commence le 01/06 de cette année
debutExercice = new Date(currentYear, 5, 1); // 01/06/N
} else {
// On est entre janvier et mai → exercice a commencé le 01/06 de l'année dernière
debutExercice = new Date(currentYear - 1, 5, 1); // 01/06/N-1
}
// ⭐ Si aujourd'hui est le PREMIER JOUR du nouvel exercice ou après → MODE NORMAL
// ⭐ Si on est encore dans l'exercice commencé avant → MODE TRANSITION
const finExercice = new Date(debutExercice);
finExercice.setFullYear(finExercice.getFullYear() + 1);
finExercice.setMonth(4, 31); // 31/05/N+1
// Si on vient de passer le 01/06, c'est le nouvel exercice → mode NORMAL
const nouveauExercice = new Date(currentYear, 5, 1);
return today < nouveauExercice; // TRUE = mode transition (avant le 01/06)
} else if (typeConge === 'RTT') {
// Année RTT : 01/01/N → 31/12/N
const debutAnnee = new Date(currentYear, 0, 1); // 01/01/N
// Si on vient de passer le 01/01, c'est la nouvelle année → mode NORMAL
return today < debutAnnee; // TRUE = mode transition (avant le 01/01)
}
return false;
}
/**
* Version améliorée : retourne le mode ET la date de début à utiliser
*/
/**
* Détermine le mode de calcul et la date de début selon le contexte
* @param {string} typeConge - 'CP' ou 'RTT'
* @param {Date|null} dateEntree - Date d'entrée du collaborateur
* @returns {Object} { mode, dateDebut, description }
*/
function getModeCalcul(typeConge, dateEntree = null) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const currentYear = today.getFullYear();
const currentMonth = today.getMonth() + 1;
if (typeConge === 'CP') {
// Déterminer le début de l'exercice ACTUEL
let debutExerciceActuel;
if (currentMonth >= 6) {
debutExerciceActuel = new Date(currentYear, 5, 1); // 01/06/N
} else {
debutExerciceActuel = new Date(currentYear - 1, 5, 1); // 01/06/N-1
}
debutExerciceActuel.setHours(0, 0, 0, 0);
// ⭐ RÈGLE DE BASCULE :
// On bascule en mode NORMAL uniquement si :
// 1. On est dans un NOUVEL exercice (après le prochain 01/06)
// 2. ET la personne est arrivée APRÈS le début de ce nouvel exercice
// Calculer le début du PROCHAIN exercice
const prochainExercice = new Date(currentYear, 5, 1); // 01/06/N
if (currentMonth < 6) {
// Si on est avant juin, le prochain exercice est cette année
prochainExercice.setFullYear(currentYear);
} else {
// Si on est après juin, le prochain exercice est l'année prochaine
prochainExercice.setFullYear(currentYear + 1);
}
// ⭐ BASCULE : on passe en mode NORMAL seulement si aujourd'hui >= prochain exercice
if (today >= prochainExercice && dateEntree) {
const entree = new Date(dateEntree);
entree.setHours(0, 0, 0, 0);
// Si la personne est arrivée APRÈS le début du nouvel exercice
if (entree >= prochainExercice) {
return {
mode: 'NORMAL',
dateDebut: entree,
description: `CP avec DateEntree (${entree.toLocaleDateString('fr-FR')})`
};
}
}
// ⭐ MODE TRANSITION : calcul depuis début de l'exercice actuel (SANS DateEntree)
return {
mode: 'TRANSITION',
dateDebut: debutExerciceActuel,
description: `CP mode transition (depuis ${debutExerciceActuel.toLocaleDateString('fr-FR')})`
};
} else if (typeConge === 'RTT') {
const debutAnneeActuelle = new Date(currentYear, 0, 1); // 01/01/N
debutAnneeActuelle.setHours(0, 0, 0, 0);
// ⭐ RÈGLE DE BASCULE :
// On bascule en mode NORMAL uniquement si :
// 1. On est dans une NOUVELLE année (après le prochain 01/01)
// 2. ET la personne est arrivée APRÈS le début de cette nouvelle année
// Calculer le début de la PROCHAINE année
const prochaineAnnee = new Date(currentYear + 1, 0, 1); // 01/01/N+1
// ⭐ BASCULE : on passe en mode NORMAL seulement si aujourd'hui >= prochaine année
if (today >= prochaineAnnee && dateEntree) {
const entree = new Date(dateEntree);
entree.setHours(0, 0, 0, 0);
// Si la personne est arrivée APRÈS le début de la nouvelle année
if (entree >= prochaineAnnee) {
return {
mode: 'NORMAL',
dateDebut: entree,
description: `RTT avec DateEntree (${entree.toLocaleDateString('fr-FR')})`
};
}
}
// ⭐ MODE TRANSITION : calcul depuis début de l'année actuelle (SANS DateEntree)
return {
mode: 'TRANSITION',
dateDebut: debutAnneeActuelle,
description: `RTT mode transition (depuis ${debutAnneeActuelle.toLocaleDateString('fr-FR')})`
};
}
return null;
}
// ========================================
// TÂCHES CRON
// ========================================
cron.schedule('1 0 1 1 *', async () => {
console.log('\n🎉 ===== RÉINITIALISATION RTT - 1ER JANVIER =====');
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const today = new Date();
const nouvelleAnnee = today.getFullYear();
const ancienneAnnee = nouvelleAnnee - 1;
console.log(` 📅 Passage de ${ancienneAnnee} à ${nouvelleAnnee}`);
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ?', ['RTT']);
if (rttType.length === 0) {
throw new Error('Type de congé RTT introuvable');
}
const rttTypeId = rttType[0].Id;
const [collaborateurs] = await conn.query(`
SELECT id, prenom, nom, TypeContrat, role
FROM CollaborateurAD
WHERE (Actif = 1 OR Actif IS NULL)
AND (role IS NULL OR role != 'Apprenti')
`);
console.log(` 👥 ${collaborateurs.length} collaborateurs à traiter`);
let compteursReinitialises = 0;
for (const collab of collaborateurs) {
const collaborateurId = collab.id;
const [ancienCompteur] = await conn.query(`
SELECT Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, ancienneAnnee]);
if (ancienCompteur.length > 0) {
const soldeAncien = parseFloat(ancienCompteur[0].Solde || 0);
console.log(` 👤 ${collab.prenom} ${collab.nom}: ${soldeAncien.toFixed(2)}j RTT perdus`);
await conn.query(`
UPDATE CompteurConges
SET DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, ancienneAnnee]);
}
const [nouveauCompteur] = await conn.query(`
SELECT id FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, nouvelleAnnee]);
if (nouveauCompteur.length === 0) {
await conn.query(`
INSERT INTO CompteurConges (
CollaborateurADId, TypeCongeId, Annee,
Total, Solde, SoldeReporte, DerniereMiseAJour
)
VALUES (?, ?, ?, 0, 0, 0, GETDATE())
`, [collaborateurId, rttTypeId, nouvelleAnnee]);
console.log(` ✅ Compteur RTT ${nouvelleAnnee} créé à 0`);
} else {
await conn.query(`
UPDATE CompteurConges
SET Total = 0, Solde = 0, SoldeReporte = 0, DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, nouvelleAnnee]);
console.log(` ✅ Compteur RTT ${nouvelleAnnee} réinitialisé à 0`);
}
compteursReinitialises++;
}
await conn.commit();
console.log(`\n✅ Réinitialisation RTT terminée : ${compteursReinitialises} compteurs`);
} catch (error) {
await conn.rollback();
console.error('❌ Erreur réinitialisation RTT:', error);
} finally {
conn.release();
}
}, {
timezone: "Europe/Paris"
});
// ============================================================================
// 📅 CRON REPORT CP - 31 mai 23h59
// ============================================================================
cron.schedule('59 23 31 5 *', async () => {
console.log('📅 [CRON] Traitement fin d\'exercice CP...');
try {
const conn = await pool.getConnection();
await conn.beginTransaction();
const [collaborateurs] = await conn.query('SELECT id, CampusId FROM CollaborateurAD');
let successCount = 0;
for (const collab of collaborateurs) {
try {
await processEndOfExerciceCP(conn, collab.id);
successCount++;
} catch (error) {
console.error(`❌ Erreur CP pour ${collab.id}:`, error.message);
}
}
await conn.commit();
console.log(`✅ [CRON] ${successCount}/${collaborateurs.length} CP reportés`);
conn.release();
} catch (error) {
console.error('❌ [CRON] Erreur traitement fin d\'exercice:', error);
}
});
// ============================================================================
// 📊 CRON ARRÊTÉS MENSUELS
// ============================================================================
cron.schedule('55 23 28-31 * *', async () => {
const today = new Date();
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0);
if (today.getDate() === lastDay.getDate()) {
console.log(`📅 [CRON] Création arrêté fin de mois: ${today.toISOString().split('T')[0]}`);
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const annee = today.getFullYear();
const mois = today.getMonth() + 1;
const dateArrete = today.toISOString().split('T')[0];
const [existing] = await conn.query(
'SELECT Id FROM ArreteComptable WHERE Annee = ? AND Mois = ?',
[annee, mois]
);
if (existing.length > 0) {
console.log(`⚠️ [CRON] Arrêté ${mois}/${annee} existe déjà, skip`);
await conn.rollback();
conn.release();
return;
}
const [result] = await conn.query(`
INSERT INTO ArreteComptable
(DateArrete, Annee, Mois, Libelle, Description, Statut, DateCreation)
VALUES (?, ?, ?, ?, ?, 'En cours', GETDATE())
`, [
dateArrete,
annee,
mois,
`Arrêté comptable ${getMonthName(mois)} ${annee}`,
`Arrêté mensuel automatique - Clôture des soldes au ${dateArrete}`
]);
const arreteId = result.insertId;
console.log(`✅ [CRON] Arrêté créé: ID ${arreteId}`);
await conn.query('CALL sp_creer_snapshot_arrete(?)', [arreteId]);
console.log(`📸 [CRON] Snapshot créé pour l'arrêté ${arreteId}`);
const [count] = await conn.query(
'SELECT COUNT(*) as total FROM SoldesFiges WHERE ArreteId = ?',
[arreteId]
);
await conn.commit();
console.log(`🎉 [CRON] Arrêté ${mois}/${annee} terminé: ${count[0].total} soldes figés`);
} catch (error) {
await conn.rollback();
console.error(`❌ [CRON] Erreur création arrêté:`, error.message);
} finally {
conn.release();
}
}
});
// ============================================================================
// 📧 CRON MAILS COMPTE-RENDU
// ============================================================================
cron.schedule('0 9 1 * *', async () => {
console.log('📧 Envoi mails compte-rendu mensuel...');
const conn = await pool.getConnection();
const [cadres] = await conn.query(`
SELECT id, email, prenom, nom
FROM CollaborateurAD
WHERE TypeContrat = 'forfait_jour' AND (actif = 1 OR actif IS NULL)
`);
const moisPrecedent = new Date();
moisPrecedent.setMonth(moisPrecedent.getMonth() - 1);
for (const cadre of cadres) {
// Envoyer mail via Microsoft Graph API
// Enregistrer dans MailsCompteRendu
}
conn.release();
});
// ============================================================================
// 🔔 CRON RELANCES
// ============================================================================
cron.schedule('0 9 * * 1', async () => {
console.log('🔔 Relance hebdomadaire compte-rendu...');
const conn = await pool.getConnection();
const moisCourant = new Date().getMonth() + 1;
const anneeCourante = new Date().getFullYear();
const [nonValides] = await conn.query(`
SELECT DISTINCT ca.id, ca.email, ca.prenom, ca.nom
FROM CollaborateurAD ca
LEFT JOIN CompteRenduMensuel crm ON ca.id = crm.CollaborateurADId
AND crm.Annee = ? AND crm.Mois = ?
WHERE ca.TypeContrat = 'forfait_jour'
AND (crm.Statut IS NULL OR crm.Statut != 'Validé')
`, [anneeCourante, moisCourant - 1]);
for (const cadre of nonValides) {
// Envoyer relance
}
conn.release();
});
// ============================================================================
// 🚀 RATTRAPAGE IMMÉDIAT - 5 minutes après démarrage
// ============================================================================
setTimeout(() => {
console.log('🚀 ===== EXÉCUTION IMMÉDIATE - RATTRAPAGE DEPUIS LE 1ER DU MOIS =====');
updateMonthlyCounters(true); // true = mode rattrapage
}, 5 * 60 * 1000);
console.log('⏰ CRON quotidien programmé : 00h00 Europe/Paris');
console.log('⏰ CRON réinitialisation RTT programmé : 1er janvier à 00h01');
console.log(`⏰ CRON immédiat programmé dans 5 minutes`);
// ⭐ Fonction helper pour les noms de mois
function getMonthName(mois) {
const mois_names = ['', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
return mois_names[mois] || mois;
}
async function getGraphToken() {
try {
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: AZURE_CONFIG.clientId,
client_secret: AZURE_CONFIG.clientSecret,
scope: 'https://graph.microsoft.com/.default'
});
const response = await axios.post(
`https://login.microsoftonline.com/${AZURE_CONFIG.tenantId}/oauth2/v2.0/token`,
params.toString(),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
return response.data.access_token;
} catch (error) {
console.error('Erreur obtention token:', error);
return null;
}
}
async function sendMailGraph(accessToken, fromEmail, toEmail, subject, bodyHtml) {
try {
await axios.post(
`https://graph.microsoft.com/v1.0/users/${fromEmail}/sendMail`,
{
message: {
subject,
body: { contentType: 'HTML', content: bodyHtml },
toRecipients: [{ emailAddress: { address: toEmail } }]
},
saveToSentItems: false
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
);
return true;
} catch (error) {
console.error('Erreur envoi email:', error);
return false;
}
}
function getWorkingDays(startDate, endDate) {
let workingDays = 0;
const current = new Date(startDate);
const end = new Date(endDate);
while (current <= end) {
const dayOfWeek = current.getDay();
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
workingDays++;
}
current.setDate(current.getDate() + 1);
}
return workingDays;
}
function formatDate(date) {
const d = new Date(date);
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
return `${day}/${month}/${year}`;
}
function getExerciceCP(date = new Date()) {
const d = new Date(date);
const annee = d.getFullYear();
const mois = d.getMonth() + 1;
if (mois >= 1 && mois <= 5) {
return `${annee - 1}-${annee}`;
}
return `${annee}-${annee + 1}`;
}
function getMoisTravaillesCP(date = new Date(), dateEntree = null) {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
const mois = d.getMonth() + 1;
let debutExercice;
if (mois >= 6) {
debutExercice = new Date(annee, 5, 1);
} else {
debutExercice = new Date(annee - 1, 5, 1);
}
debutExercice.setHours(0, 0, 0, 0);
if (dateEntree) {
const entree = new Date(dateEntree);
entree.setHours(0, 0, 0, 0);
if (entree > debutExercice) {
debutExercice = entree;
}
}
// ✅ Calculer jusqu'à aujourd'hui
const diffMs = d - debutExercice;
const diffJours = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + 1;
const moisTravailles = diffJours / 30.44;
return Math.max(0, Math.min(12, moisTravailles));
}
function getMoisTravaillesRTT(date = new Date(), dateEntree = null) {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
let debutAnnee = new Date(annee, 0, 1);
debutAnnee.setHours(0, 0, 0, 0);
if (dateEntree) {
const entree = new Date(dateEntree);
entree.setHours(0, 0, 0, 0);
if (entree.getFullYear() === annee && entree > debutAnnee) {
debutAnnee = entree;
} else if (entree.getFullYear() > annee) {
return 0;
}
}
const diffMs = d - debutAnnee;
const diffJours = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + 1;
const moisTravailles = diffJours / 30.44;
return Math.max(0, Math.min(12, moisTravailles));
}
function calculerAcquisitionCumulee(typeConge, dateReference = new Date(), dateEntree = null) {
const rules = LEAVE_RULES[typeConge];
if (!rules) return 0;
let moisTravailles;
if (typeConge === 'CP') {
moisTravailles = getMoisTravaillesCP(dateReference, dateEntree);
} else {
moisTravailles = getMoisTravaillesRTT(dateReference, dateEntree);
}
const acquisition = moisTravailles * rules.acquisitionMensuelle;
return Math.round(acquisition * 100) / 100;
}
async function processEndOfYearRTT(conn, collaborateurId) {
const currentYear = new Date().getFullYear();
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
if (rttType.length === 0) return null;
await conn.query(
`UPDATE CompteurConges SET Solde = 0, Total = 0, SoldeReporte = 0, DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, rttType[0].Id, currentYear]
);
return { type: 'RTT', action: 'reset_end_of_year', annee: currentYear };
}
async function processEndOfExerciceCP(conn, collaborateurId) {
const today = new Date();
const currentYear = today.getFullYear();
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
if (cpType.length === 0) return null;
const cpTypeId = cpType[0].Id;
const [currentCounter] = await conn.query(
`SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, cpTypeId, currentYear]
);
if (currentCounter.length === 0) return null;
const soldeAReporter = parseFloat(currentCounter[0].Solde);
const [nextYearCounter] = await conn.query(
`SELECT Id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, cpTypeId, currentYear + 1]
);
if (nextYearCounter.length > 0) {
await conn.query(
`UPDATE CompteurConges SET SoldeReporte = ?, Solde = Solde + ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`,
[soldeAReporter, soldeAReporter, nextYearCounter[0].Id]
);
} else {
await conn.query(
`INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, 0, ?, ?, GETDATE())`,
[collaborateurId, cpTypeId, currentYear + 1, soldeAReporter, soldeAReporter]
);
}
return { type: 'CP', action: 'report_exercice', soldeReporte: soldeAReporter };
}
async function deductLeaveBalance(conn, collaborateurId, typeCongeId, nombreJours) {
const currentYear = new Date().getFullYear();
const previousYear = currentYear - 1;
let joursRestants = nombreJours;
const deductions = [];
const [compteurN1] = await conn.query(
`SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, previousYear]
);
if (compteurN1.length > 0 && compteurN1[0].SoldeReporte > 0) {
const soldeN1 = parseFloat(compteurN1[0].SoldeReporte);
const aDeduireN1 = Math.min(soldeN1, joursRestants);
if (aDeduireN1 > 0) {
await conn.query(
`UPDATE CompteurConges SET SoldeReporte = CASE WHEN (SoldeReporte - ?) < 0 THEN 0 ELSE (SoldeReporte - ?) END, Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END WHERE Id = ?`,
[aDeduireN1, aDeduireN1, compteurN1[0].Id]
);
deductions.push({ annee: previousYear, type: 'Reporté N-1', joursUtilises: aDeduireN1, soldeAvant: soldeN1 });
joursRestants -= aDeduireN1;
}
}
if (joursRestants > 0) {
const [compteurN] = await conn.query(
`SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, currentYear]
);
if (compteurN.length > 0) {
const soldeN = parseFloat(compteurN[0].Solde) - parseFloat(compteurN[0].SoldeReporte || 0);
const aDeduireN = Math.min(soldeN, joursRestants);
if (aDeduireN > 0) {
await conn.query(
`UPDATE CompteurConges SET Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END WHERE Id = ?`,
[aDeduireN, compteurN[0].Id]
);
deductions.push({ annee: currentYear, type: 'Année actuelle N', joursUtilises: aDeduireN, soldeAvant: soldeN });
joursRestants -= aDeduireN;
}
}
}
return { success: joursRestants === 0, joursDeduitsTotal: nombreJours - joursRestants, joursNonDeduits: joursRestants, details: deductions };
}
async function checkLeaveBalance(conn, collaborateurId, repartition) {
const currentYear = new Date().getFullYear();
const previousYear = currentYear - 1;
const verification = [];
for (const rep of repartition) {
const typeCode = rep.TypeConge;
const joursNecessaires = parseFloat(rep.NombreJours);
if (typeCode === 'ABS' || typeCode === 'Formation') continue;
const typeName = typeCode === 'CP' ? 'Congé payé' : typeCode === 'RTT' ? 'RTT' : typeCode;
const [typeRow] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [typeName]);
if (typeRow.length === 0) continue;
const typeCongeId = typeRow[0].Id;
const [compteurN1] = await conn.query(
`SELECT SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, previousYear]
);
const [compteurN] = await conn.query(
`SELECT Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, currentYear]
);
const soldeN1 = compteurN1.length > 0 ? parseFloat(compteurN1[0].SoldeReporte || 0) : 0;
const soldeN = compteurN.length > 0 ? parseFloat(compteurN[0].Solde || 0) - parseFloat(compteurN[0].SoldeReporte || 0) : 0;
const soldeTotal = soldeN1 + soldeN;
verification.push({ type: typeName, joursNecessaires, soldeN1, soldeN, soldeTotal, suffisant: soldeTotal >= joursNecessaires, deficit: Math.max(0, joursNecessaires - soldeTotal) });
}
const insuffisants = verification.filter(v => !v.suffisant);
return { valide: insuffisants.length === 0, details: verification, insuffisants };
}
// ============================================================================
// 📅 FONCTION UTILITAIRE - Obtenir le nombre de jours du mois
// ============================================================================
function getJoursDuMois(date) {
const annee = date.getFullYear();
const mois = date.getMonth(); // 0-11
// Le jour 0 du mois suivant = dernier jour du mois actuel
const dernierJour = new Date(annee, mois + 1, 0);
return dernierJour.getDate();
}
// ============================================================================
// 🔄 FONCTION DE MISE À JOUR DES COMPTEURS (CORRIGÉE - DÉCRÉMENTE)
// ============================================================================
async function updateMonthlyCounters(rattrapage = false) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const today = new Date();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth();
const jourActuel = today.getDate();
// ⭐ Obtenir le nombre de jours du mois actuel
const joursDuMois = getJoursDuMois(today);
console.log(`\n🔄 === MISE À JOUR ${rattrapage ? 'RATTRAPAGE' : 'QUOTIDIENNE'} COMPTEURS - ${today.toLocaleDateString('fr-FR')} ===`);
console.log(` 📅 Mois actuel : ${joursDuMois} jours`);
// ⭐ RATTRAPAGE : Calculer les jours manqués depuis le 1er du mois
let joursARattraper = 1; // Par défaut : incrément d'1 jour
if (rattrapage) {
joursARattraper = jourActuel; // Du 1er au jour actuel
console.log(` 📅 Rattrapage depuis le 1er du mois : ${joursARattraper} jours`);
}
// Récupérer tous les collaborateurs actifs
const [collaborateurs] = await conn.query(`
SELECT id, prenom, nom, DateEntree, TypeContrat, role
FROM CollaborateurAD
WHERE (Actif = 1 OR Actif IS NULL)
`);
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ?', ['Congé payé']);
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ?', ['RTT']);
let compteursMisAJour = 0;
for (const collab of collaborateurs) {
const collaborateurId = collab.id;
const dateEntree = collab.DateEntree;
const typeContrat = collab.TypeContrat || '37h';
const role = collab.role;
console.log(`\n 👤 ${collab.prenom} ${collab.nom}`);
// ====================================
// 📅 CP - Incrément avec prorata mensuel
// ====================================
if (cpType.length > 0) {
const modeCP = getModeCalcul('CP', dateEntree);
// ⭐ Calcul acquisition mensuelle
const acquisitionMensuelleCP = 25 / 12; // 2.0833j/mois
// ⭐ Calcul acquisition quotidienne selon le nombre de jours du mois
const incrementJournalierCP = acquisitionMensuelleCP / joursDuMois;
const incrementTotal = incrementJournalierCP * joursARattraper;
console.log(` CP: ${incrementJournalierCP.toFixed(6)}j/jour (${acquisitionMensuelleCP.toFixed(4)}j/${joursDuMois}j)`);
// Vérifier si compteur existe
const [compteurCP] = await conn.query(`
SELECT Total, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, cpType[0].Id, currentYear]);
if (compteurCP.length > 0) {
const totalAvant = parseFloat(compteurCP[0].Total || 0);
const soldeAvant = parseFloat(compteurCP[0].Solde || 0);
// ⭐ INCRÉMENTER (ne pas écraser)
await conn.query(`
UPDATE CompteurConges
SET
Total = LEAST(Total + ?, 25),
Solde = LEAST(Solde + ?, 25 + COALESCE(SoldeReporte, 0)),
DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [incrementTotal, incrementTotal, collaborateurId, cpType[0].Id, currentYear]);
console.log(` CP: ${totalAvant.toFixed(2)}j → ${(totalAvant + incrementTotal).toFixed(2)}j (+${incrementTotal.toFixed(4)}j) [${modeCP.mode}]`);
compteursMisAJour++;
} else {
// Créer compteur initial avec Smart
const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree);
// Générer l'ID manuellement
const compteurCPId = await getNextId(conn, 'CompteurConges');
await conn.query(`
INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, ?, 0, GETDATE())
`, [compteurCPId, collaborateurId, cpType[0].Id, currentYear, acquisCP, acquisCP]);
console.log(` ✅ CP créé avec ID ${compteurCPId}: ${acquisCP.toFixed(2)}j [${modeCP.mode}]`);
compteursMisAJour++;
}
}
// ====================================
// 📅 RTT - Incrément avec prorata mensuel
// ====================================
if (rttType.length > 0 && role !== 'Apprenti') {
const modeRTT = getModeCalcul('RTT', dateEntree);
const rttConfig = await getConfigurationRTT(conn, currentYear, typeContrat);
// ⭐ Calcul acquisition mensuelle
const acquisitionMensuelleRTT = rttConfig.joursAnnuels / 12;
// ⭐ Calcul acquisition quotidienne selon le nombre de jours du mois
const incrementJournalierRTT = acquisitionMensuelleRTT / joursDuMois;
const incrementTotal = incrementJournalierRTT * joursARattraper;
console.log(` RTT: ${incrementJournalierRTT.toFixed(6)}j/jour (${acquisitionMensuelleRTT.toFixed(4)}j/${joursDuMois}j)`);
// Vérifier si compteur existe
const [compteurRTT] = await conn.query(`
SELECT Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttType[0].Id, currentYear]);
if (compteurRTT.length > 0) {
const totalAvant = parseFloat(compteurRTT[0].Total || 0);
const soldeAvant = parseFloat(compteurRTT[0].Solde || 0);
// ⭐ INCRÉMENTER (ne pas écraser)
await conn.query(`
UPDATE CompteurConges
SET
Total = LEAST(Total + ?, ?),
Solde = LEAST(Solde + ?, ?),
DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [incrementTotal, rttConfig.joursAnnuels, incrementTotal, rttConfig.joursAnnuels, collaborateurId, rttType[0].Id, currentYear]);
console.log(` RTT: ${totalAvant.toFixed(2)}j → ${(totalAvant + incrementTotal).toFixed(2)}j (+${incrementTotal.toFixed(4)}j) [${modeRTT.mode}]`);
compteursMisAJour++;
} else {
// Créer compteur initial avec Smart
const rttData = await calculerAcquisitionRTT_Smart(conn, collaborateurId, today);
// ✅ CODE CORRIGÉ - Génération manuelle de l'ID
console.log(` 🆕 RTT créé pour ${collaborateurId}`);
// Générer l'ID manuellement
const compteurRTTId = await getNextId(conn, 'CompteurConges');
await conn.query(`
INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, 0, GETDATE())
`, [compteurRTTId, collaborateurId, rttType[0].Id, currentYear, rttData.acquisition, rttData.acquisition]);
console.log(` ✅ RTT créé avec ID ${compteurRTTId}: ${rttData.acquisition.toFixed(2)}j [${modeRTT.mode}]`);
compteursMisAJour++;
}
}
}
await conn.commit();
console.log(`\n✅ Mise à jour terminée : ${compteursMisAJour} compteurs pour ${collaborateurs.length} collaborateurs`);
} catch (error) {
await conn.rollback();
console.error('❌ Erreur mise à jour compteurs:', error);
throw error;
} finally {
conn.release();
}
}
// ============================================================================
// ⏰ PLANIFICATION DES CRONS
// ============================================================================
// ❌ DÉSACTIVER le rattrapage immédiat (commenté)
// ✅ EXÉCUTION QUOTIDIENNE à 00h01 (à partir de demain - DÉCRÉMENTE)
cron.schedule('1 0 * * *', () => {
console.log('🕐 ===== EXÉCUTION QUOTIDIENNE AUTOMATIQUE - 00h01 =====');
updateMonthlyCounters(false); // false = incrément normal d'1 jour
}, {
timezone: "Europe/Paris"
});
console.log('⏰ CRON quotidien programmé : 00h01 Europe/Paris (à partir de demain)');
console.log('⏰ CRON réinitialisation RTT programmé : 1er janvier à 00h01');
console.log('⚠️ Rattrapage immédiat DÉSACTIVÉ');
// ============================================================================
// 🎉 CRON - RÉINITIALISATION RTT AU 1ER JANVIER
// ============================================================================
cron.schedule('1 0 1 1 *', async () => {
console.log('\n🎉 ===== RÉINITIALISATION RTT - 1ER JANVIER =====');
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const today = new Date();
const nouvelleAnnee = today.getFullYear();
const ancienneAnnee = nouvelleAnnee - 1;
console.log(` 📅 Passage de ${ancienneAnnee} à ${nouvelleAnnee}`);
// Récupérer le TypeCongeId pour RTT
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ?', ['RTT']);
if (rttType.length === 0) {
throw new Error('Type de congé RTT introuvable');
}
const rttTypeId = rttType[0].Id;
// Récupérer tous les collaborateurs actifs (sauf apprentis)
const [collaborateurs] = await conn.query(`
SELECT id, prenom, nom, TypeContrat, role
FROM CollaborateurAD
WHERE (Actif = 1 OR Actif IS NULL)
AND (role IS NULL OR role != 'Apprenti')
`);
console.log(` 👥 ${collaborateurs.length} collaborateurs à traiter`);
let compteursReinitialises = 0;
for (const collab of collaborateurs) {
const collaborateurId = collab.id;
// 1⃣ Archiver/Marquer l'ancien compteur RTT N-1
const [ancienCompteur] = await conn.query(`
SELECT Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, ancienneAnnee]);
if (ancienCompteur.length > 0) {
const soldeAncien = parseFloat(ancienCompteur[0].Solde || 0);
console.log(` 👤 ${collab.prenom} ${collab.nom}: ${soldeAncien.toFixed(2)}j RTT perdus`);
// Marquer l'ancien compteur comme "clos"
await conn.query(`
UPDATE CompteurConges
SET DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, ancienneAnnee]);
}
// 2⃣ Créer ou réinitialiser le compteur RTT pour la nouvelle année
const [nouveauCompteur] = await conn.query(`
SELECT id FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, nouvelleAnnee]);
if (nouveauCompteur.length === 0) {
// Créer le compteur à 0
await conn.query(`
INSERT INTO CompteurConges (
CollaborateurADId,
TypeCongeId,
Annee,
Total,
Solde,
SoldeReporte,
DerniereMiseAJour
)
VALUES (?, ?, ?, 0, 0, 0, GETDATE())
`, [collaborateurId, rttTypeId, nouvelleAnnee]);
console.log(` ✅ Compteur RTT ${nouvelleAnnee} créé à 0`);
} else {
// Le compteur existe déjà, le remettre à 0
await conn.query(`
UPDATE CompteurConges
SET Total = 0, Solde = 0, SoldeReporte = 0, DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, nouvelleAnnee]);
console.log(` ✅ Compteur RTT ${nouvelleAnnee} réinitialisé à 0`);
}
compteursReinitialises++;
}
await conn.commit();
console.log(`\n✅ Réinitialisation RTT terminée : ${compteursReinitialises} compteurs`);
} catch (error) {
await conn.rollback();
console.error('❌ Erreur réinitialisation RTT:', error);
} finally {
conn.release();
}
}, {
timezone: "Europe/Paris"
});
console.log('⏰ CRON réinitialisation RTT programmé : 1er janvier à 00h01');
// ========================================
// MISE À JOUR DE updateMonthlyCounters
// ========================================
async function updateMonthlyCounters_Smart(conn, collaborateurId, dateReference = null) {
const today = dateReference ? new Date(dateReference) : new Date();
const currentYear = today.getFullYear();
const updates = [];
// Récupérer les infos du collaborateur
const [collabInfo] = await conn.query(`
SELECT DateEntree, TypeContrat, CampusId, role
FROM CollaborateurAD WHERE id = ?
`, [collaborateurId]);
if (collabInfo.length === 0) {
throw new Error(`Collaborateur ${collaborateurId} non trouvé`);
}
const dateEntree = collabInfo[0].DateEntree || null;
const typeContrat = collabInfo[0].TypeContrat || '37h';
const isApprenti = collabInfo[0].role === 'Apprenti';
console.log(`\n📊 === Mise à jour pour collaborateur ${collaborateurId} ===`);
console.log(` Date référence: ${today.toLocaleDateString('fr-FR')}`);
// ======================================
// CP (Congés Payés)
// ======================================
const acquisitionCP = calculerAcquisitionCP_Smart(today, dateEntree);
console.log(` CP - Acquisition: ${acquisitionCP.toFixed(2)}j`);
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
if (cpType.length > 0) {
const cpTypeId = cpType[0].Id;
const [existingCP] = await conn.query(`
SELECT Id, Total, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, cpTypeId, currentYear]);
if (existingCP.length > 0) {
const ancienTotal = parseFloat(existingCP[0].Total || 0);
const ancienSolde = parseFloat(existingCP[0].Solde || 0);
const soldeReporte = parseFloat(existingCP[0].SoldeReporte || 0);
const incrementAcquis = acquisitionCP - ancienTotal;
if (incrementAcquis > 0) {
console.log(` CP - Nouveaux jours: +${incrementAcquis.toFixed(2)}j`);
// Gérer le remboursement d'anticipé (logique existante)
const [anticipeUtilise] = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalAnticipe
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction = 'N Anticip'
AND dc.Statut != 'Refusée'
AND dd.JoursUtilises > 0
`, [collaborateurId, cpTypeId, currentYear]);
const anticipePris = parseFloat(anticipeUtilise[0]?.totalAnticipe || 0);
if (anticipePris > 0) {
const aRembourser = Math.min(incrementAcquis, anticipePris);
console.log(` 💳 CP - Remboursement anticipé: ${aRembourser.toFixed(2)}j`);
// [Logique de remboursement complète - identique à avant]
const [deductionsAnticipees] = await conn.query(`
SELECT dd.Id, dd.DemandeCongeId, dd.JoursUtilises
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction = 'N Anticip'
AND dc.Statut != 'Refusée'
AND dd.JoursUtilises > 0
ORDER BY dd.Id ASC
`, [collaborateurId, cpTypeId, currentYear]);
let resteARembourser = aRembourser;
for (const deduction of deductionsAnticipees) {
if (resteARembourser <= 0) break;
const joursAnticipes = parseFloat(deduction.JoursUtilises);
const aDeduiteDeCetteDeduction = Math.min(resteARembourser, joursAnticipes);
await conn.query(`
UPDATE DeductionDetails
SET JoursUtilises = CASE WHEN (JoursUtilises - ?) < 0 THEN 0 ELSE (JoursUtilises - ?) END
WHERE Id = ?
`, [aDeduiteDeCetteDeduction, deduction.Id]);
const [existingAnneeN] = await conn.query(`
SELECT Id, JoursUtilises
FROM DeductionDetails
WHERE DemandeCongeId = ?
AND TypeCongeId = ?
AND Annee = ?
AND TypeDeduction IN ('Année N', 'Anne N', 'Anne actuelle N')
`, [deduction.DemandeCongeId, cpTypeId, currentYear]);
if (existingAnneeN.length > 0) {
await conn.query(`
UPDATE DeductionDetails
SET JoursUtilises = JoursUtilises + ?
WHERE Id = ?
`, [aDeduiteDeCetteDeduction, existingAnneeN[0].Id]);
} else {
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Année N', ?)
`, [deduction.DemandeCongeId, cpTypeId, currentYear, aDeduiteDeCetteDeduction]);
}
resteARembourser -= aDeduiteDeCetteDeduction;
}
// Supprimer les déductions anticipées à zéro
await conn.query(`
DELETE FROM DeductionDetails
WHERE TypeCongeId = ?
AND Annee = ?
AND TypeDeduction = 'N Anticip'
AND JoursUtilises <= 0
`, [cpTypeId, currentYear]);
}
}
// Recalculer le solde
const [consomme] = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction NOT IN ('Accum Récup', 'Accum Recup')
AND dc.Statut != 'Refusée'
`, [collaborateurId, cpTypeId, currentYear]);
const totalConsomme = parseFloat(consomme[0].total || 0);
const nouveauSolde = Math.max(0, acquisitionCP + soldeReporte - totalConsomme);
await conn.query(`
UPDATE CompteurConges
SET Total = ?, Solde = ?, DerniereMiseAJour = GETDATE()
WHERE Id = ?
`, [acquisitionCP, nouveauSolde, existingCP[0].Id]);
updates.push({
type: 'CP',
acquisitionCumulee: acquisitionCP,
increment: incrementAcquis,
nouveauSolde: nouveauSolde
});
} else {
// Créer le compteur
await conn.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, 0, GETDATE())
`, [collaborateurId, cpTypeId, currentYear, acquisitionCP, acquisitionCP]);
updates.push({
type: 'CP',
action: 'created',
acquisitionCumulee: acquisitionCP
});
}
}
// ======================================
// RTT (identique avec Smart)
// ======================================
if (!isApprenti) {
const rttData = await calculerAcquisitionRTT(conn, collaborateurId, today);
const acquisitionRTT = rttData.acquisition;
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
if (rttType.length > 0) {
const rttTypeId = rttType[0].Id;
// 1⃣ Récupérer le compteur existant
const [existingRTT] = await conn.query(`
SELECT Id, Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, currentYear]);
if (existingRTT.length > 0) {
const ancienTotal = parseFloat(existingRTT[0].Total || 0);
const ancienSolde = parseFloat(existingRTT[0].Solde || 0);
console.log(` RTT - Ancien acquis: ${ancienTotal.toFixed(2)}j`);
console.log(` RTT - Nouvel acquis: ${acquisitionRTT.toFixed(2)}j`);
// 2⃣ Calculer l'incrément d'acquisition
const incrementAcquis = acquisitionRTT - ancienTotal;
if (incrementAcquis > 0) {
console.log(` RTT - Nouveaux jours ce mois: +${incrementAcquis.toFixed(2)}j`);
// 3⃣ Vérifier si le collaborateur a de l'anticipé utilisé
const [anticipeUtilise] = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalAnticipe
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction = 'N Anticip'
AND dc.Statut != 'Refusée'
AND dd.JoursUtilises > 0
`, [collaborateurId, rttTypeId, currentYear]);
const anticipePris = parseFloat(anticipeUtilise[0]?.totalAnticipe || 0);
if (anticipePris > 0) {
// 4⃣ Calculer le montant à rembourser
const aRembourser = Math.min(incrementAcquis, anticipePris);
console.log(` 💳 RTT - Anticipé à rembourser: ${aRembourser.toFixed(2)}j (sur ${anticipePris.toFixed(2)}j)`);
// 5⃣ Rembourser l'anticipé
const [deductionsAnticipees] = await conn.query(`
SELECT dd.Id, dd.DemandeCongeId, dd.JoursUtilises
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction = 'N Anticip'
AND dc.Statut != 'Refusée'
AND dd.JoursUtilises > 0
ORDER BY dd.Id ASC
`, [collaborateurId, rttTypeId, currentYear]);
let resteARembourser = aRembourser;
for (const deduction of deductionsAnticipees) {
if (resteARembourser <= 0) break;
const joursAnticipes = parseFloat(deduction.JoursUtilises);
const aDeduiteDeCetteDeduction = Math.min(resteARembourser, joursAnticipes);
// Réduire l'anticipé
await conn.query(`
UPDATE DeductionDetails
SET JoursUtilises = CASE WHEN (JoursUtilises - ?) < 0 THEN 0 ELSE (JoursUtilises - ?) END
WHERE Id = ?
`, [aDeduiteDeCetteDeduction, deduction.Id]);
// Vérifier si une déduction "Année N" existe déjà
const [existingAnneeN] = await conn.query(`
SELECT Id, JoursUtilises
FROM DeductionDetails
WHERE DemandeCongeId = ?
AND TypeCongeId = ?
AND Annee = ?
AND TypeDeduction IN ('Année N', 'Anne N')
`, [deduction.DemandeCongeId, rttTypeId, currentYear]);
if (existingAnneeN.length > 0) {
await conn.query(`
UPDATE DeductionDetails
SET JoursUtilises = JoursUtilises + ?
WHERE Id = ?
`, [aDeduiteDeCetteDeduction, existingAnneeN[0].Id]);
} else {
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Année N', ?)
`, [deduction.DemandeCongeId, rttTypeId, currentYear, aDeduiteDeCetteDeduction]);
}
resteARembourser -= aDeduiteDeCetteDeduction;
console.log(` ✅ RTT - Remboursé ${aDeduiteDeCetteDeduction.toFixed(2)}j (Demande ${deduction.DemandeCongeId})`);
}
// Supprimer les déductions anticipées à zéro
await conn.query(`
DELETE FROM DeductionDetails
WHERE TypeCongeId = ?
AND Annee = ?
AND TypeDeduction = 'N Anticip'
AND JoursUtilises <= 0
`, [rttTypeId, currentYear]);
}
}
// 6⃣ Recalculer le solde total
const [consomme] = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction NOT IN ('Accum Récup', 'Accum Recup', 'Récup Dosée')
AND dc.Statut != 'Refusée'
`, [collaborateurId, rttTypeId, currentYear]);
const totalConsomme = parseFloat(consomme[0].total || 0);
const nouveauSolde = Math.max(0, acquisitionRTT - totalConsomme);
console.log(` RTT - Consommé total: ${totalConsomme.toFixed(2)}j`);
console.log(` RTT - Nouveau solde: ${nouveauSolde.toFixed(2)}j`);
// 7⃣ Mettre à jour le compteur
await conn.query(`
UPDATE CompteurConges
SET Total = ?, Solde = ?, DerniereMiseAJour = GETDATE()
WHERE Id = ?
`, [acquisitionRTT, nouveauSolde, existingRTT[0].Id]);
updates.push({
type: 'RTT',
annee: currentYear,
typeContrat: rttData.typeContrat,
config: `${rttData.config.joursAnnuels}j/an`,
moisTravailles: rttData.moisTravailles,
acquisitionCumulee: acquisitionRTT,
increment: incrementAcquis,
nouveauSolde: nouveauSolde
});
} else {
// Créer le compteur s'il n'existe pas
await conn.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, 0, GETDATE())
`, [collaborateurId, rttTypeId, currentYear, acquisitionRTT, acquisitionRTT]);
console.log(` RTT - Compteur créé: ${acquisitionRTT.toFixed(2)}j`);
updates.push({
type: 'RTT',
annee: currentYear,
typeContrat: rttData.typeContrat,
config: `${rttData.config.joursAnnuels}j/an`,
moisTravailles: rttData.moisTravailles,
acquisitionCumulee: acquisitionRTT,
action: 'created',
nouveauSolde: acquisitionRTT
});
}
}
}
console.log(`✅ Mise à jour terminée pour collaborateur ${collaborateurId}\n`);
return updates;
}
// ========================================
// ROUTES API
// ========================================
app.post('/api/login', async (req, res) => {
try {
const { email, mot_de_passe, entraUserId, userPrincipalName } = req.body;
const accessToken = req.headers.authorization?.replace('Bearer ', '');
if (accessToken && entraUserId) {
const [users] = await pool.query(`
SELECT ca.*, s.Nom as service, so.Nom as societe_nom
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Societe so ON ca.SocieteId = so.Id
WHERE ca.entraUserId=? OR ca.email=?
LIMIT 1
`, [entraUserId, email]);
if (users.length === 0) return res.json({ success: false, message: 'Utilisateur non autorisé' });
const user = users[0];
try {
const graphResponse = await axios.get(`https://graph.microsoft.com/v1.0/users/${userPrincipalName}/memberOf?$select=id`, { headers: { Authorization: `Bearer ${accessToken}` } });
const userGroups = graphResponse.data.value.map(g => g.id);
const [allowedGroups] = await pool.query('SELECT Id FROM EntraGroups WHERE IsActive=1');
const allowed = allowedGroups.map(g => g.Id);
const authorized = userGroups.some(g => allowed.includes(g));
if (authorized) {
return res.json({
success: true,
message: 'Connexion réussie via Azure AD',
user: {
id: user.id,
prenom: user.prenom || 'Prénom',
nom: user.nom || 'Nom',
email: user.email,
role: user.role || 'Collaborateur',
service: user.service || 'Non défini',
societeId: user.SocieteId,
societeNom: user.societe_nom || 'Non défini',
typeContrat: user.TypeContrat || '37h',
description: user.description || null,
dateEntree: user.DateEntree || null,
campusId: user.CampusId || null
}
});
} else {
return res.json({ success: false, message: 'Utilisateur non autorisé' });
}
} catch (error) {
return res.json({ success: false, message: 'Erreur vérification groupes' });
}
}
if (email && mot_de_passe) {
const [users] = await pool.query(`
SELECT u.ID, u.Prenom, u.Nom, u.Email, u.Role, u.ServiceId, s.Nom AS ServiceNom
FROM Users u
LEFT JOIN Services s ON u.ServiceId = s.Id
WHERE u.Email = ? AND u.MDP = ?
`, [email, mot_de_passe]);
if (users.length === 1) {
return res.json({
success: true,
message: 'Connexion réussie',
user: {
id: users[0].ID,
prenom: users[0].Prenom,
nom: users[0].Nom,
email: users[0].Email,
role: users[0].Role,
service: users[0].ServiceNom || 'Non défini'
}
});
}
return res.json({ success: false, message: 'Identifiants incorrects' });
}
res.json({ success: false, message: 'Aucune méthode de connexion fournie' });
} catch (error) {
res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message });
}
});
app.post('/api/check-user-groups', async (req, res) => {
try {
const { userPrincipalName } = req.body;
const accessToken = req.headers.authorization?.replace('Bearer ', '');
if (!userPrincipalName || !accessToken) return res.json({ authorized: false, message: 'Email ou token manquant' });
// 1. Vérification locale
const [users] = await pool.query(`
SELECT ca.id, ca.entraUserId, ca.prenom, ca.nom, ca.email,
s.Nom as service, ca.role, ca.CampusId, ca.SocieteId,
so.Nom as societe_nom
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Societe so ON ca.SocieteId = so.Id
WHERE ca.email = ?
LIMIT 1
`, [userPrincipalName]);
if (users.length > 0) {
const user = users[0];
// Si l'utilisateur est inactif, on le bloque
if (user.Actif === 0) return res.json({ authorized: false, message: 'Compte désactivé' });
return res.json({
authorized: true,
role: user.role,
groups: [user.role],
localUserId: user.id,
user: {
...user,
societeId: user.SocieteId,
societeNom: user.societe_nom
}
});
}
// 2. Si pas trouvé, interrogation Microsoft Graph
const userGraph = await axios.get(`https://graph.microsoft.com/v1.0/users/${userPrincipalName}?$select=id,displayName,givenName,surname,mail,department,jobTitle`, { headers: { Authorization: `Bearer ${accessToken}` } });
const userInfo = userGraph.data;
const checkMemberResponse = await axios.post(`https://graph.microsoft.com/v1.0/users/${userInfo.id}/checkMemberGroups`, { groupIds: [AZURE_CONFIG.groupId] }, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' } });
const isInGroup = checkMemberResponse.data.value.includes(AZURE_CONFIG.groupId);
if (!isInGroup) return res.json({ authorized: false, message: 'Utilisateur non autorisé (Hors groupe)' });
// 3. ⭐ INSERTION AVEC VALEURS PAR DÉFAUT CRITIQUES
// On met SocieteId=1 et TypeContrat='37h' par défaut pour éviter les bugs de calcul
const [result] = await pool.query(
`INSERT INTO CollaborateurAD
(entraUserId, prenom, nom, email, service, role, SocieteId, Actif, DateEntree, TypeContrat)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
userInfo.id,
userInfo.givenName || 'Prénom',
userInfo.surname || 'Nom',
userInfo.mail || userPrincipalName,
userInfo.department,
'Collaborateur',
1, // SocieteId par défaut (ex: 1 = ENSUP)
1, // Actif = 1 (Important !)
new Date(), // DateEntree = Aujourd'hui
'37h' // TypeContrat par défaut
]
);
res.json({
authorized: true,
role: 'Collaborateur',
groups: ['Collaborateur'],
localUserId: result.insertId,
user: {
id: result.insertId,
entraUserId: userInfo.id,
prenom: userInfo.givenName,
nom: userInfo.surname,
email: userInfo.mail,
service: userInfo.department,
role: 'Collaborateur',
societeId: 1,
societeNom: 'Défaut'
}
});
} catch (error) {
console.error("Erreur check-user-groups:", error);
res.json({ authorized: false, message: 'Erreur serveur', error: error.message });
}
});
// ========================================
// ✅ CODE CORRIGÉ POUR getDetailedLeaveCounters
// À remplacer dans server.js à partir de la ligne ~1600
// ========================================
app.get('/api/getDetailedLeaveCounters', async (req, res) => {
try {
const userIdParam = req.query.user_id;
if (!userIdParam) {
return res.json({ success: false, message: 'ID utilisateur manquant' });
}
const conn = await pool.getConnection();
const isUUID = userIdParam.length > 10 && userIdParam.includes('-');
const userQuery = `
SELECT
ca.id, ca.prenom, ca.nom, ca.email, ca.role, ca.TypeContrat, ca.DateEntree,
ca.CampusId, ca.SocieteId, s.Nom as service, so.Nom as societeNom, ca.description
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Societe so ON ca.SocieteId = so.Id
WHERE ${isUUID ? 'ca.entraUserId' : 'ca.id'} = ?
AND (ca.Actif = 1 OR ca.Actif IS NULL)
LIMIT 1
`;
const [userInfo] = await conn.query(userQuery, [userIdParam]);
if (userInfo.length === 0) {
conn.release();
return res.json({ success: false, message: 'Utilisateur non trouvé' });
}
const user = userInfo[0];
const userId = user.id;
const dateEntree = user.DateEntree;
const typeContrat = user.TypeContrat || '37h';
const today = new Date();
const currentYear = today.getFullYear();
const previousYear = currentYear - 1;
console.log('\n📊 === CALCUL COMPTEURS ===');
console.log('User:', user.prenom, user.nom, '(ID:', userId, ')');
console.log('Date référence:', today.toLocaleDateString('fr-FR'));
// ═══════════════════════════════════════════════════════════
// 1⃣ CALCUL AVEC LES FORMULES INTELLIGENTES
// ═══════════════════════════════════════════════════════════
// CP N
const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree);
console.log('🧮 CP calculé:', acquisCP.toFixed(2) + 'j');
// RTT N
let acquisRTT = 0;
let rttTypeId = null;
const [rttType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['RTT']);
if (rttType.length > 0 && user.role !== 'Apprenti') {
rttTypeId = rttType[0].Id;
const rttData = await calculerAcquisitionRTT_Smart(conn, userId, today);
acquisRTT = rttData.acquisition;
console.log('🧮 RTT calculé:', acquisRTT.toFixed(2) + 'j');
}
// ═══════════════════════════════════════════════════════════
// 2⃣ RÉCUPÉRER INFOS POUR CALCUL DES SOLDES
// ═══════════════════════════════════════════════════════════
const [cpType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['Congé payé']);
if (cpType.length === 0) {
conn.release();
return res.json({ success: false, message: 'Type de congé CP non trouvé' });
}
// Solde reporté CP
let soldeReporte = 0;
const [compteurCP] = await conn.query(`
SELECT SoldeReporte FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[userId, cpType[0].Id, currentYear]
);
if (compteurCP.length > 0) {
soldeReporte = parseFloat(compteurCP[0].SoldeReporte) || 0;
}
// Consommé CP
const [consommeCP] = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction NOT IN ('Accum Récup', 'Accum Recup')
AND dc.Statut != 'Refusé'`,
[userId, cpType[0].Id, currentYear]
);
const totalConsommeCP = parseFloat(consommeCP[0].total) || 0;
const nouveauSoldeCP = Math.max(0, acquisCP + soldeReporte - totalConsommeCP);
console.log('💰 CP - Acquis:', acquisCP.toFixed(2), '+ Reporté:', soldeReporte.toFixed(2), '- Consommé:', totalConsommeCP.toFixed(2), '= Solde:', nouveauSoldeCP.toFixed(2));
// Consommé RTT
let totalConsommeRTT = 0;
let nouveauSoldeRTT = 0;
if (rttTypeId) {
const [consommeRTT] = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction NOT IN ('Accum Récup', 'Accum Recup', 'Récup Dose')
AND dc.Statut != 'Refusé'`,
[userId, rttTypeId, currentYear]
);
totalConsommeRTT = parseFloat(consommeRTT[0].total) || 0;
nouveauSoldeRTT = Math.max(0, acquisRTT - totalConsommeRTT);
console.log('💰 RTT - Acquis:', acquisRTT.toFixed(2), '- Consommé:', totalConsommeRTT.toFixed(2), '= Solde:', nouveauSoldeRTT.toFixed(2));
}
// ═══════════════════════════════════════════════════════════
// 3⃣ ENVOYER À GTA-RH POUR SYNCHRONISATION
// ═══════════════════════════════════════════════════════════
try {
const rhUrl = process.env.RH_SERVER_URL || 'http://192.168.0.4:3001';
const syncPayload = {
collaborateurId: userId,
annee: currentYear,
compteurs: [
{
typeConge: 'Congé payé',
typeCongeId: cpType[0].Id,
total: parseFloat(acquisCP.toFixed(2)),
solde: parseFloat(nouveauSoldeCP.toFixed(2)),
source: 'calcul_gta'
}
]
};
if (rttTypeId) {
syncPayload.compteurs.push({
typeConge: 'RTT',
typeCongeId: rttTypeId,
total: parseFloat(acquisRTT.toFixed(2)),
solde: parseFloat(nouveauSoldeRTT.toFixed(2)),
source: 'calcul_gta'
});
}
console.log('📤 Envoi synchronisation vers GTA-RH...');
const syncResponse = await fetch(`${rhUrl}/api/syncCompteursFromGTA`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(syncPayload)
});
if (syncResponse.ok) {
const syncResult = await syncResponse.json();
console.log('✅ Synchronisation GTA-RH réussie:', syncResult.message);
} else {
console.warn('⚠️ Synchronisation GTA-RH échouée:', syncResponse.status);
}
} catch (syncError) {
console.error('❌ Erreur synchronisation GTA-RH:', syncError.message);
// On continue même si la sync échoue
}
// ═══════════════════════════════════════════════════════════
// 4⃣ RELIRE LA BASE POUR AFFICHER LES VRAIES VALEURS
// ═══════════════════════════════════════════════════════════
console.log('\n📖 Lecture base après synchronisation...');
// Ancienneté
const ancienneteMs = today - new Date(dateEntree || today);
const ancienneteMois = Math.floor(ancienneteMs / (1000 * 60 * 60 * 24 * 30.44));
let counters = {
user: {
id: user.id,
nom: `${user.prenom} ${user.nom}`,
prenom: user.prenom,
nomFamille: user.nom,
email: user.email,
service: user.service || 'Non défini',
role: user.role,
description: user.description,
typeContrat: typeContrat,
societeId: user.SocieteId,
societeNom: user.societeNom || 'Non défini',
dateEntree: dateEntree ? formatDateWithoutUTC(dateEntree) : null,
ancienneteMois: ancienneteMois,
ancienneteAnnees: Math.floor(ancienneteMois / 12),
ancienneteMoisRestants: ancienneteMois % 12
},
dateReference: today.toISOString().split('T')[0],
exerciceCP: getExerciceCP(today),
anneeRTT: currentYear,
cpN1: null,
cpN: null,
rttN: null,
recupN: null,
totalDisponible: { cp: 0, rtt: 0, recup: 0, total: 0 }
};
// ✅ CP N-1 (Report) - LECTURE BASE
const [cpN1Data] = await conn.query(`
SELECT Annee, Total, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[userId, cpType[0].Id, previousYear]
);
if (cpN1Data.length > 0) {
const totalAcquis = parseFloat(cpN1Data[0].Total) || 0;
const soldeReporte = parseFloat(cpN1Data[0].Solde) || 0;
const pris = Math.max(0, totalAcquis - soldeReporte);
counters.cpN1 = {
annee: previousYear,
exercice: `${previousYear}-${previousYear + 1}`,
reporte: parseFloat(totalAcquis.toFixed(2)),
pris: parseFloat(pris.toFixed(2)),
solde: parseFloat(soldeReporte.toFixed(2)),
pourcentageUtilise: totalAcquis > 0 ? parseFloat((pris / totalAcquis * 100).toFixed(1)) : 0
};
counters.totalDisponible.cp += counters.cpN1.solde;
console.log('✅ CP N-1 BASE: Acquis=' + totalAcquis + 'j, Solde=' + soldeReporte + 'j');
}
// ✅ CP N - LECTURE BASE
const [cpNData] = await conn.query(`
SELECT Total, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[userId, cpType[0].Id, currentYear]
);
if (cpNData.length > 0) {
const totalAcquis = parseFloat(cpNData[0].Total) || 0;
const soldeBDD = parseFloat(cpNData[0].Solde) || 0;
const soldeReporteBDD = parseFloat(cpNData[0].SoldeReporte) || 0;
const soldeReel = Math.max(0, soldeBDD - soldeReporteBDD);
const pris = Math.max(0, totalAcquis - soldeReel);
counters.cpN = {
annee: currentYear,
exercice: getExerciceCP(today),
totalAnnuel: 25.00,
acquis: parseFloat(totalAcquis.toFixed(2)),
pris: parseFloat(pris.toFixed(2)),
solde: parseFloat(soldeReel.toFixed(2)),
pourcentageUtilise: totalAcquis > 0 ? parseFloat((pris / totalAcquis * 100).toFixed(1)) : 0
};
counters.totalDisponible.cp += counters.cpN.solde;
console.log('✅ CP N BASE: Acquis=' + totalAcquis + 'j, Solde=' + soldeReel + 'j');
}
// ✅ RTT N - LECTURE BASE
if (rttType.length > 0 && user.role !== 'Apprenti') {
const [rttNData] = await conn.query(`
SELECT Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[userId, rttTypeId, currentYear]
);
if (rttNData.length > 0) {
const totalAcquis = parseFloat(rttNData[0].Total) || 0;
const soldeBDD = parseFloat(rttNData[0].Solde) || 0;
const pris = Math.max(0, totalAcquis - soldeBDD);
const rttConfig = await getConfigurationRTT(conn, currentYear, typeContrat);
counters.rttN = {
annee: currentYear,
typeContrat: typeContrat,
totalAnnuel: parseFloat(rttConfig.joursAnnuels.toFixed(2)),
acquis: parseFloat(totalAcquis.toFixed(2)),
pris: parseFloat(pris.toFixed(2)),
solde: parseFloat(soldeBDD.toFixed(2)),
pourcentageUtilise: totalAcquis > 0 ? parseFloat((pris / totalAcquis * 100).toFixed(1)) : 0
};
counters.totalDisponible.rtt += counters.rttN.solde;
console.log('✅ RTT BASE: Acquis=' + totalAcquis + 'j, Solde=' + soldeBDD + 'j');
}
}
// ✅ Récup - LECTURE BASE
const [recupType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['Récupération']);
if (recupType.length > 0) {
const [recupData] = await conn.query(`
SELECT
COALESCE(SUM(CASE WHEN dd.TypeDeduction IN ('Accum Récup', 'Accum Recup') THEN dd.JoursUtilises ELSE 0 END), 0) as acquis,
COALESCE(SUM(CASE WHEN dd.TypeDeduction = 'Récup Dose' THEN dd.JoursUtilises ELSE 0 END), 0) as pris
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dc.Statut != 'Refusé'`,
[userId, recupType[0].Id, currentYear]
);
if (recupData.length > 0) {
const acquis = parseFloat(recupData[0].acquis) || 0;
const pris = parseFloat(recupData[0].pris) || 0;
const solde = Math.max(0, acquis - pris);
counters.recupN = {
annee: currentYear,
acquis: parseFloat(acquis.toFixed(2)),
pris: parseFloat(pris.toFixed(2)),
solde: parseFloat(solde.toFixed(2)),
message: "Jours de récupération accumulés suite à du temps supplémentaire"
};
counters.totalDisponible.recup = counters.recupN.solde;
console.log('✅ RÉCUP BASE: Acquis=' + acquis + 'j, Solde=' + solde + 'j');
}
}
// Total disponible
counters.totalDisponible.total =
counters.totalDisponible.cp +
counters.totalDisponible.rtt +
counters.totalDisponible.recup;
conn.release();
console.log('\n✅ Réponse envoyée au frontend');
console.log(' CP disponible:', counters.totalDisponible.cp + 'j');
console.log(' RTT disponible:', counters.totalDisponible.rtt + 'j');
console.log(' RÉCUP disponible:', counters.totalDisponible.recup + 'j');
console.log(' TOTAL disponible:', counters.totalDisponible.total + 'j');
res.json({ success: true, data: counters });
} catch (error) {
console.error('❌ Erreur getDetailedLeaveCounters:', error);
res.status(500).json({ success: false, message: error.message });
}
});
app.post('/api/updateCounters', async (req, res) => {
const conn = await pool.getConnection();
try {
const { collaborateur_id } = req.body;
if (!collaborateur_id) return res.json({ success: false, message: 'ID collaborateur manquant' });
await conn.beginTransaction();
const updates = await updateMonthlyCounters(conn, collaborateur_id, new Date());
await conn.commit();
res.json({ success: true, message: 'Compteurs mis à jour', updates });
} catch (error) {
await conn.rollback();
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
} finally {
conn.release();
}
});
app.post('/api/updateAllCounters', async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [collaborateurs] = await conn.query('SELECT id, CampusId FROM CollaborateurAD WHERE actif = 1 OR actif IS NULL');
const allUpdates = [];
for (const collab of collaborateurs) {
const updates = await updateMonthlyCounters(conn, collab.id, new Date());
allUpdates.push({ collaborateur_id: collab.id, updates });
}
await conn.commit();
res.json({ success: true, message: `Compteurs mis à jour pour ${collaborateurs.length} collaborateurs`, total_collaborateurs: collaborateurs.length, details: allUpdates });
} catch (error) {
await conn.rollback();
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
} finally {
conn.release();
}
});
async function deductLeaveBalanceWithTracking(conn, collaborateurId, typeCongeId, nombreJours, demandeCongeId) {
const currentYear = new Date().getFullYear();
const previousYear = currentYear - 1;
let joursRestants = nombreJours;
const deductions = [];
// Étape 1: Déduire du reporté N-1 d'abord
const [compteurN1] = await conn.query(
`SELECT Id, Solde, SoldeReporte FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, previousYear]
);
if (compteurN1.length > 0 && compteurN1[0].SoldeReporte > 0) {
const soldeN1 = parseFloat(compteurN1[0].SoldeReporte);
const aDeduireN1 = Math.min(soldeN1, joursRestants);
if (aDeduireN1 > 0) {
// Déduction dans la base
await conn.query(
`UPDATE CompteurConges
SET SoldeReporte = CASE WHEN (SoldeReporte - ?) < 0 THEN 0 ELSE (SoldeReporte - ?) END,
Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END
WHERE Id = ?`,
[aDeduireN1, aDeduireN1, compteurN1[0].Id]
);
// Sauvegarde du détail de la déduction
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Accum Récup', ?)
`, [demandeId, recupType[0].Id, currentYear, recupJours]);
deductions.push({
annee: previousYear,
type: 'Reporté N-1',
joursUtilises: aDeduireN1,
soldeAvant: soldeN1
});
joursRestants -= aDeduireN1;
}
}
// Étape 2: Déduire de l'année N si besoin
if (joursRestants > 0) {
const [compteurN] = await conn.query(
`SELECT Id, Solde, SoldeReporte FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, currentYear]
);
if (compteurN.length > 0) {
const soldeN = parseFloat(compteurN[0].Solde) - parseFloat(compteurN[0].SoldeReporte || 0);
const aDeduireN = Math.min(soldeN, joursRestants);
if (aDeduireN > 0) {
// Déduction dans la base
await conn.query(
`UPDATE CompteurConges
SET Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END
WHERE Id = ?`,
[aDeduireN, compteurN[0].Id]
);
// Sauvegarde du détail de la déduction
await conn.query(
`INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Année N', ?)`,
[demandeCongeId, typeCongeId, currentYear, aDeduireN]
);
deductions.push({
annee: currentYear,
type: 'Année actuelle N',
joursUtilises: aDeduireN,
soldeAvant: soldeN
});
joursRestants -= aDeduireN;
}
}
}
return {
success: joursRestants === 0,
joursDeduitsTotal: nombreJours - joursRestants,
joursNonDeduits: joursRestants,
details: deductions
};
};
async function restoreLeaveBalance(conn, demandeCongeId, collaborateurId) {
try {
console.log(`\n🔄 === RESTAURATION COMPTEURS ===`);
console.log(`Demande ID: ${demandeCongeId}`);
console.log(`Collaborateur ID: ${collaborateurId}`);
const [deductions] = await conn.query(
`SELECT dd.TypeCongeId, dd.Annee, dd.TypeDeduction, dd.JoursUtilises, tc.Nom as TypeNom
FROM DeductionDetails dd
JOIN TypeConge tc ON dd.TypeCongeId = tc.Id
WHERE dd.DemandeCongeId = ?
ORDER BY dd.Id DESC`,
[demandeCongeId]
);
console.log(`📊 ${deductions.length} déductions trouvées`);
if (deductions.length === 0) {
console.log('⚠️ Aucune déduction trouvée pour cette demande');
return { success: false, message: 'Aucune déduction à restaurer' };
}
const restorations = [];
for (const deduction of deductions) {
const { TypeCongeId, Annee, TypeDeduction, JoursUtilises, TypeNom } = deduction;
console.log(`\n🔍 Traitement: ${TypeNom} - ${TypeDeduction} - ${JoursUtilises}j (Année: ${Annee})`);
// ⭐ NOUVEAU : Gestion des Récup posées
if (TypeDeduction === 'Récup Posée') {
console.log(`🔄 Restauration Récup posée: +${JoursUtilises}j`);
const [compteur] = await conn.query(
`SELECT Id, Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, TypeCongeId, Annee]
);
if (compteur.length > 0) {
const ancienSolde = parseFloat(compteur[0].Solde || 0);
const nouveauSolde = ancienSolde + parseFloat(JoursUtilises);
await conn.query(
`UPDATE CompteurConges
SET Solde = ?,
DerniereMiseAJour = GETDATE()
WHERE Id = ?`,
[nouveauSolde, compteur[0].Id]
);
restorations.push({
type: TypeNom,
annee: Annee,
typeDeduction: TypeDeduction,
joursRestores: JoursUtilises
});
console.log(`✅ Récup restaurée: ${ancienSolde}${nouveauSolde}`);
}
continue;
}
// 🔹 N+1 Anticipé - ⭐ RESTAURATION CORRECTE
if (TypeDeduction === 'N+1 Anticipé') {
console.log(`🔄 Restauration N+1 Anticipé: +${JoursUtilises}j`);
const [compteur] = await conn.query(
`SELECT Id, SoldeAnticipe FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, TypeCongeId, Annee]
);
if (compteur.length > 0) {
const ancienSolde = parseFloat(compteur[0].SoldeAnticipe || 0);
const nouveauSolde = ancienSolde + parseFloat(JoursUtilises);
await conn.query(
`UPDATE CompteurConges
SET SoldeAnticipe = ?,
DerniereMiseAJour = GETDATE()
WHERE Id = ?`,
[nouveauSolde, compteur[0].Id]
);
restorations.push({
type: TypeNom,
annee: Annee,
typeDeduction: TypeDeduction,
joursRestores: JoursUtilises
});
console.log(`✅ N+1 Anticipé restauré: ${ancienSolde}${nouveauSolde}`);
} else {
// ⭐ Créer le compteur N+1 s'il n'existe pas
await conn.query(
`INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, SoldeAnticipe, DerniereMiseAJour)
VALUES (?, ?, ?, 0, 0, 0, ?, GETDATE())`,
[collaborateurId, TypeCongeId, Annee, JoursUtilises]
);
restorations.push({
type: TypeNom,
annee: Annee,
typeDeduction: TypeDeduction,
joursRestores: JoursUtilises
});
console.log(`✅ Compteur N+1 créé avec ${JoursUtilises}j anticipés`);
}
continue;
}
// 🔹 N Anticipé - ⭐ RESTAURATION CORRECTE
if (TypeDeduction === 'N Anticipé') {
console.log(`🔄 Restauration N Anticipé: +${JoursUtilises}j`);
const [compteur] = await conn.query(
`SELECT Id, SoldeAnticipe FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, TypeCongeId, Annee]
);
if (compteur.length > 0) {
const ancienSolde = parseFloat(compteur[0].SoldeAnticipe || 0);
const nouveauSolde = ancienSolde + parseFloat(JoursUtilises);
await conn.query(
`UPDATE CompteurConges
SET SoldeAnticipe = ?,
DerniereMiseAJour = GETDATE()
WHERE Id = ?`,
[nouveauSolde, compteur[0].Id]
);
restorations.push({
type: TypeNom,
annee: Annee,
typeDeduction: TypeDeduction,
joursRestores: JoursUtilises
});
console.log(`✅ N Anticipé restauré: ${ancienSolde}${nouveauSolde}`);
} else {
// ⭐ Créer le compteur s'il n'existe pas (cas rare)
await conn.query(
`INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, SoldeAnticipe, DerniereMiseAJour)
VALUES (?, ?, ?, 0, 0, 0, ?, GETDATE())`,
[collaborateurId, TypeCongeId, Annee, JoursUtilises]
);
restorations.push({
type: TypeNom,
annee: Annee,
typeDeduction: TypeDeduction,
joursRestores: JoursUtilises
});
console.log(`✅ Compteur N créé avec ${JoursUtilises}j anticipés`);
}
continue;
}
// 🔹 Reporté N-1
if (TypeDeduction === 'Reporté N-1' || TypeDeduction === 'Report N-1') {
const [compteur] = await conn.query(
`SELECT Id, SoldeReporte, Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, TypeCongeId, Annee]
);
if (compteur.length > 0) {
const ancienSolde = parseFloat(compteur[0].Solde || 0);
const nouveauSolde = ancienSolde + parseFloat(JoursUtilises);
await conn.query(
`UPDATE CompteurConges
SET SoldeReporte = SoldeReporte + ?,
Solde = Solde + ?,
DerniereMiseAJour = GETDATE()
WHERE Id = ?`,
[JoursUtilises, JoursUtilises, compteur[0].Id]
);
restorations.push({
type: TypeNom,
annee: Annee,
typeDeduction: TypeDeduction,
joursRestores: JoursUtilises
});
console.log(`✅ Reporté restauré: ${ancienSolde}${nouveauSolde}`);
}
}
// 🔹 Année N
else if (TypeDeduction === 'Année N') {
const [compteur] = await conn.query(
`SELECT Id, Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, TypeCongeId, Annee]
);
if (compteur.length > 0) {
const ancienSolde = parseFloat(compteur[0].Solde || 0);
const nouveauSolde = ancienSolde + parseFloat(JoursUtilises);
await conn.query(
`UPDATE CompteurConges
SET Solde = Solde + ?,
DerniereMiseAJour = GETDATE()
WHERE Id = ?`,
[JoursUtilises, compteur[0].Id]
);
restorations.push({
type: TypeNom,
annee: Annee,
typeDeduction: TypeDeduction,
joursRestores: JoursUtilises
});
console.log(`✅ Année N restaurée: ${ancienSolde}${nouveauSolde}`);
}
}
}
// ⭐ IMPORTANT : Recalculer les soldes anticipés après restauration
console.log(`\n🔄 Recalcul des soldes anticipés...`);
await updateSoldeAnticipe(conn, collaborateurId);
console.log(`\n✅ Restauration terminée: ${restorations.length} opérations\n`);
return {
success: true,
restorations,
message: `${restorations.length} restaurations effectuées`
};
} catch (error) {
console.error('❌ Erreur lors de la restauration des soldes:', error);
throw error;
}
}
app.get('/api/testProrata', async (req, res) => {
try {
const userId = parseInt(req.query.user_id || 0);
if (userId <= 0) return res.json({ success: false, message: 'ID utilisateur requis' });
const conn = await pool.getConnection();
const [userInfo] = await conn.query(`SELECT id, prenom, nom, DateEntree, TypeContrat, CampusId FROM CollaborateurAD WHERE id = ?`, [userId]);
if (userInfo.length === 0) { conn.release(); return res.json({ success: false, message: 'Utilisateur non trouvé' }); }
const user = userInfo[0];
const dateEntree = user.DateEntree;
const typeContrat = user.TypeContrat || '37h';
const today = new Date();
const moisCP = getMoisTravaillesCP(today, dateEntree);
const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree);
const rttData = await calculerAcquisitionRTT_Smart(conn, userId, today);
conn.release();
res.json({
success: true,
user: {
id: user.id,
nom: `${user.prenom} ${user.nom}`,
dateEntree: dateEntree ? dateEntree.toISOString().split('T')[0] : null,
typeContrat: typeContrat
},
dateReference: today.toISOString().split('T')[0],
calculs: {
CP: {
moisTravailles: parseFloat(moisCP.toFixed(2)),
acquisitionMensuelle: 25 / 12,
acquisitionCumulee: acquisCP,
formule: `${moisCP.toFixed(2)} mois × ${(25 / 12).toFixed(2)}j/mois = ${acquisCP}j`
},
RTT: {
moisTravailles: rttData.moisTravailles,
acquisitionMensuelle: rttData.config.acquisitionMensuelle,
acquisitionCumulee: rttData.acquisition,
totalAnnuel: rttData.config.joursAnnuels,
formule: `${rttData.moisTravailles} mois × ${rttData.config.acquisitionMensuelle.toFixed(6)}j/mois = ${rttData.acquisition}j`
}
}
});
} catch (error) {
console.error('Erreur testProrata:', error);
res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message });
}
});
app.post('/api/fixAllCounters', async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const today = new Date();
const currentYear = today.getFullYear();
const [collaborateurs] = await conn.query('SELECT id, prenom, nom, DateEntree, CampusId FROM CollaborateurAD WHERE (actif = 1 OR actif IS NULL)');
console.log(`🔄 Correction de ${collaborateurs.length} compteurs...`);
const corrections = [];
for (const collab of collaborateurs) {
const dateEntree = collab.DateEntree;
const moisCP = getMoisTravaillesCP(today, dateEntree);
const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree);
const rttData = await calculerAcquisitionRTT_Smart(conn, collab.id, today);
const acquisRTT = rttData.acquisition;
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
if (cpType.length > 0) {
const [existingCP] = await conn.query(`SELECT Id, Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collab.id, cpType[0].Id, currentYear]);
if (existingCP.length > 0) {
const ancienTotal = parseFloat(existingCP[0].Total);
const ancienSolde = parseFloat(existingCP[0].Solde);
const difference = acquisCP - ancienTotal;
const nouveauSolde = Math.max(0, ancienSolde + difference);
await conn.query(`UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [acquisCP, nouveauSolde, existingCP[0].Id]);
corrections.push({ collaborateur: `${collab.prenom} ${collab.nom}`, type: 'CP', ancienTotal: ancienTotal.toFixed(2), nouveauTotal: acquisCP.toFixed(2), ancienSolde: ancienSolde.toFixed(2), nouveauSolde: nouveauSolde.toFixed(2) });
}
}
if (rttType.length > 0) {
const [existingRTT] = await conn.query(`SELECT Id, Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collab.id, rttType[0].Id, currentYear]);
if (existingRTT.length > 0) {
const ancienTotal = parseFloat(existingRTT[0].Total);
const ancienSolde = parseFloat(existingRTT[0].Solde);
const difference = acquisRTT - ancienTotal;
const nouveauSolde = Math.max(0, ancienSolde + difference);
await conn.query(`UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [acquisRTT, nouveauSolde, existingRTT[0].Id]);
corrections.push({ collaborateur: `${collab.prenom} ${collab.nom}`, type: 'RTT', ancienTotal: ancienTotal.toFixed(2), nouveauTotal: acquisRTT.toFixed(2), ancienSolde: ancienSolde.toFixed(2), nouveauSolde: nouveauSolde.toFixed(2) });
}
}
}
await conn.commit();
res.json({ success: true, message: `${collaborateurs.length} compteurs corrigés`, corrections: corrections });
} catch (error) {
await conn.rollback();
console.error('❌ Erreur correction compteurs:', error);
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
} finally {
conn.release();
}
});
app.post('/api/processEndOfYear', async (req, res) => {
const conn = await pool.getConnection();
try {
const { collaborateur_id } = req.body;
await conn.beginTransaction();
let result;
if (collaborateur_id) {
result = await processEndOfYearRTT(conn, collaborateur_id);
} else {
const [collaborateurs] = await conn.query('SELECT id, CampusId FROM CollaborateurAD');
const results = [];
for (const c of collaborateurs) {
const r = await processEndOfYearRTT(conn, c.id);
if (r) results.push({ collaborateur_id: c.id, ...r });
}
result = results;
}
await conn.commit();
res.json({ success: true, message: 'Traitement de fin d\'année effectué', result });
} catch (error) {
await conn.rollback();
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
} finally {
conn.release();
}
});
app.post('/api/processEndOfExercice', async (req, res) => {
const conn = await pool.getConnection();
try {
const { collaborateur_id } = req.body;
await conn.beginTransaction();
let result;
if (collaborateur_id) {
result = await processEndOfExerciceCP(conn, collaborateur_id);
} else {
const [collaborateurs] = await conn.query('SELECT id, CampusId FROM CollaborateurAD');
const results = [];
for (const c of collaborateurs) {
const r = await processEndOfExerciceCP(conn, c.id);
if (r) results.push({ collaborateur_id: c.id, ...r });
}
result = results;
}
await conn.commit();
res.json({ success: true, message: 'Traitement de fin d\'exercice CP effectué', result });
} catch (error) {
await conn.rollback();
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
} finally {
conn.release();
}
});
app.get('/api/getAcquisitionDetails', async (req, res) => {
try {
const today = new Date();
const details = { date_reference: today.toISOString().split('T')[0], CP: { exercice: getExerciceCP(today), mois_travailles: getMoisTravaillesCP(today), acquisition_mensuelle: LEAVE_RULES.CP.acquisitionMensuelle, acquisition_cumulee: calculerAcquisitionCumulee('CP', today), total_annuel: LEAVE_RULES.CP.joursAnnuels, periode: '01/06 - 31/05', reportable: LEAVE_RULES.CP.reportable }, RTT: { annee: today.getFullYear(), mois_travailles: getMoisTravaillesRTT(today), acquisition_mensuelle: LEAVE_RULES.RTT.acquisitionMensuelle, acquisition_cumulee: calculerAcquisitionCumulee('RTT', today), total_annuel: LEAVE_RULES.RTT.joursAnnuels, periode: '01/01 - 31/12', reportable: LEAVE_RULES.RTT.reportable } };
res.json({ success: true, details });
} catch (error) {
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
}
});
app.get('/api/getLeaveCounters', async (req, res) => {
try {
const userId = parseInt(req.query.user_id || 0);
const data = {};
if (userId > 0) {
const [rows] = await pool.query(`SELECT tc.Nom, cc.Annee, cc.Solde, cc.Total, cc.SoldeReporte FROM CompteurConges cc JOIN TypeConge tc ON cc.TypeCongeId = tc.Id WHERE cc.CollaborateurADId = ?`, [userId]);
rows.forEach(row => { data[row.Nom] = { Annee: row.Annee, Solde: parseFloat(row.Solde), Total: parseFloat(row.Total), SoldeReporte: parseFloat(row.SoldeReporte) }; });
}
res.json({ success: true, message: 'Compteurs récupérés', counters: data });
} catch (error) {
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
}
});
app.get('/api/getEmploye', async (req, res) => {
try {
const id = parseInt(req.query.id || 0);
if (id <= 0) return res.json({ success: false, message: 'ID invalide' });
const conn = await pool.getConnection();
// 1⃣ Récupérer les infos du collaborateur
const [rows] = await conn.query(`
SELECT
ca.id,
ca.Nom,
ca.Prenom,
ca.Email,
ca.role,
ca.TypeContrat,
ca.DateEntree,
ca.CampusId,
ca.SocieteId,
s.Nom as service,
so.Nom as societe_nom
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Societe so ON ca.SocieteId = so.Id
WHERE ca.id = ?
`, [id]);
if (rows.length === 0) {
conn.release();
return res.json({ success: false, message: 'Collaborateur non trouvé' });
}
const employee = rows[0];
// 2⃣ Déterminer si c'est un apprenti
const isApprenti = normalizeRole(employee.role) === 'apprenti';
// 3⃣ Récupérer les compteurs CP
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
let cpTotal = 0, cpSolde = 0;
if (cpType.length > 0) {
const currentYear = new Date().getFullYear();
const previousYear = currentYear - 1;
// CP N-1 (reporté)
const [cpN1] = await conn.query(`
SELECT SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [id, cpType[0].Id, previousYear]);
// CP N (année courante)
const [cpN] = await conn.query(`
SELECT Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [id, cpType[0].Id, currentYear]);
const cpN1Solde = cpN1.length > 0 ? parseFloat(cpN1[0].SoldeReporte || 0) : 0;
const cpNSolde = cpN.length > 0 ? parseFloat(cpN[0].Solde || 0) : 0;
const cpNReporte = cpN.length > 0 ? parseFloat(cpN[0].SoldeReporte || 0) : 0;
cpTotal = cpN1Solde + (cpNSolde - cpNReporte);
cpSolde = cpTotal;
}
// 4⃣ Récupérer les compteurs RTT (sauf pour apprentis)
let rttTotal = 0, rttSolde = 0;
if (!isApprenti) {
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
if (rttType.length > 0) {
const currentYear = new Date().getFullYear();
const [rttN] = await conn.query(`
SELECT Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [id, rttType[0].Id, currentYear]);
rttTotal = rttN.length > 0 ? parseFloat(rttN[0].Solde || 0) : 0;
rttSolde = rttTotal;
}
}
conn.release();
// 5⃣ Retourner les données complètes
res.json({
success: true,
employee: {
id: employee.id,
Nom: employee.Nom || 'Non défini',
Prenom: employee.Prenom || 'Non défini',
Email: employee.Email || 'Non défini',
role: employee.role || 'Collaborateur',
TypeContrat: employee.TypeContrat || '37h',
DateEntree: employee.DateEntree,
CampusId: employee.CampusId,
SocieteId: employee.SocieteId,
service: employee.service || 'Non défini',
societe_nom: employee.societe_nom || 'Non défini',
conges_restants: parseFloat(cpSolde.toFixed(2)),
rtt_restants: parseFloat(rttSolde.toFixed(2))
}
});
} catch (error) {
console.error('❌ Erreur getEmploye:', error);
res.status(500).json({
success: false,
message: 'Erreur DB',
error: error.message
});
}
});
app.get('/api/getEmployeRequest', async (req, res) => {
try {
const id = parseInt(req.query.id || 0);
if (id <= 0) return res.json({ success: false, message: 'ID invalide' });
const [rows] = await pool.query(`
SELECT
dc.Id,
dc.DateDebut,
dc.DateFin,
dc.NombreJours as days,
dc.Statut as status,
dc.DateDemande,
GROUP_CONCAT(DISTINCT tc.Nom ORDER BY tc.Nom SEPARATOR ', ') AS type,
CONCAT(
DATE_FORMAT(dc.DateDebut, '%d/%m/%Y'),
IF(dc.DateDebut = dc.DateFin, '', CONCAT(' - ', DATE_FORMAT(dc.DateFin, '%d/%m/%Y')))
) as date_display
FROM DemandeConge dc
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.CollaborateurADId = ?
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.NombreJours, dc.Statut, dc.DateDemande
ORDER BY dc.DateDemande DESC
`, [id]);
res.json({
success: true,
requests: rows
});
} catch (error) {
console.error('❌ Erreur getEmployeRequest:', error);
res.status(500).json({
success: false,
message: 'Erreur DB',
error: error.message
});
}
});
app.get('/api/getRequests', async (req, res) => {
try {
const userId = req.query.user_id;
if (!userId) return res.json({ success: false, message: 'ID utilisateur manquant' });
// 🔍 Déterminer si c'est un UUID ou un ID numérique
const isUUID = userId.length > 10 && userId.includes('-');
console.log(`📝 Type userId détecté: ${isUUID ? 'UUID (entraUserId)' : 'ID numérique'}`);
let mainRequest = pool.request();
let whereClause;
if (isUUID) {
// Si UUID, chercher d'abord le CollaborateurADId
const lookupRequest = pool.request();
lookupRequest.input('entraUserId', userId);
const userLookup = await lookupRequest.query(`
SELECT id
FROM CollaborateurAD
WHERE entraUserId = @entraUserId
`);
if (userLookup.recordset.length === 0) {
return res.json({
success: false,
message: 'Utilisateur non trouvé',
requests: [],
total: 0
});
}
const collaborateurId = userLookup.recordset[0].id;
console.log(`✅ CollaborateurADId trouvé: ${collaborateurId}`);
// Créer une nouvelle request pour la requête principale
mainRequest.input('collaborateurId', collaborateurId);
whereClause = 'dc.CollaborateurADId = @collaborateurId';
} else {
// Si ID numérique, utiliser directement
mainRequest.input('userId', parseInt(userId));
whereClause = '(dc.EmployeeId = @userId OR dc.CollaborateurADId = @userId)';
}
// ✅ REQUÊTE CORRIGÉE pour MSSQL avec la table de liaison
const result = await mainRequest.query(`
SELECT
dc.Id,
dc.DateDebut,
dc.DateFin,
dc.Statut,
dc.DateDemande,
dc.Commentaire,
dc.CommentaireValidation,
dc.Validateur,
dc.DocumentJoint,
(
SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom)
FROM (
SELECT DISTINCT tc2.Nom
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id
) AS DistinctTypes
) AS TypeConges,
(
SELECT SUM(dct2.NombreJours)
FROM DemandeCongeType dct2
WHERE dct2.DemandeCongeId = dc.Id
) AS NombreJoursTotal
FROM DemandeConge dc
WHERE ${whereClause}
ORDER BY dc.DateDemande DESC
`);
const rows = result.recordset;
console.log(`📋 ${rows.length} demandes trouvées pour userId: ${userId}`);
const requests = rows.map(row => {
const workingDays = getWorkingDays(row.DateDebut, row.DateFin);
const dateDisplay = row.DateDebut === row.DateFin
? formatDate(row.DateDebut)
: `${formatDate(row.DateDebut)} - ${formatDate(row.DateFin)}`;
let fileUrl = null;
if (row.TypeConges && row.TypeConges.includes('Congé maladie') && row.DocumentJoint) {
fileUrl = `/uploads/${path.basename(row.DocumentJoint)}`;
}
return {
id: row.Id,
type: row.TypeConges || 'Non défini', // ✅ Gérer le cas null
startDate: row.DateDebut,
endDate: row.DateFin,
dateDisplay,
days: row.NombreJoursTotal || workingDays, // ✅ Utiliser le total de la table de liaison
status: row.Statut,
reason: row.Commentaire || 'Aucun commentaire',
submittedAt: row.DateDemande,
submittedDisplay: formatDate(row.DateDemande),
validator: row.Validateur || null,
validationComment: row.CommentaireValidation || null,
fileUrl
};
});
res.json({
success: true,
message: 'Demandes récupérées',
requests,
total: requests.length
});
} catch (error) {
console.error('❌ Erreur getRequests:', error);
res.status(500).json({
success: false,
message: 'Erreur',
error: error.message
});
}
});
app.get('/api/getAllTeamRequests', async (req, res) => {
try {
const managerId = req.query.SuperieurId;
if (!managerId) return res.json({ success: false, message: 'Paramètre SuperieurId manquant' });
const [rows] = await pool.query(`SELECT dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.DocumentJoint, dc.CollaborateurADId AS employee_id, CONCAT(ca.Prenom, ' ', ca.Nom) as employee_name, ca.Email as employee_email, tc.Nom as type FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id JOIN TypeConge tc ON dc.TypeCongeId = tc.Id JOIN HierarchieValidationAD hv ON hv.CollaborateurId = ca.id WHERE hv.SuperieurId = ? ORDER BY dc.DateDemande DESC`, [managerId]);
const requests = rows.map(row => ({ id: row.Id, employee_id: row.employee_id, employee_name: row.employee_name, employee_email: row.employee_email, type: row.type, start_date: row.DateDebut, end_date: row.DateFin, date_display: row.DateDebut === row.DateFin ? formatDate(row.DateDebut) : `${formatDate(row.DateDebut)} - ${formatDate(row.DateFin)}`, days: getWorkingDays(row.DateDebut, row.DateFin), status: row.Statut, reason: row.Commentaire || '', file: row.DocumentJoint || null, submitted_at: row.DateDemande, submitted_display: formatDate(row.DateDemande) }));
res.json({ success: true, requests });
} catch (error) {
res.status(500).json({ success: false, message: 'Erreur DB', error: error.message });
}
});
app.get('/api/getPendingRequests', async (req, res) => {
try {
const validatorId = req.query.validator_id;
if (!validatorId) {
return res.json({ success: false, message: 'ID validateur manquant' });
}
const conn = await pool.getConnection();
// Récupérer les infos du validateur
const [managerRows] = await conn.query(
'SELECT TOP 1 ServiceId, CampusId, role FROM CollaborateurAD WHERE id = ?',
[validatorId]
);
if (managerRows.length === 0) {
conn.release();
return res.json({ success: false, message: 'Validateur non trouvé' });
}
const serviceId = managerRows[0].ServiceId;
const campusId = managerRows[0].CampusId;
const role = normalizeRole(managerRows[0].role);
let requests;
if (role === 'admin' || role === 'president' || role === 'rh') {
// Admin/President/RH : toutes les demandes en attente
[requests] = await conn.query(`
SELECT
dc.Id,
dc.CollaborateurADId,
dc.DateDebut,
dc.DateFin,
dc.NombreJours,
dc.Statut,
dc.Commentaire,
tc.Nom as TypeConge,
ca.prenom,
ca.nom,
s.Nom as service_name,
camp.Nom as campus_name
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.Id
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Campus camp ON ca.CampusId = camp.Id
WHERE dc.Statut = 'En attente'
ORDER BY dc.DateCreation DESC
`);
} else if (role === 'validateur' || role === 'directeur de campus') {
// Validateur/Directeur : leurs collaborateurs directs via hiérarchie
[requests] = await conn.query(`
SELECT DISTINCT
dc.Id,
dc.CollaborateurADId,
dc.DateDebut,
dc.DateFin,
dc.NombreJours,
dc.Statut,
dc.Commentaire,
tc.Nom as TypeConge,
ca.prenom,
ca.nom,
s.Nom as service_name,
camp.Nom as campus_name
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.Id
JOIN HierarchieValidationAD hv ON ca.id = hv.CollaborateurId
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Campus camp ON ca.CampusId = camp.Id
WHERE dc.Statut = 'En attente'
AND hv.SuperieurId = ?
ORDER BY dc.DateCreation DESC
`, [validatorId]);
} else {
conn.release();
return res.json({ success: false, message: 'Rôle non autorisé pour validation' });
}
conn.release();
res.json({
success: true,
message: 'Demandes récupérées',
requests,
service_id: serviceId,
campus_id: campusId
});
} catch (error) {
console.error('❌ Erreur getPendingRequests:', error);
res.status(500).json({
success: false,
message: 'Erreur',
error: error.message
});
}
});
// Route getTeamMembers - Membres de l'équipe
app.get('/api/getTeamMembers', async (req, res) => {
try {
const managerId = req.query.manager_id;
if (!managerId) return res.json({ success: false, message: 'ID manager manquant' });
const managerRequest = pool.request();
managerRequest.input('managerId', managerId);
const managerResult = await managerRequest.query(`
SELECT TOP 1 ServiceId, CampusId, role, email
FROM CollaborateurAD
WHERE id = @managerId
`);
if (managerResult.recordset.length === 0) {
return res.json({ success: false, message: 'Manager non trouvé' });
}
const managerInfo = managerResult.recordset[0];
const serviceId = managerInfo.ServiceId;
const campusId = managerInfo.CampusId;
const role = normalizeRole(managerInfo.role);
console.log(`🔍 getTeamMembers - Manager: ${managerId}, Role: ${role}, Campus: ${campusId}`);
let members;
if (role === 'admin' || role === 'president' || role === 'rh') {
// CAS 1: Admin/President/RH - Vue globale
console.log("CAS 1: Admin/President/RH - Vue globale");
const membersRequest = pool.request();
membersRequest.input('managerId', managerId);
const membersResult = await membersRequest.query(`
SELECT
c.id,
c.nom,
c.prenom,
c.email,
c.role,
s.Nom as service_name,
c.CampusId,
camp.Nom as campus_name
FROM CollaborateurAD c
JOIN Services s ON c.ServiceId = s.Id
LEFT JOIN Campus camp ON c.CampusId = camp.Id
WHERE c.id != @managerId
AND (c.actif = 1 OR c.actif IS NULL)
ORDER BY c.prenom, c.nom
`);
members = membersResult.recordset;
console.log(`${members.length} collaborateur(s) au total`);
} else if (role === 'validateur' || role === 'directeur de campus') {
// CAS 2: Validateur/Directeur - Collaborateurs directs via hiérarchie
console.log("CAS 2: Validateur/Directeur - Collaborateurs directs via hiérarchie");
const membersRequest = pool.request();
membersRequest.input('managerId', managerId);
const membersResult = await membersRequest.query(`
SELECT DISTINCT
c.id,
c.nom,
c.prenom,
c.email,
c.role,
s.Nom as service_name,
c.CampusId,
camp.Nom as campus_name
FROM CollaborateurAD c
JOIN HierarchieValidationAD hv ON c.id = hv.CollaborateurId
JOIN Services s ON c.ServiceId = s.Id
LEFT JOIN Campus camp ON c.CampusId = camp.Id
WHERE hv.SuperieurId = @managerId
AND (c.actif = 1 OR c.actif IS NULL)
ORDER BY c.prenom, c.nom
`);
members = membersResult.recordset;
console.log(`${members.length} collaborateur(s) sous ${managerId}`);
} else if (role === 'collaborateur' || role === 'apprenti') {
// CAS 3: Collaborateur/Apprenti - Collègues du même service ET campus
console.log("CAS 3: Collaborateur/Apprenti - Collègues du même service et campus");
const membersRequest = pool.request();
membersRequest.input('managerId', managerId);
membersRequest.input('serviceId', serviceId);
membersRequest.input('campusId', campusId);
const membersResult = await membersRequest.query(`
SELECT
c.id,
c.nom,
c.prenom,
c.email,
c.role,
s.Nom as service_name,
c.CampusId,
camp.Nom as campus_name
FROM CollaborateurAD c
JOIN Services s ON c.ServiceId = s.Id
LEFT JOIN Campus camp ON c.CampusId = camp.Id
WHERE c.ServiceId = @serviceId
AND c.CampusId = @campusId
AND c.id != @managerId
AND (c.actif = 1 OR c.actif IS NULL)
ORDER BY c.prenom, c.nom
`);
members = membersResult.recordset;
console.log(`${members.length} collègue(s) trouvé(s)`);
} else {
return res.json({ success: false, message: 'Rôle non autorisé' });
}
res.json({
success: true,
team_members: members || [],
service_id: serviceId,
campus_id: campusId
});
} catch (error) {
console.error('❌ Erreur getTeamMembers:', error);
res.status(500).json({
success: false,
message: 'Erreur',
error: error.message
});
}
});
app.get('/api/getNotifications', async (req, res) => {
try {
const userIdParam = req.query.user_id;
if (!userIdParam) {
return res.json({ success: false, message: 'ID utilisateur manquant' });
}
const conn = await pool.getConnection();
// ✅ Déterminer si c'est un UUID ou un ID numérique
const isUUID = userIdParam.length > 10 && userIdParam.includes('-');
// ✅ Récupérer l'ID numérique si on a un UUID
let userId = userIdParam;
if (isUUID) {
const [userRows] = await conn.query(
'SELECT id, CampusId FROM CollaborateurAD WHERE entraUserId = ? AND (Actif = 1 OR Actif IS NULL)',
[userIdParam]
);
if (userRows.length === 0) {
conn.release();
return res.json({
success: false,
message: 'Utilisateur non trouvé ou compte désactivé'
});
}
userId = userRows[0].id;
} else {
userId = parseInt(userIdParam);
}
// ✅ Utiliser l'ID numérique pour la requête
const [notifications] = await conn.query(`
SELECT * FROM Notifications
WHERE CollaborateurADId = ?
ORDER BY DateCreation DESC
LIMIT 50
`, [userId]);
conn.release();
res.json({
success: true,
notifications: notifications || []
});
} catch (error) {
console.error('Erreur getNotifications:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
app.post('/api/markNotificationRead', async (req, res) => {
try {
const { notificationId } = req.body;
if (!notificationId || notificationId <= 0) return res.status(400).json({ success: false, message: 'ID notification invalide' });
await pool.query('UPDATE Notifications SET lu = 1 WHERE Id = ?', [notificationId]);
res.json({ success: true, message: 'Notification marquée comme lue' });
} catch (error) {
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
}
});
// À ajouter avant app.listen()
/**
* POST /saisirRecupJour
* Saisir une journée de récupération (samedi travaillé)
*/
app.post('/api/saisirRecupJour', async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const {
user_id,
date, // Date du samedi travaillé
nombre_heures = 1, // Par défaut 1 jour = 1 samedi
commentaire
} = req.body;
console.log('\n📝 === SAISIE RÉCUP ===');
console.log('User ID:', user_id);
console.log('Date:', date);
console.log('Heures:', nombre_heures);
if (!user_id || !date) {
await conn.rollback();
conn.release();
return res.json({
success: false,
message: 'Données manquantes'
});
}
// Vérifier que c'est bien un samedi
const dateObj = new Date(date);
const dayOfWeek = dateObj.getDay();
if (dayOfWeek !== 6) {
await conn.rollback();
conn.release();
return res.json({
success: false,
message: 'La récupération ne peut être saisie que pour un samedi'
});
}
// Vérifier que ce samedi n'a pas déjà été saisi
const [existing] = await conn.query(`
SELECT dc.Id
FROM DemandeConge dc
JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.CollaborateurADId = ?
AND dc.DateDebut = ?
AND tc.Nom = 'Récupération'
`, [user_id, date]);
if (existing.length > 0) {
await conn.rollback();
conn.release();
return res.json({
success: false,
message: 'Ce samedi a déjà été déclaré'
});
}
// Récupérer infos utilisateur
const [userInfo] = await conn.query(
'SELECT prenom, nom, email, CampusId FROM CollaborateurAD WHERE id = ?',
[user_id]
);
if (userInfo.length === 0) {
await conn.rollback();
conn.release();
return res.json({
success: false,
message: 'Utilisateur non trouvé'
});
}
const user = userInfo[0];
const userName = `${user.prenom} ${user.nom}`;
const dateFormatted = dateObj.toLocaleDateString('fr-FR');
// Récupérer le type Récupération
const [recupType] = await conn.query(
'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1',
['Récupération']
);
if (recupType.length === 0) {
await conn.rollback();
conn.release();
return res.json({
success: false,
message: 'Type Récupération non trouvé'
});
}
const recupTypeId = recupType[0].Id;
const currentYear = dateObj.getFullYear();
// CRÉER LA DEMANDE (validée automatiquement)
const [result] = await conn.query(`
INSERT INTO DemandeConge
(CollaborateurADId, DateDebut, DateFin, TypeCongeId,
Statut, DateDemande, Commentaire, NombreJours)
VALUES (?, ?, ?, ?, 'Validée', GETDATE(), ?, ?)
`, [user_id, date, date, recupTypeId, commentaire || `Samedi travaillé - ${dateFormatted}`, nombre_heures]);
const demandeId = result.insertId;
// SAUVEGARDER DANS DemandeCongeType
await conn.query(`
INSERT INTO DemandeCongeType
(DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee)
VALUES (?, ?, ?, 'Journée entière')
`, [demandeId, recupTypeId, nombre_heures]);
// ACCUMULER DANS LE COMPTEUR
const [compteur] = await conn.query(`
SELECT Id, Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [user_id, recupTypeId, currentYear]);
if (compteur.length > 0) {
await conn.query(`
UPDATE CompteurConges
SET Total = Total + ?,
Solde = Solde + ?,
DerniereMiseAJour = GETDATE()
WHERE Id = ?
`, [nombre_heures, nombre_heures, compteur[0].Id]);
console.log(`✅ Compteur mis à jour: ${parseFloat(compteur[0].Solde) + nombre_heures}j`);
} else {
await conn.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, 0, GETDATE())
`, [user_id, recupTypeId, currentYear, nombre_heures, nombre_heures]);
console.log(`✅ Compteur créé: ${nombre_heures}j`);
}
// ENREGISTRER L'ACCUMULATION
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Accum Récup', ?)
`, [demandeId, recupTypeId, currentYear, nombre_heures]);
// CRÉER NOTIFICATION
await conn.query(`
INSERT INTO Notifications
(CollaborateurADId, Type, Titre, Message, DemandeCongeId, DateCreation, Lu)
VALUES (?, 'Success', '✅ Récupération enregistrée', ?, ?, GETDATE(), 0)
`, [
user_id,
`Samedi ${dateFormatted} enregistré : +${nombre_heures}j de récupération`,
demandeId
]);
await conn.commit();
conn.release();
res.json({
success: true,
message: `Samedi ${dateFormatted} enregistré`,
jours_ajoutes: nombre_heures,
demande_id: demandeId
});
} catch (error) {
await conn.rollback();
if (conn) conn.release();
console.error('❌ Erreur saisie récup:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
/**
* GET /getMesSamedis
* Récupérer les samedis déjà déclarés
*/
app.get('/api/getMesSamedis', async (req, res) => {
try {
const { user_id, annee } = req.query;
const conn = await pool.getConnection();
const [samedis] = await conn.query(`
SELECT
dc.Id,
DATE_FORMAT(dc.DateDebut, '%Y-%m-%d') as date,
dc.NombreJours as jours,
dc.Commentaire as commentaire,
DATE_FORMAT(dc.DateDemande, '%d/%m/%Y à %H:%i') as date_saisie
FROM DemandeConge dc
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
WHERE dc.CollaborateurADId = ?
AND tc.Nom = 'Récupération'
AND YEAR(dc.DateDebut) = ?
ORDER BY dc.DateDebut DESC
`, [user_id, annee]);
conn.release();
res.json({
success: true,
samedis: samedis
});
} catch (error) {
console.error('Erreur getMesSamedis:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
async function checkLeaveBalanceWithAnticipation(conn, collaborateurId, repartition, dateDebut) {
const dateDebutObj = new Date(dateDebut);
const currentYear = dateDebutObj.getFullYear();
const previousYear = currentYear - 1;
console.log('\n🔍 === CHECK SOLDES AVEC ANTICIPATION ===');
console.log(`📅 Date demande: ${dateDebut}`);
console.log(`📅 Année demande: ${currentYear}`);
const verification = [];
for (const rep of repartition) {
const typeCode = rep.TypeConge;
const joursNecessaires = parseFloat(rep.NombreJours || 0);
if (typeCode === 'ABS' || typeCode === 'Formation') {
continue;
}
const typeName = typeCode === 'CP' ? 'Congé payé' : typeCode === 'RTT' ? 'RTT' : typeCode;
const [typeRow] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [typeName]);
if (typeRow.length === 0) {
continue;
}
const typeCongeId = typeRow[0].Id;
// ====================================
// 1⃣ Récupérer les infos du collaborateur
// ====================================
const [collabInfo] = await conn.query(`
SELECT DateEntree, TypeContrat, role
FROM CollaborateurAD
WHERE id = ?
`, [collaborateurId]);
const dateEntree = collabInfo[0]?.DateEntree || null;
const typeContrat = collabInfo[0]?.TypeContrat || '37h';
const isApprenti = collabInfo[0]?.role === 'Apprenti';
// ====================================
// 2⃣ Calculer l'acquisition à la date de la demande
// ====================================
let acquisALaDate = 0;
let budgetAnnuel = 0;
if (typeCode === 'CP') {
acquisALaDate = calculerAcquisitionCP_Smart(dateDebutObj, dateEntree);
budgetAnnuel = 25;
console.log(`💰 Acquisition CP à la date ${dateDebut}: ${acquisALaDate.toFixed(2)}j`);
} else if (typeCode === 'RTT' && !isApprenti) {
const rttData = await calculerAcquisitionRTT_Smart(conn, collaborateurId, dateDebutObj);
acquisALaDate = rttData.acquisition;
budgetAnnuel = rttData.config.joursAnnuels;
console.log(`💰 Acquisition RTT à la date ${dateDebut}: ${acquisALaDate.toFixed(2)}j`);
}
// ====================================
// 3⃣ Récupérer le report N-1 (CP uniquement)
// ====================================
let reporteN1 = 0;
if (typeCode === 'CP') {
const [compteurN1] = await conn.query(`
SELECT Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, typeCongeId, previousYear]);
if (compteurN1.length > 0) {
reporteN1 = parseFloat(compteurN1[0].Solde || 0);
}
}
// ====================================
// 4⃣ Calculer ce qui a déjà été posé (SANS l'anticipé)
// ====================================
const [dejaPose] = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction NOT IN ('N Anticip', 'N+1 Anticip', 'Accum Récup', 'Accum Recup')
AND dc.Statut != 'Refusée'
`, [collaborateurId, typeCongeId, currentYear]);
const dejaPoseNormal = parseFloat(dejaPose[0]?.total || 0);
// ====================================
// 5⃣ Calculer l'anticipé déjà utilisé
// ====================================
const [anticipeUtilise] = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction = 'N Anticip'
AND dc.Statut != 'Refusée'
`, [collaborateurId, typeCongeId, currentYear]);
const dejaPoseAnticipe = parseFloat(anticipeUtilise[0]?.total || 0);
// ====================================
// 6⃣ Calculer l'anticipé disponible
// ====================================
const anticipableMax = Math.max(0, budgetAnnuel - acquisALaDate);
const anticipeDisponible = Math.max(0, anticipableMax - dejaPoseAnticipe);
console.log(`💳 Anticipé max possible: ${anticipableMax.toFixed(2)}j`);
console.log(`💳 Anticipé déjà utilisé: ${dejaPoseAnticipe.toFixed(2)}j`);
console.log(`💳 Anticipé disponible: ${anticipeDisponible.toFixed(2)}j`);
// ====================================
// 7⃣ Calculer le solde TOTAL disponible
// ====================================
const soldeActuel = Math.max(0, reporteN1 + acquisALaDate - dejaPoseNormal);
const soldeTotal = soldeActuel + anticipeDisponible;
console.log(`📊 Soldes détaillés ${typeCode}:`);
console.log(` - Report N-1: ${reporteN1.toFixed(2)}j`);
console.log(` - Acquis à date: ${acquisALaDate.toFixed(2)}j`);
console.log(` - Déjà posé (normal): ${dejaPoseNormal.toFixed(2)}j`);
console.log(` - Solde actuel: ${soldeActuel.toFixed(2)}j`);
console.log(` - Anticipé disponible: ${anticipeDisponible.toFixed(2)}j`);
console.log(` ✅ TOTAL DISPONIBLE: ${soldeTotal.toFixed(2)}j`);
// ====================================
// 8⃣ Vérifier la suffisance
// ====================================
const suffisant = soldeTotal >= joursNecessaires;
const deficit = Math.max(0, joursNecessaires - soldeTotal);
verification.push({
type: typeName,
joursNecessaires,
reporteN1,
acquisALaDate,
dejaPoseNormal,
dejaPoseAnticipe,
soldeActuel,
anticipeDisponible,
soldeTotal,
suffisant,
deficit
});
console.log(`🔍 Vérification ${typeCode}: ${joursNecessaires}j demandés vs ${soldeTotal.toFixed(2)}j disponibles → ${suffisant ? '✅ OK' : '❌ INSUFFISANT'}`);
}
const insuffisants = verification.filter(v => !v.suffisant);
return {
valide: insuffisants.length === 0,
details: verification,
insuffisants
};
}
/**
* Déduit les jours d'un compteur avec gestion de l'anticipation
* Ordre de déduction : N-1 → N → N Anticip
*/
// ========================================
// 💰 FONCTION DE DÉDUCTION AVEC ANTICIPATION (SANS ID MANUEL)
// ========================================
async function deductLeaveBalanceWithAnticipation(conn, collaborateurId, typeCongeId, nombreJours, demandeCongeId, dateDebut) {
const dateDebutObj = new Date(dateDebut);
const currentYear = dateDebutObj.getFullYear();
const previousYear = currentYear - 1;
let joursRestants = nombreJours;
const deductions = [];
console.log(`💳 === DÉDUCTION AVEC ANTICIPATION ===`);
console.log(` Collaborateur: ${collaborateurId}`);
console.log(` Type congé: ${typeCongeId}`);
console.log(` Jours à déduire: ${nombreJours}j`);
console.log(` Date début: ${dateDebut}`);
// ===== ÉTAPE 1 : Déduire du REPORT N-1 =====
try {
const [compteurN1] = await conn.query(`
SELECT Id, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, typeCongeId, previousYear]);
if (compteurN1.length > 0 && compteurN1[0].SoldeReporte > 0) {
const soldeN1 = parseFloat(compteurN1[0].SoldeReporte || 0);
const aDeduireN1 = Math.min(soldeN1, joursRestants);
if (aDeduireN1 > 0) {
await conn.query(`
UPDATE CompteurConges
SET SoldeReporte = SoldeReporte - ?,
Solde = Solde - ?,
DerniereMiseAJour = GETDATE()
WHERE Id = ?
`, [aDeduireN1, aDeduireN1, compteurN1[0].Id]);
// ⭐ SANS SPÉCIFIER L'ID
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, ?, ?)
`, [demandeCongeId, typeCongeId, previousYear, 'Report N-1', aDeduireN1]);
deductions.push({
annee: previousYear,
type: 'Report N-1',
joursUtilises: aDeduireN1
});
joursRestants -= aDeduireN1;
console.log(` ✅ Report N-1: ${aDeduireN1.toFixed(2)}j déduits - reste ${joursRestants}j`);
}
}
} catch (error) {
console.error('❌ Erreur déduction N-1:', error.message);
throw error;
}
// ===== ÉTAPE 2 : Déduire du SOLDE N =====
if (joursRestants > 0) {
try {
const [compteurN] = await conn.query(`
SELECT Id, Solde, SoldeReporte, Total
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, typeCongeId, currentYear]);
if (compteurN.length > 0) {
const soldeN = parseFloat(compteurN[0].Solde) - parseFloat(compteurN[0].SoldeReporte || 0);
const aDeduireN = Math.min(soldeN, joursRestants);
if (aDeduireN > 0) {
await conn.query(`
UPDATE CompteurConges
SET Solde = Solde - ?,
DerniereMiseAJour = GETDATE()
WHERE Id = ?
`, [aDeduireN, compteurN[0].Id]);
// ⭐ SANS SPÉCIFIER L'ID
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, ?, ?)
`, [demandeCongeId, typeCongeId, currentYear, 'Année N', aDeduireN]);
deductions.push({
annee: currentYear,
type: 'Année N',
joursUtilises: aDeduireN
});
joursRestants -= aDeduireN;
console.log(` ✅ Solde N: ${aDeduireN.toFixed(2)}j déduits - reste ${joursRestants}j`);
}
}
} catch (error) {
console.error('❌ Erreur déduction N:', error.message);
throw error;
}
}
// ===== ÉTAPE 3 : ANTICIPÉ =====
if (joursRestants > 0) {
console.log(` 💳 Il reste ${joursRestants.toFixed(2)}j à déduire → Anticipé`);
try {
const [collabInfo] = await conn.query(`
SELECT DateEntree, TypeContrat, role
FROM CollaborateurAD
WHERE id = ?
`, [collaborateurId]);
const dateEntree = collabInfo[0]?.DateEntree || null;
const [typeInfo] = await conn.query(`
SELECT Nom FROM TypeConge WHERE Id = ?
`, [typeCongeId]);
const typeNom = typeInfo[0]?.Nom;
let budgetAnnuel = 0;
let acquisALaDate = 0;
if (typeNom === 'Congé payé') {
acquisALaDate = calculerAcquisitionCP_Smart(dateDebutObj, dateEntree);
budgetAnnuel = 25;
} else if (typeNom === 'RTT') {
const rttData = await calculerAcquisitionRTT_Smart(conn, collaborateurId, dateDebutObj);
acquisALaDate = rttData.acquisition;
budgetAnnuel = rttData.config.joursAnnuels;
}
const anticipableMax = Math.max(0, budgetAnnuel - acquisALaDate);
const [anticipeUtilise] = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction = 'N Anticip'
AND dc.Statut != 'Refusée'
`, [collaborateurId, typeCongeId, currentYear]);
const dejaPrisAnticipe = parseFloat(anticipeUtilise[0]?.total || 0);
const anticipeDisponible = Math.max(0, anticipableMax - dejaPrisAnticipe);
console.log(` 💳 Anticipable max: ${anticipableMax.toFixed(2)}j`);
console.log(` 💳 Déjà pris: ${dejaPrisAnticipe.toFixed(2)}j`);
console.log(` 💳 Disponible: ${anticipeDisponible.toFixed(2)}j`);
if (anticipeDisponible >= joursRestants) {
// ⭐ SANS SPÉCIFIER L'ID
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, ?, ?)
`, [demandeCongeId, typeCongeId, currentYear, 'N Anticip', joursRestants]);
deductions.push({
annee: currentYear,
type: 'N Anticip',
joursUtilises: joursRestants
});
console.log(` ✅ Anticipé: ${joursRestants.toFixed(2)}j`);
joursRestants = 0;
} else {
return {
success: false,
joursDeduitsTotal: nombreJours - joursRestants,
joursNonDeduits: joursRestants,
details: deductions,
error: `Solde insuffisant (manque ${joursRestants.toFixed(2)}j)`
};
}
} catch (error) {
console.error('❌ Erreur anticipé:', error.message);
throw error;
}
}
console.log(` ✅ Déduction OK - Total: ${(nombreJours - joursRestants).toFixed(2)}j`);
return {
success: joursRestants === 0,
joursDeduitsTotal: nombreJours - joursRestants,
joursNonDeduits: joursRestants,
details: deductions
};
}
// ========================================
// 🔧 FONCTION HELPER - GÉNÉRATION D'ID
// ========================================
// ✅ VERSION CORRIGÉE
async function getNextId(connection, tableName) {
try {
const [result] = await connection.query(
`SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM ${tableName}`
);
return result[0].NextId;
} catch (error) {
console.error(`❌ Erreur génération ID pour ${tableName}:`, error.message);
throw error;
}
}
app.post('/api/submitLeaveRequest', uploadMedical.array('medicalDocuments', 5), async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const currentYear = new Date().getFullYear();
// ✅ Récupérer les fichiers uploadés
const uploadedFiles = req.files || [];
console.log('📎 Fichiers médicaux reçus:', uploadedFiles.length);
// ✅ Les données arrivent différemment avec FormData
const DateDebut = req.body.DateDebut;
const DateFin = req.body.DateFin;
const NombreJours = parseFloat(req.body.NombreJours);
const Email = req.body.Email;
const Nom = req.body.Nom;
const Commentaire = req.body.Commentaire || '';
const statut = req.body.statut || null;
// ✅ Parser la répartition (elle arrive en string depuis FormData)
let Repartition;
try {
Repartition = JSON.parse(req.body.Repartition || '[]');
} catch (parseError) {
console.error('❌ Erreur parsing Repartition:', parseError);
if (req.files) {
req.files.forEach(file => {
if (fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
});
}
return res.status(400).json({
success: false,
message: 'Erreur de format de la répartition'
});
}
if (!DateDebut || !DateFin || !Repartition || !Email || !Nom) {
uploadedFiles.forEach(file => {
if (fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
});
return res.json({ success: false, message: 'Données manquantes' });
}
// ✅ VALIDATION : Si arrêt maladie, il faut au moins 1 fichier
const hasABS = Repartition.some(r => r.TypeConge === 'ABS');
if (hasABS && uploadedFiles.length === 0) {
await conn.rollback();
conn.release();
return res.json({
success: false,
message: 'Un justificatif médical est obligatoire pour un arrêt maladie'
});
}
// ⭐ VALIDATION DE LA RÉPARTITION
console.log('\n📥 === SOUMISSION DEMANDE CONGÉ ===');
console.log('Email:', Email);
console.log('Période:', DateDebut, '→', DateFin);
console.log('Nombre de jours total:', NombreJours);
console.log('Répartition reçue:', JSON.stringify(Repartition, null, 2));
// ⭐ Ne compter que CP, RTT ET RÉCUP dans la répartition
const sommeRepartition = Repartition.reduce((sum, r) => {
if (r.TypeConge === 'CP' || r.TypeConge === 'RTT' || r.TypeConge === 'Récup') {
return sum + parseFloat(r.NombreJours || 0);
}
return sum;
}, 0);
console.log('Somme répartition CP+RTT+Récup:', sommeRepartition.toFixed(2));
// ⭐ VALIDATION : La somme doit correspondre au total
const hasCountableLeave = Repartition.some(r =>
r.TypeConge === 'CP' || r.TypeConge === 'RTT' || r.TypeConge === 'Récup'
);
if (hasCountableLeave && Math.abs(sommeRepartition - NombreJours) > 0.01) {
console.error('❌ ERREUR : Répartition incohérente !');
uploadedFiles.forEach(file => {
if (fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
});
await conn.rollback();
conn.release();
return res.json({
success: false,
message: `Erreur de répartition : la somme (${sommeRepartition.toFixed(2)}j) ne correspond pas au total (${NombreJours}j)`
});
}
console.log('✅ Validation répartition OK');
// ⭐ Récup n'est PAS une demande auto-validée
const isFormationOnly = Repartition.length === 1 && Repartition[0].TypeConge === 'Formation';
const statutDemande = statut || (isFormationOnly ? 'Validée' : 'En attente');
console.log('🔍 Type de demande:', { isFormationOnly, statut: statutDemande });
const [collabAD] = await conn.query('SELECT id, CampusId FROM CollaborateurAD WHERE email = ? LIMIT 1', [Email]);
const isAD = collabAD.length > 0;
const collaborateurId = isAD ? collabAD[0].id : null;
let employeeId = null;
if (!isAD) {
const [user] = await conn.query('SELECT ID FROM Users WHERE Email = ? LIMIT 1', [Email]);
if (user.length === 0) {
uploadedFiles.forEach(file => {
if (fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
});
await conn.rollback();
conn.release();
return res.json({ success: false, message: 'Utilisateur non trouvé' });
}
employeeId = user[0].ID;
}
// ========================================
// ÉTAPE 1 : Vérification des soldes AVANT tout (MODE MIXTE AVEC ANTICIPATION N+1)
// ========================================
if (isAD && collaborateurId && !isFormationOnly) {
console.log('\n🔍 Vérification des soldes en mode mixte avec anticipation...');
console.log('Date début:', DateDebut);
const [userRole] = await conn.query('SELECT role FROM CollaborateurAD WHERE id = ?', [collaborateurId]);
const isApprenti = userRole.length > 0 && userRole[0].role === 'Apprenti';
const checkResult = await checkLeaveBalanceWithAnticipation(
conn,
collaborateurId,
Repartition,
DateDebut
);
if (!checkResult.valide) {
uploadedFiles.forEach(file => {
if (fs.existsSync(file.path)) fs.unlinkSync(file.path);
});
await conn.rollback();
conn.release();
const messagesErreur = checkResult.insuffisants.map(ins => {
return `${ins.type}: ${ins.joursNecessaires}j demandés mais seulement ${ins.soldeTotal.toFixed(2)}j disponibles (déficit: ${ins.deficit.toFixed(2)}j)`;
}).join('\n');
return res.json({
success: false,
message: `❌ Solde(s) insuffisant(s):\n${messagesErreur}`,
details: checkResult.details,
insuffisants: checkResult.insuffisants
});
}
console.log('✅ Tous les soldes sont suffisants (incluant anticipation si nécessaire)\n');
}
// ========================================
// ÉTAPE 2 : CRÉER LA DEMANDE (AVEC GÉNÉRATION MANUELLE D'ID)
// ========================================
console.log('\n📝 Création de la demande...');
const typeIds = [];
for (const rep of Repartition) {
const code = rep.TypeConge;
if (code === 'ABS' || code === 'Formation') {
continue;
}
const name = code === 'CP' ? 'Congé payé' :
code === 'RTT' ? 'RTT' :
code === 'Récup' ? 'Récupération' : code;
const [typeRow] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [name]);
if (typeRow.length > 0) typeIds.push(typeRow[0].Id);
}
if (typeIds.length === 0) {
const firstType = Repartition[0]?.TypeConge;
const name = firstType === 'Formation' ? 'Formation' :
firstType === 'ABS' ? 'Congé maladie' : 'Congé payé';
const [typeRow] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [name]);
if (typeRow.length > 0) {
typeIds.push(typeRow[0].Id);
} else {
uploadedFiles.forEach(file => {
if (fs.existsSync(file.path)) fs.unlinkSync(file.path);
});
await conn.rollback();
conn.release();
return res.json({ success: false, message: 'Aucun type de congé valide' });
}
}
const typeCongeIdCsv = typeIds.join(',');
// 🔥 GÉNÉRATION MANUELLE DE L'ID (CONTOURNEMENT IDENTITY)
const [maxIdResult] = await conn.query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeConge');
const demandeId = maxIdResult[0].NextId;
console.log(`🆔 ID généré manuellement: ${demandeId}`);
// 🔥 INSERT AVEC ID EXPLICITE
await conn.query(`
INSERT INTO DemandeConge
(Id, CollaborateurADId, DateDebut, DateFin, TypeCongeId, Statut, DateDemande, Commentaire, Validateur, NombreJours)
VALUES (?, ?, ?, ?, ?, ?, GETDATE(), ?, ?, ?)
`, [
demandeId,
collaborateurId,
DateDebut,
DateFin,
typeCongeIdCsv,
statutDemande,
Commentaire || '',
'',
NombreJours
]);
console.log(`✅ Demande créée avec ID ${demandeId} - Statut: ${statutDemande}`);
// ========================================
// ÉTAPE 3 : Sauvegarder les fichiers médicaux
// ========================================
if (uploadedFiles.length > 0) {
console.log('\n📎 Sauvegarde des fichiers médicaux...');
for (const file of uploadedFiles) {
await conn.query(`
INSERT INTO DocumentsMedicaux
(DemandeCongeId, NomFichier, CheminFichier, TypeMime, TailleFichier, DateUpload)
VALUES (?, ?, ?, ?, ?, GETDATE())
`, [demandeId, file.originalname, file.path, file.mimetype, file.size]);
console.log(`${file.originalname}`);
}
}
// ========================================
// ÉTAPE 4 : Sauvegarder la répartition
// ========================================
// ========================================
// ÉTAPE 4 : Sauvegarder la répartition
// ========================================
// 5⃣ Sauvegarder la répartition
console.log('\n📊 Sauvegarde de la répartition en base...');
for (const rep of Repartition) {
const code = rep.TypeConge;
const name = code === 'CP' ? 'Congé payé' :
code === 'RTT' ? 'RTT' :
code === 'ABS' ? 'Congé maladie' :
code === 'Formation' ? 'Formation' :
code === 'Récup' ? 'Récupération' : code;
const [typeRow] = await conn.query(
'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1',
[name]
);
if (typeRow.length > 0) {
// ⭐ GÉNÉRER L'ID MANUELLEMENT
const demandeCongeTypeId = await getNextId(conn, 'DemandeCongeType');
await conn.query(`
INSERT INTO DemandeCongeType
(Id, DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee)
VALUES (?, ?, ?, ?, ?)
`, [
demandeCongeTypeId,
demandeId,
typeRow[0].Id,
rep.NombreJours,
rep.PeriodeJournee || 'Journée entière'
]);
console.log(`${name}: ${rep.NombreJours}j (${rep.PeriodeJournee || 'Journée entière'})`);
}
}
// ========================================
// ÉTAPE 5 : Déduction des compteurs CP/RTT/RÉCUP (AVEC ANTICIPATION N+1)
// ========================================
if (isAD && collaborateurId && !isFormationOnly) {
console.log('\n📉 Déduction des compteurs (avec anticipation N+1)...');
for (const rep of Repartition) {
if (rep.TypeConge === 'ABS' || rep.TypeConge === 'Formation') {
console.log(`${rep.TypeConge} ignoré (pas de déduction)`);
continue;
}
// ⭐ TRAITEMENT SPÉCIAL POUR RÉCUP
if (rep.TypeConge === 'Récup') {
const [recupType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Récupération']);
if (recupType.length > 0) {
await conn.query(`
UPDATE CompteurConges
SET Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END,
DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [rep.NombreJours, rep.NombreJours, collaborateurId, recupType[0].Id, currentYear]);
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Récup Posée', ?)
`, [demandeId, recupType[0].Id, currentYear, rep.NombreJours]);
console.log(` ✓ Récup: ${rep.NombreJours}j déduits`);
}
continue;
}
// ⭐ CP et RTT : AVEC ANTICIPATION N+1
const name = rep.TypeConge === 'CP' ? 'Congé payé' : 'RTT';
const [typeRow] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [name]);
if (typeRow.length > 0) {
const deductResult = await deductLeaveBalanceWithAnticipation(
conn,
collaborateurId,
typeRow[0].Id,
rep.NombreJours,
demandeId,
DateDebut
);
console.log(`${name}: ${rep.NombreJours}j déduits`);
if (deductResult.details && deductResult.details.length > 0) {
deductResult.details.forEach(d => {
console.log(` - ${d.type} (${d.annee}): ${d.joursUtilises}j`);
});
}
}
}
await updateSoldeAnticipe(conn, collaborateurId);
console.log('✅ Déductions terminées\n');
}
// ========================================
// ÉTAPE 6 : Notifications (Formation uniquement)
// ========================================
const dateDebut = new Date(DateDebut).toLocaleDateString('fr-FR');
const dateFin = new Date(DateFin).toLocaleDateString('fr-FR');
const datesPeriode = dateDebut === dateFin ? dateDebut : `du ${dateDebut} au ${dateFin}`;
if (isFormationOnly && isAD && collaborateurId) {
await conn.query(`
INSERT INTO Notifications (CollaborateurADId, Type, Titre, Message, DemandeCongeId, DateCreation, Lu)
VALUES (?, ?, ?, ?, ?, GETDATE(), 0)
`, [
collaborateurId,
'Success',
'✅ Formation validée automatiquement',
`Votre période de formation ${datesPeriode} a été validée automatiquement.`,
demandeId
]);
console.log('\n📬 Notification formation créée');
}
// ========================================
// ÉTAPE 7 : Récupérer les managers
// ========================================
let managers = [];
if (isAD) {
const [rows] = await conn.query(`
SELECT c.email FROM HierarchieValidationAD hv
JOIN CollaborateurAD c ON hv.SuperieurId = c.id
WHERE hv.CollaborateurId = ?
`, [collaborateurId]);
managers = rows.map(r => r.email);
}
await conn.commit();
console.log('\n🎉 Transaction validée\n');
// ========================================
// ÉTAPE 8 : Notifier les clients SSE
// ========================================
if (isFormationOnly && isAD && collaborateurId) {
notifyCollabClients({
type: 'demande-validated',
demandeId: parseInt(demandeId),
statut: 'Validée',
timestamp: new Date().toISOString()
}, collaborateurId);
}
// ========================================
// ENVOI DES EMAILS
// ========================================
const accessToken = await getGraphToken();
if (accessToken) {
const fromEmail = 'gtanoreply@ensup.eu';
const typesConges = Repartition.map(rep => {
const typeNom = rep.TypeConge === 'CP' ? 'Congé payé' :
rep.TypeConge === 'RTT' ? 'RTT' :
rep.TypeConge === 'ABS' ? 'Congé maladie' :
rep.TypeConge === 'Formation' ? 'Formation' :
rep.TypeConge === 'Récup' ? 'Récupération' : rep.TypeConge;
return `${typeNom}: ${rep.NombreJours}j`;
}).join(' | ');
if (isFormationOnly) {
const subjectCollab = '✅ Formation enregistrée et validée';
const bodyCollab = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #10b981; color: white; padding: 20px;">
<h2 style="margin: 0;">✅ Formation validée</h2>
</div>
<div style="padding: 20px;">
<p>Bonjour <strong>${Nom}</strong>,</p>
<p>Votre période de formation a été automatiquement validée.</p>
<p><strong>Période :</strong> ${datesPeriode}</p>
<p><strong>Durée :</strong> ${NombreJours} jour(s)</p>
</div>
</div>
`;
try {
await sendMailGraph(accessToken, fromEmail, Email, subjectCollab, bodyCollab);
} catch (mailError) {
console.error('❌ Erreur email:', mailError.message);
}
} else {
const subjectCollab = '✅ Confirmation de réception de votre demande de congé';
const bodyCollab = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #3b82f6; color: white; padding: 20px;">
<h2 style="margin: 0;">✅ Demande enregistrée</h2>
</div>
<div style="padding: 20px;">
<p>Bonjour <strong>${Nom}</strong>,</p>
<p>Votre demande de congé a bien été enregistrée.</p>
<p><strong>Type :</strong> ${typesConges}</p>
<p><strong>Période :</strong> ${datesPeriode}</p>
<p><strong>Durée :</strong> ${NombreJours} jour(s)</p>
</div>
</div>
`;
try {
await sendMailGraph(accessToken, fromEmail, Email, subjectCollab, bodyCollab);
} catch (mailError) {
console.error('❌ Erreur email:', mailError.message);
}
for (const managerEmail of managers) {
const subjectManager = `📋 Nouvelle demande de congé - ${Nom}`;
const bodyManager = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #f59e0b; color: white; padding: 20px;">
<h2 style="margin: 0;">📋 Nouvelle demande</h2>
</div>
<div style="padding: 20px;">
<p><strong>${Nom}</strong> a soumis une nouvelle demande.</p>
<p><strong>Type :</strong> ${typesConges}</p>
<p><strong>Période :</strong> ${datesPeriode}</p>
</div>
</div>
`;
try {
await sendMailGraph(accessToken, fromEmail, managerEmail, subjectManager, bodyManager);
} catch (mailError) {
console.error('❌ Erreur email manager:', mailError.message);
}
}
}
}
res.json({
success: true,
message: isFormationOnly ? 'Formation enregistrée et validée automatiquement' : 'Demande soumise',
request_id: demandeId,
managers,
auto_validated: isFormationOnly,
files_uploaded: uploadedFiles.length
});
} catch (error) {
await conn.rollback();
if (req.files) {
req.files.forEach(file => {
if (fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
console.log(`🗑️ Fichier supprimé: ${file.originalname}`);
}
});
}
console.error('\n❌ ERREUR submitLeaveRequest:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
} finally {
conn.release();
}
});
app.get('/api/download-medical/:documentId', async (req, res) => {
try {
const { documentId } = req.params;
const conn = await pool.getConnection();
const [docs] = await conn.query(
'SELECT TOP 1 * FROM DocumentsMedicaux WHERE Id = ?',
[documentId]
);
conn.release();
if (docs.length === 0) {
return res.status(404).json({ success: false, message: 'Document non trouvé' });
}
const doc = docs[0];
if (!fs.existsSync(doc.CheminFichier)) {
return res.status(404).json({ success: false, message: 'Fichier introuvable' });
}
res.download(doc.CheminFichier, doc.NomFichier);
} catch (error) {
console.error('Erreur téléchargement:', error);
res.status(500).json({ success: false, message: 'Erreur serveur' });
}
});
// Récupérer les documents d'une demande
app.get('/api/medical-documents/:demandeId', async (req, res) => {
try {
const { demandeId } = req.params;
const conn = await pool.getConnection();
const [docs] = await conn.query(
`SELECT Id, NomFichier, TypeMime, TailleFichier, DateUpload
FROM DocumentsMedicaux
WHERE DemandeCongeId = ?
ORDER BY DateUpload DESC`,
[demandeId]
);
conn.release();
res.json({
success: true,
documents: docs.map(doc => ({
id: doc.Id,
nom: doc.NomFichier,
type: doc.TypeMime,
taille: doc.TailleFichier,
date: doc.DateUpload,
downloadUrl: `/download-medical/${doc.Id}`
}))
});
} catch (error) {
console.error('Erreur récupération documents:', error);
res.status(500).json({ success: false, message: 'Erreur serveur' });
}
});
app.post('/api/validateRequest', async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const { request_id, action, validator_id, comment } = req.body;
console.log(`\n🔍 Validation demande #${request_id} - Action: ${action}`);
if (!request_id || !action || !validator_id) {
return res.json({ success: false, message: 'Données manquantes' });
}
// Récupérer le validateur
const [validator] = await conn.query(
'SELECT TOP 1 Id, prenom, nom, email, CampusId FROM CollaborateurAD WHERE Id = ?',
[validator_id]
);
if (validator.length === 0) {
throw new Error('Validateur introuvable');
}
// Récupérer la demande
const [requests] = await conn.query(
`SELECT TOP 1
dc.Id,
dc.CollaborateurADId,
dc.TypeCongeId,
dc.NombreJours,
dc.DateDebut,
dc.DateFin,
dc.Commentaire,
dc.Statut,
ca.prenom,
ca.nom,
ca.email as collaborateur_email,
tc.Nom as TypeConge
FROM DemandeConge dc
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
LEFT JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.Id
WHERE dc.Id = ?`,
[request_id]
);
if (requests.length === 0) {
throw new Error('Demande non trouvée');
}
const request = requests[0];
console.log('\n=== DONNÉES RÉCUPÉRÉES ===');
console.log('request:', JSON.stringify(request, null, 2));
console.log('validator:', JSON.stringify(validator[0], null, 2));
console.log('========================\n');
if (request.Statut !== 'En attente') {
throw new Error(`La demande a déjà été traitée (Statut: ${request.Statut})`);
}
const newStatus = action === 'approve' ? 'Validée' : 'Refusée';
// Si refus, restaurer les soldes
if (action === 'reject' && request.CollaborateurADId) {
console.log(`\n🔄 REFUS - Restauration des soldes...`);
const restoration = await restoreLeaveBalance(conn, request_id, request.CollaborateurADId);
console.log('Restauration:', restoration);
}
// Mettre à jour le statut
await conn.query(
`UPDATE DemandeConge
SET Statut = ?,
ValidateurId = ?,
ValidateurADId = ?,
DateValidation = GETDATE(),
CommentaireValidation = ?
WHERE Id = ?`,
[newStatus, validator_id, validator_id, comment || '', request_id]
);
// Créer une notification
const notifTitle = action === 'approve' ? 'Demande approuvée ✅' : 'Demande refusée ❌';
let notifMessage = `Votre demande a été ${action === 'approve' ? 'approuvée' : 'refusée'}`;
if (comment) notifMessage += ` (Commentaire: ${comment})`;
const notifType = action === 'approve' ? 'Success' : 'Error';
await conn.query(
`INSERT INTO Notifications
(CollaborateurADId, Titre, Message, Type, DemandeCongeId, DateCreation, lu)
VALUES (?, ?, ?, ?, ?, GETDATE(), 0)`,
[request.CollaborateurADId, notifTitle, notifMessage, notifType, request_id]
);
await conn.commit();
// Envoyer email via Microsoft Graph
console.log('\n📧 === TENTATIVE ENVOI EMAIL ===');
console.log('1. Récupération token...');
const accessToken = await getGraphToken();
console.log('2. Token obtenu ?', accessToken ? 'OUI' : 'NON');
if (accessToken && request.collaborateur_email) {
const fromEmail = 'gtanoreply@ensup.eu';
const collaborateurNom = `${request.prenom} ${request.nom}`;
const validateurNom = `${validator[0].prenom} ${validator[0].nom}`;
console.log('3. Préparation email professionnel...');
console.log(' De:', fromEmail);
console.log(' À:', request.collaborateur_email);
console.log(' Collaborateur:', collaborateurNom);
console.log(' Validateur:', validateurNom);
const dateDebut = new Date(request.DateDebut).toLocaleDateString('fr-FR');
const dateFin = new Date(request.DateFin).toLocaleDateString('fr-FR');
const datesPeriode = dateDebut === dateFin ? dateDebut : `du ${dateDebut} au ${dateFin}`;
const subject = action === 'approve'
? '✅ Votre demande de congé a été approuvée'
: '❌ Votre demande de congé a été refusée';
const emailHtml = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #ddd; border-radius: 8px;">
<h2 style="color: ${action === 'approve' ? '#10b981' : '#ef4444'};">${subject}</h2>
<p>Bonjour ${collaborateurNom},</p>
<p>Votre demande de ${request.TypeConge} pour ${request.NombreJours} jour(s) ${datesPeriode} a été <strong>${action === 'approve' ? 'approuvée' : 'refusée'}</strong> par ${validateurNom}.</p>
${comment ? `<p><strong>Commentaire:</strong> ${comment}</p>` : ''}
<p>Vous pouvez consulter les détails dans l'application GTA.</p>
<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">
Ceci est un email automatique, merci de ne pas y répondre.
</p>
</div>
</body>
</html>
`;
const emailSent = await sendMailGraph(accessToken, fromEmail, request.collaborateur_email, subject, emailHtml);
console.log('4. Email envoyé ?', emailSent ? 'OUI ✅' : 'NON ❌');
}
res.json({
success: true,
message: `Demande ${action === 'approve' ? 'approuvée' : 'refusée'} avec succès`
});
} catch (error) {
await conn.rollback();
console.error('❌ Erreur validateRequest:', error);
res.status(500).json({ success: false, message: error.message });
} finally {
conn.release();
}
});
app.get('/api/testRestoration', async (req, res) => {
const conn = await pool.getConnection();
try {
const { demande_id, collab_id } = req.query;
if (!demande_id || !collab_id) {
return res.json({
success: false,
message: 'Paramètres manquants: demande_id et collab_id requis'
});
}
// 1. Voir les déductions enregistrées
const [deductions] = await conn.query(
`SELECT dd.*, tc.Nom as TypeNom
FROM DeductionDetails dd
JOIN TypeConge tc ON dd.TypeCongeId = tc.Id
WHERE dd.DemandeCongeId = ?`,
[demande_id]
);
// 2. Voir l'état actuel des compteurs
const [compteurs] = await conn.query(
`SELECT cc.*, tc.Nom as TypeNom
FROM CompteurConges cc
JOIN TypeConge tc ON cc.TypeCongeId = tc.Id
WHERE cc.CollaborateurADId = ?
ORDER BY tc.Nom, cc.Annee DESC`,
[collab_id]
);
// 3. Calculer ce que devrait être la restauration
const planRestauration = deductions.map(d => ({
type: d.TypeNom,
annee: d.Annee,
typeDeduction: d.TypeDeduction,
joursARestorer: d.JoursUtilises,
action: d.TypeDeduction === 'Reporté N-1'
? 'Ajouter au SoldeReporte ET au Solde'
: 'Ajouter au Solde uniquement'
}));
conn.release();
res.json({
success: true,
demande_id: demande_id,
collaborateur_id: collab_id,
deductions_enregistrees: deductions,
compteurs_actuels: compteurs,
plan_restauration: planRestauration,
instructions: planRestauration.length === 0
? "❌ Aucune déduction trouvée - La demande a été créée avant l'installation du tracking"
: "✅ Déductions trouvées - La restauration devrait fonctionner"
});
} catch (error) {
conn.release();
res.status(500).json({
success: false,
error: error.message
});
}
});
function normalizeRole(role) {
if (!role) return null;
const roleLower = role.toLowerCase();
// Normaliser les variantes féminines et masculines
if (roleLower === 'collaboratrice') return 'collaborateur';
if (roleLower === 'validatrice') return 'validateur';
if (roleLower === 'directrice de campus') return 'directeur de campus';
if (roleLower === 'apprentie') return 'apprenti';
return roleLower;
}
app.get('/api/getSocietesByCampus', async (req, res) => {
try {
const { campusId } = req.query;
const conn = await pool.getConnection();
const [societes] = await conn.query(`
SELECT DISTINCT s.Id, s.Nom
FROM SocieteCampus sc
JOIN Societe s ON sc.SocieteId = s.Id
WHERE sc.CampusId = ?
ORDER BY
CASE WHEN s.Nom LIKE '%SOLUTION%' THEN 1 ELSE 2 END,
s.Nom
`, [campusId]);
conn.release();
res.json({
success: true,
societes: societes
});
} catch (error) {
console.error('Erreur getSocietesByCampus:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// ⭐ NOUVELLE ROUTE HELPER : Récupérer les campus d'une société
app.get('/api/getCampusBySociete', async (req, res) => {
try {
const { societeId } = req.query;
const conn = await pool.getConnection();
const [campus] = await conn.query(`
SELECT DISTINCT c.Id, c.Nom, sc.Principal
FROM SocieteCampus sc
JOIN Campus c ON sc.CampusId = c.Id
WHERE sc.SocieteId = ?
ORDER BY
sc.Principal DESC, -- Principal en premier
c.Nom
`, [societeId]);
conn.release();
res.json({
success: true,
campus: campus,
isMultiCampus: campus.length > 1
});
} catch (error) {
console.error('Erreur getCampusBySociete:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// ========================================
// ROUTE getTeamLeaves COMPLÈTE
// ========================================
app.get('/api/getTeamLeaves', async (req, res) => {
try {
let { user_id: userIdParam, role: roleParam, selectedCampus, selectedSociete, selectedService } = req.query;
console.log(`🔍 Paramètres reçus: user_id=${userIdParam}, role=${roleParam}, selectedCampus=${selectedCampus}`);
if (!userIdParam) {
return res.json({ success: false, message: 'ID utilisateur manquant' });
}
const isUUID = userIdParam.length > 10 && userIdParam.includes('-');
console.log(`📝 Type ID détecté: ${isUUID ? 'UUID' : 'Numérique'}`);
const userRequest = pool.request();
userRequest.input('userIdParam', userIdParam);
const userQuery = `
SELECT
ca.id,
ca.ServiceId,
ca.CampusId,
ca.SocieteId,
ca.email,
s.Nom AS serviceNom,
c.Nom AS campusNom,
so.Nom AS societeNom
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Campus c ON ca.CampusId = c.Id
LEFT JOIN Societe so ON ca.SocieteId = so.Id
WHERE ${isUUID ? 'ca.entraUserId' : 'ca.id'} = @userIdParam
`;
const userResult = await userRequest.query(userQuery);
if (!userResult.recordset || userResult.recordset.length === 0) {
return res.json({ success: false, message: 'Collaborateur non trouvé' });
}
const userInfo = userResult.recordset[0];
const serviceId = userInfo.ServiceId;
const campusId = userInfo.CampusId;
const societeId = userInfo.SocieteId;
const userEmail = userInfo.email;
const campusNom = userInfo.campusNom;
const serviceNom = userInfo.serviceNom;
const societeNom = userInfo.societeNom;
function normalizeRole(role) {
if (!role) return null;
const roleLower = role.toLowerCase();
if (roleLower === 'collaboratrice') return 'collaborateur';
if (roleLower === 'validatrice') return 'validateur';
if (roleLower === 'directrice de campus') return 'directeur de campus';
if (roleLower === 'apprentie') return 'apprenti';
return roleLower;
}
const roleOriginal = roleParam?.toLowerCase();
const role = normalizeRole(roleOriginal);
console.log(`👤 Utilisateur trouvé:`);
console.log(` - ID: ${userInfo.id}`);
console.log(` - Email: ${userEmail}`);
console.log(` - ServiceId: ${serviceId}`);
console.log(` - CampusId: ${campusId}`);
console.log(` - CampusNom: ${campusNom}`);
console.log(` - ServiceNom: ${serviceNom}`);
console.log(` - SocieteId: ${societeId}`);
console.log(` - Role normalisé: ${role}`);
const filters = {};
// ========================================
// CAS 1: PRESIDENT, ADMIN, RH, DIRECTEUR DE CAMPUS
// ⚠️ SANS VALIDATEUR - C'est la correction principale !
// ========================================
if (role === 'president' || role === 'admin' || role === 'rh' || role === 'directeur de campus') {
console.log("CAS 1: President/Admin/RH/Directeur de Campus - Vue globale");
console.log(` Filtres reçus: Société=${selectedSociete}, Campus=${selectedCampus}, Service=${selectedService}`);
// 1⃣ SOCIÉTÉS
const societesRequest = pool.request();
const societesResult = await societesRequest.query(`
SELECT DISTINCT Nom
FROM Societe
ORDER BY Nom
`);
filters.societes = societesResult.recordset.map(s => s.Nom);
console.log('📊 Sociétés disponibles:', filters.societes);
// 2⃣ CAMPUS
let campusRequest = pool.request();
let campusQuery;
if (selectedSociete && selectedSociete !== 'all') {
campusQuery = `
SELECT DISTINCT c.Nom
FROM Campus c
JOIN CollaborateurAD ca ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
WHERE so.Nom = @selectedSociete
AND (ca.actif = 1 OR ca.actif IS NULL)
ORDER BY c.Nom
`;
campusRequest.input('selectedSociete', selectedSociete);
} else {
campusQuery = `
SELECT DISTINCT Nom
FROM Campus
ORDER BY Nom
`;
}
const campusResult = await campusRequest.query(campusQuery);
filters.campus = campusResult.recordset.map(c => c.Nom);
console.log('📊 Campus disponibles:', filters.campus);
if (role === 'directeur de campus') {
filters.defaultCampus = campusNom;
console.log('🏢 Campus par défaut pour directeur:', campusNom);
}
// 3⃣ SERVICES
let servicesQuery = `
SELECT DISTINCT s.Nom
FROM Services s
JOIN CollaborateurAD ca ON ca.ServiceId = s.Id
`;
let servicesConditions = ['(ca.actif = 1 OR ca.actif IS NULL)'];
let servicesRequest = pool.request();
if (selectedSociete && selectedSociete !== 'all') {
servicesQuery += '\nJOIN Societe so ON ca.SocieteId = so.Id';
servicesConditions.push('so.Nom = @selectedSociete');
servicesRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
servicesQuery += '\nJOIN Campus c ON ca.CampusId = c.Id';
servicesConditions.push('c.Nom = @selectedCampus');
servicesRequest.input('selectedCampus', selectedCampus);
}
servicesQuery += `\nWHERE ${servicesConditions.join(' AND ')}\nORDER BY s.Nom`;
const servicesResult = await servicesRequest.query(servicesQuery);
filters.services = servicesResult.recordset.map(s => s.Nom);
// ⭐ LISTE DES EMPLOYÉS
let employeesQuery = `
SELECT
CONCAT(ca.prenom, ' ', ca.nom) AS fullname,
c.Nom AS campusnom,
so.Nom AS societenom,
s.Nom AS servicenom
FROM CollaborateurAD ca
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
`;
let employeesConditions = [];
let employeesRequest = pool.request();
if (selectedSociete && selectedSociete !== 'all') {
employeesConditions.push('so.Nom = @selectedSociete');
employeesRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
employeesConditions.push('c.Nom = @selectedCampus');
employeesRequest.input('selectedCampus', selectedCampus);
} else if (role === 'directeur de campus' && campusNom) {
employeesConditions.push('c.Nom = @campusNom');
employeesRequest.input('campusNom', campusNom);
}
if (selectedService && selectedService !== 'all') {
employeesConditions.push('s.Nom = @selectedService');
employeesRequest.input('selectedService', selectedService);
}
if (employeesConditions.length > 0) {
employeesQuery += ` AND ${employeesConditions.join(' AND ')}`;
}
employeesQuery += ` ORDER BY so.Nom, c.Nom, ca.prenom, ca.nom`;
const employeesResult = await employeesRequest.query(employeesQuery);
filters.employees = employeesResult.recordset.map(e => ({
name: e.fullname,
campus: e.campusnom,
societe: e.societenom,
service: e.servicenom
}));
console.log(`👥 Employés trouvés:`, filters.employees.length);
// QUERY DES CONGÉS
let whereConditions = [`dc.Statut IN ('Validé', 'Validée', 'Valide', 'En attente')`];
let queryRequest = pool.request();
if (selectedSociete && selectedSociete !== 'all') {
whereConditions.push('so.Nom = @selectedSociete');
queryRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
whereConditions.push('c.Nom = @selectedCampus');
queryRequest.input('selectedCampus', selectedCampus);
} else if (role === 'directeur de campus' && campusNom) {
whereConditions.push('c.Nom = @campusNom');
queryRequest.input('campusNom', campusNom);
}
if (selectedService && selectedService !== 'all') {
whereConditions.push('s.Nom = @selectedService');
queryRequest.input('selectedService', selectedService);
}
const query = `
SELECT
CONVERT(VARCHAR(10), dc.DateDebut, 23) AS startdate,
CONVERT(VARCHAR(10), dc.DateFin, 23) AS enddate,
CONCAT(ca.prenom, ' ', ca.nom) AS employeename,
(
SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom)
FROM (SELECT DISTINCT tc2.Nom
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id) AS DistinctTypes
) AS type,
CONCAT(
'[',
STRING_AGG(
CONCAT(
'{"type":"', tc.Nom,
'","jours":', dct.NombreJours,
',"periode":"', COALESCE(dct.PeriodeJournee, 'Journée entière'), '"}'
),
','
),
']'
) AS detailsconges,
MAX(tc.CouleurHex) AS color,
dc.Statut AS statut,
s.Nom AS servicenom,
c.Nom AS campusnom,
so.Nom AS societenom,
dc.NombreJours AS nombrejoursouvres
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
WHERE ${whereConditions.join(' AND ')}
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, ca.prenom, ca.nom, dc.Statut, s.Nom, c.Nom, so.Nom, dc.NombreJours
ORDER BY c.Nom, dc.DateDebut ASC
`;
console.log(`🔍 Query finale WHERE:`, whereConditions.join(' AND '));
const leavesResult = await queryRequest.query(query);
const formattedLeaves = leavesResult.recordset.map(leave => ({
...leave
}));
console.log(`${formattedLeaves.length} congés trouvés`);
return res.json({
success: true,
role: role,
leaves: formattedLeaves,
filters: filters
});
}
// ========================================
// CAS 2: VALIDATEUR - Vue équipe via HierarchieValidationAD
// ⚠️ C'est ici que le validateur doit tomber !
// ========================================
else if (role === 'validateur') {
console.log("CAS 2: Validateur - Vue de l'équipe via HierarchieValidationAD");
console.log(`📍 Validateur: Service=${serviceNom}, Campus=${campusNom}, Société=${societeNom}`);
const isFirstLoad = !selectedCampus && !selectedService && !selectedSociete;
if (isFirstLoad) {
console.log('🎯 Premier chargement validateur : initialisation avec valeurs par défaut');
selectedCampus = campusNom;
selectedService = serviceNom;
selectedSociete = societeNom;
}
console.log(`📍 Filtres appliqués: Société=${selectedSociete}, Campus=${selectedCampus}, Service=${selectedService}`);
// Sociétés disponibles
const societesRequest = pool.request();
const societesResult = await societesRequest.query(`
SELECT DISTINCT so.Nom
FROM Societe so
JOIN CollaborateurAD ca ON ca.SocieteId = so.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
ORDER BY so.Nom
`);
filters.societes = societesResult.recordset.map(s => s.Nom);
// Campus disponibles
let campusQuery = `
SELECT DISTINCT c.Nom
FROM Campus c
JOIN CollaborateurAD ca ON ca.CampusId = c.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
`;
let campusRequest = pool.request();
if (selectedSociete && selectedSociete !== 'all') {
campusQuery += ` AND ca.SocieteId = (SELECT Id FROM Societe WHERE Nom = @selectedSociete)`;
campusRequest.input('selectedSociete', selectedSociete);
}
campusQuery += ` ORDER BY c.Nom`;
const campusResult = await campusRequest.query(campusQuery);
filters.campus = campusResult.recordset.map(c => c.Nom);
// Services disponibles
let servicesQuery = `
SELECT DISTINCT s.Nom
FROM Services s
JOIN CollaborateurAD ca ON ca.ServiceId = s.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
`;
let servicesRequest = pool.request();
if (selectedSociete && selectedSociete !== 'all') {
servicesQuery += ` AND ca.SocieteId = (SELECT Id FROM Societe WHERE Nom = @selectedSociete)`;
servicesRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
servicesQuery += ` AND ca.CampusId = (SELECT Id FROM Campus WHERE Nom = @selectedCampus)`;
servicesRequest.input('selectedCampus', selectedCampus);
}
servicesQuery += ` ORDER BY s.Nom`;
const servicesResult = await servicesRequest.query(servicesQuery);
filters.services = servicesResult.recordset.map(s => s.Nom);
filters.defaultCampus = campusNom;
filters.defaultService = serviceNom;
filters.defaultSociete = societeNom;
// ⭐ LISTE DES EMPLOYÉS - UNIQUEMENT CEUX DE L'ÉQUIPE DU VALIDATEUR
let employeesQuery = `
SELECT
CONCAT(ca.prenom, ' ', ca.nom) AS fullname,
c.Nom AS campusnom,
so.Nom AS societenom,
s.Nom AS servicenom
FROM CollaborateurAD ca
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
JOIN HierarchieValidationAD h ON ca.id = h.CollaborateurId
WHERE h.SuperieurId = @userId
AND (ca.actif = 1 OR ca.actif IS NULL)
`;
let employeesRequest = pool.request();
employeesRequest.input('userId', userInfo.id);
let employeesConditions = [];
if (selectedSociete && selectedSociete !== 'all') {
employeesConditions.push('so.Nom = @selectedSociete');
employeesRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
employeesConditions.push('c.Nom = @selectedCampus');
employeesRequest.input('selectedCampus', selectedCampus);
}
if (selectedService && selectedService !== 'all') {
employeesConditions.push('s.Nom = @selectedService');
employeesRequest.input('selectedService', selectedService);
}
if (employeesConditions.length > 0) {
employeesQuery += ` AND ${employeesConditions.join(' AND ')}`;
}
employeesQuery += ` ORDER BY s.Nom, ca.prenom, ca.nom`;
const employeesResult = await employeesRequest.query(employeesQuery);
filters.employees = employeesResult.recordset.map(emp => ({
name: emp.fullname,
campus: emp.campusnom,
societe: emp.societenom,
service: emp.servicenom
}));
console.log(`👥 Équipe du validateur: ${filters.employees.length} personnes`);
// ⭐ QUERY DES CONGÉS - UNIQUEMENT LES COLLABORATEURS DU VALIDATEUR
let queryConditions = `WHERE dc.Statut IN ('Validé', 'Validée', 'Valide', 'En attente')
AND ca.id IN (
SELECT CollaborateurId FROM HierarchieValidationAD WHERE SuperieurId = @userId
)`;
let queryRequest = pool.request();
queryRequest.input('userId', userInfo.id);
let congesConditions = [];
if (selectedSociete && selectedSociete !== 'all') {
congesConditions.push('so.Nom = @selectedSociete');
queryRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
congesConditions.push('c.Nom = @selectedCampus');
queryRequest.input('selectedCampus', selectedCampus);
}
if (selectedService && selectedService !== 'all') {
congesConditions.push('s.Nom = @selectedService');
queryRequest.input('selectedService', selectedService);
}
if (congesConditions.length > 0) {
queryConditions += ` AND ${congesConditions.join(' AND ')}`;
}
const query = `
SELECT
CONVERT(VARCHAR(10), dc.DateDebut, 23) AS startdate,
CONVERT(VARCHAR(10), dc.DateFin, 23) AS enddate,
CONCAT(ca.prenom, ' ', ca.nom) AS employeename,
(
SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom)
FROM (SELECT DISTINCT tc2.Nom
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id) AS DistinctTypes
) AS type,
CONCAT(
'[',
STRING_AGG(
CONCAT(
'{"type":"', tc.Nom,
'","jours":', dct.NombreJours,
',"periode":"', COALESCE(dct.PeriodeJournee, 'Journée entière'), '"}'
),
','
),
']'
) AS detailsconges,
MAX(tc.CouleurHex) AS color,
dc.Statut AS statut,
s.Nom AS servicenom,
c.Nom AS campusnom,
so.Nom AS societenom,
dc.NombreJours AS nombrejoursouvres
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
${queryConditions}
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, ca.prenom, ca.nom, dc.Statut, s.Nom, c.Nom, so.Nom, dc.NombreJours
ORDER BY s.Nom, dc.DateDebut ASC
`;
console.log(`🔍 Query WHERE final validateur:`, queryConditions);
const leavesResult = await queryRequest.query(query);
const formattedLeaves = leavesResult.recordset.map(leave => ({
...leave
}));
console.log(`${formattedLeaves.length} congés trouvés pour l'équipe du validateur`);
return res.json({
success: true,
role: role,
leaves: formattedLeaves,
filters: filters
});
}
// ========================================
// CAS 3: COLLABORATEUR / APPRENTI
// ========================================
else if (role === 'collaborateur' || role === 'apprenti') {
console.log("CAS 3: Collaborateur/Apprenti avec filtres avancés");
console.log(`📍 Filtres reçus du frontend: Société=${selectedSociete}, Campus=${selectedCampus}, Service=${selectedService}`);
const isFirstLoad = !selectedCampus && !selectedService && !selectedSociete;
if (isFirstLoad) {
console.log('🎯 Premier chargement : initialisation avec service par défaut');
selectedCampus = campusNom;
selectedService = serviceNom;
selectedSociete = societeNom;
}
console.log(`📍 Filtres appliqués finaux: Société=${selectedSociete}, Campus=${selectedCampus}, Service=${selectedService}`);
// Sociétés disponibles
const societesRequest = pool.request();
const societesResult = await societesRequest.query(`
SELECT DISTINCT so.Nom
FROM Societe so
JOIN CollaborateurAD ca ON ca.SocieteId = so.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
ORDER BY so.Nom
`);
filters.societes = societesResult.recordset.map(s => s.Nom);
// Campus disponibles
let campusQuery = `
SELECT DISTINCT c.Nom
FROM Campus c
JOIN CollaborateurAD ca ON ca.CampusId = c.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
`;
let campusRequest = pool.request();
if (selectedSociete && selectedSociete !== 'all') {
campusQuery += ` AND ca.SocieteId = (SELECT Id FROM Societe WHERE Nom = @selectedSociete)`;
campusRequest.input('selectedSociete', selectedSociete);
}
campusQuery += ` ORDER BY c.Nom`;
const campusResult = await campusRequest.query(campusQuery);
filters.campus = campusResult.recordset.map(c => c.Nom);
// Services disponibles
let servicesQuery = `
SELECT DISTINCT s.Nom
FROM Services s
JOIN CollaborateurAD ca ON ca.ServiceId = s.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
`;
let servicesRequest = pool.request();
if (selectedSociete && selectedSociete !== 'all') {
servicesQuery += ` AND ca.SocieteId = (SELECT Id FROM Societe WHERE Nom = @selectedSociete)`;
servicesRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
servicesQuery += ` AND ca.CampusId = (SELECT Id FROM Campus WHERE Nom = @selectedCampus)`;
servicesRequest.input('selectedCampus', selectedCampus);
}
servicesQuery += ` ORDER BY s.Nom`;
const servicesResult = await servicesRequest.query(servicesQuery);
filters.services = servicesResult.recordset.map(s => s.Nom);
filters.defaultCampus = campusNom;
filters.defaultService = serviceNom;
filters.defaultSociete = societeNom;
// ⭐ LISTE DES EMPLOYÉS
let employeesQuery = `
SELECT
CONCAT(ca.prenom, ' ', ca.nom) AS fullname,
c.Nom AS campusnom,
so.Nom AS societenom,
s.Nom AS servicenom
FROM CollaborateurAD ca
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
`;
let employeesRequest = pool.request();
let employeesConditions = [];
if (selectedSociete && selectedSociete !== 'all') {
employeesConditions.push('so.Nom = @selectedSociete');
employeesRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
employeesConditions.push('c.Nom = @selectedCampus');
employeesRequest.input('selectedCampus', selectedCampus);
}
if (selectedService && selectedService !== 'all') {
employeesConditions.push('s.Nom = @selectedService');
employeesRequest.input('selectedService', selectedService);
}
if (employeesConditions.length > 0) {
employeesQuery += ` AND ${employeesConditions.join(' AND ')}`;
}
employeesQuery += ` ORDER BY s.Nom, ca.prenom, ca.nom`;
const employeesResult = await employeesRequest.query(employeesQuery);
filters.employees = employeesResult.recordset.map(emp => ({
name: emp.fullname,
campus: emp.campusnom,
societe: emp.societenom,
service: emp.servicenom
}));
console.log(`👥 Employés trouvés: ${filters.employees.length}`);
// QUERY DES CONGÉS
let queryConditions = `WHERE dc.Statut IN ('Validé', 'Validée', 'Valide', 'En attente')`;
let queryRequest = pool.request();
let congesConditions = [];
if (selectedSociete && selectedSociete !== 'all') {
congesConditions.push('so.Nom = @selectedSociete');
queryRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
congesConditions.push('c.Nom = @selectedCampus');
queryRequest.input('selectedCampus', selectedCampus);
}
if (selectedService && selectedService !== 'all') {
congesConditions.push('s.Nom = @selectedService');
queryRequest.input('selectedService', selectedService);
}
if (congesConditions.length > 0) {
queryConditions += ` AND ${congesConditions.join(' AND ')}`;
}
const query = `
SELECT
CONVERT(VARCHAR(10), dc.DateDebut, 23) AS startdate,
CONVERT(VARCHAR(10), dc.DateFin, 23) AS enddate,
CONCAT(ca.prenom, ' ', ca.nom) AS employeename,
(
SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom)
FROM (SELECT DISTINCT tc2.Nom
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id) AS DistinctTypes
) AS type,
CONCAT(
'[',
STRING_AGG(
CONCAT(
'{"type":"', tc.Nom,
'","jours":', dct.NombreJours,
',"periode":"', COALESCE(dct.PeriodeJournee, 'Journée entière'), '"}'
),
','
),
']'
) AS detailsconges,
MAX(tc.CouleurHex) AS color,
dc.Statut AS statut,
s.Nom AS servicenom,
c.Nom AS campusnom,
so.Nom AS societenom,
dc.NombreJours AS nombrejoursouvres
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
${queryConditions}
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, ca.prenom, ca.nom, dc.Statut, s.Nom, c.Nom, so.Nom, dc.NombreJours
ORDER BY s.Nom, dc.DateDebut ASC
`;
console.log(`🔍 Query WHERE final:`, queryConditions);
const leavesResult = await queryRequest.query(query);
const formattedLeaves = leavesResult.recordset.map(leave => ({
...leave
}));
console.log(`${formattedLeaves.length} congés trouvés`);
return res.json({
success: true,
role: role,
leaves: formattedLeaves,
filters: filters
});
}
// ========================================
// CAS 4: AUTRES RÔLES (Fallback)
// ========================================
else {
console.log("CAS 4: Autres rôles - Fallback service/campus");
if (!serviceId) {
return res.json({
success: false,
message: 'ServiceId manquant'
});
}
const checkServiceRequest = pool.request();
checkServiceRequest.input('serviceId', serviceId);
const checkServiceResult = await checkServiceRequest.query(`SELECT Nom FROM Services WHERE Id = @serviceId`);
const serviceNomCheck = checkServiceResult.recordset.length > 0 ? checkServiceResult.recordset[0].Nom : "Inconnu";
const isAdminFinancier = serviceNomCheck === "Administratif & Financier";
if (isAdminFinancier) {
// Service multi-campus
const employeesRequest = pool.request();
employeesRequest.input('serviceId', serviceId);
const employeesResult = await employeesRequest.query(`
SELECT
CONCAT(ca.prenom, ' ', ca.nom) AS fullname,
c.Nom AS campusnom,
so.Nom AS societenom,
s.Nom AS servicenom
FROM CollaborateurAD ca
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
WHERE ca.ServiceId = @serviceId
AND (ca.actif = 1 OR ca.actif IS NULL)
ORDER BY ca.prenom, ca.nom
`);
filters.employees = employeesResult.recordset.map(e => ({
name: e.fullname,
campus: e.campusnom,
societe: e.societenom,
service: e.servicenom
}));
const queryRequest = pool.request();
queryRequest.input('serviceId', serviceId);
const query = `
SELECT
CONVERT(VARCHAR(10), dc.DateDebut, 23) AS startdate,
CONVERT(VARCHAR(10), dc.DateFin, 23) AS enddate,
CONCAT(ca.prenom, ' ', ca.nom) AS employeename,
(
SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom)
FROM (SELECT DISTINCT tc2.Nom
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id) AS DistinctTypes
) AS type,
CONCAT(
'[',
STRING_AGG(
CONCAT(
'{"type":"', tc.Nom,
'","jours":', dct.NombreJours,
',"periode":"', COALESCE(dct.PeriodeJournee, 'Journée entière'), '"}'
),
','
),
']'
) AS detailsconges,
MAX(tc.CouleurHex) AS color,
dc.Statut AS statut,
s.Nom AS servicenom,
c.Nom AS campusnom,
so.Nom AS societenom,
dc.NombreJours AS nombrejoursouvres
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
WHERE dc.Statut IN ('Validé', 'Validée', 'Valide', 'En attente')
AND ca.ServiceId = @serviceId
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, ca.prenom, ca.nom, dc.Statut, s.Nom, c.Nom, so.Nom, dc.NombreJours
ORDER BY c.Nom, dc.DateDebut ASC
`;
const leavesResult = await queryRequest.query(query);
const formattedLeaves = leavesResult.recordset.map(leave => ({
...leave
}));
console.log(`${formattedLeaves.length} congés trouvés`);
return res.json({
success: true,
role: role,
leaves: formattedLeaves,
filters: filters
});
} else {
// Service + Campus
const employeesRequest = pool.request();
employeesRequest.input('serviceId', serviceId);
employeesRequest.input('campusId', campusId);
const employeesResult = await employeesRequest.query(`
SELECT
CONCAT(ca.prenom, ' ', ca.nom) AS fullname,
c.Nom AS campusnom,
so.Nom AS societenom,
s.Nom AS servicenom
FROM CollaborateurAD ca
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
WHERE ca.ServiceId = @serviceId
AND ca.CampusId = @campusId
AND (ca.actif = 1 OR ca.actif IS NULL)
ORDER BY ca.prenom, ca.nom
`);
filters.employees = employeesResult.recordset.map(e => ({
name: e.fullname,
campus: e.campusnom,
societe: e.societenom,
service: e.servicenom
}));
const queryRequest = pool.request();
queryRequest.input('serviceId', serviceId);
queryRequest.input('campusId', campusId);
const query = `
SELECT
CONVERT(VARCHAR(10), dc.DateDebut, 23) AS startdate,
CONVERT(VARCHAR(10), dc.DateFin, 23) AS enddate,
CONCAT(ca.prenom, ' ', ca.nom) AS employeename,
(
SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom)
FROM (SELECT DISTINCT tc2.Nom
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id) AS DistinctTypes
) AS type,
CONCAT(
'[',
STRING_AGG(
CONCAT(
'{"type":"', tc.Nom,
'","jours":', dct.NombreJours,
',"periode":"', COALESCE(dct.PeriodeJournee, 'Journée entière'), '"}'
),
','
),
']'
) AS detailsconges,
MAX(tc.CouleurHex) AS color,
dc.Statut AS statut,
s.Nom AS servicenom,
c.Nom AS campusnom,
so.Nom AS societenom,
dc.NombreJours AS nombrejoursouvres
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
WHERE dc.Statut IN ('Validé', 'Validée', 'Valide', 'En attente')
AND ca.ServiceId = @serviceId
AND ca.CampusId = @campusId
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, ca.prenom, ca.nom, dc.Statut, s.Nom, c.Nom, so.Nom, dc.NombreJours
ORDER BY c.Nom, dc.DateDebut ASC
`;
const leavesResult = await queryRequest.query(query);
const formattedLeaves = leavesResult.recordset.map(leave => ({
...leave
}));
console.log(`${formattedLeaves.length} congés trouvés`);
return res.json({
success: true,
role: role,
leaves: formattedLeaves,
filters: filters
});
}
}
} catch (error) {
console.error("❌ Erreur getTeamLeaves:", error);
res.status(500).json({
success: false,
message: "Erreur serveur",
error: error.message
});
}
});
// ========================================
// SYNCHRONISATION ENTRA ID CORRIGÉE
// À remplacer dans server.js à partir de la ligne ~3700
// ========================================
app.post('/api/initial-sync', async (req, res) => {
let errorCount = 0;
const syncResults = {
processed: 0,
inserted: 0,
updated: 0,
deactivated: 0,
errors: []
};
try {
console.log('\n🔄 === DÉBUT SYNCHRONISATION ENTRA ID ===');
// 1⃣ Obtenir le token Admin
const accessToken = await getGraphToken();
if (!accessToken) {
return res.json({
success: false,
message: '❌ Impossible d\'obtenir le token Microsoft Graph'
});
}
console.log('✅ Token Microsoft Graph obtenu');
// =============================================================================
// SCÉNARIO 1 : Synchronisation unitaire (Un seul utilisateur spécifique)
// =============================================================================
if (req.body.userPrincipalName || req.body.mail) {
const userEmail = (req.body.mail || req.body.userPrincipalName).toLowerCase().trim();
const entraUserId = req.body.id;
console.log(`\n🔄 Synchronisation utilisateur unique : ${userEmail}`);
// ⭐ VALIDATION : Email requis
if (!userEmail || userEmail === '') {
return res.json({
success: false,
message: '❌ Email utilisateur manquant ou invalide'
});
}
// ⭐ VALIDATION : Format email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(userEmail)) {
return res.json({
success: false,
message: `❌ Format d'email invalide : ${userEmail}`
});
}
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// Vérifier si l'utilisateur existe déjà
const [existing] = await conn.query(
'SELECT id, email, Actif FROM CollaborateurAD WHERE LOWER(email) = ?',
[userEmail]
);
if (existing.length > 0) {
// MISE À JOUR
await conn.query(`
UPDATE CollaborateurAD
SET
entraUserId = ?,
prenom = ?,
nom = ?,
service = ?,
description = ?,
Actif = 1
WHERE LOWER(email) = ?
`, [
entraUserId || existing[0].entraUserId,
req.body.givenName || 'Prénom',
req.body.surname || 'Nom',
req.body.department || '',
req.body.jobTitle || null,
userEmail
]);
console.log(` ✅ Utilisateur mis à jour : ${userEmail}`);
syncResults.updated++;
} else {
// INSERTION
await conn.query(`
INSERT INTO CollaborateurAD
(entraUserId, prenom, nom, email, service, description, role, SocieteId, Actif, DateEntree, TypeContrat)
VALUES (?, ?, ?, ?, ?, ?, 'Collaborateur', 1, 1, GETDATE(), '37h')
`, [
entraUserId,
req.body.givenName || 'Prénom',
req.body.surname || 'Nom',
userEmail,
req.body.department || '',
req.body.jobTitle || null
]);
console.log(` ✅ Nouvel utilisateur créé : ${userEmail}`);
syncResults.inserted++;
}
// Récupération des données fraîches
const [userRows] = await conn.query(`
SELECT
ca.id as localUserId,
ca.entraUserId,
ca.prenom,
ca.nom,
ca.email,
ca.role,
s.Nom as service,
ca.TypeContrat as typeContrat,
ca.DateEntree as dateEntree,
ca.description,
ca.CampusId,
ca.SocieteId,
so.Nom as societe_nom
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Societe so ON ca.SocieteId = so.Id
WHERE LOWER(ca.email) = ?
`, [userEmail]);
await conn.commit();
if (userRows.length === 0) {
await conn.rollback();
throw new Error('Utilisateur synchronisé mais introuvable en base');
}
const userData = userRows[0];
console.log(`✅ Synchronisation réussie : ${userData.email}`);
return res.json({
success: true,
message: 'Utilisateur synchronisé avec succès',
localUserId: userData.localUserId,
role: userData.role,
service: userData.service,
typeContrat: userData.typeContrat,
dateEntree: userData.dateEntree,
societeId: userData.SocieteId,
user: userData
});
} catch (syncError) {
await conn.rollback();
console.error('❌ Erreur sync unitaire:', syncError);
return res.json({
success: false,
message: `❌ Erreur synchronisation: ${syncError.message}`
});
} finally {
conn.release();
}
}
// =============================================================================
// SCÉNARIO 2 : Full Sync (Tous les membres du groupe Azure)
// =============================================================================
console.log('\n🔄 === FULL SYNC - Tous les membres du groupe ===');
// A. Récupérer le nom du groupe
const groupResponse = await axios.get(
`https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
const groupName = groupResponse.data.displayName;
console.log(`📋 Groupe : ${groupName}`);
// B. Récupérer TOUS les membres avec pagination
let allAzureMembers = [];
let nextLink = `https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}/members?$select=id,givenName,surname,mail,department,jobTitle,accountEnabled&$top=999`;
console.log('📥 Récupération des membres...');
while (nextLink) {
const membersResponse = await axios.get(nextLink, {
headers: { Authorization: `Bearer ${accessToken}` }
});
allAzureMembers = allAzureMembers.concat(membersResponse.data.value);
nextLink = membersResponse.data['@odata.nextLink'];
if (nextLink) {
console.log(` 📄 ${allAzureMembers.length} membres récupérés... (suite)`);
}
}
console.log(`${allAzureMembers.length} membres trouvés dans Entra ID`);
// C. Filtrer et valider les emails
const validMembers = allAzureMembers.filter(m => {
// Ignorer si pas d'email
if (!m.mail || m.mail.trim() === '') {
console.log(` ⚠️ Ignoré (pas d'email) : ${m.givenName} ${m.surname} (${m.id})`);
return false;
}
// Ignorer si compte désactivé
if (m.accountEnabled === false) {
console.log(` ⚠️ Ignoré (compte désactivé) : ${m.mail}`);
return false;
}
// Valider format email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(m.mail)) {
console.log(` ⚠️ Ignoré (format email invalide) : ${m.mail}`);
return false;
}
return true;
});
console.log(`${validMembers.length} membres valides à synchroniser`);
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// D. Construire la liste des emails valides
const azureEmails = new Set();
validMembers.forEach(m => {
azureEmails.add(m.mail.toLowerCase().trim());
});
console.log('\n📝 Traitement des utilisateurs...');
// E. Traitement de chaque membre valide
for (const m of validMembers) {
try {
const emailClean = m.mail.toLowerCase().trim();
syncResults.processed++;
// Vérifier si l'utilisateur existe déjà
const [existing] = await conn.query(
'SELECT id, email, entraUserId, Actif FROM CollaborateurAD WHERE LOWER(email) = ?',
[emailClean]
);
if (existing.length > 0) {
// MISE À JOUR si changements
await conn.query(`
UPDATE CollaborateurAD
SET
entraUserId = ?,
prenom = ?,
nom = ?,
service = ?,
description = ?,
Actif = 1
WHERE LOWER(email) = ?
`, [
m.id,
m.givenName || existing[0].prenom || 'Prénom',
m.surname || existing[0].nom || 'Nom',
m.department || '',
m.jobTitle || null,
emailClean
]);
syncResults.updated++;
console.log(` ✓ Mis à jour : ${emailClean}`);
} else {
// INSERTION nouveau
await conn.query(`
INSERT INTO CollaborateurAD
(entraUserId, prenom, nom, email, service, description, role, SocieteId, Actif, DateEntree, TypeContrat)
VALUES (?, ?, ?, ?, ?, ?, 'Collaborateur', 1, 1, GETDATE(), '37h')
`, [
m.id,
m.givenName || 'Prénom',
m.surname || 'Nom',
emailClean,
m.department || '',
m.jobTitle || null
]);
syncResults.inserted++;
console.log(` ✓ Créé : ${emailClean}`);
}
} catch (userError) {
errorCount++;
syncResults.errors.push({
email: m.mail,
error: userError.message
});
console.error(` ❌ Erreur ${m.mail}:`, userError.message);
// Continuer avec les autres
}
}
// F. ⭐ DÉSACTIVATION SÉCURISÉE
console.log('\n🔍 Désactivation des comptes obsolètes...');
if (azureEmails.size > 0) {
const activeEmailsArray = Array.from(azureEmails);
const placeholders = activeEmailsArray.map(() => '?').join(',');
// ⭐ REQUÊTE PROTÉGÉE : Ne désactive que les comptes qui :
// 1. Ont un email valide
// 2. Ne sont pas dans Entra ID
// 3. Ne sont pas RH/Admin/President
// 4. Sont actuellement actifs
const [resultDeactivate] = await conn.query(`
UPDATE CollaborateurAD
SET Actif = 0
WHERE
Email IS NOT NULL
AND Email != ''
AND Email NOT LIKE '%@noemail.local'
AND LOWER(Email) NOT IN (${placeholders})
AND Actif = 1
AND role NOT IN ('RH', 'Admin', 'President')
`, activeEmailsArray);
syncResults.deactivated = resultDeactivate.affectedRows;
console.log(`${syncResults.deactivated} compte(s) désactivé(s)`);
}
await conn.commit();
// G. Logging final
console.log('\n📊 === RÉSUMÉ SYNCHRONISATION ===');
console.log(` Groupe Azure: ${groupName}`);
console.log(` Total membres Entra: ${allAzureMembers.length}`);
console.log(` Membres valides: ${validMembers.length}`);
console.log(` Traités: ${syncResults.processed}`);
console.log(` Créés: ${syncResults.inserted}`);
console.log(` Mis à jour: ${syncResults.updated}`);
console.log(` Désactivés: ${syncResults.deactivated}`);
console.log(` Erreurs: ${errorCount}`);
res.json({
success: true,
message: 'Synchronisation globale terminée',
groupe_sync: groupName,
stats: {
total_azure: allAzureMembers.length,
membres_valides: validMembers.length,
processed: syncResults.processed,
inserted: syncResults.inserted,
updated: syncResults.updated,
deactivated: syncResults.deactivated,
errors: errorCount,
error_details: syncResults.errors.length > 0 ? syncResults.errors : undefined
}
});
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
} catch (error) {
console.error('\n❌ === ERREUR CRITIQUE SYNCHRONISATION ===');
console.error('Message:', error.message);
console.error('Stack:', error.stack);
res.status(500).json({
success: false,
message: 'Erreur lors de la synchronisation',
error: error.message,
stats: syncResults
});
}
});
''
// ========================================
// NOUVELLES ROUTES ADMINISTRATION RTT
// ========================================
app.get('/api/getAllCollaborateurs', async (req, res) => {
try {
const [collaborateurs] = await pool.query(`
SELECT
ca.id,
ca.prenom,
ca.nom,
ca.email,
ca.role,
ca.TypeContrat,
ca.DateEntree,
s.Nom as service,
ca.CampusId,
ca.SocieteId,
so.Nom as societe_nom
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Societe so ON ca.SocieteId = so.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
ORDER BY ca.nom, ca.prenom
`);
res.json({
success: true,
collaborateurs: collaborateurs,
total: collaborateurs.length
});
} catch (error) {
console.error('Erreur getAllCollaborateurs:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
app.post('/api/updateTypeContrat', async (req, res) => {
try {
const { collaborateur_id, type_contrat } = req.body;
if (!collaborateur_id || !type_contrat) {
return res.json({
success: false,
message: 'Données manquantes'
});
}
const typesValides = ['37h', 'forfait_jour', 'temps_partiel'];
if (!typesValides.includes(type_contrat)) {
return res.json({
success: false,
message: 'Type de contrat invalide'
});
}
const [collab] = await pool.query(
'SELECT prenom, nom, CampusId FROM CollaborateurAD WHERE id = ?',
[collaborateur_id]
);
if (collab.length === 0) {
return res.json({
success: false,
message: 'Collaborateur non trouvé'
});
}
await pool.query(
'UPDATE CollaborateurAD SET TypeContrat = ? WHERE id = ?',
[type_contrat, collaborateur_id]
);
res.json({
success: true,
message: 'Type de contrat mis à jour',
nom: `${collab[0].prenom} ${collab[0].nom}`,
nouveau_type: type_contrat
});
} catch (error) {
console.error('Erreur updateTypeContrat:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
app.get('/api/getConfigurationRTT', async (req, res) => {
try {
const annee = parseInt(req.query.annee || new Date().getFullYear());
const [configs] = await pool.query(
`SELECT Annee, TypeContrat, JoursAnnuels, AcquisitionMensuelle, Description
FROM ConfigurationRTT
WHERE Annee = ?
ORDER BY TypeContrat`,
[annee]
);
res.json({ success: true, configs });
} catch (error) {
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
}
});
app.post('/api/updateConfigurationRTT', async (req, res) => {
try {
const { annee, typeContrat, joursAnnuels } = req.body;
if (!annee || !typeContrat || !joursAnnuels) {
return res.json({ success: false, message: 'Données manquantes' });
}
const acquisitionMensuelle = joursAnnuels / 12;
await pool.query(
`INSERT INTO ConfigurationRTT (Annee, TypeContrat, JoursAnnuels, AcquisitionMensuelle)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
JoursAnnuels = ?, AcquisitionMensuelle = ?`,
[annee, typeContrat, joursAnnuels, acquisitionMensuelle, joursAnnuels, acquisitionMensuelle]
);
res.json({ success: true, message: 'Configuration mise à jour' });
} catch (error) {
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
}
});
app.post('/api/updateRequest', upload.array('medicalDocuments', 5), async (req, res) => {
let connection;
try {
console.log('\n📥 === MODIFICATION DEMANDE ===');
console.log('Body reçu:', req.body);
console.log('Fichiers reçus:', req.files?.length || 0);
const {
requestId,
leaveType,
startDate,
endDate,
reason,
businessDays,
userId,
userEmail,
userName,
accessToken
} = req.body;
// ⭐ PARSER LA RÉPARTITION (CRITIQUE)
let Repartition;
try {
Repartition = JSON.parse(req.body.Repartition || '[]');
console.log('📊 Répartition parsée:', JSON.stringify(Repartition, null, 2));
} catch (parseError) {
console.error('❌ Erreur parsing Repartition:', parseError);
if (req.files) {
req.files.forEach(file => {
if (fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
});
}
return res.status(400).json({
success: false,
message: 'Erreur de format de la répartition'
});
}
// Validation
if (!requestId || !leaveType || !startDate || !endDate || !businessDays || !userId) {
if (req.files) {
req.files.forEach(file => {
if (fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
});
}
return res.status(400).json({
success: false,
message: '❌ Données manquantes'
});
}
connection = await pool.getConnection();
await connection.beginTransaction();
const uploadedFiles = req.files || [];
console.log(`Demande ID: ${requestId}, User ID: ${userId}`);
// 1⃣ RÉCUPÉRER LA DEMANDE ORIGINALE
const [originalRequest] = await connection.query(
'SELECT * FROM DemandeConge WHERE Id = ? AND CollaborateurADId = ?',
[requestId, userId]
);
if (originalRequest.length === 0) {
await connection.rollback();
if (req.files) {
req.files.forEach(file => {
if (fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
});
}
return res.status(404).json({
success: false,
message: '❌ Demande introuvable ou non autorisée'
});
}
const original = originalRequest[0];
const oldStatus = original.Statut;
console.log(`📋 Demande originale: Statut=${oldStatus}`);
// ⭐ VALIDATION : Si arrêt maladie, il faut au moins 1 fichier
const hasABS = Repartition.some(r => r.TypeConge === 'ABS');
if (hasABS && uploadedFiles.length === 0) {
await connection.rollback();
return res.json({
success: false,
message: 'Un justificatif médical est obligatoire pour un arrêt maladie'
});
}
// ⭐ VALIDATION DE LA RÉPARTITION
console.log('📊 Validation répartition...');
console.log('Nombre de jours total:', businessDays);
console.log('Répartition reçue:', JSON.stringify(Repartition, null, 2));
// Ne compter que CP, RTT ET RÉCUP dans la répartition
const sommeRepartition = Repartition.reduce((sum, r) => {
if (r.TypeConge === 'CP' || r.TypeConge === 'RTT' || r.TypeConge === 'Récup') {
return sum + parseFloat(r.NombreJours || 0);
}
return sum;
}, 0);
console.log('Somme répartition CP+RTT+Récup:', sommeRepartition.toFixed(2));
// Validation : La somme doit correspondre au total
const hasCountableLeave = Repartition.some(r =>
r.TypeConge === 'CP' || r.TypeConge === 'RTT' || r.TypeConge === 'Récup'
);
if (hasCountableLeave && Math.abs(sommeRepartition - businessDays) > 0.01) {
console.error('❌ ERREUR : Répartition incohérente !');
if (req.files) {
req.files.forEach(file => {
if (fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
});
}
await connection.rollback();
return res.json({
success: false,
message: `Erreur de répartition : la somme (${sommeRepartition.toFixed(2)}j) ne correspond pas au total (${businessDays}j)`
});
}
console.log('✅ Validation répartition OK');
// 2⃣ REMBOURSER L'ANCIENNE DEMANDE (via DeductionDetails)
let restorationStats = { count: 0, details: [] };
if (oldStatus !== 'Refusée' && oldStatus !== 'Annulée' && original.TypeCongeId !== 3) {
console.log(`🔄 Remboursement de l'ancienne demande...`);
const [oldDeductions] = await connection.query(
'SELECT * FROM DeductionDetails WHERE DemandeCongeId = ?',
[requestId]
);
if (oldDeductions.length > 0) {
for (const d of oldDeductions) {
const [compteur] = await connection.query(
'SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?',
[userId, d.TypeCongeId, d.Annee]
);
if (compteur.length > 0) {
const newSolde = parseFloat(compteur[0].Solde) + parseFloat(d.JoursUtilises);
await connection.query(
'UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?',
[newSolde, compteur[0].Id]
);
restorationStats.count++;
restorationStats.details.push(`${d.JoursUtilises}j rendus (Type ${d.TypeCongeId}, Année ${d.Annee})`);
console.log(` ✅ Remboursé ${d.JoursUtilises}j au compteur TypeId=${d.TypeCongeId}, Annee=${d.Annee}`);
}
}
// Supprimer les anciennes déductions
await connection.query('DELETE FROM DeductionDetails WHERE DemandeCongeId = ?', [requestId]);
console.log(` 🧹 ${oldDeductions.length} déduction(s) supprimée(s)`);
}
}
// 3⃣ METTRE À JOUR LA DEMANDE (⭐ SQL SERVER : GETDATE + FORMAT)
console.log('📝 Mise à jour de la demande...');
// Si elle était validée, on la repasse en "En attente"
const newStatus = (oldStatus === 'Validée' || oldStatus === 'Validé') ? 'En attente' : oldStatus;
await connection.query(
`UPDATE DemandeConge
SET TypeCongeId = ?,
DateDebut = ?,
DateFin = ?,
Commentaire = ?,
NombreJours = ?,
Statut = ?,
DateValidation = GETDATE(),
CommentaireValidation = COALESCE(CommentaireValidation, '') +
CHAR(10) + '[Modifiée le ' +
FORMAT(GETDATE(), 'dd/MM/yyyy à HH:mm', 'fr-FR') +
']'
WHERE Id = ?`,
[leaveType, startDate, endDate, reason || '', businessDays, newStatus, requestId]
);
console.log(`✅ Demande ${requestId} modifiée - Statut: ${newStatus}`);
// 4⃣ SUPPRIMER L'ANCIENNE RÉPARTITION DANS DemandeCongeType
await connection.query('DELETE FROM DemandeCongeType WHERE DemandeCongeId = ?', [requestId]);
// 5⃣ Sauvegarder la nouvelle répartition AVEC GÉNÉRATION MANUELLE D'ID
console.log('\n📊 Sauvegarde de la nouvelle répartition en base...');
for (const rep of Repartition) {
const code = rep.TypeConge;
const name = code === 'CP' ? 'Congé payé' :
code === 'RTT' ? 'RTT' :
code === 'ABS' ? 'Congé maladie' :
code === 'Formation' ? 'Formation' :
code === 'Récup' ? 'Récupération' : code;
const [typeRow] = await connection.query(
'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1',
[name]
);
if (typeRow.length > 0) {
// ⭐ GÉNÉRER L'ID MANUELLEMENT
const demandeCongeTypeId = await getNextId(connection, 'DemandeCongeType');
await connection.query(`
INSERT INTO DemandeCongeType
(Id, DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee)
VALUES (?, ?, ?, ?, ?)
`, [
demandeCongeTypeId,
requestId,
typeRow[0].Id,
rep.NombreJours,
rep.PeriodeJournee || 'Journée entière'
]);
console.log(`${name}: ${rep.NombreJours}j (${rep.PeriodeJournee || 'Journée entière'})`);
}
}
// 6⃣ CALCULER ET APPLIQUER LA NOUVELLE DÉDUCTION
let newRepartition = [];
const currentYear = new Date().getFullYear();
const previousYear = currentYear - 1;
const isFormationOnly = Repartition.length === 1 && Repartition[0].TypeConge === 'Formation';
if (!isFormationOnly) {
console.log('📉 Déduction des compteurs...');
for (const rep of Repartition) {
if (rep.TypeConge === 'ABS' || rep.TypeConge === 'Formation') {
console.log(`${rep.TypeConge} ignoré (pas de déduction)`);
continue;
}
// ⭐ TRAITEMENT SPÉCIAL POUR RÉCUP
if (rep.TypeConge === 'Récup') {
const [recupType] = await connection.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Récupération']);
if (recupType.length > 0) {
await connection.query(`
UPDATE CompteurConges
SET Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END,
DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [rep.NombreJours, rep.NombreJours, userId, recupType[0].Id, currentYear]);
await connection.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Récup Posée', ?)
`, [requestId, recupType[0].Id, currentYear, rep.NombreJours]);
newRepartition.push({
typeCongeId: recupType[0].Id,
annee: currentYear,
jours: rep.NombreJours,
typeDeduction: 'Récup Posée'
});
console.log(` ✓ Récup: ${rep.NombreJours}j déduits`);
}
continue;
}
// ⭐ CP et RTT : déduction normale
const name = rep.TypeConge === 'CP' ? 'Congé payé' : 'RTT';
const [typeRow] = await connection.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [name]);
if (typeRow.length > 0) {
let joursRestants = parseFloat(rep.NombreJours);
// A. Essayer N-1 (CP uniquement)
if (rep.TypeConge === 'CP') {
const [compteurN1] = await connection.query(
'SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?',
[userId, typeRow[0].Id, previousYear]
);
if (compteurN1.length > 0 && compteurN1[0].Solde > 0) {
const disponibleN1 = parseFloat(compteurN1[0].Solde);
const aPrendreN1 = Math.min(disponibleN1, joursRestants);
await connection.query(
'UPDATE CompteurConges SET Solde = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ?',
[aPrendreN1, compteurN1[0].Id]
);
await connection.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Année N-1', ?)
`, [requestId, typeRow[0].Id, previousYear, aPrendreN1]);
newRepartition.push({
typeCongeId: typeRow[0].Id,
annee: previousYear,
jours: aPrendreN1,
typeDeduction: 'Année N-1'
});
joursRestants -= aPrendreN1;
console.log(`${name} N-1: ${aPrendreN1}j déduits (reste: ${joursRestants}j)`);
}
}
// B. Essayer N
if (joursRestants > 0) {
const [compteurN] = await connection.query(
'SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?',
[userId, typeRow[0].Id, currentYear]
);
if (compteurN.length > 0) {
const disponibleN = parseFloat(compteurN[0].Solde);
const aPrendreN = Math.min(disponibleN, joursRestants);
await connection.query(
'UPDATE CompteurConges SET Solde = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ?',
[aPrendreN, compteurN[0].Id]
);
await connection.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Année N', ?)
`, [requestId, typeRow[0].Id, currentYear, aPrendreN]);
newRepartition.push({
typeCongeId: typeRow[0].Id,
annee: currentYear,
jours: aPrendreN,
typeDeduction: 'Année N'
});
joursRestants -= aPrendreN;
console.log(`${name} N: ${aPrendreN}j déduits (reste: ${joursRestants}j)`);
}
}
// C. Si il reste des jours → Anticipé
if (joursRestants > 0) {
const [compteurN] = await connection.query(
'SELECT Id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?',
[userId, typeRow[0].Id, currentYear]
);
if (compteurN.length > 0) {
await connection.query(
'UPDATE CompteurConges SET Solde = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ?',
[joursRestants, compteurN[0].Id]
);
await connection.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'N Anticip', ?)
`, [requestId, typeRow[0].Id, currentYear, joursRestants]);
newRepartition.push({
typeCongeId: typeRow[0].Id,
annee: currentYear,
jours: joursRestants,
typeDeduction: 'N Anticip'
});
console.log(` ⚠️ ${name} Anticipé: ${joursRestants}j`);
}
}
}
}
}
await connection.commit();
console.log(`✅ Demande ${requestId} modifiée avec succès`);
// 7⃣ ENVOI DES EMAILS (Asynchrone)
const graphToken = await getGraphToken();
if (graphToken) {
const [managerInfo] = await connection.query(
`SELECT m.Email, m.Prenom, m.Nom
FROM CollaborateurAD c
JOIN HierarchieValidationAD h ON c.id = h.CollaborateurId
JOIN CollaborateurAD m ON h.SuperieurId = m.id
WHERE c.id = ?`,
[userId]
);
if (managerInfo.length > 0) {
const manager = managerInfo[0];
// Email au manager
sendMailGraph(
graphToken,
'gtanoreply@ensup.eu',
manager.Email,
'🔄 Modification de demande de congé',
`
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0;">🔄 Modification de demande</h1>
</div>
<div style="background-color: #f8f9fa; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px; color: #333;">Bonjour <strong>${manager.Prenom}</strong>,</p>
<p style="font-size: 16px; color: #333;"><strong>${userName}</strong> a modifié sa demande de congé :</p>
<div style="background-color: white; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #3b82f6;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666;"><strong>Type :</strong></td>
<td style="padding: 8px 0; color: #333;">${getLeaveTypeName(leaveType)}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666;"><strong>Dates :</strong></td>
<td style="padding: 8px 0; color: #333;">du ${formatDateFR(startDate)} au ${formatDateFR(endDate)}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666;"><strong>Durée :</strong></td>
<td style="padding: 8px 0; color: #333;">${businessDays} jour(s)</td>
</tr>
</table>
</div>
${restorationStats.count > 0 ? `
<div style="background-color: #dbeafe; padding: 15px; border-radius: 8px; margin-top: 20px;">
<p style="margin: 0; color: #1e40af;">
✅ Les compteurs ont été automatiquement recalculés (${restorationStats.count} opération(s)).
</p>
</div>
` : ''}
<p style="font-size: 16px; color: #333;">Merci de valider ou refuser cette demande dans l'application.</p>
</div>
</div>
`
).catch(err => console.error('❌ Erreur email manager:', err));
// Email de confirmation au collaborateur
sendMailGraph(
graphToken,
'gtanoreply@ensup.eu',
userEmail,
'✅ Confirmation de modification',
`
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0;">✅ Demande modifiée</h1>
</div>
<div style="background-color: #f8f9fa; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px; color: #333;">Bonjour <strong>${userName.split(' ')[0]}</strong>,</p>
<p style="font-size: 16px; color: #333;">Votre demande de congé a bien été modifiée :</p>
<div style="background-color: white; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #10b981;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666;"><strong>Type :</strong></td>
<td style="padding: 8px 0; color: #333;">${getLeaveTypeName(leaveType)}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666;"><strong>Dates :</strong></td>
<td style="padding: 8px 0; color: #333;">du ${formatDateFR(startDate)} au ${formatDateFR(endDate)}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666;"><strong>Durée :</strong></td>
<td style="padding: 8px 0; color: #333;">${businessDays} jour(s)</td>
</tr>
</table>
</div>
<p style="font-size: 16px; color: #333;">Elle est maintenant <strong style="color: #f59e0b;">en attente de validation</strong>.</p>
</div>
</div>
`
).catch(err => console.error('❌ Erreur email collaborateur:', err));
}
}
res.json({
success: true,
message: '✅ Demande modifiée avec succès',
newStatus: newStatus,
restoration: restorationStats,
repartition: newRepartition
});
} catch (error) {
if (connection) {
await connection.rollback();
}
console.error('❌ Erreur updateRequest:', error);
res.status(500).json({
success: false,
message: error.message || 'Erreur lors de la modification'
});
} finally {
if (connection) {
connection.release();
}
}
});
// ⭐ Fonction helper pour calculer la répartition CP
function calculateCPRepartition(joursNecessaires, soldeN1, soldeN) {
const repartition = [];
let reste = joursNecessaires;
// D'abord utiliser N-1
if (reste > 0 && soldeN1 > 0) {
const joursN1 = Math.min(reste, soldeN1);
repartition.push({
type: 'CP',
annee: 'N-1',
jours: joursN1
});
reste -= joursN1;
}
// Puis utiliser N
if (reste > 0 && soldeN > 0) {
const joursN = Math.min(reste, soldeN);
repartition.push({
type: 'CP',
annee: 'N',
jours: joursN
});
reste -= joursN;
}
return repartition;
}
// ⭐ Fonction helper pour obtenir le champ de compteur
function getCounterField(type, annee) {
if (type === 'CP' && annee === 'N-1') return 'SoldeCP_N1';
if (type === 'CP' && annee === 'N') return 'SoldeCP_N';
if (type === 'RTT' && annee === 'N') return 'SoldeRTT_N';
return null;
}
// ⭐ Fonction helper pour le nom du type de congé
function getLeaveTypeName(typeId) {
const types = {
1: 'Congé payé',
2: 'RTT',
3: 'Arrêt maladie',
4: 'Formation',
5: 'Récupération'
};
return types[typeId] || 'Inconnu';
}
// ⭐ Fonction helper pour formater les dates
function formatDateFR(dateStr) {
const date = new Date(dateStr);
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
/**
* Route pour SUPPRIMER une demande de congé
* POST /deleteRequest
*/
app.post('/api/deleteRequest', async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const { requestId, userId, userEmail, userName } = req.body;
if (!requestId || !userId) {
await conn.rollback();
conn.release();
return res.status(400).json({
success: false,
message: 'Paramètres manquants (requestId ou userId)'
});
}
console.log('\n🗑 === ANNULATION DEMANDE ===');
console.log(`Demande ID: ${requestId}, User ID: ${userId}`);
// 1⃣ Vérifier que la demande existe
const [existingRequest] = await conn.query(
`SELECT
d.Id,
d.DateDebut,
d.DateFin,
d.Statut,
d.NombreJours,
d.CollaborateurADId
FROM DemandeConge d
WHERE d.Id = ? AND d.CollaborateurADId = ?`,
[requestId, userId]
);
if (existingRequest.length === 0) {
await conn.rollback();
conn.release();
return res.status(404).json({
success: false,
message: 'Demande introuvable ou non autorisée'
});
}
const request = existingRequest[0];
const requestStatus = request.Statut;
// ⭐ CORRECTION 1 : Déclarer `aujourdhui` et `dateDebut` AVANT de les utiliser
const aujourdhui = new Date();
aujourdhui.setHours(0, 0, 0, 0);
const dateDebut = request.DateDebut ? new Date(request.DateDebut) : null;
if (dateDebut) {
dateDebut.setHours(0, 0, 0, 0);
}
if (!dateDebut || isNaN(dateDebut.getTime())) {
console.warn('⚠️ Date invalide, on autorise l\'annulation');
// Ne pas bloquer l'annulation si la date est invalide
} else {
dateDebut.setHours(0, 0, 0, 0);
console.log(`📋 Demande: Statut=${requestStatus}, Date début=${dateDebut.toLocaleDateString('fr-FR')}`);
// ❌ BLOQUER SI DATE DÉJÀ PASSÉE
if (dateDebut <= aujourdhui && requestStatus === 'Validée') {
await conn.rollback();
conn.release();
return res.status(400).json({
success: false,
message: '❌ Impossible d\'annuler : la date de début est déjà passée ou c\'est aujourd\'hui',
dateDebut: dateDebut.toISOString().split('T')[0]
});
}
}
// 2⃣ RÉCUPÉRER LA RÉPARTITION (pour l'email)
const [repartition] = await conn.query(`
SELECT dct.*, tc.Nom as TypeNom
FROM DemandeCongeType dct
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dct.DemandeCongeId = ?
ORDER BY tc.Nom
`, [requestId]);
// 3⃣ RESTAURER LES COMPTEURS via DeductionDetails
let restorationStats = { count: 0, details: [] };
if (requestStatus !== 'Refusée' && requestStatus !== 'Annulée') {
console.log(`🔄 Restauration des compteurs...`);
const [deductions] = await conn.query(
'SELECT * FROM DeductionDetails WHERE DemandeCongeId = ?',
[requestId]
);
if (deductions.length > 0) {
for (const d of deductions) {
const [compteur] = await conn.query(
'SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?',
[userId, d.TypeCongeId, d.Annee]
);
if (compteur.length > 0) {
const c = compteur[0];
const newSolde = parseFloat(c.Solde) + parseFloat(d.JoursUtilises);
await conn.query(
'UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?',
[newSolde, c.Id]
);
restorationStats.count++;
restorationStats.details.push({
typeCongeId: d.TypeCongeId,
annee: d.Annee,
joursRendus: d.JoursUtilises
});
console.log(` ✅ Remboursé ${d.JoursUtilises}j au compteur TypeId=${d.TypeCongeId} Année=${d.Annee}`);
} else {
console.warn(`⚠️ Compteur introuvable (Type ${d.TypeCongeId}, Année ${d.Annee})`);
}
}
// Supprimer les déductions
await conn.query('DELETE FROM DeductionDetails WHERE DemandeCongeId = ?', [requestId]);
console.log(` 🧹 ${deductions.length} ligne(s) DeductionDetails supprimée(s)`);
} else {
console.log(' Aucune déduction à rembourser');
}
}
// 4⃣ METTRE À JOUR LE STATUT
await conn.query(
`UPDATE DemandeConge
SET Statut = 'Annulée',
DateValidation = GETDATE(),
CommentaireValidation = COALESCE(CommentaireValidation, '') +
CHAR(10) + '[Annulée par le collaborateur le ' +
FORMAT(GETDATE(), 'dd/MM/yyyy à HH:mm', 'fr-FR') +
']'
WHERE Id = ?`,
[requestId]
);
console.log(`✅ Demande ${requestId} marquée comme Annulée`);
await conn.commit();
conn.release();
// 5⃣ ENVOI DES EMAILS
let emailsSent = { collaborateur: false, manager: false };
const graphToken = await getGraphToken();
if (graphToken) {
const [collabInfo] = await conn.query(
'SELECT email, prenom, nom FROM CollaborateurAD WHERE id = ?',
[userId]
);
const collabEmail = collabInfo.length > 0 ? collabInfo[0].email : userEmail;
const collabName = collabInfo.length > 0
? `${collabInfo[0].prenom} ${collabInfo[0].nom}`
: userName;
// ⭐ CORRECTION : Formater correctement les dates pour l'email
const dateDebutFormatted = request.DateDebut
? new Date(request.DateDebut).toLocaleDateString('fr-FR')
: 'Date inconnue';
const dateFinFormatted = request.DateFin
? new Date(request.DateFin).toLocaleDateString('fr-FR')
: 'Date inconnue';
const datesPeriode = dateDebutFormatted === dateFinFormatted
? dateDebutFormatted
: `du ${dateDebutFormatted} au ${dateFinFormatted}`;
const repartitionText = repartition.map(r =>
`<tr>
<td style="padding: 8px 0; color: #666;"><strong>${r.TypeNom} :</strong></td>
<td style="padding: 8px 0; color: #333;">${r.NombreJours}j ${r.PeriodeJournee !== 'Journée entière' ? `(${r.PeriodeJournee})` : ''}</td>
</tr>`
).join('');
// 📧 EMAIL AU COLLABORATEUR
if (collabEmail) {
try {
const subjectCollab = '✅ Confirmation d\'annulation de votre demande';
const bodyCollab = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0;">✅ Demande annulée</h1>
</div>
<div style="background-color: #f8f9fa; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px; color: #333;">Bonjour <strong>${collabName}</strong>,</p>
<p style="font-size: 16px; color: #333;">
Votre demande de congé a bien été <strong style="color: #10b981;">annulée</strong>.
</p>
<div style="background-color: white; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #10b981;">
<h3 style="color: #10b981; margin-top: 0;">📋 Demande annulée</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666;"><strong>Période :</strong></td>
<td style="padding: 8px 0; color: #333;">${datesPeriode}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666;"><strong>Durée totale :</strong></td>
<td style="padding: 8px 0; color: #333;">${request.NombreJours} jour(s)</td>
</tr>
<tr>
<td colspan="2" style="padding: 12px 0 4px 0; color: #666; border-top: 1px solid #e5e7eb;">
<strong>Répartition :</strong>
</td>
</tr>
${repartitionText}
</table>
</div>
${restorationStats.count > 0 ? `
<div style="background-color: #dbeafe; padding: 15px; border-radius: 8px; margin-top: 20px;">
<p style="margin: 0; color: #1e40af;">
✅ <strong>Vos compteurs ont été restaurés</strong><br>
${restorationStats.count} opération(s) de remboursement effectuée(s).
</p>
</div>
` : ''}
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="font-size: 14px; color: #666; margin: 0;">
📧 Cet email est envoyé automatiquement, merci de ne pas y répondre.
</p>
</div>
</div>
</div>
`;
await sendMailGraph(graphToken, 'gtanoreply@ensup.eu', collabEmail, subjectCollab, bodyCollab);
emailsSent.collaborateur = true;
console.log('✅ Email envoyé au collaborateur');
} catch (emailError) {
console.error('❌ Erreur email collaborateur:', emailError.message);
}
}
// 📧 EMAIL AU MANAGER
const [hierarchie] = await conn.query(
`SELECT h.SuperieurId, m.email as managerEmail,
m.prenom as managerPrenom, m.nom as managerNom
FROM HierarchieValidationAD h
LEFT JOIN CollaborateurAD m ON h.SuperieurId = m.id
WHERE h.CollaborateurId = ?`,
[userId]
);
const managerEmail = hierarchie[0]?.managerEmail;
const managerName = hierarchie[0]
? `${hierarchie[0].managerPrenom} ${hierarchie[0].managerNom}`
: 'Manager';
if (managerEmail && requestStatus !== 'Refusée' && requestStatus !== 'Annulée') {
try {
const isValidated = requestStatus === 'Validée' || requestStatus === 'Validé';
const subjectManager = isValidated
? `🗑️ Annulation de congé validé - ${collabName}`
: `🗑️ Annulation de demande - ${collabName}`;
const bodyManager = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, ${isValidated ? '#ef4444' : '#f59e0b'} 0%, ${isValidated ? '#991b1b' : '#d97706'} 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0;">🗑️ Annulation de ${isValidated ? 'congé' : 'demande'}</h1>
</div>
<div style="background-color: #f8f9fa; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px; color: #333;">Bonjour ${managerName},</p>
<p style="font-size: 16px; color: #333;">
<strong>${collabName}</strong> a annulé ${isValidated ? 'son congé <strong>validé</strong>' : 'sa demande de congé'}.
</p>
<div style="background-color: white; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid ${isValidated ? '#ef4444' : '#f59e0b'};">
<h3 style="color: ${isValidated ? '#ef4444' : '#f59e0b'}; margin-top: 0;">📋 Demande annulée</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666;"><strong>Statut initial :</strong></td>
<td style="padding: 8px 0; color: #333;">${requestStatus}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666;"><strong>Période :</strong></td>
<td style="padding: 8px 0; color: #333;">${datesPeriode}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666;"><strong>Durée totale :</strong></td>
<td style="padding: 8px 0; color: #333;">${request.NombreJours} jour(s)</td>
</tr>
<tr>
<td colspan="2" style="padding: 12px 0 4px 0; color: #666; border-top: 1px solid #e5e7eb;">
<strong>Répartition :</strong>
</td>
</tr>
${repartitionText}
</table>
</div>
${restorationStats.count > 0 ? `
<div style="background-color: #dbeafe; padding: 15px; border-radius: 8px; margin-top: 20px;">
<p style="margin: 0; color: #1e40af;">
✅ Les compteurs ont été automatiquement restaurés (${restorationStats.count} opération(s)).
</p>
</div>
` : ''}
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="font-size: 14px; color: #666; margin: 0;">
📧 Cet email est envoyé automatiquement, merci de ne pas y répondre.
</p>
</div>
</div>
</div>
`;
await sendMailGraph(graphToken, 'gtanoreply@ensup.eu', managerEmail, subjectManager, bodyManager);
emailsSent.manager = true;
console.log('✅ Email envoyé au manager');
} catch (emailError) {
console.error('❌ Erreur email manager:', emailError.message);
}
}
}
res.json({
success: true,
message: 'Demande annulée avec succès',
restoration: restorationStats,
emailsSent: emailsSent
});
} catch (error) {
await conn.rollback();
console.error('❌ Erreur deleteRequest:', error);
res.status(500).json({
success: false,
message: 'Erreur lors de l\'annulation',
error: error.message
});
} finally {
if (conn) conn.release();
}
});
app.get('/api/exportCompteurs', async (req, res) => {
try {
const dateRef = req.query.dateRef || new Date().toISOString().split('T')[0];
const conn = await pool.getConnection();
const [collaborateurs] = await pool.query(`
SELECT
ca.id,
ca.prenom,
ca.nom,
ca.email,
ca.role,
ca.TypeContrat,
ca.DateEntree,
s.Nom as service,
ca.CampusId,
ca.SocieteId,
so.Nom as societe_nom
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Societe so ON ca.SocieteId = so.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
ORDER BY ca.nom, ca.prenom
`);
const rapport = [];
for (const collab of collaborateurs) {
const dateEntree = collab.DateEntree;
const dateReference = new Date(dateRef);
const acquisCP = calculerAcquisitionCP_Smart(dateReference, dateEntree);
let acquisRTT = 0;
if (collab.role !== 'Apprenti') {
const rttData = await calculerAcquisitionRTT_Smart(conn, collab.id, dateReference);
acquisRTT = rttData.acquisition;
}
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
let soldeCP = 0;
let soldeRTT = 0;
if (cpType.length > 0) {
const [compteurCP] = await conn.query(
'SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?',
[collab.id, cpType[0].Id, dateReference.getFullYear()]
);
soldeCP = compteurCP.length > 0 ? parseFloat(compteurCP[0].Solde) : 0;
}
if (rttType.length > 0 && collab.role !== 'Apprenti') {
const [compteurRTT] = await conn.query(
'SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?',
[collab.id, rttType[0].Id, dateReference.getFullYear()]
);
soldeRTT = compteurRTT.length > 0 ? parseFloat(compteurRTT[0].Solde) : 0;
}
rapport.push({
id: collab.id,
prenom: collab.prenom,
nom: collab.nom,
email: collab.email,
role: collab.role,
service: collab.service,
societe_id: collab.SocieteId,
societe_nom: collab.societe_nom,
type_contrat: collab.TypeContrat,
date_entree: dateEntree ? formatDateWithoutUTC(dateEntree) : null,
cp_acquis: parseFloat(acquisCP.toFixed(2)),
cp_solde: parseFloat(soldeCP.toFixed(2)),
rtt_acquis: parseFloat(acquisRTT.toFixed(2)),
rtt_solde: parseFloat(soldeRTT.toFixed(2)),
date_reference: dateRef
});
}
conn.release();
res.json({
success: true,
date_reference: dateRef,
total_collaborateurs: rapport.length,
rapport: rapport
});
} catch (error) {
console.error('Erreur exportCompteurs:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
function isInPeriodeAnticipation(dateDebut, typeConge) {
const date = new Date(dateDebut);
const year = date.getFullYear();
const month = date.getMonth() + 1; // 1-12
if (typeConge === 'CP') {
// CP : 01/06 année N → 31/05 année N+1
// Période anticipation : du 01/06 de l'année suivante
return month >= 6; // Si >= juin, c'est pour l'exercice N+1
} else if (typeConge === 'RTT') {
// RTT : 01/01 année N → 31/12 année N
// Pas d'anticipation possible car année civile
return month >= 1 && month <= 12;
}
return false;
}
function getAnneeCompteur(dateDebut, typeConge) {
const date = new Date(dateDebut);
const year = date.getFullYear();
const month = date.getMonth() + 1;
if (typeConge === 'CP') {
// Si date entre 01/06 et 31/12 → année N
// Si date entre 01/01 et 31/05 → année N-1 (exercice précédent)
return month >= 6 ? year : year - 1;
} else {
// RTT : toujours année civile
return year;
}
}
/**
* Vérifie la disponibilité des soldes pour une demande
* Retourne : { available: boolean, details: {}, useN1: boolean }
*/
async function checkSoldesDisponiblesMixte(conn, collaborateurId, repartition, dateDebut, isApprenti) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const currentYear = today.getFullYear();
const dateDemandeObj = new Date(dateDebut);
dateDemandeObj.setHours(0, 0, 0, 0);
const demandeYear = dateDemandeObj.getFullYear();
const demandeMonth = dateDemandeObj.getMonth() + 1;
console.log('\n🔍 === CHECK SOLDES MIXTE (AVEC ANTICIPATION) ===');
console.log('📅 Date AUJOURD\'HUI:', today.toISOString().split('T')[0]);
console.log('📅 Date DEMANDE:', dateDebut);
console.log('📅 Année demande:', demandeYear, '/ Mois:', demandeMonth);
console.log('📅 Année actuelle:', currentYear);
let totalDisponible = 0;
let totalNecessaire = 0;
const details = {};
for (const rep of repartition) {
const typeCode = rep.TypeConge;
const joursNecessaires = parseFloat(rep.NombreJours || 0);
// Ignorer ABS et Formation
if (typeCode === 'ABS' || typeCode === 'Formation') {
continue;
}
totalNecessaire += joursNecessaires;
if (typeCode === 'CP') {
// ⭐ RÉCUPÉRER LES INFOS COLLABORATEUR
const [collabInfo] = await conn.query(
`SELECT DateEntree FROM CollaborateurAD WHERE id = ?`,
[collaborateurId]
);
const dateEntree = collabInfo[0]?.DateEntree;
// ⭐ CALCULER L'ACQUISITION JUSQU'À LA DATE DEMANDÉE
const acquisALaDate = calculerAcquisitionCP_Smart(dateDemandeObj, dateEntree);
console.log('💰 Acquisition CP à la date', dateDebut, ':', acquisALaDate.toFixed(2), 'j');
// ⭐ RÉCUPÉRER LE REPORTÉ N-1
const previousYear = currentYear - 1;
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
const cpTypeId = cpType[0].Id;
const [compteurN1] = await conn.query(`
SELECT SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, cpTypeId, previousYear]);
const reporteN1 = compteurN1.length > 0 ? parseFloat(compteurN1[0].SoldeReporte || 0) : 0;
// ⭐ RÉCUPÉRER CE QUI A DÉJÀ ÉTÉ POSÉ (toutes demandes validées ou en attente)
const [totalPose] = await conn.query(`
SELECT COALESCE(SUM(dct.NombreJours), 0) as totalPose
FROM DemandeConge dc
JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.CollaborateurADId = ?
AND tc.Nom = 'Congé payé'
AND dc.Statut IN ('Validée', 'En attente')
AND dc.DateDebut <= ?
`, [collaborateurId, dateDebut]);
const dejaPose = parseFloat(totalPose[0].totalPose || 0);
// ⭐ SOLDE RÉEL = Reporté N-1 + Acquisition - Déjà posé
const soldeReel = reporteN1 + acquisALaDate - dejaPose;
console.log('💰 Soldes CP détaillés:', {
reporteN1: reporteN1.toFixed(2),
acquisALaDate: acquisALaDate.toFixed(2),
dejaPose: dejaPose.toFixed(2),
soldeReel: soldeReel.toFixed(2)
});
details.CP = {
reporteN1: reporteN1,
acquisALaDate: acquisALaDate,
dejaPose: dejaPose,
soldeReel: soldeReel,
necessaire: joursNecessaires
};
totalDisponible += Math.max(0, soldeReel);
if (soldeReel < joursNecessaires) {
return {
available: false,
message: `Solde CP insuffisant (${Math.max(0, soldeReel).toFixed(2)}j disponibles avec anticipation, ${joursNecessaires}j demandés)`,
details,
manque: joursNecessaires - soldeReel
};
}
} else if (typeCode === 'RTT') {
if (isApprenti) {
return {
available: false,
message: 'Les apprentis ne peuvent pas poser de RTT',
details
};
}
// ⭐ CALCUL RTT (utiliser la fonction existante)
const rttData = await calculerAcquisitionRTT_Smart(conn, collaborateurId, dateDemandeObj);
const acquisALaDate = rttData.acquisition;
console.log('💰 Acquisition RTT à la date', dateDebut, ':', acquisALaDate.toFixed(2), 'j');
// ⭐ RÉCUPÉRER CE QUI A DÉJÀ ÉTÉ POSÉ
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
const rttTypeId = rttType[0].Id;
const [totalPose] = await conn.query(`
SELECT COALESCE(SUM(dct.NombreJours), 0) as totalPose
FROM DemandeConge dc
JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.CollaborateurADId = ?
AND tc.Nom = 'RTT'
AND dc.Statut IN ('Validée', 'En attente')
AND dc.DateDebut <= ?
`, [collaborateurId, dateDebut]);
const dejaPose = parseFloat(totalPose[0].totalPose || 0);
// ⭐ SOLDE RÉEL = Acquisition - Déjà posé
const soldeReel = acquisALaDate - dejaPose;
console.log('💰 Soldes RTT détaillés:', {
acquisALaDate: acquisALaDate.toFixed(2),
dejaPose: dejaPose.toFixed(2),
soldeReel: soldeReel.toFixed(2)
});
details.RTT = {
acquisALaDate: acquisALaDate,
dejaPose: dejaPose,
soldeReel: soldeReel,
necessaire: joursNecessaires
};
totalDisponible += Math.max(0, soldeReel);
if (soldeReel < joursNecessaires) {
return {
available: false,
message: `Solde RTT insuffisant (${Math.max(0, soldeReel).toFixed(2)}j disponibles avec anticipation, ${joursNecessaires}j demandés)`,
details,
manque: joursNecessaires - soldeReel
};
}
} else if (typeCode === 'Récup') {
const [recupType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Récupération']);
if (recupType.length === 0) continue;
const recupTypeId = recupType[0].Id;
const [compteur] = await conn.query(
`SELECT Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, recupTypeId, currentYear]
);
const soldeRecup = compteur.length > 0 ? parseFloat(compteur[0].Solde || 0) : 0;
console.log('💰 Solde Récup:', soldeRecup.toFixed(2), 'j');
details.Recup = {
soldeN: soldeRecup,
total: soldeRecup,
necessaire: joursNecessaires
};
totalDisponible += Math.min(joursNecessaires, soldeRecup);
if (soldeRecup < joursNecessaires) {
return {
available: false,
message: `Solde Récupération insuffisant (${soldeRecup.toFixed(2)}j disponibles, ${joursNecessaires}j demandés)`,
details,
manque: joursNecessaires - soldeRecup
};
}
}
}
console.log('\n✅ Check final:', {
totalDisponible: totalDisponible.toFixed(2),
totalNecessaire: totalNecessaire.toFixed(2),
available: totalDisponible >= totalNecessaire
});
return {
available: totalDisponible >= totalNecessaire,
details,
totalDisponible,
totalNecessaire
};
}
// ========================================
// FONCTIONS HELPER
// ========================================
async function getSoldesCP(conn, collaborateurId, dateEntree, includeN1Anticipe = false) {
const currentYear = new Date().getFullYear();
const previousYear = currentYear - 1;
console.log(`\n📊 getSoldesCP - includeN1Anticipe: ${includeN1Anticipe}`);
const [cpType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = 'Congé payé' LIMIT 1`);
const typeCongeId = cpType[0].Id;
// N-1 (reporté)
const [compteursN1] = await conn.query(
`SELECT Solde, SoldeReporte FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, previousYear]
);
const soldeN1 = compteursN1.length > 0 ? parseFloat(compteursN1[0].SoldeReporte || 0) : 0;
// N (actuel)
const [compteursN] = await conn.query(
`SELECT Solde, SoldeReporte, Total FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, currentYear]
);
const soldeN = compteursN.length > 0
? parseFloat(compteursN[0].Solde || 0) - parseFloat(compteursN[0].SoldeReporte || 0)
: 0;
const totalAcquisN = compteursN.length > 0 ? parseFloat(compteursN[0].Total || 0) : 0;
// Anticipation N
const finExerciceN = new Date(currentYear + 1, 4, 31); // 31 mai N+1
const acquisTotaleN = calculerAcquisitionCP_Smart(finExerciceN, dateEntree);
const soldeAnticipeN = Math.max(0, acquisTotaleN - totalAcquisN);
console.log(' N-1:', soldeN1);
console.log(' N:', soldeN);
console.log(' Anticipé N:', soldeAnticipeN);
// ⭐ Anticipation N+1 (si demandé)
let soldeAnticipeN1 = 0;
if (includeN1Anticipe) {
const debutExerciceN1 = new Date(currentYear + 1, 5, 1); // 01 juin N+1
const finExerciceN1 = new Date(currentYear + 2, 4, 31); // 31 mai N+2
let dateCalculN1 = debutExerciceN1;
if (dateEntree && new Date(dateEntree) > debutExerciceN1) {
dateCalculN1 = new Date(dateEntree);
}
const acquisTotaleN1 = calculerAcquisitionCP_Smart(finExerciceN1, dateCalculN1);
soldeAnticipeN1 = acquisTotaleN1;
console.log(' Anticipé N+1:', soldeAnticipeN1);
}
return { soldeN1, soldeN, soldeAnticipeN, soldeAnticipeN1 };
}
async function getSoldesRTT(conn, collaborateurId, typeContrat, dateEntree) {
const currentYear = new Date().getFullYear();
const rttType = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = 'RTT' LIMIT 1`);
const typeCongeId = rttType[0].Id;
const compteursN = await conn.query(
`SELECT Solde, Total FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, currentYear]
);
const soldeN = compteursN.length > 0 ? parseFloat(compteursN[0].Solde || 0) : 0;
const totalAcquisN = compteursN.length > 0 ? parseFloat(compteursN[0].Total || 0) : 0;
// Calcul anticipation N
const finAnneeN = new Date(currentYear, 11, 31); // 31 déc N
const rttDataTotalN = await calculerAcquisitionRTT_Smart(conn, collaborateurId, finAnneeN);
const soldeAnticipeN = Math.max(0, rttDataTotalN.acquisition - totalAcquisN);
return { soldeN, soldeAnticipeN };
}
async function getSoldesRecup(conn, collaborateurId) {
const currentYear = new Date().getFullYear();
const recupType = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = 'Récupération' LIMIT 1`);
if (recupType.length === 0) return 0;
const typeCongeId = recupType[0].Id;
const compteur = await conn.query(
`SELECT Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, currentYear]
);
return compteur.length > 0 ? parseFloat(compteur[0].Solde || 0) : 0;
}
app.get('/api/getAnticipationDisponible', async (req, res) => {
try {
const { userid, dateDebut } = req.query;
if (!userid || !dateDebut) {
return res.json({ success: false, message: 'Paramètres manquants' });
}
const conn = await pool.getConnection();
const [collabInfo] = await conn.query(
`SELECT DateEntree, TypeContrat, role FROM CollaborateurAD WHERE id = ?`,
[userid]
);
const dateEntree = collabInfo.DateEntree;
const isApprenti = collabInfo.role === 'Apprenti';
// Déterminer si c'est une demande N+1
const dateDemandeObj = new Date(dateDebut);
const currentYear = new Date().getFullYear();
const demandeYear = dateDemandeObj.getFullYear();
const demandeMonth = dateDemandeObj.getMonth() + 1;
const isN1 = (demandeYear === currentYear + 1 && demandeMonth >= 6) ||
(demandeYear === currentYear + 2 && demandeMonth <= 5);
// Calculer les soldes avec anticipation
const soldesCP = await getSoldesCP(conn, userid, dateEntree, isN1);
const soldesRTT = isApprenti ? { soldeN: 0, soldeAnticipeN: 0 } :
await getSoldesRTT(conn, userid, collabInfo.TypeContrat, dateEntree);
const soldesRecup = await getSoldesRecup(conn, userid);
conn.release();
res.json({
success: true,
isN1Request: isN1,
CP: {
actuel: soldesCP.soldeN1 + soldesCP.soldeN,
anticipeN: soldesCP.soldeAnticipeN,
anticipeN1: isN1 ? soldesCP.soldeAnticipeN1 : 0,
total: soldesCP.soldeN1 + soldesCP.soldeN + soldesCP.soldeAnticipeN + (isN1 ? soldesCP.soldeAnticipeN1 : 0)
},
RTT: {
actuel: soldesRTT.soldeN,
anticipeN: soldesRTT.soldeAnticipeN,
total: soldesRTT.soldeN + soldesRTT.soldeAnticipeN
},
Recup: {
actuel: soldesRecup,
total: soldesRecup
}
});
} catch (error) {
console.error('Erreur getAnticipationDisponible:', error);
res.status(500).json({ success: false, message: error.message });
}
});
async function deductLeaveBalanceWithN1(conn, collaborateurId, typeCongeId, nombreJours, demandeCongeId, dateDebut) {
const currentYear = new Date().getFullYear();
const previousYear = currentYear - 1;
const nextYear = currentYear + 1;
let joursRestants = nombreJours;
const deductions = [];
const dateDemandeObj = new Date(dateDebut);
const demandeYear = dateDemandeObj.getFullYear();
const demandeMonth = dateDemandeObj.getMonth() + 1;
// Déterminer le type de congé
const [typeRow] = await conn.query('SELECT Nom FROM TypeConge WHERE Id = ?', [typeCongeId]);
const typeNom = typeRow[0].Nom;
const isCP = typeNom === 'Congé payé';
// Vérifier si demande pour N+1
let useN1 = false;
if (isCP) {
useN1 = (demandeYear === nextYear && demandeMonth >= 6) ||
(demandeYear === nextYear + 1 && demandeMonth <= 5);
} else {
useN1 = demandeYear === nextYear;
}
console.log(`\n💰 Déduction ${typeNom}: ${nombreJours}j (useN1: ${useN1})`);
if (useN1) {
// ORDRE N+1 : N+1 anticipé → N anticipé → N actuel → N-1
// 1. N+1 Anticipé (priorité absolue)
const compteurN1Anticipe = await conn.query(
`SELECT Id, SoldeAnticipe FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, nextYear]
);
if (compteurN1Anticipe.length === 0 && joursRestants > 0) {
// Créer le compteur N+1 si inexistant
await conn.query(
`INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, SoldeAnticipe, IsAnticipe)
VALUES (?, ?, ?, 0, 0, 0, 0, 0)`,
[collaborateurId, typeCongeId, nextYear]
);
}
// Récupérer à nouveau après création
const compteurN1A = await conn.query(
`SELECT Id, SoldeAnticipe FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, nextYear]
);
if (compteurN1A.length > 0) {
const aDeduire = Math.min(joursRestants, joursRestants); // Tous les jours restants
if (aDeduire > 0) {
await conn.query(
`UPDATE CompteurConges
SET SoldeAnticipe = SoldeAnticipe + ?, IsAnticipe = 1
WHERE Id = ?`,
[aDeduire, compteurN1A[0].Id]
);
await conn.query(
`INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'N+1 Anticipé', ?)`,
[demandeCongeId, typeCongeId, nextYear, aDeduire]
);
deductions.push({
annee: nextYear,
type: 'N+1 Anticipé',
joursUtilises: aDeduire
});
joursRestants -= aDeduire;
console.log(`✓ N+1 Anticipé: ${aDeduire}j - reste: ${joursRestants}j`);
}
}
// 2. N anticipé
if (joursRestants > 0) {
const [compteurN_Anticipe] = await conn.query(`
SELECT Id, SoldeAnticipe
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, typeCongeId, currentYear]);
if (compteurN_Anticipe.length > 0) {
const soldeNA = parseFloat(compteurN_Anticipe[0].SoldeAnticipe || 0);
const aDeduire = Math.min(soldeNA, joursRestants);
if (aDeduire > 0) {
await conn.query(`
UPDATE CompteurConges
SET SoldeAnticipe = CASE WHEN (SoldeAnticipe - ?) < 0 THEN 0 ELSE (SoldeAnticipe - ?) END
WHERE Id = ?
`, [aDeduire, compteurN_Anticipe[0].Id]);
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'N Anticipé', ?)
`, [demandeCongeId, typeCongeId, currentYear, aDeduire]);
deductions.push({
annee: currentYear,
type: 'N Anticipé',
joursUtilises: aDeduire,
soldeAvant: soldeNA
});
joursRestants -= aDeduire;
console.log(` ✓ N Anticipé: ${aDeduire}j (reste: ${joursRestants}j)`);
}
}
}
// 3. N actuel
if (joursRestants > 0) {
const [compteurN] = await conn.query(`
SELECT Id, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, typeCongeId, currentYear]);
if (compteurN.length > 0) {
const soldeN = parseFloat(compteurN[0].Solde) - parseFloat(compteurN[0].SoldeReporte || 0);
const aDeduire = Math.min(soldeN, joursRestants);
if (aDeduire > 0) {
await conn.query(`
UPDATE CompteurConges
SET Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END
WHERE Id = ?
`, [aDeduire, compteurN[0].Id]);
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Année N', ?)
`, [demandeCongeId, typeCongeId, currentYear, aDeduire]);
deductions.push({
annee: currentYear,
type: 'Année N',
joursUtilises: aDeduire,
soldeAvant: soldeN
});
joursRestants -= aDeduire;
console.log(` ✓ Année N: ${aDeduire}j (reste: ${joursRestants}j)`);
}
}
}
// 4. N-1 reporté
if (joursRestants > 0) {
const [compteurN1] = await conn.query(`
SELECT Id, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, typeCongeId, previousYear]);
if (compteurN1.length > 0) {
const soldeN1 = parseFloat(compteurN1[0].SoldeReporte || 0);
const aDeduire = Math.min(soldeN1, joursRestants);
if (aDeduire > 0) {
await conn.query(`
UPDATE CompteurConges
SET SoldeReporte = CASE WHEN (SoldeReporte - ?) < 0 THEN 0 ELSE (SoldeReporte - ?) END,
Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END
WHERE Id = ?
`, [aDeduire, aDeduire, compteurN1[0].Id]);
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Reporté N-1', ?)
`, [demandeCongeId, typeCongeId, previousYear, aDeduire]);
deductions.push({
annee: previousYear,
type: 'Reporté N-1',
joursUtilises: aDeduire,
soldeAvant: soldeN1
});
joursRestants -= aDeduire;
console.log(` ✓ Reporté N-1: ${aDeduire}j (reste: ${joursRestants}j)`);
}
}
}
} else {
// ORDRE NORMAL : N-1 → N → N anticipé
// 1. Reporté N-1
const [compteurN1] = await conn.query(`
SELECT Id, SoldeReporte, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, typeCongeId, previousYear]);
if (compteurN1.length > 0 && compteurN1[0].SoldeReporte > 0) {
const soldeN1 = parseFloat(compteurN1[0].SoldeReporte);
const aDeduire = Math.min(soldeN1, joursRestants);
if (aDeduire > 0) {
await conn.query(`
UPDATE CompteurConges
SET SoldeReporte = CASE WHEN (SoldeReporte - ?) < 0 THEN 0 ELSE (SoldeReporte - ?) END,
Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END
WHERE Id = ?
`, [aDeduire, aDeduire, compteurN1[0].Id]);
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Reporté N-1', ?)
`, [demandeCongeId, typeCongeId, previousYear, aDeduire]);
deductions.push({
annee: previousYear,
type: 'Reporté N-1',
joursUtilises: aDeduire,
soldeAvant: soldeN1
});
joursRestants -= aDeduire;
console.log(` ✓ Reporté N-1: ${aDeduire}j (reste: ${joursRestants}j)`);
}
}
// 2. Année N
if (joursRestants > 0) {
const [compteurN] = await conn.query(`
SELECT Id, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, typeCongeId, currentYear]);
if (compteurN.length > 0) {
const soldeN = parseFloat(compteurN[0].Solde) - parseFloat(compteurN[0].SoldeReporte || 0);
const aDeduire = Math.min(soldeN, joursRestants);
if (aDeduire > 0) {
await conn.query(`
UPDATE CompteurConges
SET Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END
WHERE Id = ?
`, [aDeduire, compteurN[0].Id]);
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Année N', ?)
`, [demandeCongeId, typeCongeId, currentYear, aDeduire]);
deductions.push({
annee: currentYear,
type: 'Année N',
joursUtilises: aDeduire,
soldeAvant: soldeN
});
joursRestants -= aDeduire;
console.log(` ✓ Année N: ${aDeduire}j (reste: ${joursRestants}j)`);
}
}
}
// 3. N anticipé
if (joursRestants > 0) {
const [compteurN_Anticipe] = await conn.query(`
SELECT Id, SoldeAnticipe
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, typeCongeId, currentYear]);
if (compteurN_Anticipe.length > 0) {
const soldeNA = parseFloat(compteurN_Anticipe[0].SoldeAnticipe || 0);
const aDeduire = Math.min(soldeNA, joursRestants);
if (aDeduire > 0) {
await conn.query(`
UPDATE CompteurConges
SET SoldeAnticipe = CASE WHEN (SoldeAnticipe - ?) < 0 THEN 0 ELSE (SoldeAnticipe - ?) END
WHERE Id = ?
`, [aDeduire, compteurN_Anticipe[0].Id]);
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'N Anticipé', ?)
`, [demandeCongeId, typeCongeId, currentYear, aDeduire]);
deductions.push({
annee: currentYear,
type: 'N Anticipé',
joursUtilises: aDeduire,
soldeAvant: soldeNA
});
joursRestants -= aDeduire;
console.log(` ✓ N Anticipé: ${aDeduire}j (reste: ${joursRestants}j)`);
}
}
}
}
return {
success: joursRestants === 0,
joursDeduitsTotal: nombreJours - joursRestants,
joursNonDeduits: joursRestants,
details: deductions,
useN1: useN1
};
}
/**
* Met à jour les soldes anticipés pour un collaborateur
* Appelée après chaque mise à jour de compteur ou soumission de demande
*/
/**
* Met à jour les soldes anticipés pour un collaborateur
* Appelée après chaque mise à jour de compteur ou soumission de demande
*/
async function updateSoldeAnticipe(conn, collaborateurId) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const currentYear = today.getFullYear();
console.log(`🔄 Mise à jour soldes anticipés pour collaborateur ${collaborateurId}`);
const collab = await conn.query(`
SELECT DateEntree, TypeContrat, role
FROM CollaborateurAD
WHERE id = ?
`, [collaborateurId]);
if (collab.length === 0) {
console.log(`❌ Collaborateur non trouvé`);
return;
}
const dateEntree = collab[0].DateEntree;
const typeContrat = collab[0].TypeContrat || '37h';
const isApprenti = collab[0].role === 'Apprenti';
// ========================================
// CP ANTICIPÉ
// ========================================
const cpType = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['Congé payé']);
if (cpType.length > 0) {
const cpAnticipe = calculerAcquisitionCPAnticipee(today, dateEntree);
// Vérifier si le compteur existe
const compteurCP = await conn.query(`
SELECT Id FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, cpType[0].Id, currentYear]);
if (compteurCP.length > 0) {
await conn.query(`
UPDATE CompteurConges
SET SoldeAnticipe = ?,
DerniereMiseAJour = GETDATE()
WHERE Id = ?
`, [cpAnticipe, compteurCP[0].Id]);
} else {
// Créer le compteur s'il n'existe pas
const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree);
await conn.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, SoldeAnticipe, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, 0, ?, GETDATE())
`, [collaborateurId, cpType[0].Id, currentYear, acquisCP, acquisCP, cpAnticipe]);
}
console.log(` ✓ CP Anticipé: ${cpAnticipe.toFixed(2)}j`);
}
// ========================================
// RTT ANTICIPÉ
// ========================================
if (!isApprenti) {
const rttType = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['RTT']);
if (rttType.length > 0) {
const rttAnticipe = await calculerAcquisitionRTTAnticipee(conn, collaborateurId, today);
// Vérifier si le compteur existe
const compteurRTT = await conn.query(`
SELECT Id FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttType[0].Id, currentYear]);
if (compteurRTT.length > 0) {
await conn.query(`
UPDATE CompteurConges
SET SoldeAnticipe = ?,
DerniereMiseAJour = GETDATE()
WHERE Id = ?
`, [rttAnticipe, compteurRTT[0].Id]);
} else {
// Créer le compteur s'il n'existe pas
const rttData = await calculerAcquisitionRTT_Smart(conn, collaborateurId, today);
await conn.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, SoldeAnticipe, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, 0, ?, GETDATE())
`, [collaborateurId, rttType[0].Id, currentYear, rttData.acquisition, rttData.acquisition, rttAnticipe]);
}
console.log(` ✓ RTT Anticipé: ${rttAnticipe.toFixed(2)}j`);
}
}
console.log(` ✅ Soldes anticipés mis à jour`);
}
/**
* GET /getSoldesAnticipes
* Retourne les soldes actuels ET anticipés pour un collaborateur
*/
app.get('/api/getSoldesAnticipes', async (req, res) => {
try {
const userIdParam = req.query.user_id;
const dateRefParam = req.query.date_reference;
if (!userIdParam) {
return res.json({ success: false, message: 'ID utilisateur manquant' });
}
const conn = await pool.getConnection();
// Déterminer l'ID
const isUUID = userIdParam.length > 10 && userIdParam.includes('-');
const userQuery = `
SELECT ca.id, ca.prenom, ca.nom, ca.DateEntree, ca.TypeContrat, ca.role
FROM CollaborateurAD ca
WHERE ${isUUID ? 'ca.entraUserId' : 'ca.id'} = ?
AND (ca.Actif = 1 OR ca.Actif IS NULL)
`;
const [userInfo] = await conn.query(userQuery, [userIdParam]);
if (userInfo.length === 0) {
conn.release();
return res.json({ success: false, message: 'Utilisateur non trouvé' });
}
const user = userInfo[0];
const userId = user.id;
const dateEntree = user.DateEntree;
const typeContrat = user.TypeContrat || '37h';
const isApprenti = user.role === 'Apprenti';
const dateReference = dateRefParam ? new Date(dateRefParam) : new Date();
dateReference.setHours(0, 0, 0, 0);
const currentYear = dateReference.getFullYear();
// ===== CP =====
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
let cpData = {
acquis: 0,
solde: 0,
anticipe: 0,
totalDisponible: 0
};
if (cpType.length > 0) {
// Acquisition actuelle
const acquisCP = calculerAcquisitionCP_Smart(dateReference, dateEntree);
// Anticipé
const anticipeCP = calculerAcquisitionCPAnticipee(dateReference, dateEntree);
// Solde en base (avec consommations déduites)
const [compteurCP] = await conn.query(`
SELECT Solde, SoldeReporte, SoldeAnticipe
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [userId, cpType[0].Id, currentYear]);
// Reporté N-1
const [compteurCPN1] = await conn.query(`
SELECT Solde as SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [userId, cpType[0].Id, currentYear - 1]);
const soldeN1 = compteurCPN1.length > 0 ? parseFloat(compteurCPN1[0].SoldeReporte || 0) : 0;
const soldeN = compteurCP.length > 0 ? parseFloat(compteurCP[0].Solde || 0) : acquisCP;
cpData = {
acquis: parseFloat(acquisCP.toFixed(2)),
soldeN1: parseFloat(soldeN1.toFixed(2)),
soldeN: parseFloat((soldeN - soldeN1).toFixed(2)),
soldeTotal: parseFloat(soldeN.toFixed(2)),
anticipe: parseFloat(anticipeCP.toFixed(2)),
totalDisponible: parseFloat((soldeN + anticipeCP).toFixed(2))
};
}
// ===== RTT =====
let rttData = {
acquis: 0,
solde: 0,
anticipe: 0,
totalDisponible: 0,
isApprenti: isApprenti
};
if (!isApprenti) {
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
if (rttType.length > 0) {
// Acquisition actuelle
const rttCalc = await calculerAcquisitionRTT_Smart(conn, userId, dateReference);
const acquisRTT = rttCalc.acquisition;
// Anticipé
const anticipeRTT = await calculerAcquisitionRTTAnticipee(conn, userId, dateReference);
// Solde en base
const [compteurRTT] = await conn.query(`
SELECT Solde, SoldeAnticipe
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [userId, rttType[0].Id, currentYear]);
const soldeRTT = compteurRTT.length > 0 ? parseFloat(compteurRTT[0].Solde || 0) : acquisRTT;
rttData = {
acquis: parseFloat(acquisRTT.toFixed(2)),
solde: parseFloat(soldeRTT.toFixed(2)),
anticipe: parseFloat(anticipeRTT.toFixed(2)),
totalDisponible: parseFloat((soldeRTT + anticipeRTT).toFixed(2)),
config: rttCalc.config,
typeContrat: typeContrat
};
}
}
// ===== RÉCUP =====
const [recupType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Récupération']);
let recupData = { solde: 0 };
if (recupType.length > 0) {
const [compteurRecup] = await conn.query(`
SELECT Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [userId, recupType[0].Id, currentYear]);
recupData.solde = compteurRecup.length > 0 ? parseFloat(compteurRecup[0].Solde || 0) : 0;
}
conn.release();
res.json({
success: true,
user: {
id: user.id,
nom: `${user.prenom} ${user.nom}`,
typeContrat: typeContrat,
dateEntree: dateEntree ? formatDateWithoutUTC(dateEntree) : null
},
dateReference: dateReference.toISOString().split('T')[0],
cp: cpData,
rtt: rttData,
recup: recupData,
totalGeneral: {
disponibleActuel: parseFloat((cpData.soldeTotal + rttData.solde + recupData.solde).toFixed(2)),
disponibleAvecAnticipe: parseFloat((cpData.totalDisponible + rttData.totalDisponible + recupData.solde).toFixed(2))
}
});
} catch (error) {
console.error('Erreur getSoldesAnticipes:', error);
res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message });
}
});
/**
* GET /getCongesAnticipes
* Calcule les congés anticipés disponibles pour un collaborateur
*/
app.get('/api/getCongesAnticipes', async (req, res) => {
try {
const userIdParam = req.query.user_id;
if (!userIdParam) {
return res.json({ success: false, message: 'ID utilisateur manquant' });
}
const conn = await pool.getConnection();
// Déterminer l'ID (UUID ou numérique)
const isUUID = userIdParam.length > 10 && userIdParam.includes('-');
const userQuery = `
SELECT
ca.id,
ca.prenom,
ca.nom,
ca.email,
ca.DateEntree,
ca.TypeContrat,
ca.role,
ca.CampusId
FROM CollaborateurAD ca
WHERE ${isUUID ? 'ca.entraUserId' : 'ca.id'} = ?
AND (ca.Actif = 1 OR ca.Actif IS NULL)
`;
const [userInfo] = await conn.query(userQuery, [userIdParam]);
if (userInfo.length === 0) {
conn.release();
return res.json({ success: false, message: 'Utilisateur non trouvé' });
}
const user = userInfo[0];
const userId = user.id;
const dateEntree = user.DateEntree;
const typeContrat = user.TypeContrat || '37h';
const today = new Date();
const currentYear = today.getFullYear();
const finAnnee = new Date(currentYear, 11, 31); // 31 décembre
// ========================================
// CALCUL CP (Congés Payés)
// ========================================
// Acquisition actuelle
const acquisActuelleCP = calculerAcquisitionCP_Smart(today, dateEntree);
// Acquisition prévue à la fin de l'exercice (31 mai N+1)
const finExerciceCP = new Date(currentYear + 1, 4, 31); // 31 mai N+1
const acquisTotaleCP = calculerAcquisitionCP_Smart(finExerciceCP, dateEntree);
// Récupérer le solde actuel
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
let soldeActuelCP = 0;
let dejaPrisCP = 0;
if (cpType.length > 0) {
const [compteurCP] = await conn.query(`
SELECT Total, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [userId, cpType[0].Id, currentYear]);
if (compteurCP.length > 0) {
const total = parseFloat(compteurCP[0].Total || 0);
soldeActuelCP = parseFloat(compteurCP[0].Solde || 0);
dejaPrisCP = total - (soldeActuelCP - parseFloat(compteurCP[0].SoldeReporte || 0));
}
}
// Calculer le potentiel anticipé pour CP
const acquisRestanteCP = acquisTotaleCP - acquisActuelleCP;
const anticipePossibleCP = Math.max(0, acquisRestanteCP);
const limiteAnticipeCP = Math.min(anticipePossibleCP, 25 - dejaPrisCP);
// ========================================
// CALCUL RTT
// ========================================
let anticipePossibleRTT = 0;
let limiteAnticipeRTT = 0;
let soldeActuelRTT = 0;
let dejaPrisRTT = 0;
let acquisActuelleRTT = 0;
let acquisTotaleRTT = 0;
if (user.role !== 'Apprenti') {
// Acquisition actuelle
const rttDataActuel = await calculerAcquisitionRTT_Smart(conn, userId, today);
acquisActuelleRTT = rttDataActuel.acquisition;
// Acquisition prévue à la fin de l'année
const rttDataTotal = await calculerAcquisitionRTT_Smart(conn, userId, finAnnee);
acquisTotaleRTT = rttDataTotal.acquisition;
// Récupérer le solde actuel
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
if (rttType.length > 0) {
const [compteurRTT] = await conn.query(`
SELECT Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [userId, rttType[0].Id, currentYear]);
if (compteurRTT.length > 0) {
const total = parseFloat(compteurRTT[0].Total || 0);
soldeActuelRTT = parseFloat(compteurRTT[0].Solde || 0);
dejaPrisRTT = total - soldeActuelRTT;
}
}
// Calculer le potentiel anticipé pour RTT
const acquisRestanteRTT = acquisTotaleRTT - acquisActuelleRTT;
anticipePossibleRTT = Math.max(0, acquisRestanteRTT);
const maxRTT = typeContrat === 'forfait_jour' ? 12 : 10;
limiteAnticipeRTT = Math.min(anticipePossibleRTT, maxRTT - dejaPrisRTT);
}
conn.release();
res.json({
success: true,
user: {
id: user.id,
nom: `${user.prenom} ${user.nom}`,
email: user.email,
typeContrat: typeContrat,
dateEntree: dateEntree ? formatDateWithoutUTC(dateEntree) : null
},
dateReference: today.toISOString().split('T')[0],
congesPayes: {
acquisActuelle: parseFloat(acquisActuelleCP.toFixed(2)),
acquisTotalePrevu: parseFloat(acquisTotaleCP.toFixed(2)),
acquisRestante: parseFloat((acquisTotaleCP - acquisActuelleCP).toFixed(2)),
soldeActuel: parseFloat(soldeActuelCP.toFixed(2)),
dejaPris: parseFloat(dejaPrisCP.toFixed(2)),
anticipePossible: parseFloat(anticipePossibleCP.toFixed(2)),
limiteAnticipe: parseFloat(limiteAnticipeCP.toFixed(2)),
totalDisponible: parseFloat((soldeActuelCP + limiteAnticipeCP).toFixed(2)),
message: limiteAnticipeCP > 0
? `Vous pouvez poser jusqu'à ${limiteAnticipeCP.toFixed(1)} jours de CP en anticipé`
: "Vous avez atteint la limite d'anticipation pour les CP"
},
rtt: user.role !== 'Apprenti' ? {
acquisActuelle: parseFloat(acquisActuelleRTT.toFixed(2)),
acquisTotalePrevu: parseFloat(acquisTotaleRTT.toFixed(2)),
acquisRestante: parseFloat((acquisTotaleRTT - acquisActuelleRTT).toFixed(2)),
soldeActuel: parseFloat(soldeActuelRTT.toFixed(2)),
dejaPris: parseFloat(dejaPrisRTT.toFixed(2)),
anticipePossible: parseFloat(anticipePossibleRTT.toFixed(2)),
limiteAnticipe: parseFloat(limiteAnticipeRTT.toFixed(2)),
totalDisponible: parseFloat((soldeActuelRTT + limiteAnticipeRTT).toFixed(2)),
message: limiteAnticipeRTT > 0
? `Vous pouvez poser jusqu'à ${limiteAnticipeRTT.toFixed(1)} jours de RTT en anticipé`
: "Vous avez atteint la limite d'anticipation pour les RTT"
} : null,
regles: {
cpMaxAnnuel: 25,
rttMaxAnnuel: typeContrat === 'forfait_jour' ? 12 : 10,
description: "Les congés anticipés sont basés sur l'acquisition prévue jusqu'à la fin de l'exercice/année"
}
});
} catch (error) {
console.error('Erreur getCongesAnticipes:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
/**
* Calcule l'acquisition CP ANTICIPÉE (ce qui reste à acquérir jusqu'à fin d'exercice)
*/
function calculerAcquisitionCPAnticipee(dateReference = new Date(), dateEntree = null) {
const d = new Date(dateReference);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
const mois = d.getMonth() + 1;
// 1⃣ Déterminer la fin de l'exercice CP (31/05)
let finExercice;
if (mois >= 6) {
finExercice = new Date(annee + 1, 4, 31); // 31/05/N+1
} else {
finExercice = new Date(annee, 4, 31); // 31/05/N
}
finExercice.setHours(0, 0, 0, 0);
// 2⃣ Calculer l'acquisition actuelle
const acquisActuelle = calculerAcquisitionCP_Smart(d, dateEntree);
// 3⃣ Calculer l'acquisition totale à fin d'exercice
const acquisTotaleFinExercice = calculerAcquisitionCP_Smart(finExercice, dateEntree);
// 4⃣ Anticipée = Totale - Actuelle (plafonnée à 25)
const acquisAnticipee = Math.min(25, acquisTotaleFinExercice) - acquisActuelle;
return Math.max(0, Math.round(acquisAnticipee * 100) / 100);
}
/**
* Calcule l'acquisition RTT ANTICIPÉE (ce qui reste à acquérir jusqu'à fin d'année)
*/
async function calculerAcquisitionRTTAnticipee(conn, collaborateurId, dateReference = new Date()) {
const d = new Date(dateReference);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
// 1⃣ Récupérer les infos du collaborateur
const [collabInfo] = await conn.query(
`SELECT TypeContrat, DateEntree, role FROM CollaborateurAD WHERE id = ?`,
[collaborateurId]
);
if (collabInfo.length === 0) {
return 0;
}
const typeContrat = collabInfo[0].TypeContrat || '37h';
const isApprenti = collabInfo[0].role === 'Apprenti';
// 2⃣ Apprentis = pas de RTT
if (isApprenti) {
return 0;
}
// 3⃣ Récupérer la configuration RTT
const config = await getConfigurationRTT(conn, annee, typeContrat);
// 4⃣ Calculer l'acquisition actuelle
const rttActuel = await calculerAcquisitionRTT_Smart(conn, collaborateurId, d);
const acquisActuelle = rttActuel.acquisition;
// 5⃣ Calculer l'acquisition totale à fin d'année (31/12)
const finAnnee = new Date(annee, 11, 31);
finAnnee.setHours(0, 0, 0, 0);
const rttFinAnnee = await calculerAcquisitionRTT_Smart(conn, collaborateurId, finAnnee);
const acquisTotaleFinAnnee = rttFinAnnee.acquisition;
// 6⃣ Anticipée = Totale - Actuelle (plafonnée au max annuel)
const acquisAnticipee = Math.min(config.joursAnnuels, acquisTotaleFinAnnee) - acquisActuelle;
return Math.max(0, Math.round(acquisAnticipee * 100) / 100);
}
app.get('/api/getStatistiquesCompteurs', async (req, res) => {
try {
const conn = await pool.getConnection();
const currentYear = new Date().getFullYear();
const [totalCollabs] = await conn.query(
'SELECT COUNT(*) as total FROM CollaborateurAD WHERE actif = 1 OR actif IS NULL'
);
const [statsTypeContrat] = await conn.query(`
SELECT
TypeContrat,
COUNT(*) as nombre,
GROUP_CONCAT(CONCAT(prenom, ' ', nom) SEPARATOR ', ') as noms
FROM CollaborateurAD
WHERE actif = 1 OR actif IS NULL
GROUP BY TypeContrat
`);
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
let statsCP = { total_acquis: 0, total_solde: 0, moyenne_utilisation: 0 };
if (cpType.length > 0) {
const [cpStats] = await conn.query(`
SELECT
SUM(Total) as total_acquis,
SUM(Solde) as total_solde,
AVG(CASE WHEN Total > 0 THEN ((Total - Solde) / Total) * 100 ELSE 0 END) as moyenne_utilisation
FROM CompteurConges
WHERE TypeCongeId = ? AND Annee = ?
`, [cpType[0].Id, currentYear]);
if (cpStats.length > 0) {
statsCP = {
total_acquis: parseFloat((cpStats[0].total_acquis || 0).toFixed(2)),
total_solde: parseFloat((cpStats[0].total_solde || 0).toFixed(2)),
moyenne_utilisation: parseFloat((cpStats[0].moyenne_utilisation || 0).toFixed(1))
};
}
}
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
let statsRTT = { total_acquis: 0, total_solde: 0, moyenne_utilisation: 0 };
if (rttType.length > 0) {
const [rttStats] = await conn.query(`
SELECT
SUM(Total) as total_acquis,
SUM(Solde) as total_solde,
AVG(CASE WHEN Total > 0 THEN ((Total - Solde) / Total) * 100 ELSE 0 END) as moyenne_utilisation
FROM CompteurConges
WHERE TypeCongeId = ? AND Annee = ?
`, [rttType[0].Id, currentYear]);
if (rttStats.length > 0) {
statsRTT = {
total_acquis: parseFloat((rttStats[0].total_acquis || 0).toFixed(2)),
total_solde: parseFloat((rttStats[0].total_solde || 0).toFixed(2)),
moyenne_utilisation: parseFloat((rttStats[0].moyenne_utilisation || 0).toFixed(1))
};
}
}
conn.release();
res.json({
success: true,
annee: currentYear,
statistiques: {
collaborateurs: {
total: totalCollabs[0].total,
par_type_contrat: statsTypeContrat
},
conges_payes: statsCP,
rtt: statsRTT
}
});
} catch (error) {
console.error('Erreur getStatistiquesCompteurs:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
async function hasCompteRenduAccess(userId) {
try {
const conn = await pool.getConnection();
const [user] = await conn.query(`
SELECT TypeContrat, role
FROM CollaborateurAD
WHERE id = ?
`, [userId]);
conn.release();
if (!user.length) return false;
const userInfo = user[0];
// Accès si :
// 1. TypeContrat = 'forfait_jour'
// 2. role = 'Directeur Campus' ou 'Directrice Campus'
// 3. role = 'RH' ou 'Admin'
return (
userInfo.TypeContrat === 'forfait_jour' ||
userInfo.role === 'Directeur Campus' ||
userInfo.role === 'Directrice Campus' ||
userInfo.role === 'RH' ||
userInfo.role === 'Admin'
);
} catch (error) {
console.error('Erreur vérification accès:', error);
return false;
}
}
// Récupérer les jours du mois
// GET - Récupérer les données du compte-rendu
app.get('/api/getCompteRenduActivites', async (req, res) => {
const { user_id, annee, mois } = req.query;
try {
// Vérifier l'accès
const hasAccess = await hasCompteRenduAccess(user_id);
if (!hasAccess) {
return res.json({
success: false,
message: 'Accès réservé aux collaborateurs en forfait jour et aux directeurs de campus'
});
}
const conn = await pool.getConnection();
const [jours] = await conn.query(`
SELECT
id,
CollaborateurADId,
Annee,
Mois,
DATE_FORMAT(JourDate, '%Y-%m-%d') as JourDate,
JourTravaille,
ReposQuotidienRespect,
ReposHebdomadaireRespect,
CommentaireRepos,
Verrouille,
DateSaisie,
SaisiePar
FROM CompteRenduActivites
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ?
ORDER BY JourDate
`, [user_id, annee, mois]);
console.log('🔍 Backend - Jours trouvés:', jours.length);
if (jours.length > 0) {
console.log('📅 Premier jour:', jours[0].JourDate, 'Type:', typeof jours[0].JourDate);
}
const [mensuel] = await conn.query(`
SELECT * FROM CompteRenduMensuel
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ?
`, [user_id, annee, mois]);
conn.release();
res.json({
success: true,
jours: jours,
mensuel: mensuel[0] || null
});
} catch (error) {
console.error('Erreur getCompteRenduActivites:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// POST - Sauvegarder un jour avec AUTO-VERROUILLAGE
app.post('/api/saveCompteRenduJour', async (req, res) => {
const { user_id, date, jour_travaille, repos_quotidien, repos_hebdo, commentaire, rh_override } = req.body;
try {
const conn = await pool.getConnection();
await conn.beginTransaction();
const dateJour = new Date(date);
const aujourdhui = new Date();
aujourdhui.setHours(0, 0, 0, 0);
dateJour.setHours(0, 0, 0, 0);
// Bloquer saisie du jour actuel (il faut attendre le lendemain)
if (dateJour >= aujourdhui) {
await conn.rollback();
conn.release();
return res.json({ success: false, message: 'Vous ne pouvez pas saisir le jour actuel. Veuillez attendre demain.' });
}
const annee = dateJour.getFullYear();
const mois = dateJour.getMonth() + 1;
// Vérifier si le JOUR est déjà verrouillé (pas le mois entier)
const [jourExistant] = await conn.query(
'SELECT Id, Verrouille FROM CompteRenduActivites WHERE CollaborateurADId = ? AND JourDate = ?',
[user_id, date]
);
if (jourExistant.length > 0 && jourExistant[0].Verrouille && !rh_override) {
await conn.rollback();
conn.release();
return res.json({ success: false, message: 'Ce jour est verrouillé - Contactez les RH pour modification' });
}
// Vérifier commentaire obligatoire
if (!repos_quotidien || !repos_hebdo) {
if (!commentaire || commentaire.trim() === '') {
await conn.rollback();
conn.release();
return res.json({ success: false, message: 'Commentaire obligatoire en cas de non-respect des repos' });
}
}
// ⭐ FIX: Utiliser IF EXISTS pattern au lieu de ON DUPLICATE KEY UPDATE
if (jourExistant.length > 0) {
// UPDATE
await conn.query(`
UPDATE CompteRenduActivites
SET JourTravaille = ?,
ReposQuotidienRespect = ?,
ReposHebdomadaireRespect = ?,
CommentaireRepos = ?,
SaisiePar = ?,
Verrouille = 1
WHERE CollaborateurADId = ? AND JourDate = ?
`, [jour_travaille, repos_quotidien, repos_hebdo, commentaire, user_id, user_id, date]);
} else {
// INSERT
await conn.query(`
INSERT INTO CompteRenduActivites
(CollaborateurADId, Annee, Mois, JourDate, JourTravaille,
ReposQuotidienRespect, ReposHebdomadaireRespect, CommentaireRepos,
DateSaisie, SaisiePar, Verrouille)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, GETDATE(), ?, 1)
`, [user_id, annee, mois, date, jour_travaille, repos_quotidien, repos_hebdo, commentaire, user_id]);
}
// Mettre à jour les statistiques mensuelles (SANS verrouiller le mois)
const [stats] = await conn.query(`
SELECT
COUNT(*) as nbJours,
SUM(CASE WHEN ReposQuotidienRespect = 0 THEN 1 ELSE 0 END) as nbNonRespectQuotidien,
SUM(CASE WHEN ReposHebdomadaireRespect = 0 THEN 1 ELSE 0 END) as nbNonRespectHebdo
FROM CompteRenduActivites
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ?
AND JourTravaille = 1
`, [user_id, annee, mois]);
// ⭐ FIX: Vérifier si le mensuel existe
const [mensuelExistant] = await conn.query(`
SELECT Id FROM CompteRenduMensuel
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ?
`, [user_id, annee, mois]);
if (mensuelExistant.length > 0) {
await conn.query(`
UPDATE CompteRenduMensuel
SET NbJoursTravailles = ?,
NbJoursNonRespectsReposQuotidien = ?,
NbJoursNonRespectsReposHebdo = ?,
DateValidation = GETDATE()
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ?
`, [stats[0].nbJours, stats[0].nbNonRespectQuotidien, stats[0].nbNonRespectHebdo, user_id, annee, mois]);
} else {
await conn.query(`
INSERT INTO CompteRenduMensuel
(CollaborateurADId, Annee, Mois, NbJoursTravailles,
NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo,
Statut, DateValidation)
VALUES (?, ?, ?, ?, ?, ?, 'En cours', GETDATE())
`, [user_id, annee, mois, stats[0].nbJours, stats[0].nbNonRespectQuotidien, stats[0].nbNonRespectHebdo]);
}
await conn.commit();
conn.release();
res.json({
success: true,
message: 'Jour enregistré et verrouillé',
verrouille: true
});
} catch (error) {
console.error('❌ Erreur saveCompteRenduJour:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// POST - Saisie en masse avec AUTO-VERROUILLAGE
app.post('/api/saveCompteRenduMasse', async (req, res) => {
const { user_id, annee, mois, jours, rh_override } = req.body;
try {
const conn = await pool.getConnection();
await conn.beginTransaction();
let count = 0;
let blocked = 0;
for (const jour of jours) {
const dateJour = new Date(jour.date);
const aujourdhui = new Date();
aujourdhui.setHours(0, 0, 0, 0);
dateJour.setHours(0, 0, 0, 0);
// Bloquer le jour actuel
if (dateJour >= aujourdhui) {
blocked++;
continue;
}
// Vérifier si déjà verrouillé
const [jourExistant] = await conn.query(
'SELECT Id, Verrouille FROM CompteRenduActivites WHERE CollaborateurADId = ? AND JourDate = ?',
[user_id, jour.date]
);
if (jourExistant.length > 0 && jourExistant[0].Verrouille && !rh_override) {
blocked++;
continue;
}
// ⭐ FIX: Utiliser IF EXISTS pattern au lieu de ON DUPLICATE KEY UPDATE
if (jourExistant.length > 0) {
// UPDATE
await conn.query(`
UPDATE CompteRenduActivites
SET JourTravaille = ?,
ReposQuotidienRespect = ?,
ReposHebdomadaireRespect = ?,
CommentaireRepos = ?,
SaisiePar = ?,
Verrouille = 1
WHERE CollaborateurADId = ? AND JourDate = ?
`, [jour.jour_travaille, jour.repos_quotidien, jour.repos_hebdo,
jour.commentaire || null, user_id, user_id, jour.date]);
} else {
// INSERT
await conn.query(`
INSERT INTO CompteRenduActivites
(CollaborateurADId, Annee, Mois, JourDate, JourTravaille,
ReposQuotidienRespect, ReposHebdomadaireRespect, CommentaireRepos,
DateSaisie, SaisiePar, Verrouille)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, GETDATE(), ?, 1)
`, [user_id, annee, mois, jour.date, jour.jour_travaille,
jour.repos_quotidien, jour.repos_hebdo, jour.commentaire || null, user_id]);
}
count++;
}
// Mettre à jour statistiques mensuelles
const [stats] = await conn.query(`
SELECT
COUNT(*) as nbJours,
SUM(CASE WHEN ReposQuotidienRespect = 0 THEN 1 ELSE 0 END) as nbNonRespectQuotidien,
SUM(CASE WHEN ReposHebdomadaireRespect = 0 THEN 1 ELSE 0 END) as nbNonRespectHebdo
FROM CompteRenduActivites
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ?
AND JourTravaille = 1
`, [user_id, annee, mois]);
// ⭐ FIX: Vérifier si le mensuel existe
const [mensuelExistant] = await conn.query(`
SELECT Id FROM CompteRenduMensuel
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ?
`, [user_id, annee, mois]);
if (mensuelExistant.length > 0) {
await conn.query(`
UPDATE CompteRenduMensuel
SET NbJoursTravailles = ?,
NbJoursNonRespectsReposQuotidien = ?,
NbJoursNonRespectsReposHebdo = ?,
DateValidation = GETDATE()
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ?
`, [stats[0].nbJours, stats[0].nbNonRespectQuotidien, stats[0].nbNonRespectHebdo, user_id, annee, mois]);
} else {
await conn.query(`
INSERT INTO CompteRenduMensuel
(CollaborateurADId, Annee, Mois, NbJoursTravailles,
NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo,
Statut, DateValidation)
VALUES (?, ?, ?, ?, ?, ?, 'En cours', GETDATE())
`, [user_id, annee, mois, stats[0].nbJours, stats[0].nbNonRespectQuotidien, stats[0].nbNonRespectHebdo]);
}
await conn.commit();
conn.release();
res.json({
success: true,
count: count,
blocked: blocked,
message: `${count} jours enregistrés${blocked > 0 ? `, ${blocked} ignorés (jour actuel ou déjà verrouillés)` : ''}`
});
} catch (error) {
console.error('❌ Erreur saisie masse:', error);
res.status(500).json({ success: false, message: error.message });
}
});
app.post('/api/deverrouillerJour', async (req, res) => {
const { user_id, date, rh_user_id } = req.body;
try {
const conn = await pool.getConnection();
const [rhUser] = await conn.query(
'SELECT role FROM CollaborateurAD WHERE id = ?',
[rh_user_id]
);
if (!rhUser.length || (rhUser[0].role !== 'RH' && rhUser[0].role !== 'Admin')) {
conn.release();
return res.json({ success: false, message: 'Action réservée aux RH' });
}
await conn.query(`
UPDATE CompteRenduActivites
SET Verrouille = FALSE
WHERE CollaborateurADId = ? AND JourDate = ?
`, [user_id, date]);
conn.release();
res.json({ success: true });
} catch (error) {
console.error('❌ Erreur déverrouillage jour:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// POST - Verrouiller (RH uniquement)
app.post('/api/verrouillerCompteRendu', async (req, res) => {
const { user_id, annee, mois, rh_user_id } = req.body;
try {
const conn = await pool.getConnection();
// Vérifier que l'utilisateur est RH
const [rhUser] = await conn.query(
'SELECT role FROM CollaborateurAD WHERE id = ?',
[rh_user_id]
);
if (!rhUser.length || (rhUser[0].role !== 'RH' && rhUser[0].role !== 'Admin')) {
conn.release();
return res.json({ success: false, message: 'Action réservée aux RH' });
}
await conn.query(`
UPDATE CompteRenduMensuel
SET Verrouille = TRUE,
DateModification = GETDATE()
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ?
`, [user_id, annee, mois]);
conn.release();
res.json({ success: true });
} catch (error) {
console.error('Erreur verrouillage:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// POST - Déverrouiller (RH uniquement)
app.post('/api/deverrouillerCompteRendu', async (req, res) => {
const { user_id, annee, mois, rh_user_id } = req.body;
try {
const conn = await pool.getConnection();
// Vérifier que l'utilisateur est RH
const [rhUser] = await conn.query(
'SELECT role FROM CollaborateurAD WHERE id = ?',
[rh_user_id]
);
if (!rhUser.length || (rhUser[0].role !== 'RH' && rhUser[0].role !== 'Admin')) {
conn.release();
return res.json({ success: false, message: 'Action réservée aux RH' });
}
await conn.query(`
UPDATE CompteRenduMensuel
SET Verrouille = FALSE,
DateDeverrouillage = GETDATE(),
DeverrouillePar = ?
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ?
`, [rh_user_id, user_id, annee, mois]);
conn.release();
res.json({ success: true });
} catch (error) {
console.error('Erreur déverrouillage:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// GET - Stats annuelles
app.get('/api/getStatsAnnuelles', async (req, res) => {
const { user_id, annee } = req.query;
try {
const conn = await pool.getConnection();
const [stats] = await conn.query(`
SELECT
SUM(NbJoursTravailles) as totalJoursTravailles,
SUM(NbJoursNonRespectsReposQuotidien) as totalNonRespectQuotidien,
SUM(NbJoursNonRespectsReposHebdo) as totalNonRespectHebdo
FROM CompteRenduMensuel
WHERE CollaborateurADId = ? AND Annee = ?
`, [user_id, annee]);
conn.release();
res.json({
success: true,
stats: stats[0] || {
totalJoursTravailles: 0,
totalNonRespectQuotidien: 0,
totalNonRespectHebdo: 0
}
});
} catch (error) {
console.error('Erreur stats:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// GET - Export PDF (RH uniquement)
app.get('/api/exportCompteRenduPDF', async (req, res) => {
const { user_id, annee, mois } = req.query;
try {
const conn = await pool.getConnection();
// Récupérer les données du collaborateur
const [collab] = await conn.query(
'SELECT prenom, nom, email FROM CollaborateurAD WHERE id = ?',
[user_id]
);
// Récupérer les jours du mois
const [jours] = await conn.query(`
SELECT * FROM CompteRenduActivites
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ?
ORDER BY JourDate
`, [user_id, annee, mois]);
// Récupérer le mensuel
const [mensuel] = await conn.query(`
SELECT * FROM CompteRenduMensuel
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ?
`, [user_id, annee, mois]);
conn.release();
// TODO: Générer le PDF avec une bibliothèque comme pdfkit ou puppeteer
// Pour l'instant, retourner les données JSON
res.json({
success: true,
collaborateur: collab[0],
jours: jours,
mensuel: mensuel[0],
message: 'Export PDF à implémenter'
});
} catch (error) {
console.error('Erreur export PDF:', error);
res.status(500).json({ success: false, message: error.message });
}
});
app.post('/api/addUserFromEntra', async (req, res) => {
try {
const { email } = req.body;
if (!email) {
return res.json({ success: false, message: 'Email requis' });
}
console.log(`\n🔍 Recherche utilisateur Entra ID: ${email}`);
// 1⃣ Obtenir le token
const accessToken = await getGraphToken();
if (!accessToken) {
return res.json({ success: false, message: 'Impossible d\'obtenir le token Microsoft' });
}
// 2⃣ Rechercher l'utilisateur dans Entra ID
const searchUrl = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(email)}?$select=id,givenName,surname,mail,userPrincipalName,department,jobTitle,accountEnabled`;
let userData;
try {
const response = await axios.get(searchUrl, {
headers: { Authorization: `Bearer ${accessToken}` }
});
userData = response.data;
} catch (error) {
return res.json({
success: false,
message: `Utilisateur ${email} non trouvé dans Entra ID`
});
}
// 3⃣ Vérifier si compte actif
if (userData.accountEnabled === false) {
return res.json({
success: false,
message: 'Ce compte est désactivé dans Entra ID'
});
}
// 4⃣ Vérifier s'il existe déjà en base
const conn = await pool.getConnection();
const [existing] = await conn.query(
'SELECT id, email FROM CollaborateurAD WHERE LOWER(email) = ?',
[email.toLowerCase()]
);
if (existing.length > 0) {
conn.release();
return res.json({
success: false,
message: 'Cet utilisateur existe déjà en base',
userId: existing[0].id
});
}
// 5⃣ Insérer l'utilisateur (SANS DateEntree pour éviter GETDATE())
await conn.query(`
INSERT INTO CollaborateurAD
(entraUserId, prenom, nom, email, service, description, role, SocieteId, Actif, TypeContrat)
VALUES (?, ?, ?, ?, ?, ?, 'Collaborateur', 1, 1, '37h')
`, [
userData.id,
userData.givenName || 'Prénom',
userData.surname || 'Nom',
email.toLowerCase(),
userData.department || '',
userData.jobTitle || ''
]);
// 6⃣ Récupérer l'utilisateur créé
const [newUser] = await conn.query(
'SELECT id, prenom, nom, email FROM CollaborateurAD WHERE LOWER(email) = ?',
[email.toLowerCase()]
);
conn.release();
console.log(`✅ Utilisateur créé: ${newUser[0].prenom} ${newUser[0].nom} (ID: ${newUser[0].id})`);
res.json({
success: true,
message: 'Utilisateur ajouté avec succès',
user: newUser[0]
});
} catch (error) {
console.error('❌ Erreur addUserFromEntra:', error);
res.status(500).json({
success: false,
message: error.message
});
}
});
async function syncEntraIdUsers() {
const syncResults = {
processed: 0,
inserted: 0,
updated: 0,
deactivated: 0,
errors: []
};
try {
console.log('\n🔄 === DÉBUT SYNCHRONISATION ENTRA ID ===');
// 1⃣ Obtenir le token
const accessToken = await getGraphToken();
if (!accessToken) {
console.error('❌ Impossible d\'obtenir le token');
return syncResults;
}
console.log('✅ Token obtenu');
// 2⃣ Récupérer le groupe
const groupResponse = await axios.get(
`https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
const groupName = groupResponse.data.displayName;
console.log(`📋 Groupe : ${groupName}`);
// 3⃣ Récupérer tous les membres avec pagination
let allAzureMembers = [];
let nextLink = `https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}/members?$select=id,givenName,surname,mail,department,jobTitle,officeLocation,accountEnabled&$top=999`;
console.log('📥 Récupération des membres...');
while (nextLink) {
const membersResponse = await axios.get(nextLink, {
headers: { Authorization: `Bearer ${accessToken}` }
});
allAzureMembers = allAzureMembers.concat(membersResponse.data.value);
nextLink = membersResponse.data['@odata.nextLink'];
if (nextLink) {
console.log(` 📄 ${allAzureMembers.length} membres récupérés...`);
}
}
console.log(`${allAzureMembers.length} membres trouvés`);
// 4⃣ Filtrer les membres valides
const validMembers = allAzureMembers.filter(m => {
if (!m.mail || m.mail.trim() === '') return false;
if (m.accountEnabled === false) return false;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(m.mail);
});
console.log(`${validMembers.length} membres valides`);
// 5⃣ Traitement avec transaction
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
const azureEmails = new Set();
validMembers.forEach(m => {
azureEmails.add(m.mail.toLowerCase().trim());
});
console.log('\n📝 Traitement des utilisateurs...');
// 6⃣ Pour chaque membre
for (const m of validMembers) {
try {
const emailClean = m.mail.toLowerCase().trim();
syncResults.processed++;
// ✅ CORRIGÉ : Utiliser entraUserId et Actif
const request = new sql.Request(transaction);
request.input('email', sql.NVarChar, emailClean);
const result = await request.query(`
SELECT id, email, entraUserId, Actif
FROM CollaborateurAD
WHERE LOWER(email) = LOWER(@email)
`);
if (result.recordset.length > 0) {
// ✅ MISE À JOUR avec bonnes colonnes
const updateRequest = new sql.Request(transaction);
updateRequest.input('entraUserId', sql.NVarChar, m.id);
updateRequest.input('prenom', sql.NVarChar, m.givenName || '');
updateRequest.input('nom', sql.NVarChar, m.surname || '');
updateRequest.input('service', sql.NVarChar, m.department || '');
updateRequest.input('description', sql.NVarChar, m.jobTitle || '');
updateRequest.input('email', sql.NVarChar, emailClean);
await updateRequest.query(`
UPDATE CollaborateurAD
SET
entraUserId = @entraUserId,
prenom = @prenom,
nom = @nom,
service = @service,
description = @description,
Actif = 1
WHERE LOWER(email) = LOWER(@email)
`);
syncResults.updated++;
console.log(` ✓ Mis à jour : ${emailClean}`);
} else {
// ✅ INSERTION avec bonnes colonnes
const insertRequest = new sql.Request(transaction);
insertRequest.input('entraUserId', sql.NVarChar, m.id);
insertRequest.input('prenom', sql.NVarChar, m.givenName || '');
insertRequest.input('nom', sql.NVarChar, m.surname || '');
insertRequest.input('email', sql.NVarChar, emailClean);
insertRequest.input('service', sql.NVarChar, m.department || '');
insertRequest.input('description', sql.NVarChar, m.jobTitle || '');
await insertRequest.query(`
INSERT INTO CollaborateurAD
(entraUserId, prenom, nom, email, service, description, role, SocieteId, Actif, TypeContrat)
VALUES (@entraUserId, @prenom, @nom, @email, @service, @description, 'Collaborateur', 1, 1, '37h')
`);
syncResults.inserted++;
console.log(` ✓ Créé : ${emailClean}`);
}
} catch (userError) {
syncResults.errors.push({
email: m.mail,
error: userError.message
});
console.error(` ❌ Erreur ${m.mail}:`, userError.message);
}
}
// 7⃣ ✅ DÉSACTIVATION avec bonne colonne (Actif, pas dateMiseAJour)
console.log('\n🔍 Désactivation des comptes obsolètes...');
if (azureEmails.size > 0) {
const activeEmailsList = Array.from(azureEmails).map(e => `'${e}'`).join(',');
const deactivateRequest = new sql.Request(transaction);
const deactivateResult = await deactivateRequest.query(`
UPDATE CollaborateurAD
SET Actif = 0
WHERE
email IS NOT NULL
AND email != ''
AND LOWER(email) NOT IN (${activeEmailsList})
AND Actif = 1
`);
syncResults.deactivated = deactivateResult.rowsAffected[0];
console.log(`${syncResults.deactivated} compte(s) désactivé(s)`);
}
await transaction.commit();
console.log('\n📊 === RÉSUMÉ ===');
console.log(` Groupe: ${groupName}`);
console.log(` Total Entra: ${allAzureMembers.length}`);
console.log(` Valides: ${validMembers.length}`);
console.log(` Traités: ${syncResults.processed}`);
console.log(` Créés: ${syncResults.inserted}`);
console.log(` Mis à jour: ${syncResults.updated}`);
console.log(` Désactivés: ${syncResults.deactivated}`);
console.log(` Erreurs: ${syncResults.errors.length}`);
} catch (error) {
await transaction.rollback();
throw error;
}
} catch (error) {
console.error('\n❌ ERREUR SYNCHRONISATION:', error.message);
}
return syncResults;
}
app.post('/api/sync-all', async (req, res) => {
try {
console.log('🚀 Sync complète manuelle...');
const results = await syncEntraIdUsers();
res.json({
success: true,
message: 'Sync terminée',
stats: results
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message
});
}
});
// Route diagnostic
app.get('/api/diagnostic-sync', async (req, res) => {
try {
const totalDB = await pool.query(
'SELECT COUNT(*) as total, SUM(CASE WHEN actif = 1 THEN 1 ELSE 0 END) as actifs FROM CollaborateurAD',
[]
);
const sansEmail = await pool.query(
'SELECT COUNT(*) as total FROM CollaborateurAD WHERE email IS NULL OR email = \'\'',
[]
);
const derniers = await pool.query(
'SELECT TOP 10 id, prenom, nom, email, CollaborateurADId, actif FROM CollaborateurAD ORDER BY id DESC',
[]
);
// Test Entra
let entraStatus = { connected: false };
try {
const token = await getGraphToken();
if (token) {
const groupResponse = await axios.get(
`https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`,
{ headers: { Authorization: `Bearer ${token}` } }
);
entraStatus = {
connected: true,
groupName: groupResponse.data.displayName
};
}
} catch (err) {
entraStatus.error = err.message;
}
res.json({
success: true,
database: {
total: totalDB[0]?.total || 0,
actifs: totalDB[0]?.actifs || 0,
sansEmail: sansEmail[0]?.total || 0
},
entraId: entraStatus,
derniers_utilisateurs: derniers
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
// GET - Compteur de demandes en attente pour le badge
app.get('/api/getPendingRequestsCount', async (req, res) => {
try {
const userId = req.query.user_id;
const userRole = req.query.role;
const userEmail = req.query.email;
if (!userId) {
return res.json({ success: false, message: 'ID utilisateur manquant' });
}
const conn = await pool.getConnection();
let count = 0;
// Normaliser le rôle
const role = normalizeRole(userRole);
console.log(`🔍 Comptage demandes pour: ${userEmail}, Role: ${role}`);
if (role === 'rh' || role === 'admin' || role === 'president' || role === 'directeur de campus') {
// Pour RH/Admin/President/Directeur : toutes les demandes en attente de leur périmètre
const [userInfo] = await conn.query(
'SELECT ServiceId, CampusId, SocieteId FROM CollaborateurAD WHERE id = ?',
[userId]
);
if (userInfo.length === 0) {
conn.release();
return res.json({ success: false, message: 'Utilisateur non trouvé' });
}
const serviceId = userInfo[0].ServiceId;
const campusId = userInfo[0].CampusId;
// ⭐ Vérifier accès transversal
const accesTransversal = getUserAccesTransversal(userEmail);
if (accesTransversal) {
// Accès service multi-campus
const [result] = await conn.query(`
SELECT COUNT(DISTINCT dc.Id) as count
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
JOIN Services s ON ca.ServiceId = s.Id
WHERE dc.Statut = 'En attente'
AND s.Nom = ?
`, [accesTransversal.serviceNom]);
count = result[0].count;
} else if (role === 'directeur de campus') {
// Directeur de campus : toutes les demandes de son campus
const [result] = await conn.query(`
SELECT COUNT(DISTINCT dc.Id) as count
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
WHERE dc.Statut = 'En attente'
AND ca.CampusId = ?
`, [campusId]);
count = result[0].count;
} else {
// RH : son service sur son campus uniquement
const [result] = await conn.query(`
SELECT COUNT(DISTINCT dc.Id) as count
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
WHERE dc.Statut = 'En attente'
AND ca.ServiceId = ?
AND ca.CampusId = ?
`, [serviceId, campusId]);
count = result[0].count;
}
} else if (role === 'validateur' || role === 'validatrice') {
// Pour validateurs : demandes de leur service
const [userInfo] = await conn.query(
'SELECT ServiceId, CampusId FROM CollaborateurAD WHERE id = ?',
[userId]
);
if (userInfo.length === 0) {
conn.release();
return res.json({ success: false, message: 'Utilisateur non trouvé' });
}
const serviceId = userInfo[0].ServiceId;
const campusId = userInfo[0].CampusId;
const [result] = await conn.query(`
SELECT COUNT(DISTINCT dc.Id) as count
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
WHERE dc.Statut = 'En attente'
AND ca.ServiceId = ?
AND ca.CampusId = ?
AND ca.id != ?
`, [serviceId, campusId, userId]);
count = result[0].count;
}
conn.release();
console.log(`✅ Nombre de demandes en attente: ${count}`);
res.json({
success: true,
count: count,
role: role
});
} catch (error) {
console.error('❌ Erreur getPendingRequestsCount:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
app.listen(PORT, "0.0.0.0", async () => {
console.log("✅ ==========================================");
console.log(" SERVEUR PRINCIPAL DÉMARRÉ");
console.log(" Port:", PORT);
console.log(` Base: ${dbConfig.database}@${dbConfig.server}`);
console.log("==========================================");
// ⚡ Synchronisation Entra ID au démarrage (après 5 secondes)
setTimeout(async () => {
console.log("🚀 Lancement synchronisation Entra ID...");
await syncEntraIdUsers();
}, 5000);
});