// ============================================================================
// đ GTA - GESTION DES TEMPS ET ABSENCES
// ============================================================================
// Serveur Backend Node.js avec SQL Server
// Port: 3004
// Base de données: GTA (SQL Server)
// ============================================================================
import express from 'express';
import sql from 'mssql';
import cors from 'cors';
import axios from 'axios';
import multer from 'multer';
import path from 'path';
import { fileURLToPath } from 'url';
import cron from 'node-cron';
import crypto from 'crypto';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
import WebhookManager from './webhook-utils.js';
import { WEBHOOKS, EVENTS } from './webhook-config.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = 3000;
const webhookManager = new WebhookManager(WEBHOOKS.SECRET_KEY);
const sseClientsCollab = new Set();
process.on('uncaughtException', (err) => {
console.error('đ„ ERREUR CRITIQUE NON CATCHĂE:', err);
console.error('Stack:', err.stack);
// On ne crash pas pour pouvoir déboguer
});
process.on('unhandledRejection', (reason, promise) => {
console.error('đ„ PROMESSE REJETĂE NON GĂRĂE:', reason);
console.error('Promise:', promise);
});
app.use(cors({
origin: ['http://localhost:3013', 'http://localhost:80', 'https://mygta.ensup-adm.net'],
credentials: true
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const dbConfig = {
server: '192.168.0.3', // â 'server' au lieu de 'host'
user: 'gta_app',
password: 'GTA2025!Secure',
database: 'GTA',
port: 1433, // â Nombre, pas string
options: {
encrypt: true, // â Pas de SSL en rĂ©seau local
trustServerCertificate: true,
enableArithAbort: true,
connectTimeout: 60000,
requestTimeout: 60000
},
pool: {
max: 10,
min: 0,
idleTimeoutMillis: 30000
}
};
function nowFR() {
const d = new Date();
d.setHours(d.getHours() + 2);
return d.toISOString().slice(0, 19).replace('T', ' ');
}
function formatDateWithoutUTC(date) {
if (!date) return null;
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
function formatDateToFrenchTime(date) {
if (!date) return null;
// Créer un objet Date et le convertir en heure française (Europe/Paris)
const d = new Date(date);
// Formater en ISO avec le fuseau horaire français
return d.toLocaleString('fr-FR', {
timeZone: 'Europe/Paris',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
}
// Ou plus simple, pour avoir un format ISO compatible avec le frontend
function formatDateToFrenchISO(date) {
if (!date) return null;
const d = new Date(date);
// Convertir en heure française
const frenchDate = new Date(d.toLocaleString('en-US', { timeZone: 'Europe/Paris' }));
return frenchDate.toISOString();
}
/**
* RĂ©cupĂšre le dernier arrĂȘtĂ© validĂ©/clĂŽturĂ©
*/
async function getDernierArrete(conn) {
const [arretes] = await conn.query(`
SELECT TOP 1 * FROM ArreteComptable
WHERE Statut IN ('Validé', 'ClÎturé')
ORDER BY DateArrete DESC
`);
return arretes.length > 0 ? arretes[0] : null;
}
/**
* VĂ©rifie si une date est avant le dernier arrĂȘtĂ©
*/
async function estAvantArrete(conn, date) {
const dernierArrete = await getDernierArrete(conn);
if (!dernierArrete) {
return false; // Pas d'arrĂȘtĂ© = toutes les dates sont autorisĂ©es
}
const dateTest = new Date(date);
const dateArrete = new Date(dernierArrete.DateArrete);
return dateTest <= dateArrete;
}
async function getSoldeFige(conn, collaborateurId, typeCongeId, annee) {
const dernierArrete = await getDernierArrete(conn);
if (!dernierArrete) {
return null; // Pas d'arrĂȘtĂ©
}
const [soldes] = await conn.query(`
SELECT TOP 1 * FROM SoldesFiges
WHERE ArreteId = ?
AND CollaborateurADId = ?
AND TypeCongeId = ?
AND Annee = ?
`, [dernierArrete.Id, collaborateurId, typeCongeId, annee]);
return soldes.length > 0 ? soldes[0] : null;
}
// ============================================================
// đ§ WORKER : scrute RappelsCRAPending toutes les 60 secondes
// et envoie les emails via Microsoft Graph
// ============================================================
// Template email unifié (quotidien ou hebdomadaire)
function buildRappelCRAHtml(prenom, joursArray, type, moisLabel = null) {
const nb = joursArray.length;
function grouperEnPlages(jours) {
if (!jours || jours.length === 0) return [];
const sorted = [...jours].sort();
const plages = [];
let debut = sorted[0];
let fin = sorted[0];
for (let i = 1; i < sorted.length; i++) {
const prev = new Date(sorted[i - 1] + 'T00:00:00');
const curr = new Date(sorted[i] + 'T00:00:00');
const diffJ = (curr - prev) / 86400000;
if (diffJ <= 3) {
fin = sorted[i];
} else {
plages.push({ debut, fin });
debut = sorted[i];
fin = sorted[i];
}
}
plages.push({ debut, fin });
return plages;
}
function formatPlage(debut, fin) {
const opts = { weekday: 'long', day: 'numeric', month: 'long' };
const d = new Date(debut + 'T00:00:00').toLocaleDateString('fr-FR', opts);
const f = new Date(fin + 'T00:00:00').toLocaleDateString('fr-FR', opts);
return debut === fin ? d : `Du ${d} au ${f}`;
}
const plages = grouperEnPlages(joursArray);
const nbPlages = plages.length;
const plagesHtml = plages.slice(0, 8).map(({ debut, fin }) => {
const label = formatPlage(debut, fin);
const nbJoursPlage = joursArray.filter(j => j >= debut && j <= fin).length;
const badge = nbJoursPlage > 1
? `${nbJoursPlage}j`
: '';
return `
đ
${label}${badge}
`;
}).join('');
const surplus = nbPlages > 8
? `
⊠et ${nbPlages - 8} autre${nbPlages - 8 > 1 ? 's' : ''}
période${nbPlages - 8 > 1 ? 's' : ''}.
`
: '';
// â
Config selon le type de rappel
const cfg =
type === 'quotidien' ? {
couleur: '#f59e0b',
emoji: 'đ
',
titre: `${nb} jour${nb > 1 ? 's' : ''} non saisi${nb > 1 ? 's' : ''} dans votre CRA`,
intro: `Vous avez ${nb} jour${nb > 1 ? 's' : ''} ouvrable${nb > 1 ? 's' : ''}
non saisi${nb > 1 ? 's' : ''} dans votre Compte-Rendu d'Activités.`
}
: type === 'mensuel' ? {
couleur: '#8b5cf6',
emoji: 'đ',
titre: `CRA de ${moisLabel} non saisi`,
intro: `Votre Compte-Rendu d'Activités du mois de
${moisLabel} n'a pas encore été saisi.
Il contient ${nb} jour${nb > 1 ? 's' : ''}
ouvrable${nb > 1 ? 's' : ''} Ă renseigner.`
}
: {
couleur: '#3b82f6',
emoji: 'đ',
titre: `${nb} jour${nb > 1 ? 's' : ''} non saisi${nb > 1 ? 's' : ''} dans votre CRA`,
intro: `Vous avez ${nb} jour${nb > 1 ? 's' : ''} ouvrable${nb > 1 ? 's' : ''}
non saisi${nb > 1 ? 's' : ''} dans votre Compte-Rendu d'Activités.`
};
return `
${cfg.emoji} ${cfg.titre}
Compte-Rendu d'ActivitĂ©s â Forfait jour
Bonjour ${prenom},
${cfg.intro}
đ PĂ©riode${nbPlages > 1 ? 's' : ''} Ă saisir
(${nb} jour${nb > 1 ? 's' : ''} au total)
${surplus}
Email automatique â GTA ENSUP · Ne pas rĂ©pondre
`;
}
// Worker principal
async function traiterRappelsCRAPending() {
let rappels;
try {
const [rows] = await pool.query(`
SELECT TOP 20
Id, CollabId, Email, Prenom, NomComplet,
TypeRappel, JoursJson, NbJours,
Annee, Mois
FROM RappelsCRAPending
WHERE Statut = 'pending'
ORDER BY DateCreation ASC
`);
rappels = rows;
} catch (err) {
console.error('â Worker CRA â lecture table:', err.message);
return;
}
if (!rappels || rappels.length === 0) return;
console.log(`đŹ Worker CRA : ${rappels.length} rappel(s) Ă traiter`);
const accessToken = await getGraphToken();
if (!accessToken) {
console.error('â Worker CRA â token Graph indisponible');
return;
}
// Tableau des mois en français
const moisNoms = [
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'
];
for (const rappel of rappels) {
try {
const joursArray = JSON.parse(rappel.JoursJson).map(j => j.d);
// â
Label mois pour le type mensuel
const moisLabel = (rappel.Mois && rappel.Annee)
? `${moisNoms[rappel.Mois - 1]} ${rappel.Annee}`
: null;
// â
Sujet selon le type
const sujet =
rappel.TypeRappel === 'quotidien'
? `đ
Rappel CRA â hier non saisi`
: rappel.TypeRappel === 'mensuel'
? `đ Rappel CRA â ${moisLabel} non saisi`
: `đ Rappel CRA â ${rappel.NbJours} jour${rappel.NbJours > 1 ? 's' : ''} non saisi${rappel.NbJours > 1 ? 's' : ''}`;
// â
HTML avec moisLabel passé en paramÚtre
const html = buildRappelCRAHtml(
rappel.Prenom,
joursArray,
rappel.TypeRappel,
moisLabel
);
const envoye = await sendMailGraph(
accessToken,
'gtanoreply@ensup.eu',
rappel.Email,
sujet,
html
);
await pool.query(`
UPDATE RappelsCRAPending
SET Statut = ?,
DateTraitement = GETDATE()
WHERE Id = ?
`, [envoye ? 'traite' : 'erreur', rappel.Id]);
if (envoye) {
console.log(` â
${rappel.NomComplet} (${rappel.TypeRappel}) â ${rappel.Email}`);
} else {
console.error(` â Ăchec envoi â ${rappel.Email}`);
}
await new Promise(r => setTimeout(r, 400));
} catch (err) {
console.error(` â Erreur traitement rappel ID ${rappel.Id}:`, err.message);
await pool.query(`
UPDATE RappelsCRAPending
SET Statut = 'erreur', DateTraitement = GETDATE()
WHERE Id = ?
`, [rappel.Id]);
}
}
}
// Démarrer le worker (60 secondes entre chaque passage)
const workerCRA = setInterval(traiterRappelsCRAPending, 60 * 1000);
// Nettoyage optionnel des lignes traitées > 30 jours (évite que la table grossisse)
setInterval(async () => {
try {
await pool.query(`
DELETE FROM RappelsCRAPending
WHERE Statut IN ('traite', 'erreur')
AND DateCreation < DATEADD(DAY, -30, GETDATE())
`);
} catch (e) { /* silencieux */ }
}, 24 * 60 * 60 * 1000); // une fois par jour
console.log('â° Worker CRA dĂ©marrĂ© â scrute RappelsCRAPending toutes les 60s');
async function calculerAcquisitionRTTSmart(conn, collaborateurId, dateReference) {
try {
const result = await calculerAcquisitionRTT(conn, collaborateurId, dateReference);
return result; // { acquisition, moisTravailles, config, typeContrat }
} catch (error) {
console.error('â calculerAcquisitionRTTSmart erreur:', error.message);
return { acquisition: 0, moisTravailles: 0, config: { joursAnnuels: 0, acquisitionMensuelle: 0 }, typeContrat: '37h' };
}
}
async function calculerAcquisitionDepuisArrete(conn, collaborateurId, typeConge, dateReference = new Date()) {
const dernierArrete = await getDernierArrete(conn);
const anneeRef = dateReference.getFullYear();
// Déterminer le type de congé
const [typeRow] = await conn.query(
'SELECT TOP 1 Id FROM TypeConge WHERE Nom = ?',
[typeConge === 'CP' ? 'Congé payé' : 'RTT']
);
if (typeRow.length === 0) {
throw new Error(`Type de congé ${typeConge} non trouvé`);
}
const typeCongeId = typeRow[0].Id;
const [collab] = await conn.query('SELECT role FROM CollaborateurAD WHERE id = ?', [collaborateurId]);
const isApprenti = collab.length > 0 && collab[0].role === 'Apprenti';
if (typeConge === 'RTT' && isApprenti) {
return 0; // â Les apprentis n'ont pas de RTT
}
// Si pas d'arrĂȘtĂ©, calcul normal depuis le dĂ©but
if (!dernierArrete) {
if (typeConge === 'CP') {
const [collab] = await conn.query('SELECT DateEntree, CampusId FROM CollaborateurAD WHERE id = ?', [collaborateurId]);
const dateEntree = collab.length > 0 ? collab[0].DateEntree : null;
return calculerAcquisitionCP(dateReference, dateEntree);
} else {
const rttData = await calculerAcquisitionRTT(conn, collaborateurId, dateReference);
return rttData.acquisition;
}
}
const dateArrete = new Date(dernierArrete.DateArrete);
// Si la date de rĂ©fĂ©rence est AVANT l'arrĂȘtĂ©, utiliser le solde figĂ©
if (dateReference <= dateArrete) {
const soldeFige = await getSoldeFige(conn, collaborateurId, typeCongeId, anneeRef);
return soldeFige ? soldeFige.TotalAcquis : 0;
}
// Si la date est APRĂS l'arrĂȘtĂ©, partir du solde figĂ© + calcul depuis l'arrĂȘtĂ©
const soldeFige = await getSoldeFige(conn, collaborateurId, typeCongeId, anneeRef);
const acquisFigee = soldeFige ? soldeFige.TotalAcquis : 0;
// Calculer l'acquisition DEPUIS l'arrĂȘtĂ©
let acquisDepuisArrete = 0;
if (typeConge === 'CP') {
const moisDepuisArrete = getMoisTravaillesCP(dateReference, dateArrete);
acquisDepuisArrete = moisDepuisArrete * (25 / 12);
} else {
const [collab] = await conn.query('SELECT TypeContrat, CampusId FROM CollaborateurAD WHERE id = ?', [collaborateurId]);
const typeContrat = collab.length > 0 && collab[0].TypeContrat ? collab[0].TypeContrat : '37h';
const config = await getConfigurationRTT(conn, anneeRef, typeContrat);
const moisDepuisArrete = getMoisTravaillesRTT(dateReference, dateArrete);
acquisDepuisArrete = moisDepuisArrete * config.acquisitionMensuelle;
}
return acquisFigee + acquisDepuisArrete;
}
const pool = new sql.ConnectionPool(dbConfig);
// â CONNEXION AU DĂMARRAGE
pool.connect()
.then(() => {
console.log('â
==========================================');
console.log(' CONNECTĂ Ă SQL SERVER');
console.log(` Base: ${dbConfig.database}@${dbConfig.host}`);
console.log('==========================================');
})
.catch(err => {
console.error('â ==========================================');
console.error(' ERREUR CONNEXION SQL SERVER');
console.error(' Message:', err.message);
console.error('==========================================');
});
// ========================================
// đ§ MONKEY-PATCH MSSQL POUR SUPPORTER "?"
// ========================================
// MONKEY-PATCH MSSQL POUR SUPPORTER ? ET LIMIT
const originalRequest = sql.Request;
sql.Request = function (...args) {
const request = new originalRequest(...args);
const originalQuery = request.query.bind(request);
request.query = async function (queryText, ...queryArgs) {
let convertedQuery = queryText; // đ„ AJOUTĂ ICI
try {
if (!queryArgs || queryArgs.length === 0 || !Array.isArray(queryArgs[0])) {
return await originalQuery(queryText);
}
const params = queryArgs[0];
params.forEach((value, index) => {
request.input(`param${index}`, value);
});
let paramIndex = 0;
convertedQuery = convertedQuery.replace(/\?/g, () => `@param${paramIndex++}`);
// CONVERSION GETDATE() â GETDATE()
convertedQuery = convertedQuery.replace(/NOW\(\)/gi, 'GETDATE()');
// CONVERSION LEAST() â CASE WHEN
while (convertedQuery.match(/LEAST\s*\(/i)) {
convertedQuery = convertedQuery.replace(
/LEAST\s*\(\s*([^,]+?)\s*,\s*([^)]+?)\s*\)/i,
'(CASE WHEN $1 < $2 THEN $1 ELSE $2 END)'
);
}
// LIMIT â TOP conversion
convertedQuery = convertedQuery.replace(
/LIMIT\s+(\d+)\s+OFFSET\s+(\d+)/gi,
'OFFSET $2 ROWS FETCH NEXT $1 ROWS ONLY'
);
const simpleLimitMatch = convertedQuery.match(/LIMIT\s+(\d+)(?!\s+OFFSET)/i);
if (simpleLimitMatch) {
const limitValue = simpleLimitMatch[1];
convertedQuery = convertedQuery.replace(/LIMIT\s+\d+(?!\s+OFFSET)/i, '');
convertedQuery = convertedQuery.replace(
/SELECT(\s+DISTINCT)?/i,
`SELECT$1 TOP ${limitValue}`
);
}
return await originalQuery(convertedQuery);
} catch (error) {
console.error('â Erreur query SQL:', error.message);
console.error('Query originale:', queryText.substring(0, 300));
console.error('Query convertie:', convertedQuery?.substring(0, 300));
throw error;
}
};
return request;
};
console.log('â
Driver mssql patché: support des ?, LIMIT, GETDATE() et LEAST() activé');
// ========================================
// â WRAPPER POUR COMPATIBILITĂ MYSQL
// ========================================
/**
* Simule pool.getConnection() de MySQL
* Retourne un objet avec query(), beginTransaction(), commit(), rollback(), release()
*/
// â WRAPPER POUR COMPATIBILITĂ MYSQL
// â WRAPPER POUR COMPATIBILITĂ MYSQL
pool.getConnection = async function () {
if (!pool.connected) {
await pool.connect();
}
let transaction = null;
return {
query: async function (queryText, params = []) {
// â FIX: DĂ©clarer parameterizedQuery EN DEHORS du try block
let parameterizedQuery = queryText;
try {
const request = transaction ? new sql.Request(transaction) : pool.request();
// Ajouter les paramĂštres (@param0, @param1, ...)
params.forEach((value, index) => {
request.input(`param${index}`, value);
});
// Remplacer ? par @param0, @param1, etc.
let paramIndex = 0;
parameterizedQuery = parameterizedQuery.replace(/\?/g, () => `@param${paramIndex++}`);
// âââ CONVERSION LIMIT â TOP (VERSION CORRIGĂE) âââ
// 1. Gérer LIMIT avec OFFSET
parameterizedQuery = parameterizedQuery.replace(
/LIMIT\s+(\d+)\s+OFFSET\s+(\d+)/gi,
'OFFSET $2 ROWS FETCH NEXT $1 ROWS ONLY'
);
// 2. Marquer tous les LIMIT (mĂȘme sans espace avant)
parameterizedQuery = parameterizedQuery.replace(
/\s*LIMIT\s+(\d+)(?!\s+OFFSET)/gi,
' __LIMIT__$1__'
);
// 3. Injecter TOP aprĂšs SELECT
let limitValue = null;
const limitMatch = parameterizedQuery.match(/__LIMIT__(\d+)__/);
if (limitMatch) {
limitValue = limitMatch[1];
parameterizedQuery = parameterizedQuery.replace(/__LIMIT__\d+__/g, '');
}
if (limitValue) {
parameterizedQuery = parameterizedQuery.replace(
/(SELECT\s+(?:DISTINCT\s+)?)/i,
`$1TOP ${limitValue} `
);
}
// â FIX: Convertir TRUE/FALSE en 1/0 pour SQL Server
parameterizedQuery = parameterizedQuery.replace(/\bTRUE\b/gi, '1');
parameterizedQuery = parameterizedQuery.replace(/\bFALSE\b/gi, '0');
const result = await request.query(parameterizedQuery);
return [result.recordset || []];
} catch (error) {
console.error('â Erreur query SQL:', error.message);
console.error('Query originale:', queryText);
console.error('Query convertie:', parameterizedQuery?.substring(0, 500));
throw error;
}
},
beginTransaction: async function () {
transaction = new sql.Transaction(pool);
await transaction.begin();
},
commit: async function () {
if (transaction) {
await transaction.commit();
transaction = null;
}
},
rollback: async function () {
if (transaction) {
await transaction.rollback();
transaction = null;
}
},
release: function () {
console.log('đ Connection released (no-op avec mssql)');
}
};
};
// â pool.query() direct (sans transaction)
// â pool.query() direct (sans transaction)
pool.query = async function (queryText, params = []) {
if (!pool.connected) {
await pool.connect();
}
// â FIX: DĂ©clarer parameterizedQuery EN DEHORS du try/catch implicite
let parameterizedQuery = queryText;
const request = pool.request();
params.forEach((value, index) => {
request.input(`param${index}`, value);
});
let paramIndex = 0;
parameterizedQuery = parameterizedQuery.replace(/\?/g, () => `@param${paramIndex++}`);
// âââ CONVERSION LIMIT â TOP (VERSION CORRIGĂE) âââ
// 1. Gérer LIMIT avec OFFSET
parameterizedQuery = parameterizedQuery.replace(
/LIMIT\s+(\d+)\s+OFFSET\s+(\d+)/gi,
'OFFSET $2 ROWS FETCH NEXT $1 ROWS ONLY'
);
// 2. Marquer tous les LIMIT (mĂȘme sans espace avant)
parameterizedQuery = parameterizedQuery.replace(
/\s*LIMIT\s+(\d+)(?!\s+OFFSET)/gi,
' __LIMIT__$1__'
);
// 3. Injecter TOP aprĂšs SELECT
let limitValue = null;
const limitMatch = parameterizedQuery.match(/__LIMIT__(\d+)__/);
if (limitMatch) {
limitValue = limitMatch[1];
parameterizedQuery = parameterizedQuery.replace(/__LIMIT__\d+__/g, '');
}
if (limitValue) {
parameterizedQuery = parameterizedQuery.replace(
/(SELECT\s+(?:DISTINCT\s+)?)/i,
`$1TOP ${limitValue} `
);
}
// â FIX: Convertir TRUE/FALSE en 1/0 pour SQL Server
parameterizedQuery = parameterizedQuery.replace(/\bTRUE\b/gi, '1');
parameterizedQuery = parameterizedQuery.replace(/\bFALSE\b/gi, '0');
const result = await request.query(parameterizedQuery);
return [result.recordset || []];
};
const AZURE_CONFIG = {
tenantId: '9840a2a0-6ae1-4688-b03d-d2ec291be0f9',
clientId: '4bb4cc24-bac3-427c-b02c-5d14fc67b561',
clientSecret: 'gvf8Q~545Bafn8yYsgjW~QG_P1lpzaRe6gJNgb2t',
groupId: 'c1ea877c-6bca-4f47-bfad-f223640813a0'
};
const storage = multer.diskStorage({
destination: './uploads/',
filename: (req, file, cb) => {
cb(null, Date.GETDATE() + path.extname(file.originalname));
}
});
const upload = multer({ storage });
const medicalStorage = multer.diskStorage({
destination: './uploads/medical/',
filename: (req, file, cb) => {
const uniqueSuffix = Date.GETDATE() + '-' + Math.round(Math.random() * 1E9);
cb(null, 'medical-' + uniqueSuffix + path.extname(file.originalname));
}
});
const ACCES_TRANSVERSAUX = {
'sloisil@ensup.eu': {
typeAcces: 'service_multi_campus',
serviceNom: 'Pédagogie',
description: 'Sandrine - Vue complÚte Pédagogie (tous campus)'
},
'mbouteiller@ensup.eu': {
typeAcces: 'service_multi_campus',
serviceNom: 'Admissions',
description: 'Morgane - Vue complĂšte Admissions (tous campus)'
},
'vnoel@ensup.eu': {
typeAcces: 'service_multi_campus',
serviceNom: 'Relations Entreprises',
description: 'Viviane - Vue complĂšte Relations Entreprises (tous campus)'
},
'vpierrel@ensup.eu': {
typeAcces: 'service_multi_campus', // â
CORRIGĂ - mĂȘme type que les autres
serviceNom: 'Administratif & Financier',
description: 'Virginie - Vue complĂšte Administratif & Financier (tous campus)'
}
};
function getUserAccesTransversal(userEmail) {
const acces = ACCES_TRANSVERSAUX[userEmail?.toLowerCase()] || null;
if (acces) {
console.log(`đ AccĂšs transversal: ${acces.description}`);
}
return acces;
}
// Helper Ă centraliser UNE SEULE FOIS dans server.js
function getAnneesCourantes(dateReference = new Date()) {
const currentYear = dateReference.getFullYear();
const dateBascule = new Date(`${currentYear}-06-01`);
const apresBasculement = dateReference >= dateBascule;
return {
anneeN: apresBasculement ? currentYear + 1 : currentYear, // ex: 2027
anneeN1: apresBasculement ? currentYear : currentYear - 1, // ex: 2026 (report)
apresBasculement
};
}
const uploadMedical = multer({
storage: medicalStorage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Type de fichier non autorisé'));
}
}
});
import fs from 'fs';
if (!fs.existsSync('./uploads/medical')) {
fs.mkdirSync('./uploads/medical', { recursive: true });
}
app.get('/api/events/collaborateur', (req, res) => {
const userId = req.query.user_id;
if (!userId) {
return res.status(401).json({ error: 'user_id requis' });
}
console.log('đ Nouvelle connexion SSE collaborateur:', userId);
// â HEADERS CRITIQUES POUR SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.setHeader('Access-Control-Allow-Origin', '*');
// â FLUSH IMMĂDIATEMENT POUR ĂTABLIR LA CONNEXION
res.flushHeaders();
const sendEvent = (data) => {
try {
if (res.writableEnded) {
console.log('â ïž Tentative d\'envoi sur connexion fermĂ©e');
return false;
}
res.write(`data: ${JSON.stringify(data)}\n\n`);
return true;
} catch (error) {
console.error('â Erreur envoi SSE:', error);
return false;
}
};
const client = {
userId: parseInt(userId),
send: sendEvent,
res: res // â Garder rĂ©fĂ©rence pour vĂ©rifier l'Ă©tat
};
sseClientsCollab.add(client);
console.log(`đ Clients SSE collaborateurs connectĂ©s: ${sseClientsCollab.size}`);
// â ĂVĂNEMENT DE CONNEXION
sendEvent({
type: 'connected',
message: 'Connexion établie',
timestamp: new Date().toISOString()
});
// â HEARTBEAT AVEC VĂRIFICATION
const heartbeat = setInterval(() => {
const success = sendEvent({
type: 'heartbeat',
timestamp: new Date().toISOString()
});
if (!success) {
console.log('đ Heartbeat Ă©chouĂ©, nettoyage...');
clearInterval(heartbeat);
sseClientsCollab.delete(client);
}
}, 30000); // 30 secondes
// â GESTION PROPRE DE LA DĂCONNEXION
const cleanup = () => {
console.log('đ DĂ©connexion SSE collaborateur:', userId);
clearInterval(heartbeat);
sseClientsCollab.delete(client);
console.log(`đ Clients SSE collaborateurs connectĂ©s: ${sseClientsCollab.size}`);
};
req.on('close', cleanup);
req.on('error', (err) => {
console.error('â Erreur SSE connexion:', err.message);
cleanup();
});
// â TIMEOUT DE SĂCURITĂ (optionnel, mais recommandĂ©)
req.socket.setTimeout(0); // Désactiver timeout pour SSE
});
const notifyCollabClients = (event, targetUserId = null) => {
console.log(
`đą Notification SSE Collab: ${event.type}`,
targetUserId ? `pour user ${targetUserId}` : 'pour tous'
);
const deadClients = [];
sseClientsCollab.forEach(client => {
// â FILTRER PAR USER SI NĂCESSAIRE
if (targetUserId && client.userId !== targetUserId) {
return;
}
// â VĂRIFIER SI LA CONNEXION EST TOUJOURS ACTIVE
if (client.res && client.res.writableEnded) {
console.log(`đ Client mort dĂ©tectĂ©: ${client.userId}`);
deadClients.push(client);
return;
}
// â ENVOYER L'ĂVĂNEMENT
const success = client.send(event);
if (!success) {
deadClients.push(client);
}
});
// â NETTOYER LES CLIENTS MORTS
deadClients.forEach(client => {
console.log(`đ§č Nettoyage client mort: ${client.userId}`);
sseClientsCollab.delete(client);
});
if (deadClients.length > 0) {
console.log(`đ Clients SSE aprĂšs nettoyage: ${sseClientsCollab.size}`);
}
};
const sseClients = new Set();
// đ ROUTE SSE POUR LE CALENDRIER
app.get('/api/sse', (req, res) => {
const userId = req.query.user_id;
if (!userId) {
return res.status(400).json({ error: 'user_id requis' });
}
console.log('đ Nouvelle connexion SSE:', userId);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
const sendEvent = (data) => {
try {
res.write(`data: ${JSON.stringify(data)}\n\n`);
} catch (error) {
console.error('â Erreur envoi SSE:', error);
}
};
const client = { id: userId, send: sendEvent };
sseClients.add(client);
console.log(`đ Clients SSE connectĂ©s: ${sseClients.size}`);
// Envoyer un heartbeat initial
sendEvent({
type: 'ping',
message: 'Connexion établie',
timestamp: new Date().toISOString()
});
// Heartbeat toutes les 30 secondes
const heartbeat = setInterval(() => {
try {
sendEvent({ type: 'ping', timestamp: new Date().toISOString() });
} catch (error) {
console.error('â Erreur heartbeat:', error);
clearInterval(heartbeat);
}
}, 30000);
req.on('close', () => {
console.log('đ DĂ©connexion SSE:', userId);
clearInterval(heartbeat);
sseClients.delete(client);
console.log(`đ Clients SSE connectĂ©s: ${sseClients.size}`);
});
});
// đą FONCTION POUR NOTIFIER LES CLIENTS
const notifyClients = (event, userId = null) => {
console.log(`đą Notification SSE: ${event.type}${userId ? ` pour ${userId}` : ''}`);
sseClients.forEach(client => {
// Si userId est spécifié, envoyer seulement à ce client
if (userId && client.id !== userId) {
return;
}
try {
client.send(event);
} catch (error) {
console.error('â Erreur envoi event:', error);
}
});
};
app.post('/api/webhook/receive', async (req, res) => {
try {
const signature = req.headers['x-webhook-signature'];
const payload = req.body;
console.log('\nđ„ === WEBHOOK REĂU (COLLABORATEURS) ===');
console.log('Event:', payload.event);
console.log('Data:', JSON.stringify(payload.data, null, 2));
// Vérifier la signature
if (!webhookManager.verifySignature(payload, signature)) {
console.error('â Signature webhook invalide');
return res.status(401).json({ error: 'Signature invalide' });
}
const { event, data } = payload;
// Traiter selon le type d'événement
switch (event) {
case EVENTS.COMPTEUR_UPDATED:
console.log('đ WEBHOOK COMPTEUR_UPDATED REĂU');
console.log('Collaborateur:', data.collaborateurId);
console.log('Type mise Ă jour:', data.typeUpdate);
console.log('Type congé:', data.typeConge);
console.log('Année:', data.annee);
console.log('Nouveau Total:', data.nouveauTotal + 'j');
console.log('Nouveau Solde:', data.nouveauSolde + 'j');
console.log('Source:', data.source);
// â
PAS D'UPDATE EN BASE (car mĂȘme DB partagĂ©e)
// â
UNIQUEMENT NOTIFICATION SSE pour rafraĂźchir l'interface
notifyCollabClients({
type: 'compteur-updated',
collaborateurId: data.collaborateurId,
typeConge: data.typeConge,
annee: data.annee,
typeUpdate: data.typeUpdate,
nouveauTotal: data.nouveauTotal,
nouveauSolde: data.nouveauSolde,
source: data.source,
timestamp: new Date().toISOString()
}, data.collaborateurId);
console.log('â
Notification SSE envoyée au collaborateur', data.collaborateurId);
break;
case EVENTS.DEMANDE_VALIDATED:
console.log('\nâ
=== WEBHOOK DEMANDE_VALIDATED REĂU ===');
console.log(` Demande: ${data.demandeId}`);
console.log(` Statut: ${data.statut}`);
console.log(` Type: ${data.typeConge}`);
console.log(` Couleur: ${data.couleurHex}`);
// Notifier les clients SSE avec TOUTES les infos
notifyClients({
type: 'demande-validated',
demandeId: data.demandeId,
statut: data.statut,
typeConge: data.typeConge,
couleurHex: data.couleurHex || '#d946ef',
date: data.date || data.dateDebut, // đ§ AJOUTĂ
dateDebut: data.dateDebut || data.date, // đ§ AJOUTĂ
dateFin: data.dateFin || data.date, // đ§ AJOUTĂ
periode: data.periode || data.periodeJournee || 'JournĂ©e entiĂšre', // đ§ AJOUTĂ
periodeJournee: data.periodeJournee || data.periode, // đ§ AJOUTĂ
nombreJours: data.nombreJours, // đ§ AJOUTĂ
collaborateurId: data.collaborateurId,
timestamp: new Date().toISOString()
}, data.collaborateurId);
// Notifier les RH aussi
notifyClients({
type: 'demande-list-updated',
action: 'validation-collab',
demandeId: data.demandeId,
statut: data.statut,
typeConge: data.typeConge,
couleurHex: data.couleurHex || '#d946ef',
timestamp: new Date().toISOString()
});
console.log(' đą Notifications SSE envoyĂ©es');
break;
case EVENTS.DEMANDE_UPDATED:
console.log('\nâïž === WEBHOOK DEMANDE_UPDATED REĂU ===');
console.log(` Demande: ${data.demandeId}`);
console.log(` Collaborateur: ${data.collaborateurId}`);
notifyCollabClients({
type: 'demande-updated-rh',
demandeId: data.demandeId,
timestamp: new Date().toISOString()
}, data.collaborateurId);
console.log(' đą Notification modification envoyĂ©e');
break;
case EVENTS.DEMANDE_DELETED:
console.log('\nđïž === WEBHOOK DEMANDE_DELETED REĂU ===');
console.log(` Demande: ${data.demandeId}`);
console.log(` Collaborateur: ${data.collaborateurId}`);
notifyCollabClients({
type: 'demande-deleted-rh',
demandeId: data.demandeId,
timestamp: new Date().toISOString()
}, data.collaborateurId);
console.log(' đą Notification suppression envoyĂ©e');
break;
default:
console.warn(`â ïž Type d'Ă©vĂ©nement webhook inconnu: ${event}`);
}
res.json({ success: true, message: 'Webhook traité' });
} catch (error) {
console.error('â Erreur traitement webhook:', error);
res.status(500).json({ error: error.message });
}
});
function getDateFinMoisPrecedent(referenceDate = new Date()) {
const now = new Date(referenceDate);
now.setHours(0, 0, 0, 0);
return new Date(now.getFullYear(), now.getMonth(), 0);
}
function parseDateYYYYMMDD(s) {
if (!s) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
const [y, m, d] = s.split('-').map(Number);
return new Date(y, m - 1, d);
}
return new Date(s);
}
const LEAVE_RULES = {
CP: {
nom: 'Congé payé',
joursAnnuels: 25,
periodeDebut: { mois: 6, jour: 1 },
periodeFin: { mois: 5, jour: 31 },
acquisitionMensuelle: 25 / 12,
reportable: true,
periodeReport: 'exercice'
},
RTT: {
nom: 'RTT',
joursAnnuels: 10,
periodeDebut: { mois: 1, jour: 1 },
periodeFin: { mois: 12, jour: 31 },
acquisitionMensuelle: 10 / 12,
reportable: false,
periodeReport: null
}
};
// ========================================
// NOUVELLES FONCTIONS POUR RTT VARIABLES
// ========================================
/**
* RécupÚre la configuration RTT pour une année et un type de contrat donnés
* RĂGLES :
* - 37h : toujours 10 RTT/an (0.8333/mois)
* - Forfait jour 2025 : 10 RTT/an (0.8333/mois)
* - Forfait jour 2026+ : 12 RTT/an (1.0/mois)
*/
async function getConfigurationRTT(conn, annee, typeContrat = '37h') {
try {
// D'abord chercher en base de données
const [config] = await conn.query(
`SELECT JoursAnnuels, AcquisitionMensuelle
FROM ConfigurationRTT
WHERE Annee = ? AND TypeContrat = ?
LIMIT 1`,
[annee, typeContrat]
);
if (config.length > 0) {
return {
joursAnnuels: parseFloat(config[0].JoursAnnuels),
acquisitionMensuelle: parseFloat(config[0].AcquisitionMensuelle)
};
}
// Si pas en base, utiliser les rÚgles par défaut
console.warn(`â ïž Pas de config RTT en base pour ${annee}/${typeContrat}, utilisation des rĂšgles par dĂ©faut`);
return getConfigurationRTTDefaut(annee, typeContrat);
} catch (error) {
console.error('Erreur getConfigurationRTT:', error);
return getConfigurationRTTDefaut(annee, typeContrat);
}
}
function getConfigurationRTTDefaut(annee, typeContrat) {
// 37h : toujours 10 RTT/an
if (typeContrat === '37h' || typeContrat === 'temps_partiel') {
return {
joursAnnuels: 10,
acquisitionMensuelle: 10 / 12 // 0.8333
};
}
// Forfait jour : dépend de l'année
if (typeContrat === 'forfait_jour') {
if (annee <= 2025) {
// 2025 et avant : 10 RTT/an
return {
joursAnnuels: 10,
acquisitionMensuelle: 10 / 12 // 0.8333
};
} else {
// 2026 et aprĂšs : 12 RTT/an
return {
joursAnnuels: 12,
acquisitionMensuelle: 12 / 12 // 1.0
};
}
}
// Par défaut : 10 RTT/an
return {
joursAnnuels: 10,
acquisitionMensuelle: 10 / 12
};
}
/**
* Calcule l'acquisition RTT avec la formule Excel exacte
*/
async function calculerAcquisitionRTT(conn, collaborateurId, dateReference = new Date()) {
const d = new Date(dateReference);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
// 1ïžâŁ RĂ©cupĂ©rer les infos du collaborateur
const [collabInfo] = await conn.query(
`SELECT TypeContrat, DateEntree, role FROM CollaborateurAD WHERE id = ?`,
[collaborateurId]
);
if (collabInfo.length === 0) {
throw new Error(`Collaborateur ${collaborateurId} non trouvé`);
}
const typeContrat = collabInfo[0].TypeContrat || '37h';
const dateEntree = collabInfo[0].DateEntree;
const isApprenti = collabInfo[0].role === 'Apprenti';
// 2ïžâŁ Apprentis = pas de RTT
if (isApprenti) {
return {
acquisition: 0,
moisTravailles: 0,
config: { joursAnnuels: 0, acquisitionMensuelle: 0 },
typeContrat: typeContrat
};
}
// 3ïžâŁ RĂ©cupĂ©rer la configuration RTT (avec rĂšgles 2025/2026)
const config = await getConfigurationRTT(conn, annee, typeContrat);
console.log(`đ Config RTT ${annee}/${typeContrat}: ${config.joursAnnuels}j/an (${config.acquisitionMensuelle.toFixed(4)}/mois)`);
// 4ïžâŁ DĂ©but d'acquisition = 01/01/N ou date d'entrĂ©e si postĂ©rieure
let dateDebutAcquis = new Date(annee, 0, 1); // 01/01/N
dateDebutAcquis.setHours(0, 0, 0, 0);
if (dateEntree) {
const entree = new Date(dateEntree);
entree.setHours(0, 0, 0, 0);
if (entree.getFullYear() === annee && entree > dateDebutAcquis) {
dateDebutAcquis = entree;
}
if (entree.getFullYear() > annee) {
return {
acquisition: 0,
moisTravailles: 0,
config: config,
typeContrat: typeContrat
};
}
}
// 5ïžâŁ Calculer avec la formule Excel
const acquisition = calculerAcquisitionFormuleExcel(dateDebutAcquis, d, config.acquisitionMensuelle);
// 6ïžâŁ Calculer les mois travaillĂ©s (pour info)
const moisTravailles = config.acquisitionMensuelle > 0
? acquisition / config.acquisitionMensuelle
: 0;
// 7ïžâŁ Plafonner au maximum annuel
const acquisitionFinale = Math.min(acquisition, config.joursAnnuels);
return {
acquisition: Math.round(acquisitionFinale * 100) / 100,
moisTravailles: Math.round(moisTravailles * 100) / 100,
config: config,
typeContrat: typeContrat
};
}
/**
* Calcule l'acquisition avec la formule Excel exacte :
* E1 * ((JOUR(FIN.MOIS(B1;0)) - JOUR(B1) + 1) / JOUR(FIN.MOIS(B1;0))
* + DATEDIF(B1;B2;"m") - 1
* + JOUR(B2) / JOUR(FIN.MOIS(B2;0)))
*/
function calculerAcquisitionFormuleExcel(dateDebut, dateReference, coeffMensuel) {
const b1 = new Date(dateDebut);
const b2 = new Date(dateReference);
b1.setHours(0, 0, 0, 0);
b2.setHours(0, 0, 0, 0);
// Si date référence avant date début
if (b2 < b1) {
return 0;
}
// Si mĂȘme mois et mĂȘme annĂ©e
if (b1.getFullYear() === b2.getFullYear() && b1.getMonth() === b2.getMonth()) {
const joursTotal = new Date(b2.getFullYear(), b2.getMonth() + 1, 0).getDate();
const joursAcquis = b2.getDate() - b1.getDate() + 1;
return Math.round((joursAcquis / joursTotal) * coeffMensuel * 100) / 100;
}
// 1ïžâŁ Fraction du PREMIER mois
const joursFinMoisB1 = new Date(b1.getFullYear(), b1.getMonth() + 1, 0).getDate();
const jourB1 = b1.getDate();
const fractionPremierMois = (joursFinMoisB1 - jourB1 + 1) / joursFinMoisB1;
// 2ïžâŁ Mois COMPLETS entre
const moisComplets = dateDifMonths(b1, b2) - 1;
// 3ïžâŁ Fraction du DERNIER mois
const joursFinMoisB2 = new Date(b2.getFullYear(), b2.getMonth() + 1, 0).getDate();
const jourB2 = b2.getDate();
const fractionDernierMois = jourB2 / joursFinMoisB2;
// 4ïžâŁ Total
const totalMois = fractionPremierMois + Math.max(0, moisComplets) + fractionDernierMois;
const acquisition = totalMois * coeffMensuel;
return Math.round(acquisition * 100) / 100;
}
/**
* Ăquivalent de DATEDIF(date1, date2, "m") en JavaScript
*/
function dateDifMonths(date1, date2) {
const d1 = new Date(date1);
const d2 = new Date(date2);
let months = (d2.getFullYear() - d1.getFullYear()) * 12;
months += d2.getMonth() - d1.getMonth();
// Si le jour de d2 < jour de d1, on n'a pas encore complété le mois
if (d2.getDate() < d1.getDate()) {
months--;
}
return Math.max(0, months);
}
/**
* Calcule l'acquisition CP avec la formule Excel exacte
*/
function calculerAcquisitionCP(dateReference = new Date(), dateEntree = null) {
const d = new Date(dateReference);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
const mois = d.getMonth() + 1;
// 1ïžâŁ DĂ©terminer le dĂ©but de l'exercice CP (01/06)
let exerciceDebut;
if (mois >= 6) {
exerciceDebut = new Date(annee, 5, 1); // 01/06/N
} else {
exerciceDebut = new Date(annee - 1, 5, 1); // 01/06/N-1
}
exerciceDebut.setHours(0, 0, 0, 0);
// 2ïžâŁ Ajuster si date d'entrĂ©e postĂ©rieure
let dateDebutAcquis = new Date(exerciceDebut);
if (dateEntree) {
const entree = new Date(dateEntree);
entree.setHours(0, 0, 0, 0);
if (entree > exerciceDebut) {
dateDebutAcquis = entree;
}
}
// 3ïžâŁ Calculer avec la formule Excel
const coeffCP = 25 / 12; // 2.0833
const acquisition = calculerAcquisitionFormuleExcel(dateDebutAcquis, d, coeffCP);
// 4ïžâŁ Plafonner Ă 25 jours
return Math.min(acquisition, 25);
}
// ========================================
// CALCUL CP INTELLIGENT (MODE AUTO)
// ========================================
/**
* Calcule l'acquisition CP avec détection automatique du mode
*/
function calculerAcquisitionCP_Smart(dateReference = new Date(), dateEntree = null) {
const d = new Date(dateReference);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
const mois = d.getMonth() + 1;
// 1ïžâŁ DĂ©terminer le dĂ©but de l'exercice CP (01/06)
let exerciceDebut;
if (mois >= 6) {
exerciceDebut = new Date(annee, 5, 1); // 01/06/N
} else {
exerciceDebut = new Date(annee - 1, 5, 1); // 01/06/N-1
}
exerciceDebut.setHours(0, 0, 0, 0);
// 2ïžâŁ Obtenir le mode de calcul
const modeInfo = getModeCalcul('CP', dateEntree);
const dateDebutAcquis = modeInfo.dateDebut;
console.log(` đ
${modeInfo.description}`);
// 3ïžâŁ Calculer avec la formule Excel
const coeffCP = 25 / 12; // 2.0833
const acquisition = calculerAcquisitionFormuleExcel(dateDebutAcquis, d, coeffCP);
// 4ïžâŁ Plafonner Ă 25 jours
return Math.min(acquisition, 25);
}
// ========================================
// CALCUL RTT INTELLIGENT (MODE AUTO)
// ========================================
/**
* Calcule l'acquisition RTT avec détection automatique du mode
*/
async function calculerAcquisitionRTT_Smart(conn, collaborateurId, dateReference = new Date()) {
const d = new Date(dateReference);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
// 1ïžâŁ RĂ©cupĂ©rer les infos du collaborateur
const [collabInfo] = await conn.query(
`SELECT TypeContrat, DateEntree, role FROM CollaborateurAD WHERE id = ?`,
[collaborateurId]
);
if (collabInfo.length === 0) {
throw new Error(`Collaborateur ${collaborateurId} non trouvé`);
}
const typeContrat = collabInfo[0].TypeContrat || '37h';
const dateEntree = collabInfo[0].DateEntree;
const isApprenti = collabInfo[0].role === 'Apprenti';
// 2ïžâŁ Apprentis = pas de RTT
if (isApprenti) {
return {
acquisition: 0,
moisTravailles: 0,
config: { joursAnnuels: 0, acquisitionMensuelle: 0 },
typeContrat: typeContrat,
mode: 'APPRENTI'
};
}
// 3ïžâŁ RĂ©cupĂ©rer la configuration RTT (avec rĂšgles 2025/2026)
const config = await getConfigurationRTT(conn, annee, typeContrat);
// 4ïžâŁ Obtenir le mode de calcul
const modeInfo = getModeCalcul('RTT', dateEntree);
const dateDebutAcquis = modeInfo.dateDebut;
console.log(` đ
${modeInfo.description}`);
// 5ïžâŁ Calculer avec la formule Excel
const acquisition = calculerAcquisitionFormuleExcel(dateDebutAcquis, d, config.acquisitionMensuelle);
// 6ïžâŁ Calculer les mois travaillĂ©s (pour info)
const moisTravailles = config.acquisitionMensuelle > 0
? acquisition / config.acquisitionMensuelle
: 0;
// 7ïžâŁ Plafonner au maximum annuel
const acquisitionFinale = Math.min(acquisition, config.joursAnnuels);
return {
acquisition: Math.round(acquisitionFinale * 100) / 100,
moisTravailles: Math.round(moisTravailles * 100) / 100,
config: config,
typeContrat: typeContrat,
mode: modeInfo.mode
};
}
// ========================================
// FONCTION DE DĂTECTION AUTOMATIQUE DU MODE
// ========================================
/**
* Détermine automatiquement si on doit utiliser le mode transition
* en comparant la date actuelle avec le début de l'exercice
*/
function isInExerciceActuel(typeConge) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const currentYear = today.getFullYear();
const currentMonth = today.getMonth() + 1; // 1-12
if (typeConge === 'CP') {
// Exercice CP : 01/06/N â 31/05/N+1
// Déterminer le début de l'exercice ACTUEL
let debutExercice;
if (currentMonth >= 6) {
// On est entre juin et dĂ©cembre â exercice commence le 01/06 de cette annĂ©e
debutExercice = new Date(currentYear, 5, 1); // 01/06/N
} else {
// On est entre janvier et mai â exercice a commencĂ© le 01/06 de l'annĂ©e derniĂšre
debutExercice = new Date(currentYear - 1, 5, 1); // 01/06/N-1
}
// â Si aujourd'hui est le PREMIER JOUR du nouvel exercice ou aprĂšs â MODE NORMAL
// â Si on est encore dans l'exercice commencĂ© avant â MODE TRANSITION
const finExercice = new Date(debutExercice);
finExercice.setFullYear(finExercice.getFullYear() + 1);
finExercice.setMonth(4, 31); // 31/05/N+1
// Si on vient de passer le 01/06, c'est le nouvel exercice â mode NORMAL
const nouveauExercice = new Date(currentYear, 5, 1);
return today < nouveauExercice; // TRUE = mode transition (avant le 01/06)
} else if (typeConge === 'RTT') {
// AnnĂ©e RTT : 01/01/N â 31/12/N
const debutAnnee = new Date(currentYear, 0, 1); // 01/01/N
// Si on vient de passer le 01/01, c'est la nouvelle annĂ©e â mode NORMAL
return today < debutAnnee; // TRUE = mode transition (avant le 01/01)
}
return false;
}
/**
* Version améliorée : retourne le mode ET la date de début à utiliser
*/
/**
* Détermine le mode de calcul et la date de début selon le contexte
* @param {string} typeConge - 'CP' ou 'RTT'
* @param {Date|null} dateEntree - Date d'entrée du collaborateur
* @returns {Object} { mode, dateDebut, description }
*/
function getModeCalcul(typeConge, dateEntree = null) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const currentYear = today.getFullYear();
const currentMonth = today.getMonth() + 1;
if (typeConge === 'CP') {
// Déterminer le début de l'exercice ACTUEL
let debutExerciceActuel;
if (currentMonth >= 6) {
debutExerciceActuel = new Date(currentYear, 5, 1); // 01/06/N
} else {
debutExerciceActuel = new Date(currentYear - 1, 5, 1); // 01/06/N-1
}
debutExerciceActuel.setHours(0, 0, 0, 0);
// â RĂGLE DE BASCULE :
// On bascule en mode NORMAL uniquement si :
// 1. On est dans un NOUVEL exercice (aprĂšs le prochain 01/06)
// 2. ET la personne est arrivĂ©e APRĂS le dĂ©but de ce nouvel exercice
// Calculer le début du PROCHAIN exercice
const prochainExercice = new Date(currentYear, 5, 1); // 01/06/N
if (currentMonth < 6) {
// Si on est avant juin, le prochain exercice est cette année
prochainExercice.setFullYear(currentYear);
} else {
// Si on est aprÚs juin, le prochain exercice est l'année prochaine
prochainExercice.setFullYear(currentYear + 1);
}
// â BASCULE : on passe en mode NORMAL seulement si aujourd'hui >= prochain exercice
if (today >= prochainExercice && dateEntree) {
const entree = new Date(dateEntree);
entree.setHours(0, 0, 0, 0);
// Si la personne est arrivĂ©e APRĂS le dĂ©but du nouvel exercice
if (entree >= prochainExercice) {
return {
mode: 'NORMAL',
dateDebut: entree,
description: `CP avec DateEntree (${entree.toLocaleDateString('fr-FR')})`
};
}
}
// â MODE TRANSITION : calcul depuis dĂ©but de l'exercice actuel (SANS DateEntree)
return {
mode: 'TRANSITION',
dateDebut: debutExerciceActuel,
description: `CP mode transition (depuis ${debutExerciceActuel.toLocaleDateString('fr-FR')})`
};
} else if (typeConge === 'RTT') {
const debutAnneeActuelle = new Date(currentYear, 0, 1); // 01/01/N
debutAnneeActuelle.setHours(0, 0, 0, 0);
// â RĂGLE DE BASCULE :
// On bascule en mode NORMAL uniquement si :
// 1. On est dans une NOUVELLE année (aprÚs le prochain 01/01)
// 2. ET la personne est arrivĂ©e APRĂS le dĂ©but de cette nouvelle annĂ©e
// Calculer le début de la PROCHAINE année
const prochaineAnnee = new Date(currentYear + 1, 0, 1); // 01/01/N+1
// â BASCULE : on passe en mode NORMAL seulement si aujourd'hui >= prochaine annĂ©e
if (today >= prochaineAnnee && dateEntree) {
const entree = new Date(dateEntree);
entree.setHours(0, 0, 0, 0);
// Si la personne est arrivĂ©e APRĂS le dĂ©but de la nouvelle annĂ©e
if (entree >= prochaineAnnee) {
return {
mode: 'NORMAL',
dateDebut: entree,
description: `RTT avec DateEntree (${entree.toLocaleDateString('fr-FR')})`
};
}
}
// â MODE TRANSITION : calcul depuis dĂ©but de l'annĂ©e actuelle (SANS DateEntree)
return {
mode: 'TRANSITION',
dateDebut: debutAnneeActuelle,
description: `RTT mode transition (depuis ${debutAnneeActuelle.toLocaleDateString('fr-FR')})`
};
}
return null;
}
// ========================================
// TĂCHES CRON
// ========================================
cron.schedule('1 0 1 1 *', async () => {
console.log('\nđ ===== RĂINITIALISATION RTT - 1ER JANVIER =====');
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const today = new Date();
const nouvelleAnnee = today.getFullYear();
const ancienneAnnee = nouvelleAnnee - 1;
console.log(` đ
Passage de ${ancienneAnnee} Ă ${nouvelleAnnee}`);
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ?', ['RTT']);
if (rttType.length === 0) {
throw new Error('Type de congé RTT introuvable');
}
const rttTypeId = rttType[0].Id;
const [collaborateurs] = await conn.query(`
SELECT id, prenom, nom, TypeContrat, role
FROM CollaborateurAD
WHERE (Actif = 1 OR Actif IS NULL)
AND (role IS NULL OR role != 'Apprenti')
`);
console.log(` đ„ ${collaborateurs.length} collaborateurs Ă traiter`);
let compteursReinitialises = 0;
for (const collab of collaborateurs) {
const collaborateurId = collab.id;
const [ancienCompteur] = await conn.query(`
SELECT Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, ancienneAnnee]);
if (ancienCompteur.length > 0) {
const soldeAncien = parseFloat(ancienCompteur[0].Solde || 0);
console.log(` đ€ ${collab.prenom} ${collab.nom}: ${soldeAncien.toFixed(2)}j RTT perdus`);
await conn.query(`
UPDATE CompteurConges
SET DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, ancienneAnnee]);
}
const [nouveauCompteur] = await conn.query(`
SELECT id FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, nouvelleAnnee]);
if (nouveauCompteur.length === 0) {
await conn.query(`
INSERT INTO CompteurConges (
CollaborateurADId, TypeCongeId, Annee,
Total, Solde, SoldeReporte, DerniereMiseAJour
)
VALUES (?, ?, ?, 0, 0, 0, GETDATE())
`, [collaborateurId, rttTypeId, nouvelleAnnee]);
console.log(` â
Compteur RTT ${nouvelleAnnee} créé à 0`);
} else {
await conn.query(`
UPDATE CompteurConges
SET Total = 0, Solde = 0, SoldeReporte = 0, DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, nouvelleAnnee]);
console.log(` â
Compteur RTT ${nouvelleAnnee} réinitialisé à 0`);
}
compteursReinitialises++;
}
await conn.commit();
console.log(`\nâ
Réinitialisation RTT terminée : ${compteursReinitialises} compteurs`);
} catch (error) {
await conn.rollback();
console.error('â Erreur rĂ©initialisation RTT:', error);
} finally {
conn.release();
}
}, {
timezone: "Europe/Paris"
});
// ============================================================================
// đ
CRON REPORT CP - 31 mai 23h59
// ============================================================================
cron.schedule('59 23 31 5 *', async () => {
console.log('đ
[CRON] Traitement fin d\'exercice CP...');
try {
const conn = await pool.getConnection();
await conn.beginTransaction();
const [collaborateurs] = await conn.query('SELECT id, CampusId FROM CollaborateurAD');
let successCount = 0;
for (const collab of collaborateurs) {
try {
await processEndOfExerciceCP(conn, collab.id);
successCount++;
} catch (error) {
console.error(`â Erreur CP pour ${collab.id}:`, error.message);
}
}
await conn.commit();
console.log(`â
[CRON] ${successCount}/${collaborateurs.length} CP reportés`);
conn.release();
} catch (error) {
console.error('â [CRON] Erreur traitement fin d\'exercice:', error);
}
});
// ============================================================================
// đ CRON ARRĂTĂS MENSUELS
// ============================================================================
cron.schedule('55 23 28-31 * *', async () => {
const today = new Date();
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0);
if (today.getDate() === lastDay.getDate()) {
console.log(`đ
[CRON] CrĂ©ation arrĂȘtĂ© fin de mois: ${today.toISOString().split('T')[0]}`);
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const annee = today.getFullYear();
const mois = today.getMonth() + 1;
const dateArrete = today.toISOString().split('T')[0];
const [existing] = await conn.query(
'SELECT Id FROM ArreteComptable WHERE Annee = ? AND Mois = ?',
[annee, mois]
);
if (existing.length > 0) {
console.log(`â ïž [CRON] ArrĂȘtĂ© ${mois}/${annee} existe dĂ©jĂ , skip`);
await conn.rollback();
conn.release();
return;
}
const [result] = await conn.query(`
INSERT INTO ArreteComptable
(DateArrete, Annee, Mois, Libelle, Description, Statut, DateCreation)
VALUES (?, ?, ?, ?, ?, 'En cours', GETDATE())
`, [
dateArrete,
annee,
mois,
`ArrĂȘtĂ© comptable ${getMonthName(mois)} ${annee}`,
`ArrĂȘtĂ© mensuel automatique - ClĂŽture des soldes au ${dateArrete}`
]);
const arreteId = result.insertId;
console.log(`â
[CRON] ArrĂȘtĂ© créé: ID ${arreteId}`);
await conn.query('CALL sp_creer_snapshot_arrete(?)', [arreteId]);
console.log(`đž [CRON] Snapshot créé pour l'arrĂȘtĂ© ${arreteId}`);
const [count] = await conn.query(
'SELECT COUNT(*) as total FROM SoldesFiges WHERE ArreteId = ?',
[arreteId]
);
await conn.commit();
console.log(`đ [CRON] ArrĂȘtĂ© ${mois}/${annee} terminĂ©: ${count[0].total} soldes figĂ©s`);
} catch (error) {
await conn.rollback();
console.error(`â [CRON] Erreur crĂ©ation arrĂȘtĂ©:`, error.message);
} finally {
conn.release();
}
}
});
// ============================================================================
// đ§ CRON MAILS COMPTE-RENDU
// ============================================================================
cron.schedule('0 9 1 * *', async () => {
console.log('đ§ Envoi mails compte-rendu mensuel...');
const conn = await pool.getConnection();
const [cadres] = await conn.query(`
SELECT id, email, prenom, nom
FROM CollaborateurAD
WHERE TypeContrat = 'forfait_jour' AND (actif = 1 OR actif IS NULL)
`);
const moisPrecedent = new Date();
moisPrecedent.setMonth(moisPrecedent.getMonth() - 1);
for (const cadre of cadres) {
// Envoyer mail via Microsoft Graph API
// Enregistrer dans MailsCompteRendu
}
conn.release();
});
// ============================================================================
// đ CRON RELANCES
// ============================================================================
cron.schedule('0 9 * * 1', async () => {
console.log('đ Relance hebdomadaire compte-rendu...');
const conn = await pool.getConnection();
const moisCourant = new Date().getMonth() + 1;
const anneeCourante = new Date().getFullYear();
const [nonValides] = await conn.query(`
SELECT DISTINCT ca.id, ca.email, ca.prenom, ca.nom
FROM CollaborateurAD ca
LEFT JOIN CompteRenduMensuel crm ON ca.id = crm.CollaborateurADId
AND crm.Annee = ? AND crm.Mois = ?
WHERE ca.TypeContrat = 'forfait_jour'
AND (crm.Statut IS NULL OR crm.Statut != 'Validé')
`, [anneeCourante, moisCourant - 1]);
for (const cadre of nonValides) {
// Envoyer relance
}
conn.release();
});
// ============================================================================
// đ RATTRAPAGE IMMĂDIAT - 5 minutes aprĂšs dĂ©marrage
// ============================================================================
setTimeout(() => {
console.log('đ ===== EXĂCUTION IMMĂDIATE - RATTRAPAGE DEPUIS LE 1ER DU MOIS =====');
updateMonthlyCounters(true); // true = mode rattrapage
}, 5 * 60 * 1000);
console.log('ⰠCRON quotidien programmé : 00h00 Europe/Paris');
console.log('ⰠCRON réinitialisation RTT programmé : 1er janvier à 00h01');
console.log(`ⰠCRON immédiat programmé dans 5 minutes`);
// â Fonction helper pour les noms de mois
function getMonthName(mois) {
const mois_names = ['', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
return mois_names[mois] || mois;
}
async function getGraphToken() {
try {
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: AZURE_CONFIG.clientId,
client_secret: AZURE_CONFIG.clientSecret,
scope: 'https://graph.microsoft.com/.default'
});
const response = await axios.post(
`https://login.microsoftonline.com/${AZURE_CONFIG.tenantId}/oauth2/v2.0/token`,
params.toString(),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
return response.data.access_token;
} catch (error) {
console.error('Erreur obtention token:', error);
return null;
}
}
async function sendMailGraph(accessToken, fromEmail, toEmail, subject, bodyHtml) {
try {
await axios.post(
`https://graph.microsoft.com/v1.0/users/${fromEmail}/sendMail`,
{
message: {
subject,
body: { contentType: 'HTML', content: bodyHtml },
toRecipients: [{ emailAddress: { address: toEmail } }]
},
saveToSentItems: false
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
);
return true;
} catch (error) {
console.error('Erreur envoi email:', error);
return false;
}
}
function getWorkingDays(startDate, endDate) {
let workingDays = 0;
const current = new Date(startDate);
const end = new Date(endDate);
while (current <= end) {
const dayOfWeek = current.getDay();
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
workingDays++;
}
current.setDate(current.getDate() + 1);
}
return workingDays;
}
function formatDate(date) {
const d = new Date(date);
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
return `${day}/${month}/${year}`;
}
/*function getExerciceCP(date = new Date()) {
const d = new Date(date);
const annee = d.getFullYear();
const mois = d.getMonth() + 1;
if (mois >= 1 && mois <= 5) {
return `${annee - 1}-${annee}`;
}
return `${annee}-${annee + 1}`;
}
*/
function getMoisTravaillesCP(date = new Date(), dateEntree = null) {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
const mois = d.getMonth() + 1;
let debutExercice;
if (mois >= 6) {
debutExercice = new Date(annee, 5, 1);
} else {
debutExercice = new Date(annee - 1, 5, 1);
}
debutExercice.setHours(0, 0, 0, 0);
if (dateEntree) {
const entree = new Date(dateEntree);
entree.setHours(0, 0, 0, 0);
if (entree > debutExercice) {
debutExercice = entree;
}
}
// â
Calculer jusqu'Ă aujourd'hui
const diffMs = d - debutExercice;
const diffJours = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + 1;
const moisTravailles = diffJours / 30.44;
return Math.max(0, Math.min(12, moisTravailles));
}
//Pour CP Anticipés
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
// HELPER EXERCICE CP
// Annee = annĂ©e de dĂ©but de plage : 2025 â exercice 01/06/2025-31/05/2026
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
function getExerciceCP(date) {
const d = new Date(date);
const mois = d.getMonth() + 1; // 1-12
const anneeDebut = mois >= 6 ? d.getFullYear() : d.getFullYear() - 1;
return {
annee: anneeDebut, // INT â JOIN avec CompteurConges.Annee
label: `${anneeDebut}-${anneeDebut + 1}` // VARCHAR â audit lisible
};
}
/**
* Détermine le mode (NORMAL ou ANTICIPATION) et les exercices concernés.
* NORMAL : congĂ©s sur le MĂME exercice que la demande â N-1 prioritaire puis N
* ANTICIPATION : congĂ©s sur l'exercice SUIVANT â CP N uniquement
*/
function getInfosDemandeCP(dateDebut, dateDemande = new Date()) {
const exerciceDemande = getExerciceCP(dateDemande);
const exerciceConges = getExerciceCP(dateDebut);
const isAnticipe = exerciceConges.annee > exerciceDemande.annee;
return {
isAnticipe,
mode: isAnticipe ? 'ANTICIPATION' : 'NORMAL',
exerciceDemande, // { annee: 2025, label: '2025-2026' }
exerciceConges, // { annee: 2026, label: '2026-2027' }
anneeN: exerciceDemande.annee, // compteur CP N (toujours utilisé)
anneeN1: isAnticipe ? null : exerciceDemande.annee - 1 // CP N-1 seulement en NORMAL
};
}
function getMoisTravaillesRTT(date = new Date(), dateEntree = null) {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
let debutAnnee = new Date(annee, 0, 1);
debutAnnee.setHours(0, 0, 0, 0);
if (dateEntree) {
const entree = new Date(dateEntree);
entree.setHours(0, 0, 0, 0);
if (entree.getFullYear() === annee && entree > debutAnnee) {
debutAnnee = entree;
} else if (entree.getFullYear() > annee) {
return 0;
}
}
const diffMs = d - debutAnnee;
const diffJours = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + 1;
const moisTravailles = diffJours / 30.44;
return Math.max(0, Math.min(12, moisTravailles));
}
function calculerAcquisitionCumulee(typeConge, dateReference = new Date(), dateEntree = null) {
const rules = LEAVE_RULES[typeConge];
if (!rules) return 0;
let moisTravailles;
if (typeConge === 'CP') {
moisTravailles = getMoisTravaillesCP(dateReference, dateEntree);
} else {
moisTravailles = getMoisTravaillesRTT(dateReference, dateEntree);
}
const acquisition = moisTravailles * rules.acquisitionMensuelle;
return Math.round(acquisition * 100) / 100;
}
async function processEndOfYearRTT(conn, collaborateurId) {
const currentYear = new Date().getFullYear();
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
if (rttType.length === 0) return null;
await conn.query(
`UPDATE CompteurConges SET Solde = 0, Total = 0, SoldeReporte = 0, DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, rttType[0].Id, currentYear]
);
return { type: 'RTT', action: 'reset_end_of_year', annee: currentYear };
}
async function processEndOfExerciceCP(conn, collaborateurId) {
const today = new Date();
const currentYear = today.getFullYear();
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
if (cpType.length === 0) return null;
const cpTypeId = cpType[0].Id;
const [currentCounter] = await conn.query(
`SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, cpTypeId, currentYear]
);
if (currentCounter.length === 0) return null;
const soldeAReporter = parseFloat(currentCounter[0].Solde);
const [nextYearCounter] = await conn.query(
`SELECT Id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, cpTypeId, currentYear + 1]
);
if (nextYearCounter.length > 0) {
await conn.query(
`UPDATE CompteurConges SET SoldeReporte = ?, Solde = Solde + ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`,
[soldeAReporter, soldeAReporter, nextYearCounter[0].Id]
);
} else {
await conn.query(
`INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, 0, ?, ?, GETDATE())`,
[collaborateurId, cpTypeId, currentYear + 1, soldeAReporter, soldeAReporter]
);
}
return { type: 'CP', action: 'report_exercice', soldeReporte: soldeAReporter };
}
async function deductLeaveBalance(conn, collaborateurId, typeCongeId, nombreJours) {
const currentYear = new Date().getFullYear();
const previousYear = currentYear - 1;
let joursRestants = nombreJours;
const deductions = [];
const [compteurN1] = await conn.query(
`SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, previousYear]
);
if (compteurN1.length > 0 && compteurN1[0].SoldeReporte > 0) {
const soldeN1 = parseFloat(compteurN1[0].SoldeReporte);
const aDeduireN1 = Math.min(soldeN1, joursRestants);
if (aDeduireN1 > 0) {
await conn.query(
`UPDATE CompteurConges SET SoldeReporte = CASE WHEN (SoldeReporte - ?) < 0 THEN 0 ELSE (SoldeReporte - ?) END, Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END WHERE Id = ?`,
[aDeduireN1, aDeduireN1, compteurN1[0].Id]
);
deductions.push({ annee: previousYear, type: 'Reporté N-1', joursUtilises: aDeduireN1, soldeAvant: soldeN1 });
joursRestants -= aDeduireN1;
}
}
if (joursRestants > 0) {
const [compteurN] = await conn.query(
`SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, currentYear]
);
if (compteurN.length > 0) {
const soldeN = parseFloat(compteurN[0].Solde) - parseFloat(compteurN[0].SoldeReporte || 0);
const aDeduireN = Math.min(soldeN, joursRestants);
if (aDeduireN > 0) {
await conn.query(
`UPDATE CompteurConges SET Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END WHERE Id = ?`,
[aDeduireN, compteurN[0].Id]
);
deductions.push({ annee: currentYear, type: 'Année actuelle N', joursUtilises: aDeduireN, soldeAvant: soldeN });
joursRestants -= aDeduireN;
}
}
}
return { success: joursRestants === 0, joursDeduitsTotal: nombreJours - joursRestants, joursNonDeduits: joursRestants, details: deductions };
}
async function checkLeaveBalance(conn, collaborateurId, repartition) {
const currentYear = new Date().getFullYear();
const previousYear = currentYear - 1;
const verification = [];
for (const rep of repartition) {
const typeCode = rep.TypeConge;
const joursNecessaires = parseFloat(rep.NombreJours);
if (typeCode === 'ABS' || typeCode === 'Formation') continue;
const typeName = typeCode === 'CP' ? 'Congé payé' : typeCode === 'RTT' ? 'RTT' : typeCode;
const [typeRow] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [typeName]);
if (typeRow.length === 0) continue;
const typeCongeId = typeRow[0].Id;
const [compteurN1] = await conn.query(
`SELECT SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, previousYear]
);
const [compteurN] = await conn.query(
`SELECT Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, currentYear]
);
const soldeN1 = compteurN1.length > 0 ? parseFloat(compteurN1[0].SoldeReporte || 0) : 0;
const soldeN = compteurN.length > 0 ? parseFloat(compteurN[0].Solde || 0) - parseFloat(compteurN[0].SoldeReporte || 0) : 0;
const soldeTotal = soldeN1 + soldeN;
verification.push({ type: typeName, joursNecessaires, soldeN1, soldeN, soldeTotal, suffisant: soldeTotal >= joursNecessaires, deficit: Math.max(0, joursNecessaires - soldeTotal) });
}
const insuffisants = verification.filter(v => !v.suffisant);
return { valide: insuffisants.length === 0, details: verification, insuffisants };
}
// ============================================================================
// đ
FONCTION UTILITAIRE - Obtenir le nombre de jours du mois
// ============================================================================
function getJoursDuMois(date) {
const annee = date.getFullYear();
const mois = date.getMonth(); // 0-11
// Le jour 0 du mois suivant = dernier jour du mois actuel
const dernierJour = new Date(annee, mois + 1, 0);
return dernierJour.getDate();
}
// ============================================================================
// đ FONCTION DE MISE Ă JOUR DES COMPTEURS (CORRIGĂE - DĂCRĂMENTE)
// ============================================================================
async function updateMonthlyCounters(rattrapage = false) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const today = new Date();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth();
const jourActuel = today.getDate();
// â Obtenir le nombre de jours du mois actuel
const joursDuMois = getJoursDuMois(today);
console.log(`\nđ === MISE Ă JOUR ${rattrapage ? 'RATTRAPAGE' : 'QUOTIDIENNE'} COMPTEURS - ${today.toLocaleDateString('fr-FR')} ===`);
console.log(` đ
Mois actuel : ${joursDuMois} jours`);
// â RATTRAPAGE : Calculer les jours manquĂ©s depuis le 1er du mois
let joursARattraper = 1; // Par défaut : incrément d'1 jour
if (rattrapage) {
joursARattraper = jourActuel; // Du 1er au jour actuel
console.log(` đ
Rattrapage depuis le 1er du mois : ${joursARattraper} jours`);
}
// Récupérer tous les collaborateurs actifs
const [collaborateurs] = await conn.query(`
SELECT id, prenom, nom, DateEntree, TypeContrat, role
FROM CollaborateurAD
WHERE (Actif = 1 OR Actif IS NULL)
`);
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ?', ['Congé payé']);
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ?', ['RTT']);
let compteursMisAJour = 0;
for (const collab of collaborateurs) {
const collaborateurId = collab.id;
const dateEntree = collab.DateEntree;
const typeContrat = collab.TypeContrat || '37h';
const role = collab.role;
console.log(`\n đ€ ${collab.prenom} ${collab.nom}`);
// ====================================
// đ
CP - Incrément avec prorata mensuel
// ====================================
if (cpType.length > 0) {
const modeCP = getModeCalcul('CP', dateEntree);
// â Calcul acquisition mensuelle
const acquisitionMensuelleCP = 25 / 12; // 2.0833j/mois
// â Calcul acquisition quotidienne selon le nombre de jours du mois
const incrementJournalierCP = acquisitionMensuelleCP / joursDuMois;
const incrementTotal = incrementJournalierCP * joursARattraper;
console.log(` CP: ${incrementJournalierCP.toFixed(6)}j/jour (${acquisitionMensuelleCP.toFixed(4)}j/${joursDuMois}j)`);
// Vérifier si compteur existe
const [compteurCP] = await conn.query(`
SELECT Total, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, cpType[0].Id, currentYear]);
if (compteurCP.length > 0) {
const totalAvant = parseFloat(compteurCP[0].Total || 0);
const soldeAvant = parseFloat(compteurCP[0].Solde || 0);
// â INCRĂMENTER (ne pas Ă©craser)
await conn.query(`
UPDATE CompteurConges
SET
Total = LEAST(Total + ?, 25),
Solde = LEAST(Solde + ?, 25 + COALESCE(SoldeReporte, 0)),
DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [incrementTotal, incrementTotal, collaborateurId, cpType[0].Id, currentYear]);
console.log(` CP: ${totalAvant.toFixed(2)}j â ${(totalAvant + incrementTotal).toFixed(2)}j (+${incrementTotal.toFixed(4)}j) [${modeCP.mode}]`);
compteursMisAJour++;
} else {
// Créer compteur initial avec Smart
const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree);
// Générer l'ID manuellement
const compteurCPId = await getNextId(conn, 'CompteurConges');
await conn.query(`
INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, ?, 0, GETDATE())
`, [compteurCPId, collaborateurId, cpType[0].Id, currentYear, acquisCP, acquisCP]);
console.log(` â
CP créé avec ID ${compteurCPId}: ${acquisCP.toFixed(2)}j [${modeCP.mode}]`);
compteursMisAJour++;
}
}
// ====================================
// đ
RTT - Incrément avec prorata mensuel
// ====================================
if (rttType.length > 0 && role !== 'Apprenti') {
const modeRTT = getModeCalcul('RTT', dateEntree);
const rttConfig = await getConfigurationRTT(conn, currentYear, typeContrat);
// â Calcul acquisition mensuelle
const acquisitionMensuelleRTT = rttConfig.joursAnnuels / 12;
// â Calcul acquisition quotidienne selon le nombre de jours du mois
const incrementJournalierRTT = acquisitionMensuelleRTT / joursDuMois;
const incrementTotal = incrementJournalierRTT * joursARattraper;
console.log(` RTT: ${incrementJournalierRTT.toFixed(6)}j/jour (${acquisitionMensuelleRTT.toFixed(4)}j/${joursDuMois}j)`);
// Vérifier si compteur existe
const [compteurRTT] = await conn.query(`
SELECT Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttType[0].Id, currentYear]);
if (compteurRTT.length > 0) {
const totalAvant = parseFloat(compteurRTT[0].Total || 0);
const soldeAvant = parseFloat(compteurRTT[0].Solde || 0);
// â INCRĂMENTER (ne pas Ă©craser)
await conn.query(`
UPDATE CompteurConges
SET
Total = LEAST(Total + ?, ?),
Solde = LEAST(Solde + ?, ?),
DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [incrementTotal, rttConfig.joursAnnuels, incrementTotal, rttConfig.joursAnnuels, collaborateurId, rttType[0].Id, currentYear]);
console.log(` RTT: ${totalAvant.toFixed(2)}j â ${(totalAvant + incrementTotal).toFixed(2)}j (+${incrementTotal.toFixed(4)}j) [${modeRTT.mode}]`);
compteursMisAJour++;
} else {
// Créer compteur initial avec Smart
const rttData = await calculerAcquisitionRTT_Smart(conn, collaborateurId, today);
// â
CODE CORRIGà - Génération manuelle de l'ID
console.log(` đ RTT créé pour ${collaborateurId}`);
// Générer l'ID manuellement
const compteurRTTId = await getNextId(conn, 'CompteurConges');
await conn.query(`
INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, 0, GETDATE())
`, [compteurRTTId, collaborateurId, rttType[0].Id, currentYear, rttData.acquisition, rttData.acquisition]);
console.log(` â
RTT créé avec ID ${compteurRTTId}: ${rttData.acquisition.toFixed(2)}j [${modeRTT.mode}]`);
compteursMisAJour++;
}
}
}
await conn.commit();
console.log(`\nâ
Mise à jour terminée : ${compteursMisAJour} compteurs pour ${collaborateurs.length} collaborateurs`);
} catch (error) {
await conn.rollback();
console.error('â Erreur mise Ă jour compteurs:', error);
throw error;
} finally {
conn.release();
}
}
// ============================================================================
// â° PLANIFICATION DES CRONS
// ============================================================================
// â DĂSACTIVER le rattrapage immĂ©diat (commentĂ©)
// â
EXĂCUTION QUOTIDIENNE Ă 00h01 (Ă partir de demain - DĂCRĂMENTE)
cron.schedule('1 0 * * *', () => {
console.log('đ ===== EXĂCUTION QUOTIDIENNE AUTOMATIQUE - 00h01 =====');
updateMonthlyCounters(false); // false = incrément normal d'1 jour
}, {
timezone: "Europe/Paris"
});
console.log('ⰠCRON quotidien programmé : 00h01 Europe/Paris (à partir de demain)');
console.log('ⰠCRON réinitialisation RTT programmé : 1er janvier à 00h01');
console.log('â ïž Rattrapage immĂ©diat DĂSACTIVĂ');
// ============================================================================
// đ CRON - RĂINITIALISATION RTT AU 1ER JANVIER
// ============================================================================
cron.schedule('1 0 1 1 *', async () => {
console.log('\nđ ===== RĂINITIALISATION RTT - 1ER JANVIER =====');
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const today = new Date();
const nouvelleAnnee = today.getFullYear();
const ancienneAnnee = nouvelleAnnee - 1;
console.log(` đ
Passage de ${ancienneAnnee} Ă ${nouvelleAnnee}`);
// Récupérer le TypeCongeId pour RTT
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ?', ['RTT']);
if (rttType.length === 0) {
throw new Error('Type de congé RTT introuvable');
}
const rttTypeId = rttType[0].Id;
// Récupérer tous les collaborateurs actifs (sauf apprentis)
const [collaborateurs] = await conn.query(`
SELECT id, prenom, nom, TypeContrat, role
FROM CollaborateurAD
WHERE (Actif = 1 OR Actif IS NULL)
AND (role IS NULL OR role != 'Apprenti')
`);
console.log(` đ„ ${collaborateurs.length} collaborateurs Ă traiter`);
let compteursReinitialises = 0;
for (const collab of collaborateurs) {
const collaborateurId = collab.id;
// 1ïžâŁ Archiver/Marquer l'ancien compteur RTT N-1
const [ancienCompteur] = await conn.query(`
SELECT Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, ancienneAnnee]);
if (ancienCompteur.length > 0) {
const soldeAncien = parseFloat(ancienCompteur[0].Solde || 0);
console.log(` đ€ ${collab.prenom} ${collab.nom}: ${soldeAncien.toFixed(2)}j RTT perdus`);
// Marquer l'ancien compteur comme "clos"
await conn.query(`
UPDATE CompteurConges
SET DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, ancienneAnnee]);
}
// 2ïžâŁ CrĂ©er ou rĂ©initialiser le compteur RTT pour la nouvelle annĂ©e
const [nouveauCompteur] = await conn.query(`
SELECT id FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, nouvelleAnnee]);
if (nouveauCompteur.length === 0) {
// Créer le compteur à 0
await conn.query(`
INSERT INTO CompteurConges (
CollaborateurADId,
TypeCongeId,
Annee,
Total,
Solde,
SoldeReporte,
DerniereMiseAJour
)
VALUES (?, ?, ?, 0, 0, 0, GETDATE())
`, [collaborateurId, rttTypeId, nouvelleAnnee]);
console.log(` â
Compteur RTT ${nouvelleAnnee} créé à 0`);
} else {
// Le compteur existe déjà , le remettre à 0
await conn.query(`
UPDATE CompteurConges
SET Total = 0, Solde = 0, SoldeReporte = 0, DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, nouvelleAnnee]);
console.log(` â
Compteur RTT ${nouvelleAnnee} réinitialisé à 0`);
}
compteursReinitialises++;
}
await conn.commit();
console.log(`\nâ
Réinitialisation RTT terminée : ${compteursReinitialises} compteurs`);
} catch (error) {
await conn.rollback();
console.error('â Erreur rĂ©initialisation RTT:', error);
} finally {
conn.release();
}
}, {
timezone: "Europe/Paris"
});
console.log('ⰠCRON réinitialisation RTT programmé : 1er janvier à 00h01');
// ========================================
// MISE Ă JOUR DE updateMonthlyCounters
// ========================================
async function updateMonthlyCounters_Smart(conn, collaborateurId, dateReference = null) {
const today = dateReference ? new Date(dateReference) : new Date();
const currentYear = today.getFullYear();
const updates = [];
// Récupérer les infos du collaborateur
const [collabInfo] = await conn.query(`
SELECT DateEntree, TypeContrat, CampusId, role
FROM CollaborateurAD WHERE id = ?
`, [collaborateurId]);
if (collabInfo.length === 0) {
throw new Error(`Collaborateur ${collaborateurId} non trouvé`);
}
const dateEntree = collabInfo[0].DateEntree || null;
const typeContrat = collabInfo[0].TypeContrat || '37h';
const isApprenti = collabInfo[0].role === 'Apprenti';
console.log(`\nđ === Mise Ă jour pour collaborateur ${collaborateurId} ===`);
console.log(` Date référence: ${today.toLocaleDateString('fr-FR')}`);
// ======================================
// CP (Congés Payés)
// ======================================
const acquisitionCP = calculerAcquisitionCP_Smart(today, dateEntree);
console.log(` CP - Acquisition: ${acquisitionCP.toFixed(2)}j`);
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
if (cpType.length > 0) {
const cpTypeId = cpType[0].Id;
const [existingCP] = await conn.query(`
SELECT Id, Total, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, cpTypeId, currentYear]);
if (existingCP.length > 0) {
const ancienTotal = parseFloat(existingCP[0].Total || 0);
const ancienSolde = parseFloat(existingCP[0].Solde || 0);
const soldeReporte = parseFloat(existingCP[0].SoldeReporte || 0);
const incrementAcquis = acquisitionCP - ancienTotal;
if (incrementAcquis > 0) {
console.log(` CP - Nouveaux jours: +${incrementAcquis.toFixed(2)}j`);
// Gérer le remboursement d'anticipé (logique existante)
const [anticipeUtilise] = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalAnticipe
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction = 'N Anticip'
AND dc.Statut != 'Refusée'
AND dd.JoursUtilises > 0
`, [collaborateurId, cpTypeId, currentYear]);
const anticipePris = parseFloat(anticipeUtilise[0]?.totalAnticipe || 0);
if (anticipePris > 0) {
const aRembourser = Math.min(incrementAcquis, anticipePris);
console.log(` đł CP - Remboursement anticipĂ©: ${aRembourser.toFixed(2)}j`);
// [Logique de remboursement complĂšte - identique Ă avant]
const [deductionsAnticipees] = await conn.query(`
SELECT dd.Id, dd.DemandeCongeId, dd.JoursUtilises
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction = 'N Anticip'
AND dc.Statut != 'Refusée'
AND dd.JoursUtilises > 0
ORDER BY dd.Id ASC
`, [collaborateurId, cpTypeId, currentYear]);
let resteARembourser = aRembourser;
for (const deduction of deductionsAnticipees) {
if (resteARembourser <= 0) break;
const joursAnticipes = parseFloat(deduction.JoursUtilises);
const aDeduiteDeCetteDeduction = Math.min(resteARembourser, joursAnticipes);
await conn.query(`
UPDATE DeductionDetails
SET JoursUtilises = CASE WHEN (JoursUtilises - ?) < 0 THEN 0 ELSE (JoursUtilises - ?) END
WHERE Id = ?
`, [aDeduiteDeCetteDeduction, deduction.Id]);
const [existingAnneeN] = await conn.query(`
SELECT Id, JoursUtilises
FROM DeductionDetails
WHERE DemandeCongeId = ?
AND TypeCongeId = ?
AND Annee = ?
AND TypeDeduction IN ('Année N', 'Anne N', 'Anne actuelle N')
`, [deduction.DemandeCongeId, cpTypeId, currentYear]);
if (existingAnneeN.length > 0) {
await conn.query(`
UPDATE DeductionDetails
SET JoursUtilises = JoursUtilises + ?
WHERE Id = ?
`, [aDeduiteDeCetteDeduction, existingAnneeN[0].Id]);
} else {
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Année N', ?)
`, [deduction.DemandeCongeId, cpTypeId, currentYear, aDeduiteDeCetteDeduction]);
}
resteARembourser -= aDeduiteDeCetteDeduction;
}
// Supprimer les déductions anticipées à zéro
await conn.query(`
DELETE FROM DeductionDetails
WHERE TypeCongeId = ?
AND Annee = ?
AND TypeDeduction = 'N Anticip'
AND JoursUtilises <= 0
`, [cpTypeId, currentYear]);
}
}
// Recalculer le solde
const [consomme] = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction NOT IN ('Accum Récup', 'Accum Recup')
AND dc.Statut != 'Refusée'
`, [collaborateurId, cpTypeId, currentYear]);
const totalConsomme = parseFloat(consomme[0].total || 0);
const nouveauSolde = Math.max(0, acquisitionCP + soldeReporte - totalConsomme);
await conn.query(`
UPDATE CompteurConges
SET Total = ?, Solde = ?, DerniereMiseAJour = GETDATE()
WHERE Id = ?
`, [acquisitionCP, nouveauSolde, existingCP[0].Id]);
updates.push({
type: 'CP',
acquisitionCumulee: acquisitionCP,
increment: incrementAcquis,
nouveauSolde: nouveauSolde
});
} else {
// Créer le compteur
await conn.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, 0, GETDATE())
`, [collaborateurId, cpTypeId, currentYear, acquisitionCP, acquisitionCP]);
updates.push({
type: 'CP',
action: 'created',
acquisitionCumulee: acquisitionCP
});
}
}
// ======================================
// RTT (identique avec Smart)
// ======================================
if (!isApprenti) {
const rttData = await calculerAcquisitionRTT(conn, collaborateurId, today);
const acquisitionRTT = rttData.acquisition;
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
if (rttType.length > 0) {
const rttTypeId = rttType[0].Id;
// 1ïžâŁ RĂ©cupĂ©rer le compteur existant
const [existingRTT] = await conn.query(`
SELECT Id, Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttTypeId, currentYear]);
if (existingRTT.length > 0) {
const ancienTotal = parseFloat(existingRTT[0].Total || 0);
const ancienSolde = parseFloat(existingRTT[0].Solde || 0);
console.log(` RTT - Ancien acquis: ${ancienTotal.toFixed(2)}j`);
console.log(` RTT - Nouvel acquis: ${acquisitionRTT.toFixed(2)}j`);
// 2ïžâŁ Calculer l'incrĂ©ment d'acquisition
const incrementAcquis = acquisitionRTT - ancienTotal;
if (incrementAcquis > 0) {
console.log(` RTT - Nouveaux jours ce mois: +${incrementAcquis.toFixed(2)}j`);
// 3ïžâŁ VĂ©rifier si le collaborateur a de l'anticipĂ© utilisĂ©
const [anticipeUtilise] = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalAnticipe
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction = 'N Anticip'
AND dc.Statut != 'Refusée'
AND dd.JoursUtilises > 0
`, [collaborateurId, rttTypeId, currentYear]);
const anticipePris = parseFloat(anticipeUtilise[0]?.totalAnticipe || 0);
if (anticipePris > 0) {
// 4ïžâŁ Calculer le montant Ă rembourser
const aRembourser = Math.min(incrementAcquis, anticipePris);
console.log(` đł RTT - AnticipĂ© Ă rembourser: ${aRembourser.toFixed(2)}j (sur ${anticipePris.toFixed(2)}j)`);
// 5ïžâŁ Rembourser l'anticipĂ©
const [deductionsAnticipees] = await conn.query(`
SELECT dd.Id, dd.DemandeCongeId, dd.JoursUtilises
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction = 'N Anticip'
AND dc.Statut != 'Refusée'
AND dd.JoursUtilises > 0
ORDER BY dd.Id ASC
`, [collaborateurId, rttTypeId, currentYear]);
let resteARembourser = aRembourser;
for (const deduction of deductionsAnticipees) {
if (resteARembourser <= 0) break;
const joursAnticipes = parseFloat(deduction.JoursUtilises);
const aDeduiteDeCetteDeduction = Math.min(resteARembourser, joursAnticipes);
// Réduire l'anticipé
await conn.query(`
UPDATE DeductionDetails
SET JoursUtilises = CASE WHEN (JoursUtilises - ?) < 0 THEN 0 ELSE (JoursUtilises - ?) END
WHERE Id = ?
`, [aDeduiteDeCetteDeduction, deduction.Id]);
// VĂ©rifier si une dĂ©duction "AnnĂ©e N" existe dĂ©jĂ
const [existingAnneeN] = await conn.query(`
SELECT Id, JoursUtilises
FROM DeductionDetails
WHERE DemandeCongeId = ?
AND TypeCongeId = ?
AND Annee = ?
AND TypeDeduction IN ('Année N', 'Anne N')
`, [deduction.DemandeCongeId, rttTypeId, currentYear]);
if (existingAnneeN.length > 0) {
await conn.query(`
UPDATE DeductionDetails
SET JoursUtilises = JoursUtilises + ?
WHERE Id = ?
`, [aDeduiteDeCetteDeduction, existingAnneeN[0].Id]);
} else {
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Année N', ?)
`, [deduction.DemandeCongeId, rttTypeId, currentYear, aDeduiteDeCetteDeduction]);
}
resteARembourser -= aDeduiteDeCetteDeduction;
console.log(` â
RTT - Remboursé ${aDeduiteDeCetteDeduction.toFixed(2)}j (Demande ${deduction.DemandeCongeId})`);
}
// Supprimer les déductions anticipées à zéro
await conn.query(`
DELETE FROM DeductionDetails
WHERE TypeCongeId = ?
AND Annee = ?
AND TypeDeduction = 'N Anticip'
AND JoursUtilises <= 0
`, [rttTypeId, currentYear]);
}
}
// 6ïžâŁ Recalculer le solde total
const [consomme] = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction NOT IN ('Accum Récup', 'Accum Recup', 'Récup Dosée')
AND dc.Statut != 'Refusée'
`, [collaborateurId, rttTypeId, currentYear]);
const totalConsomme = parseFloat(consomme[0].total || 0);
const nouveauSolde = Math.max(0, acquisitionRTT - totalConsomme);
console.log(` RTT - Consommé total: ${totalConsomme.toFixed(2)}j`);
console.log(` RTT - Nouveau solde: ${nouveauSolde.toFixed(2)}j`);
// 7ïžâŁ Mettre Ă jour le compteur
await conn.query(`
UPDATE CompteurConges
SET Total = ?, Solde = ?, DerniereMiseAJour = GETDATE()
WHERE Id = ?
`, [acquisitionRTT, nouveauSolde, existingRTT[0].Id]);
updates.push({
type: 'RTT',
annee: currentYear,
typeContrat: rttData.typeContrat,
config: `${rttData.config.joursAnnuels}j/an`,
moisTravailles: rttData.moisTravailles,
acquisitionCumulee: acquisitionRTT,
increment: incrementAcquis,
nouveauSolde: nouveauSolde
});
} else {
// Créer le compteur s'il n'existe pas
await conn.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, 0, GETDATE())
`, [collaborateurId, rttTypeId, currentYear, acquisitionRTT, acquisitionRTT]);
console.log(` RTT - Compteur créé: ${acquisitionRTT.toFixed(2)}j`);
updates.push({
type: 'RTT',
annee: currentYear,
typeContrat: rttData.typeContrat,
config: `${rttData.config.joursAnnuels}j/an`,
moisTravailles: rttData.moisTravailles,
acquisitionCumulee: acquisitionRTT,
action: 'created',
nouveauSolde: acquisitionRTT
});
}
}
}
console.log(`â
Mise à jour terminée pour collaborateur ${collaborateurId}\n`);
return updates;
}
// ========================================
// ROUTES API
// ========================================
app.post('/api/login', async (req, res) => {
try {
const { email, mot_de_passe, entraUserId, userPrincipalName } = req.body;
const accessToken = req.headers.authorization?.replace('Bearer ', '');
if (accessToken && entraUserId) {
const [users] = await pool.query(`
SELECT ca.*, s.Nom as service, so.Nom as societe_nom
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Societe so ON ca.SocieteId = so.Id
WHERE ca.entraUserId=? OR ca.email=?
LIMIT 1
`, [entraUserId, email]);
if (users.length === 0) return res.json({ success: false, message: 'Utilisateur non autorisé' });
const user = users[0];
try {
const graphResponse = await axios.get(`https://graph.microsoft.com/v1.0/users/${userPrincipalName}/memberOf?$select=id`, { headers: { Authorization: `Bearer ${accessToken}` } });
const userGroups = graphResponse.data.value.map(g => g.id);
const [allowedGroups] = await pool.query('SELECT Id FROM EntraGroups WHERE IsActive=1');
const allowed = allowedGroups.map(g => g.Id);
const authorized = userGroups.some(g => allowed.includes(g));
if (authorized) {
return res.json({
success: true,
message: 'Connexion réussie via Azure AD',
user: {
id: user.id,
prenom: user.prenom || 'Prénom',
nom: user.nom || 'Nom',
email: user.email,
role: user.role || 'Collaborateur',
service: user.service || 'Non défini',
societeId: user.SocieteId,
societeNom: user.societe_nom || 'Non défini',
typeContrat: user.TypeContrat || '37h',
description: user.description || null,
dateEntree: user.DateEntree || null,
campusId: user.CampusId || null
}
});
} else {
return res.json({ success: false, message: 'Utilisateur non autorisé' });
}
} catch (error) {
return res.json({ success: false, message: 'Erreur vérification groupes' });
}
}
if (email && mot_de_passe) {
const [users] = await pool.query(`
SELECT u.ID, u.Prenom, u.Nom, u.Email, u.Role, u.ServiceId, s.Nom AS ServiceNom
FROM Users u
LEFT JOIN Services s ON u.ServiceId = s.Id
WHERE u.Email = ? AND u.MDP = ?
`, [email, mot_de_passe]);
if (users.length === 1) {
return res.json({
success: true,
message: 'Connexion réussie',
user: {
id: users[0].ID,
prenom: users[0].Prenom,
nom: users[0].Nom,
email: users[0].Email,
role: users[0].Role,
service: users[0].ServiceNom || 'Non défini'
}
});
}
return res.json({ success: false, message: 'Identifiants incorrects' });
}
res.json({ success: false, message: 'Aucune méthode de connexion fournie' });
} catch (error) {
res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message });
}
});
app.post('/api/check-user-groups', async (req, res) => {
try {
const { userPrincipalName } = req.body;
const accessToken = req.headers.authorization?.replace('Bearer ', '');
if (!userPrincipalName || !accessToken) return res.json({ authorized: false, message: 'Email ou token manquant' });
// 1. Vérification locale
const [users] = await pool.query(`
SELECT ca.id, ca.entraUserId, ca.prenom, ca.nom, ca.email,
s.Nom as service, ca.role, ca.CampusId, ca.SocieteId,
so.Nom as societe_nom
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Societe so ON ca.SocieteId = so.Id
WHERE ca.email = ?
LIMIT 1
`, [userPrincipalName]);
if (users.length > 0) {
const user = users[0];
// Si l'utilisateur est inactif, on le bloque
if (user.Actif === 0) return res.json({ authorized: false, message: 'Compte désactivé' });
return res.json({
authorized: true,
role: user.role,
groups: [user.role],
localUserId: user.id,
user: {
...user,
societeId: user.SocieteId,
societeNom: user.societe_nom
}
});
}
// 2. Si pas trouvé, interrogation Microsoft Graph
const userGraph = await axios.get(`https://graph.microsoft.com/v1.0/users/${userPrincipalName}?$select=id,displayName,givenName,surname,mail,department,jobTitle`, { headers: { Authorization: `Bearer ${accessToken}` } });
const userInfo = userGraph.data;
const checkMemberResponse = await axios.post(`https://graph.microsoft.com/v1.0/users/${userInfo.id}/checkMemberGroups`, { groupIds: [AZURE_CONFIG.groupId] }, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' } });
const isInGroup = checkMemberResponse.data.value.includes(AZURE_CONFIG.groupId);
if (!isInGroup) return res.json({ authorized: false, message: 'Utilisateur non autorisé (Hors groupe)' });
// 3. â INSERTION AVEC VALEURS PAR DĂFAUT CRITIQUES
// On met SocieteId=1 et TypeContrat='37h' par défaut pour éviter les bugs de calcul
const [result] = await pool.query(
`INSERT INTO CollaborateurAD
(entraUserId, prenom, nom, email, service, role, SocieteId, Actif, DateEntree, TypeContrat)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
userInfo.id,
userInfo.givenName || 'Prénom',
userInfo.surname || 'Nom',
userInfo.mail || userPrincipalName,
userInfo.department,
'Collaborateur',
1, // SocieteId par défaut (ex: 1 = ENSUP)
1, // Actif = 1 (Important !)
new Date(), // DateEntree = Aujourd'hui
'37h' // TypeContrat par défaut
]
);
res.json({
authorized: true,
role: 'Collaborateur',
groups: ['Collaborateur'],
localUserId: result.insertId,
user: {
id: result.insertId,
entraUserId: userInfo.id,
prenom: userInfo.givenName,
nom: userInfo.surname,
email: userInfo.mail,
service: userInfo.department,
role: 'Collaborateur',
societeId: 1,
societeNom: 'Défaut'
}
});
} catch (error) {
console.error("Erreur check-user-groups:", error);
res.json({ authorized: false, message: 'Erreur serveur', error: error.message });
}
});
// ========================================
// â
CODE CORRIGĂ POUR getDetailedLeaveCounters
// Ă remplacer dans server.js Ă partir de la ligne ~1600
// ========================================
app.get('/api/getDetailedLeaveCounters', async (req, res) => {
try {
const userIdParam = req.query.user_id;
if (!userIdParam) {
return res.json({ success: false, message: 'ID utilisateur manquant' });
}
const conn = await pool.getConnection();
const isUUID = userIdParam.length > 10 && userIdParam.includes('-');
const userQuery = `
SELECT
ca.id, ca.prenom, ca.nom, ca.email, ca.role, ca.TypeContrat, ca.DateEntree,
ca.CampusId, ca.SocieteId, s.Nom as service, so.Nom as societeNom, ca.description
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Societe so ON ca.SocieteId = so.Id
WHERE ${isUUID ? 'ca.entraUserId' : 'ca.id'} = ?
AND (ca.Actif = 1 OR ca.Actif IS NULL)
LIMIT 1
`;
const [userInfo] = await conn.query(userQuery, [userIdParam]);
if (userInfo.length === 0) {
conn.release();
return res.json({ success: false, message: 'Utilisateur non trouvé' });
}
const user = userInfo[0];
const userId = user.id;
const dateEntree = user.DateEntree;
const typeContrat = user.TypeContrat || '37h';
const today = new Date();
const currentYearSys = today.getFullYear();
// ââ RĂ©cupĂ©rer le TypeCongeId CP avant la dĂ©tection de bascule ââ
const [cpType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['Congé payé']);
if (cpType.length === 0) {
conn.release();
return res.json({ success: false, message: 'Type de congé CP non trouvé' });
}
// ââ DĂ©tection dynamique des annĂ©es post-bascule ââââââââââââââââ
// On lit la base plutĂŽt que la date systĂšme, car la bascule peut
// ĂȘtre faite manuellement avant le 01/06.
const [check2027] = await conn.query(`
SELECT TOP 1 Id
FROM CompteurConges
WHERE CollaborateurADId = ?
AND TypeCongeId = ?
AND Annee = ?
AND SoldeReporte > 0
`, [userId, cpType[0].Id, currentYearSys + 1]);
const basculeFaite = check2027.length > 0;
const dateBascule = new Date(`${currentYearSys}-06-01`);
const apresBasculement = today >= dateBascule;
const currentYear = basculeFaite
? currentYearSys + 1
: (apresBasculement ? currentYearSys + 1 : currentYearSys);
const previousYear = currentYear - 1;
const anneeN = currentYear;
console.log('\nđ === CALCUL COMPTEURS ===');
console.log('User:', user.prenom, user.nom, '(ID:', userId, ')');
console.log('Date référence:', today.toLocaleDateString('fr-FR'));
console.log(`đ
Années: N=${currentYear}, N-1=${previousYear} (basculeFaite=${basculeFaite})`);
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
// 1ïžâŁ CALCUL AVEC LES FORMULES INTELLIGENTES
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree);
console.log('𧟠CP calculé:', acquisCP.toFixed(2) + 'j');
let acquisRTT = 0;
let rttTypeId = null;
const [rttType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['RTT']);
if (rttType.length > 0 && user.role !== 'Apprenti') {
rttTypeId = rttType[0].Id;
const rttData = await calculerAcquisitionRTT_Smart(conn, userId, today);
acquisRTT = rttData.acquisition;
console.log('𧟠RTT calculé:', acquisRTT.toFixed(2) + 'j');
}
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
// 2ïžâŁ RĂCUPĂRER INFOS POUR CALCUL DES SOLDES
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
let soldeReporte = 0;
const [compteurCP] = await conn.query(`
SELECT SoldeReporte FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[userId, cpType[0].Id, currentYear]
);
if (compteurCP.length > 0) {
soldeReporte = parseFloat(compteurCP[0].SoldeReporte) || 0;
}
const [consommeCP] = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction NOT IN ('Accum Récup', 'Accum Recup')
AND dc.Statut != 'Refusé'`,
[userId, cpType[0].Id, currentYear]
);
const totalConsommeCP = parseFloat(consommeCP[0].total) || 0;
const nouveauSoldeCP = Math.max(0, acquisCP + soldeReporte - totalConsommeCP);
console.log('đ° CP - Acquis:', acquisCP.toFixed(2), '+ ReportĂ©:', soldeReporte.toFixed(2), '- ConsommĂ©:', totalConsommeCP.toFixed(2), '= Solde:', nouveauSoldeCP.toFixed(2));
let totalConsommeRTT = 0;
let nouveauSoldeRTT = 0;
if (rttTypeId) {
const [consommeRTT] = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction NOT IN ('Accum Récup', 'Accum Recup', 'Récup Dose')
AND dc.Statut != 'Refusé'`,
[userId, rttTypeId, currentYear]
);
totalConsommeRTT = parseFloat(consommeRTT[0].total) || 0;
nouveauSoldeRTT = Math.max(0, acquisRTT - totalConsommeRTT);
console.log('đ° RTT - Acquis:', acquisRTT.toFixed(2), '- ConsommĂ©:', totalConsommeRTT.toFixed(2), '= Solde:', nouveauSoldeRTT.toFixed(2));
}
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
// 3ïžâŁ ENVOYER Ă GTA-RH POUR SYNCHRONISATION
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
try {
const rhUrl = process.env.RH_SERVER_URL || 'http://192.168.0.4:3001';
const syncPayload = {
collaborateurId: userId,
annee: currentYear,
compteurs: [
{
typeConge: 'Congé payé',
typeCongeId: cpType[0].Id,
total: parseFloat(acquisCP.toFixed(2)),
solde: parseFloat(nouveauSoldeCP.toFixed(2)),
source: 'calcul_gta'
}
]
};
if (rttTypeId) {
syncPayload.compteurs.push({
typeConge: 'RTT',
typeCongeId: rttTypeId,
total: parseFloat(acquisRTT.toFixed(2)),
solde: parseFloat(nouveauSoldeRTT.toFixed(2)),
source: 'calcul_gta'
});
}
console.log('đ€ Envoi synchronisation vers GTA-RH...');
const syncResponse = await fetch(`${rhUrl}/api/syncCompteursFromGTA`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(syncPayload)
});
if (syncResponse.ok) {
const syncResult = await syncResponse.json();
console.log('â
Synchronisation GTA-RH réussie:', syncResult.message);
} else {
console.warn('â ïž Synchronisation GTA-RH Ă©chouĂ©e:', syncResponse.status);
}
} catch (syncError) {
console.error('â Erreur synchronisation GTA-RH:', syncError.message);
}
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
// 4ïžâŁ RELIRE LA BASE POUR AFFICHER LES VRAIES VALEURS
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
console.log('\nđ Lecture base aprĂšs synchronisation...');
const ancienneteMs = today - new Date(dateEntree || today);
const ancienneteMois = Math.floor(ancienneteMs / (1000 * 60 * 60 * 24 * 30.44));
let counters = {
user: {
id: user.id,
nom: `${user.prenom} ${user.nom}`,
prenom: user.prenom,
nomFamille: user.nom,
email: user.email,
service: user.service || 'Non défini',
role: user.role,
description: user.description,
typeContrat: typeContrat,
societeId: user.SocieteId,
societeNom: user.societeNom || 'Non défini',
dateEntree: dateEntree ? formatDateWithoutUTC(dateEntree) : null,
ancienneteMois: ancienneteMois,
ancienneteAnnees: Math.floor(ancienneteMois / 12),
ancienneteMoisRestants: ancienneteMois % 12
},
dateReference: today.toISOString().split('T')[0],
exerciceCP: getExerciceCP(today),
anneeRTT: currentYearSys,
cpN1: null,
cpN: null,
rttN: null,
recupN: null,
totalDisponible: { cp: 0, rtt: 0, recup: 0, total: 0 }
};
// â
CP N-1 (Report) - LECTURE BASE
const [cpN1Data] = await conn.query(`
SELECT Annee, Total, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[userId, cpType[0].Id, previousYear]
);
if (cpN1Data.length > 0) {
const totalAcquis = parseFloat(cpN1Data[0].Total) || 0;
const soldeReporte = parseFloat(cpN1Data[0].Solde) || 0;
const pris = Math.max(0, totalAcquis - soldeReporte);
counters.cpN1 = {
annee: previousYear,
exercice: `${previousYear - 1}-${previousYear}`,
reporte: parseFloat(totalAcquis.toFixed(2)),
pris: parseFloat(pris.toFixed(2)),
solde: parseFloat(soldeReporte.toFixed(2)),
pourcentageUtilise: totalAcquis > 0 ? parseFloat((pris / totalAcquis * 100).toFixed(1)) : 0
};
counters.totalDisponible.cp += counters.cpN1.solde;
console.log('â
CP N-1 BASE: Annee=' + previousYear + ' Acquis=' + totalAcquis + 'j, Solde=' + soldeReporte + 'j');
}
// â
CP N - LECTURE BASE
const [cpNData] = await conn.query(`
SELECT Total, Solde, SoldeReporte, SoldeAnticipe, IsAnticipe
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[userId, cpType[0].Id, currentYear]
);
if (cpNData.length > 0) {
const totalAcquis = parseFloat(cpNData[0].Total) || 0;
const soldeBDD = parseFloat(cpNData[0].Solde) || 0;
const soldeReporteBDD = parseFloat(cpNData[0].SoldeReporte) || 0;
const soldeAnticipe = parseFloat(cpNData[0].SoldeAnticipe) || 0;
const isAnticipe = (cpNData[0].IsAnticipe ?? 0) === 1;
const soldeReel = Math.max(0, soldeBDD - soldeReporteBDD);
const pris = Math.max(0, totalAcquis - soldeReel);
counters.cpN = {
annee: currentYear,
exercice: `${currentYear - 1}-${currentYear}`,
totalAnnuel: 25.00,
acquis: parseFloat(totalAcquis.toFixed(2)),
pris: parseFloat(pris.toFixed(2)),
solde: parseFloat(soldeReel.toFixed(2)),
pourcentageUtilise: totalAcquis > 0 ? parseFloat((pris / totalAcquis * 100).toFixed(1)) : 0,
soldeAnticipe: parseFloat(soldeAnticipe.toFixed(2)),
isAnticipe: isAnticipe,
anneeAnticipe: anneeN
};
counters.totalDisponible.cp += counters.cpN.solde;
console.log('â
CP N BASE: Annee=' + currentYear + ' Acquis=' + totalAcquis + 'j, Solde=' + soldeReel + 'j');
}
// â
RTT N - LECTURE BASE
// â
RTT N - LECTURE BASE
// Le RTT est annuel (01/01â31/12), il ne bascule pas comme le CP
if (rttType.length > 0 && user.role !== 'Apprenti') {
const anneeRTT = currentYearSys; // toujours 2026, pas currentYear=2027
const [rttNData] = await conn.query(`
SELECT Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[userId, rttTypeId, anneeRTT]
);
if (rttNData.length > 0) {
const totalAcquis = parseFloat(rttNData[0].Total) || 0;
const soldeBDD = parseFloat(rttNData[0].Solde) || 0;
const pris = Math.max(0, totalAcquis - soldeBDD);
const rttConfig = await getConfigurationRTT(conn, anneeRTT, typeContrat);
counters.rttN = {
annee: anneeRTT,
typeContrat: typeContrat,
totalAnnuel: parseFloat(rttConfig.joursAnnuels.toFixed(2)),
acquis: parseFloat(totalAcquis.toFixed(2)),
pris: parseFloat(pris.toFixed(2)),
solde: parseFloat(soldeBDD.toFixed(2)),
pourcentageUtilise: totalAcquis > 0 ? parseFloat((pris / totalAcquis * 100).toFixed(1)) : 0
};
counters.totalDisponible.rtt += counters.rttN.solde;
console.log('â
RTT BASE: Annee=' + anneeRTT + ' Acquis=' + totalAcquis + 'j, Solde=' + soldeBDD + 'j');
}
}
// â
Récup - LECTURE BASE
// â
Récup - LECTURE BASE
// La récup est annuelle, elle ne bascule pas comme le CP
const [recupType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['Récupération']);
if (recupType.length > 0) {
const anneeRecup = currentYearSys; // toujours 2026, pas currentYear=2027
const [compteurRecup] = await conn.query(`
SELECT Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[userId, recupType[0].Id, anneeRecup]
);
if (compteurRecup.length > 0) {
const acquis = parseFloat(compteurRecup[0].Total || 0);
const solde = parseFloat(compteurRecup[0].Solde || 0);
const pris = Math.max(0, acquis - solde);
counters.recupN = {
annee: anneeRecup,
acquis: parseFloat(acquis.toFixed(2)),
pris: parseFloat(pris.toFixed(2)),
solde: parseFloat(solde.toFixed(2)),
message: "Jours de récupération accumulés suite à du temps supplémentaire"
};
counters.totalDisponible.recup = counters.recupN.solde;
console.log('â
RĂCUP BASE: Annee=' + anneeRecup + ' Acquis=' + acquis + 'j, Solde=' + solde + 'j');
}
}
counters.totalDisponible.total =
counters.totalDisponible.cp +
counters.totalDisponible.rtt +
counters.totalDisponible.recup;
conn.release();
console.log('\nâ
Réponse envoyée au frontend');
console.log(' CP N-1 (Annee=' + previousYear + '):', counters.cpN1?.solde + 'j');
console.log(' CP N (Annee=' + currentYear + '):', counters.cpN?.solde + 'j');
console.log(' RTT disponible:', counters.totalDisponible.rtt + 'j');
console.log(' RĂCUP disponible:', counters.totalDisponible.recup + 'j');
console.log(' TOTAL disponible:', counters.totalDisponible.total + 'j');
res.json({ success: true, data: counters });
} catch (error) {
console.error('â Erreur getDetailedLeaveCounters:', error);
res.status(500).json({ success: false, message: error.message });
}
});
app.post('/api/updateCounters', async (req, res) => {
const conn = await pool.getConnection();
try {
const { collaborateur_id } = req.body;
if (!collaborateur_id) return res.json({ success: false, message: 'ID collaborateur manquant' });
await conn.beginTransaction();
const updates = await updateMonthlyCounters(conn, collaborateur_id, new Date());
await conn.commit();
res.json({ success: true, message: 'Compteurs mis Ă jour', updates });
} catch (error) {
await conn.rollback();
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
} finally {
conn.release();
}
});
app.post('/api/updateAllCounters', async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [collaborateurs] = await conn.query('SELECT id, CampusId FROM CollaborateurAD WHERE actif = 1 OR actif IS NULL');
const allUpdates = [];
for (const collab of collaborateurs) {
const updates = await updateMonthlyCounters(conn, collab.id, new Date());
allUpdates.push({ collaborateur_id: collab.id, updates });
}
await conn.commit();
res.json({ success: true, message: `Compteurs mis Ă jour pour ${collaborateurs.length} collaborateurs`, total_collaborateurs: collaborateurs.length, details: allUpdates });
} catch (error) {
await conn.rollback();
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
} finally {
conn.release();
}
});
async function deductLeaveBalanceWithTracking(conn, collaborateurId, typeCongeId, nombreJours, demandeCongeId) {
const currentYear = new Date().getFullYear();
const previousYear = currentYear - 1;
let joursRestants = nombreJours;
const deductions = [];
// Ătape 1: DĂ©duire du reportĂ© N-1 d'abord
const [compteurN1] = await conn.query(
`SELECT Id, Solde, SoldeReporte FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, previousYear]
);
if (compteurN1.length > 0 && compteurN1[0].SoldeReporte > 0) {
const soldeN1 = parseFloat(compteurN1[0].SoldeReporte);
const aDeduireN1 = Math.min(soldeN1, joursRestants);
if (aDeduireN1 > 0) {
// Déduction dans la base
await conn.query(
`UPDATE CompteurConges
SET SoldeReporte = CASE WHEN (SoldeReporte - ?) < 0 THEN 0 ELSE (SoldeReporte - ?) END,
Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END
WHERE Id = ?`,
[aDeduireN1, aDeduireN1, aDeduireN1, aDeduireN1, compteurN1[0].Id]
);
// Sauvegarde du détail de la déduction N-1
await conn.query(
`INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Reporté N-1', ?)`,
[demandeCongeId, typeCongeId, previousYear, aDeduireN1]
);
deductions.push({
annee: previousYear,
type: 'Reporté N-1',
joursUtilises: aDeduireN1,
soldeAvant: soldeN1
});
joursRestants -= aDeduireN1;
}
}
// Ătape 2: DĂ©duire de l'annĂ©e N si besoin
if (joursRestants > 0) {
const [compteurN] = await conn.query(
`SELECT Id, Solde, SoldeReporte FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, currentYear]
);
if (compteurN.length > 0) {
const soldeN = parseFloat(compteurN[0].Solde) - parseFloat(compteurN[0].SoldeReporte || 0);
const aDeduireN = Math.min(soldeN, joursRestants);
if (aDeduireN > 0) {
// Déduction dans la base
await conn.query(
`UPDATE CompteurConges
SET Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END
WHERE Id = ?`,
[aDeduireN, aDeduireN, compteurN[0].Id]
);
// Sauvegarde du détail de la déduction
await conn.query(
`INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Année N', ?)`,
[demandeCongeId, typeCongeId, currentYear, aDeduireN]
);
deductions.push({
annee: currentYear,
type: 'Année actuelle N',
joursUtilises: aDeduireN,
soldeAvant: soldeN
});
joursRestants -= aDeduireN;
}
}
}
return {
success: joursRestants === 0,
joursDeduitsTotal: nombreJours - joursRestants,
joursNonDeduits: joursRestants,
details: deductions
};
};
async function restoreLeaveBalance(conn, demandeCongeId, collaborateurId) {
try {
console.log(`\nđ === RESTAURATION COMPTEURS ===`);
console.log(`Demande ID: ${demandeCongeId}`);
console.log(`Collaborateur ID: ${collaborateurId}`);
const [deductions] = await conn.query(
`SELECT dd.Id AS DeductionId, dd.TypeCongeId, dd.Annee, dd.TypeDeduction, dd.JoursUtilises, tc.Nom as TypeNom
FROM DeductionDetails dd
JOIN TypeConge tc ON dd.TypeCongeId = tc.Id
WHERE dd.DemandeCongeId = ?
ORDER BY dd.Id DESC`,
[demandeCongeId]
);
console.log(`đ ${deductions.length} dĂ©ductions trouvĂ©es`);
if (deductions.length === 0) {
console.log('â ïž Aucune dĂ©duction trouvĂ©e pour cette demande');
return { success: false, message: 'Aucune déduction à restaurer' };
}
const restorations = [];
for (const deduction of deductions) {
const { DeductionId, TypeCongeId, Annee, TypeDeduction, JoursUtilises, TypeNom } = deduction;
console.log(`\nđ Traitement: ${TypeNom} - ${TypeDeduction} - ${JoursUtilises}j (AnnĂ©e: ${Annee})`);
// ââ RĂ©cup posĂ©e âââââââââââââââââââââââââââââââââââââââââââ
if (TypeDeduction === 'Récup Posée') {
console.log(`đ Restauration RĂ©cup posĂ©e: +${JoursUtilises}j`);
const [compteur] = await conn.query(
`SELECT Id, Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, TypeCongeId, Annee]
);
if (compteur.length > 0) {
const ancienSolde = parseFloat(compteur[0].Solde || 0);
const nouveauSolde = ancienSolde + parseFloat(JoursUtilises);
await conn.query(
`UPDATE CompteurConges
SET Solde = ?, DerniereMiseAJour = GETDATE()
WHERE Id = ?`,
[nouveauSolde, compteur[0].Id]
);
restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises });
console.log(`â
RĂ©cup restaurĂ©e: ${ancienSolde} â ${nouveauSolde}`);
}
continue;
}
// ââ AnticipĂ© N+1 ââââââââââââââââââââââââââââââââââââââââââ
// â
Plus de SoldeAnticipe â on supprime uniquement la ligne DeductionDetails
// Le solde réel de la ligne CompteurConges Annee=anneeN n'a jamais été touché
if (TypeDeduction === 'Anticipé N+1') {
console.log(`đ Restauration AnticipĂ© N+1: suppression de ${JoursUtilises}j dans DeductionDetails`);
await conn.query(
`DELETE FROM DeductionDetails WHERE Id = ?`,
[DeductionId]
);
restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises });
console.log(`â
Anticipé N+1 restauré: ligne DeductionDetails supprimée`);
continue;
}
// ââ Ancien TypeDeduction 'N AnticipĂ©' (rĂ©trocompatibilitĂ©) â
// Pour les demandes posées avant la migration vers 'Anticipé N+1'
if (TypeDeduction === 'N Anticipé' || TypeDeduction === 'N+1 Anticipé') {
console.log(`đ Restauration ${TypeDeduction} (legacy): suppression de ${JoursUtilises}j`);
// Si la ligne CompteurConges avait été modifiée (ancienne logique),
// on restitue le Solde en base pour ĂȘtre sĂ»r
const [compteur] = await conn.query(
`SELECT Id, Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, TypeCongeId, Annee]
);
if (compteur.length > 0) {
// Rembourser dans Solde au cas oĂč l'ancienne logique l'avait dĂ©duit
await conn.query(
`UPDATE CompteurConges
SET Solde = Solde + ?,
SoldeAnticipe = CASE WHEN COALESCE(SoldeAnticipe, 0) - ? < 0 THEN 0 ELSE COALESCE(SoldeAnticipe, 0) - ? END,
IsAnticipe = 0,
DerniereMiseAJour = GETDATE()
WHERE Id = ?`,
[JoursUtilises, JoursUtilises, JoursUtilises, compteur[0].Id]
);
}
// Supprimer la ligne DeductionDetails dans tous les cas
await conn.query(
`DELETE FROM DeductionDetails WHERE Id = ?`,
[DeductionId]
);
restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises });
console.log(`â
${TypeDeduction} (legacy) restauré`);
continue;
}
// ââ Report N-1 ââââââââââââââââââââââââââââââââââââââââââââ
if (TypeDeduction === 'Reporté N-1' || TypeDeduction === 'Report N-1') {
const [compteur] = await conn.query(
`SELECT Id, Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, TypeCongeId, Annee]
);
if (compteur.length > 0) {
const ancienSolde = parseFloat(compteur[0].Solde || 0);
const nouveauSolde = ancienSolde + parseFloat(JoursUtilises);
await conn.query(
`UPDATE CompteurConges
SET SoldeReporte = SoldeReporte + ?,
Solde = Solde + ?,
DerniereMiseAJour = GETDATE()
WHERE Id = ?`,
[JoursUtilises, JoursUtilises, compteur[0].Id]
);
restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises });
console.log(`â
ReportĂ© N-1 restaurĂ©: ${ancienSolde} â ${nouveauSolde}`);
}
continue;
}
// ââ AnnĂ©e N (dĂ©duction normale) âââââââââââââââââââââââââââ
if (TypeDeduction === 'Année N' || TypeDeduction === 'Anne N') {
const [compteur] = await conn.query(
`SELECT Id, Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, TypeCongeId, Annee]
);
if (compteur.length > 0) {
const ancienSolde = parseFloat(compteur[0].Solde || 0);
const nouveauSolde = ancienSolde + parseFloat(JoursUtilises);
await conn.query(
`UPDATE CompteurConges
SET Solde = Solde + ?,
DerniereMiseAJour = GETDATE()
WHERE Id = ?`,
[JoursUtilises, compteur[0].Id]
);
restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises });
console.log(`â
AnnĂ©e N restaurĂ©e: ${ancienSolde} â ${nouveauSolde}`);
}
continue;
}
// ââ AnnĂ©e N-1 (dĂ©duction normale sur exercice prĂ©cĂ©dent) ââ
if (TypeDeduction === 'Année N-1' || TypeDeduction === 'Anne N-1') {
const [compteur] = await conn.query(
`SELECT Id, Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, TypeCongeId, Annee]
);
if (compteur.length > 0) {
const ancienSolde = parseFloat(compteur[0].Solde || 0);
const nouveauSolde = ancienSolde + parseFloat(JoursUtilises);
await conn.query(
`UPDATE CompteurConges
SET Solde = Solde + ?,
DerniereMiseAJour = GETDATE()
WHERE Id = ?`,
[JoursUtilises, compteur[0].Id]
);
restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises });
console.log(`â
AnnĂ©e N-1 restaurĂ©e: ${ancienSolde} â ${nouveauSolde}`);
}
continue;
}
// ââ Anticip (jours rĂ©els dĂ©duits du solde CP N) ââââââââââââââââââââââ
if (TypeDeduction === 'Anticip') {
const [compteur] = await conn.query(
`SELECT Id, Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, TypeCongeId, Annee]
);
if (compteur.length > 0) {
const ancienSolde = parseFloat(compteur[0].Solde || 0);
const nouveauSolde = parseFloat((ancienSolde + parseFloat(JoursUtilises)).toFixed(2));
await conn.query(
`UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`,
[nouveauSolde, compteur[0].Id]
);
console.log(`â
Anticip restaurĂ©: Solde ${ancienSolde} â ${nouveauSolde}j`);
}
await conn.query(`DELETE FROM DeductionDetails WHERE Id = ?`, [DeductionId]);
restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises });
continue;
}
// ââ AnticipFutur (tracĂ© uniquement, solde non touchĂ©) ââââââââââââââââ
if (TypeDeduction === 'AnticipFutur') {
await conn.query(`DELETE FROM DeductionDetails WHERE Id = ?`, [DeductionId]);
restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises });
console.log(`â
AnticipFutur supprimé: ${JoursUtilises}j tracé retiré (aucun solde à remettre)`);
continue;
}
// ââ AnticipRTT (solde RTT N rĂ©el dĂ©duit) â remettre âââââââââââââââââ
if (TypeDeduction === 'AnticipRTT') {
const [compteur] = await conn.query(
`SELECT Id, Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, TypeCongeId, Annee]
);
if (compteur.length > 0) {
const nouveauSolde = parseFloat((parseFloat(compteur[0].Solde) + parseFloat(JoursUtilises)).toFixed(2));
await conn.query(
`UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`,
[nouveauSolde, compteur[0].Id]
);
console.log(`â
AnticipRTT restaurĂ©: Solde +${JoursUtilises}j â ${nouveauSolde}j`);
}
await conn.query(`DELETE FROM DeductionDetails WHERE Id = ?`, [DeductionId]);
restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises });
continue;
}
// ââ AnticipFuturRTT (tracĂ© uniquement, solde non touchĂ©) ââââââââââââ
if (TypeDeduction === 'AnticipFuturRTT') {
await conn.query(`DELETE FROM DeductionDetails WHERE Id = ?`, [DeductionId]);
restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises });
console.log(`â
AnticipFuturRTT supprimé: ${JoursUtilises}j tracé retiré (aucun solde à remettre)`);
continue;
}
// ââ AnticipNp1RTT (dĂ©duit du Solde compteur RTT N+1) â remettre âââââ
if (TypeDeduction === 'AnticipNp1RTT') {
const [compteur] = await conn.query(
`SELECT Id, Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, TypeCongeId, Annee]
);
if (compteur.length > 0) {
const nouveauSolde = parseFloat((parseFloat(compteur[0].Solde) + parseFloat(JoursUtilises)).toFixed(2));
await conn.query(
`UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`,
[nouveauSolde, compteur[0].Id]
);
console.log(`â
AnticipNp1RTT restaurĂ©: Solde RTT N+1 +${JoursUtilises}j â ${nouveauSolde}j`);
}
await conn.query(`DELETE FROM DeductionDetails WHERE Id = ?`, [DeductionId]);
restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises });
continue;
}
// ââ TypeDeduction non reconnu â log warning âââââââââââââââ
console.warn(`â ïž TypeDeduction non gĂ©rĂ©: "${TypeDeduction}" â ignorĂ©`);
}
// â
Suppression de updateSoldeAnticipe â plus nĂ©cessaire sans SoldeAnticipe
console.log(`\nâ
Restauration terminée: ${restorations.length} opérations\n`);
return {
success: true,
restorations,
message: `${restorations.length} restaurations effectuées`
};
} catch (error) {
console.error('â Erreur lors de la restauration des soldes:', error);
throw error;
}
}
app.get('/api/testProrata', async (req, res) => {
try {
const userId = parseInt(req.query.user_id || 0);
if (userId <= 0) return res.json({ success: false, message: 'ID utilisateur requis' });
const conn = await pool.getConnection();
const [userInfo] = await conn.query(`SELECT id, prenom, nom, DateEntree, TypeContrat, CampusId FROM CollaborateurAD WHERE id = ?`, [userId]);
if (userInfo.length === 0) { conn.release(); return res.json({ success: false, message: 'Utilisateur non trouvé' }); }
const user = userInfo[0];
const dateEntree = user.DateEntree;
const typeContrat = user.TypeContrat || '37h';
const today = new Date();
const moisCP = getMoisTravaillesCP(today, dateEntree);
const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree);
const rttData = await calculerAcquisitionRTT_Smart(conn, userId, today);
conn.release();
res.json({
success: true,
user: {
id: user.id,
nom: `${user.prenom} ${user.nom}`,
dateEntree: dateEntree ? dateEntree.toISOString().split('T')[0] : null,
typeContrat: typeContrat
},
dateReference: today.toISOString().split('T')[0],
calculs: {
CP: {
moisTravailles: parseFloat(moisCP.toFixed(2)),
acquisitionMensuelle: 25 / 12,
acquisitionCumulee: acquisCP,
formule: `${moisCP.toFixed(2)} mois Ă ${(25 / 12).toFixed(2)}j/mois = ${acquisCP}j`
},
RTT: {
moisTravailles: rttData.moisTravailles,
acquisitionMensuelle: rttData.config.acquisitionMensuelle,
acquisitionCumulee: rttData.acquisition,
totalAnnuel: rttData.config.joursAnnuels,
formule: `${rttData.moisTravailles} mois Ă ${rttData.config.acquisitionMensuelle.toFixed(6)}j/mois = ${rttData.acquisition}j`
}
}
});
} catch (error) {
console.error('Erreur testProrata:', error);
res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message });
}
});
app.post('/api/fixAllCounters', async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const today = new Date();
const currentYear = today.getFullYear();
const [collaborateurs] = await conn.query('SELECT id, prenom, nom, DateEntree, CampusId FROM CollaborateurAD WHERE (actif = 1 OR actif IS NULL)');
console.log(`đ Correction de ${collaborateurs.length} compteurs...`);
const corrections = [];
for (const collab of collaborateurs) {
const dateEntree = collab.DateEntree;
const moisCP = getMoisTravaillesCP(today, dateEntree);
const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree);
const rttData = await calculerAcquisitionRTT_Smart(conn, collab.id, today);
const acquisRTT = rttData.acquisition;
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
if (cpType.length > 0) {
const [existingCP] = await conn.query(`SELECT Id, Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collab.id, cpType[0].Id, currentYear]);
if (existingCP.length > 0) {
const ancienTotal = parseFloat(existingCP[0].Total);
const ancienSolde = parseFloat(existingCP[0].Solde);
const difference = acquisCP - ancienTotal;
const nouveauSolde = Math.max(0, ancienSolde + difference);
await conn.query(`UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [acquisCP, nouveauSolde, existingCP[0].Id]);
corrections.push({ collaborateur: `${collab.prenom} ${collab.nom}`, type: 'CP', ancienTotal: ancienTotal.toFixed(2), nouveauTotal: acquisCP.toFixed(2), ancienSolde: ancienSolde.toFixed(2), nouveauSolde: nouveauSolde.toFixed(2) });
}
}
if (rttType.length > 0) {
const [existingRTT] = await conn.query(`SELECT Id, Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collab.id, rttType[0].Id, currentYear]);
if (existingRTT.length > 0) {
const ancienTotal = parseFloat(existingRTT[0].Total);
const ancienSolde = parseFloat(existingRTT[0].Solde);
const difference = acquisRTT - ancienTotal;
const nouveauSolde = Math.max(0, ancienSolde + difference);
await conn.query(`UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`, [acquisRTT, nouveauSolde, existingRTT[0].Id]);
corrections.push({ collaborateur: `${collab.prenom} ${collab.nom}`, type: 'RTT', ancienTotal: ancienTotal.toFixed(2), nouveauTotal: acquisRTT.toFixed(2), ancienSolde: ancienSolde.toFixed(2), nouveauSolde: nouveauSolde.toFixed(2) });
}
}
}
await conn.commit();
res.json({ success: true, message: `â
${collaborateurs.length} compteurs corrigés`, corrections: corrections });
} catch (error) {
await conn.rollback();
console.error('â Erreur correction compteurs:', error);
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
} finally {
conn.release();
}
});
app.post('/api/processEndOfYear', async (req, res) => {
const conn = await pool.getConnection();
try {
const { collaborateur_id } = req.body;
await conn.beginTransaction();
let result;
if (collaborateur_id) {
result = await processEndOfYearRTT(conn, collaborateur_id);
} else {
const [collaborateurs] = await conn.query('SELECT id, CampusId FROM CollaborateurAD');
const results = [];
for (const c of collaborateurs) {
const r = await processEndOfYearRTT(conn, c.id);
if (r) results.push({ collaborateur_id: c.id, ...r });
}
result = results;
}
await conn.commit();
res.json({ success: true, message: 'Traitement de fin d\'année effectué', result });
} catch (error) {
await conn.rollback();
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
} finally {
conn.release();
}
});
app.post('/api/processEndOfExercice', async (req, res) => {
const conn = await pool.getConnection();
try {
const { collaborateur_id } = req.body;
await conn.beginTransaction();
let result;
if (collaborateur_id) {
result = await processEndOfExerciceCP(conn, collaborateur_id);
} else {
const [collaborateurs] = await conn.query('SELECT id, CampusId FROM CollaborateurAD');
const results = [];
for (const c of collaborateurs) {
const r = await processEndOfExerciceCP(conn, c.id);
if (r) results.push({ collaborateur_id: c.id, ...r });
}
result = results;
}
await conn.commit();
res.json({ success: true, message: 'Traitement de fin d\'exercice CP effectué', result });
} catch (error) {
await conn.rollback();
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
} finally {
conn.release();
}
});
app.get('/api/getAcquisitionDetails', async (req, res) => {
try {
const today = new Date();
const details = { date_reference: today.toISOString().split('T')[0], CP: { exercice: getExerciceCP(today), mois_travailles: getMoisTravaillesCP(today), acquisition_mensuelle: LEAVE_RULES.CP.acquisitionMensuelle, acquisition_cumulee: calculerAcquisitionCumulee('CP', today), total_annuel: LEAVE_RULES.CP.joursAnnuels, periode: '01/06 - 31/05', reportable: LEAVE_RULES.CP.reportable }, RTT: { annee: today.getFullYear(), mois_travailles: getMoisTravaillesRTT(today), acquisition_mensuelle: LEAVE_RULES.RTT.acquisitionMensuelle, acquisition_cumulee: calculerAcquisitionCumulee('RTT', today), total_annuel: LEAVE_RULES.RTT.joursAnnuels, periode: '01/01 - 31/12', reportable: LEAVE_RULES.RTT.reportable } };
res.json({ success: true, details });
} catch (error) {
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
}
});
app.get('/api/getLeaveCounters', async (req, res) => {
try {
const userId = parseInt(req.query.user_id || 0);
const data = {};
if (userId > 0) {
const [rows] = await pool.query(`SELECT tc.Nom, cc.Annee, cc.Solde, cc.Total, cc.SoldeReporte FROM CompteurConges cc JOIN TypeConge tc ON cc.TypeCongeId = tc.Id WHERE cc.CollaborateurADId = ?`, [userId]);
rows.forEach(row => { data[row.Nom] = { Annee: row.Annee, Solde: parseFloat(row.Solde), Total: parseFloat(row.Total), SoldeReporte: parseFloat(row.SoldeReporte) }; });
}
res.json({ success: true, message: 'Compteurs récupérés', counters: data });
} catch (error) {
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
}
});
app.get('/api/getEmploye', async (req, res) => {
try {
const id = parseInt(req.query.id || 0);
if (id <= 0) return res.json({ success: false, message: 'ID invalide' });
const conn = await pool.getConnection();
// 1ïžâŁ RĂ©cupĂ©rer les infos du collaborateur
const [rows] = await conn.query(`
SELECT
ca.id,
ca.Nom,
ca.Prenom,
ca.Email,
ca.role,
ca.TypeContrat,
ca.DateEntree,
ca.CampusId,
ca.SocieteId,
s.Nom as service,
so.Nom as societe_nom
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Societe so ON ca.SocieteId = so.Id
WHERE ca.id = ?
`, [id]);
if (rows.length === 0) {
conn.release();
return res.json({ success: false, message: 'Collaborateur non trouvé' });
}
const employee = rows[0];
// 2ïžâŁ DĂ©terminer si c'est un apprenti
const isApprenti = normalizeRole(employee.role) === 'apprenti';
// 3ïžâŁ RĂ©cupĂ©rer les compteurs CP
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
let cpTotal = 0, cpSolde = 0;
if (cpType.length > 0) {
const currentYear = new Date().getFullYear();
const previousYear = currentYear - 1;
// CP N-1 (reporté)
const [cpN1] = await conn.query(`
SELECT SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [id, cpType[0].Id, previousYear]);
// CP N (année courante)
const [cpN] = await conn.query(`
SELECT Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [id, cpType[0].Id, currentYear]);
const cpN1Solde = cpN1.length > 0 ? parseFloat(cpN1[0].SoldeReporte || 0) : 0;
const cpNSolde = cpN.length > 0 ? parseFloat(cpN[0].Solde || 0) : 0;
const cpNReporte = cpN.length > 0 ? parseFloat(cpN[0].SoldeReporte || 0) : 0;
cpTotal = cpN1Solde + (cpNSolde - cpNReporte);
cpSolde = cpTotal;
}
// 4ïžâŁ RĂ©cupĂ©rer les compteurs RTT (sauf pour apprentis)
let rttTotal = 0, rttSolde = 0;
if (!isApprenti) {
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
if (rttType.length > 0) {
const currentYear = new Date().getFullYear();
const [rttN] = await conn.query(`
SELECT Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [id, rttType[0].Id, currentYear]);
rttTotal = rttN.length > 0 ? parseFloat(rttN[0].Solde || 0) : 0;
rttSolde = rttTotal;
}
}
conn.release();
// 5ïžâŁ Retourner les donnĂ©es complĂštes
res.json({
success: true,
employee: {
id: employee.id,
Nom: employee.Nom || 'Non défini',
Prenom: employee.Prenom || 'Non défini',
Email: employee.Email || 'Non défini',
role: employee.role || 'Collaborateur',
TypeContrat: employee.TypeContrat || '37h',
DateEntree: employee.DateEntree,
CampusId: employee.CampusId,
SocieteId: employee.SocieteId,
service: employee.service || 'Non défini',
societe_nom: employee.societe_nom || 'Non défini',
conges_restants: parseFloat(cpSolde.toFixed(2)),
rtt_restants: parseFloat(rttSolde.toFixed(2))
}
});
} catch (error) {
console.error('â Erreur getEmploye:', error);
res.status(500).json({
success: false,
message: 'Erreur DB',
error: error.message
});
}
});
app.get('/api/getEmployeRequest', async (req, res) => {
try {
const id = parseInt(req.query.id) || 0;
if (!id) return res.json({ success: false, message: 'ID invalide' });
const result = await pool.request()
.input('collabId', sql.Int, id)
.query(`
SELECT
dc.Id,
dc.DateDebut,
dc.DateFin,
dc.NombreJours AS days,
dc.Statut AS status,
dc.DateDemande,
STRING_AGG(tc.Nom, ', ') WITHIN GROUP (ORDER BY tc.Nom) AS type,
CASE
WHEN dc.DateDebut = dc.DateFin
THEN FORMAT(dc.DateDebut, 'dd/MM/yyyy')
ELSE FORMAT(dc.DateDebut, 'dd/MM/yyyy') + ' - ' + FORMAT(dc.DateFin, 'dd/MM/yyyy')
END AS datedisplay
FROM DemandeConge dc
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.CollaborateurADId = @collabId
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.NombreJours, dc.Statut, dc.DateDemande
ORDER BY dc.DateDemande DESC
`);
return res.json({
success: true,
requests: result.recordset
});
} catch (error) {
console.error('Erreur getEmployeRequest', error);
return res.status(500).json({
success: false,
message: 'Erreur DB',
error: error.message
});
}
});
app.get('/api/getRequests', async (req, res) => {
try {
const userId = req.query.user_id;
if (!userId) return res.json({ success: false, message: 'ID utilisateur manquant' });
// đ DĂ©terminer si c'est un UUID ou un ID numĂ©rique
const isUUID = userId.length > 10 && userId.includes('-');
console.log(`đ Type userId dĂ©tectĂ©: ${isUUID ? 'UUID (entraUserId)' : 'ID numĂ©rique'}`);
let mainRequest = pool.request();
let whereClause;
let collaborateurId;
if (isUUID) {
// Si UUID, chercher d'abord le CollaborateurADId
const lookupRequest = pool.request();
lookupRequest.input('entraUserId', userId);
const userLookup = await lookupRequest.query(`
SELECT id
FROM CollaborateurAD
WHERE entraUserId = @entraUserId
`);
if (userLookup.recordset.length === 0) {
return res.json({
success: false,
message: 'Utilisateur non trouvé',
requests: [],
total: 0
});
}
collaborateurId = userLookup.recordset[0].id;
console.log(`â
CollaborateurADId trouvé: ${collaborateurId}`);
mainRequest.input('collaborateurId', collaborateurId);
whereClause = 'dc.CollaborateurADId = @collaborateurId';
} else {
collaborateurId = parseInt(userId);
mainRequest.input('userId', collaborateurId);
whereClause = '(dc.EmployeeId = @userId OR dc.CollaborateurADId = @userId)';
}
// â
REQUĂTE CORRIGĂE - AJOUT de PeriodeJournee et CouleurHex
const result = await mainRequest.query(`
SELECT
dc.Id,
dc.DateDebut,
dc.DateFin,
dc.Statut,
dc.DateDemande,
dc.Commentaire,
dc.CommentaireValidation,
dc.Validateur,
dc.DocumentJoint,
dc.NombreJours,
(
SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom)
FROM (
SELECT DISTINCT tc2.Nom
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id
) AS DistinctTypes
) AS TypeConges,
(
SELECT SUM(dct2.NombreJours)
FROM DemandeCongeType dct2
WHERE dct2.DemandeCongeId = dc.Id
) AS NombreJoursTotal,
-- â AJOUT: RĂ©cupĂ©rer les couleurs des types de congĂ©
(
SELECT STRING_AGG(tc3.CouleurHex, ',') WITHIN GROUP (ORDER BY tc3.Nom)
FROM (
SELECT DISTINCT tc2.CouleurHex, tc2.Nom
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id
) AS tc3
) AS Couleurs,
-- â AJOUT: RĂ©cupĂ©rer la premiĂšre couleur (principale)
(
SELECT TOP 1 tc2.CouleurHex
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id
ORDER BY dct2.NombreJours DESC
) AS CouleurPrincipale,
-- â AJOUT: RĂ©cupĂ©rer la pĂ©riode de journĂ©e
(
SELECT TOP 1 COALESCE(dct2.PeriodeJournee, 'Journée entiÚre')
FROM DemandeCongeType dct2
WHERE dct2.DemandeCongeId = dc.Id
) AS PeriodeJournee,
-- â AJOUT: DĂ©tails JSON des congĂ©s (type, jours, pĂ©riode, couleur)
(
SELECT CONCAT(
'[',
STRING_AGG(
CONCAT(
'{"type":"', tc2.Nom,
'","jours":', dct2.NombreJours,
',"periode":"', COALESCE(dct2.PeriodeJournee, 'Journée entiÚre'),
'","couleur":"', COALESCE(tc2.CouleurHex, '#6b7280'), '"}'
),
','
),
']'
)
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id
) AS DetailsCongesJSON
FROM DemandeConge dc
WHERE ${whereClause}
ORDER BY dc.DateDemande DESC
`);
const rows = result.recordset;
console.log(`đ ${rows.length} demandes trouvĂ©es pour userId: ${userId}`);
const requests = rows.map(row => {
const workingDays = getWorkingDays(row.DateDebut, row.DateFin);
const dateDisplay = row.DateDebut === row.DateFin
? formatDate(row.DateDebut)
: `${formatDate(row.DateDebut)} - ${formatDate(row.DateFin)}`;
let fileUrl = null;
if (row.TypeConges && row.TypeConges.includes('Congé maladie') && row.DocumentJoint) {
fileUrl = `/uploads/${path.basename(row.DocumentJoint)}`;
}
// â Parser les dĂ©tails JSON si disponible
let detailsConges = [];
if (row.DetailsCongesJSON) {
try {
detailsConges = JSON.parse(row.DetailsCongesJSON);
} catch (e) {
console.warn('Erreur parsing DetailsCongesJSON:', e);
}
}
return {
id: row.Id,
type: row.TypeConges || 'Non défini',
startDate: row.DateDebut,
endDate: row.DateFin,
dateDisplay,
days: row.NombreJoursTotal || row.NombreJours || workingDays,
status: row.Statut,
reason: row.Commentaire || 'Aucun commentaire',
submittedAt: row.DateDemande,
submittedDisplay: formatDate(row.DateDemande),
validator: row.Validateur || null,
validationComment: row.CommentaireValidation || null,
fileUrl,
// â NOUVEAUX CHAMPS POUR LE CALENDRIER
couleur: row.CouleurPrincipale || '#6b7280',
couleurs: row.Couleurs ? row.Couleurs.split(',') : ['#6b7280'],
periodeJournee: row.PeriodeJournee || 'Journée entiÚre',
detailsConges: detailsConges
};
});
res.json({
success: true,
message: 'Demandes récupérées',
requests,
total: requests.length
});
} catch (error) {
console.error('â Erreur getRequests:', error);
res.status(500).json({
success: false,
message: 'Erreur',
error: error.message
});
}
});
app.get('/api/getAllTeamRequests', async (req, res) => {
try {
// â
CORRIGà : manager_id (cohérent avec frontend)
const managerId = req.query.manager_id;
if (!managerId) return res.json({ success: false, message: 'ParamĂštre manager_id manquant' });
const request = pool.request();
request.input('managerId', managerId);
// Récupérer les infos du manager
const managerResult = await request.query(`
SELECT ServiceId, CampusId, role
FROM CollaborateurAD
WHERE id = @managerId
`);
if (managerResult.recordset.length === 0) {
return res.json({ success: false, message: 'Manager non trouvé' });
}
const managerRow = managerResult.recordset[0];
const serviceId = managerRow.ServiceId;
const campusId = managerRow.CampusId;
const role = normalizeRole(managerRow.role);
console.log(`đ getAllTeamRequests - Manager: ${managerId}, Role: ${role}`);
let rows;
// VALIDATEURS ET DIRECTEURS : Service + Campus (vue complĂšte)
if (role === 'validateur' || role === 'directeur de campus') {
const queryRequest = pool.request();
queryRequest.input('serviceId', serviceId);
queryRequest.input('campusId', campusId);
queryRequest.input('managerId', managerId);
const result = await queryRequest.query(`
SELECT
dc.Id,
dc.DateDebut,
dc.DateFin,
dc.Statut,
dc.DateDemande,
dc.Commentaire,
dc.DocumentJoint,
dc.CollaborateurADId AS employee_id,
CONCAT(ca.Prenom, ' ', ca.Nom) as employee_name,
ca.Email as employee_email,
STRING_AGG(tc.Nom, ', ') as type
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE ca.ServiceId = @serviceId
AND ca.CampusId = @campusId
AND ca.id != @managerId
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande,
dc.Commentaire, dc.DocumentJoint, dc.CollaborateurADId,
ca.Prenom, ca.Nom, ca.Email
ORDER BY dc.DateDemande DESC
`);
rows = result.recordset;
console.log(` â
${rows.length} demande(s) historiques (service/campus)`);
}
// RH : Service + Campus
// RH : Toutes les demandes (tous services et campus)
else if (role === 'rh') {
const queryRequest = pool.request();
queryRequest.input('managerId', managerId);
const result = await queryRequest.query(`
SELECT
dc.Id,
dc.DateDebut,
dc.DateFin,
dc.Statut,
dc.DateDemande,
dc.Commentaire,
dc.DocumentJoint,
dc.CollaborateurADId AS employee_id,
CONCAT(ca.Prenom, ' ', ca.Nom) as employee_name,
ca.Email as employee_email,
STRING_AGG(tc.Nom, ', ') as type
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE ca.id != @managerId
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande,
dc.Commentaire, dc.DocumentJoint, dc.CollaborateurADId,
ca.Prenom, ca.Nom, ca.Email
ORDER BY dc.DateDemande DESC
`);
rows = result.recordset;
console.log(` â
${rows.length} demande(s) historiques (RH - toutes)`);
}
// ADMIN/PRESIDENT : Toutes
else {
const result = await pool.request().query(`
SELECT
dc.Id,
dc.DateDebut,
dc.DateFin,
dc.Statut,
dc.DateDemande,
dc.Commentaire,
dc.DocumentJoint,
dc.CollaborateurADId AS employee_id,
CONCAT(ca.Prenom, ' ', ca.Nom) as employee_name,
ca.Email as employee_email,
STRING_AGG(tc.Nom, ', ') as type
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande,
dc.Commentaire, dc.DocumentJoint, dc.CollaborateurADId,
ca.Prenom, ca.Nom, ca.Email
ORDER BY dc.DateDemande DESC
`);
rows = result.recordset;
console.log(` â
${rows.length} demande(s) historiques (Admin)`);
}
const requests = rows.map(row => ({
id: row.Id,
employee_id: row.employee_id,
employee_name: row.employee_name,
employee_email: row.employee_email,
type: row.type || 'Non défini',
start_date: row.DateDebut,
end_date: row.DateFin,
date_display: row.DateDebut === row.DateFin
? formatDate(row.DateDebut)
: `${formatDate(row.DateDebut)} - ${formatDate(row.DateFin)}`,
days: getWorkingDays(row.DateDebut, row.DateFin),
status: row.Statut,
reason: row.Commentaire || '',
file: row.DocumentJoint || null,
submitted_at: row.DateDemande,
submitted_display: formatDate(row.DateDemande)
}));
res.json({ success: true, requests });
} catch (error) {
console.error('â Erreur getAllTeamRequests:', error);
res.status(500).json({
success: false,
message: 'Erreur DB',
error: error.message
});
}
});
app.get('/api/getPendingRequests', async (req, res) => {
try {
// â
CORRIGĂ : manager_id
const managerId = req.query.manager_id;
if (!managerId) return res.json({ success: false, message: 'ID manager manquant' });
const request = pool.request();
request.input('managerId', managerId);
const managerResult = await request.query(`
SELECT ServiceId, CampusId, role
FROM CollaborateurAD
WHERE id = @managerId
`);
if (managerResult.recordset.length === 0) {
return res.json({ success: false, message: 'Manager non trouvé' });
}
const managerRow = managerResult.recordset[0];
const serviceId = managerRow.ServiceId;
const campusId = managerRow.CampusId;
const role = normalizeRole(managerRow.role);
console.log(`đ getPendingRequests - Manager: ${managerId}, Role: ${role}`);
let rows;
// VALIDATEURS, DIRECTEURS ET PRĂSIDENTS : Leurs collaborateurs directs (hiĂ©rarchie)
if (role === 'validateur' || role === 'directeur de campus' || role === 'president') {
const queryRequest = pool.request();
queryRequest.input('managerId', managerId);
const result = await queryRequest.query(`
SELECT
dc.Id,
dc.DateDebut,
dc.DateFin,
dc.Statut,
dc.DateDemande,
dc.Commentaire,
dc.CollaborateurADId,
CONCAT(ca.prenom, ' ', ca.nom) as employee_name,
ca.email as employee_email,
STRING_AGG(tc.Nom, ', ') as types,
SUM(dct.NombreJours) as NombreJoursTotal
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
JOIN HierarchieValidationAD hv ON ca.id = hv.CollaborateurId
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE hv.SuperieurId = @managerId
AND dc.Statut = 'En attente'
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande,
dc.Commentaire, dc.CollaborateurADId, ca.prenom, ca.nom, ca.email
ORDER BY dc.DateDemande ASC
`);
rows = result.recordset;
console.log(` â
${rows.length} demande(s) à valider (hiérarchie - ${role})`);
}
// RH : Service + Campus
else if (role === 'rh') {
const queryRequest = pool.request();
queryRequest.input('serviceId', serviceId);
queryRequest.input('campusId', campusId);
queryRequest.input('managerId', managerId);
const result = await queryRequest.query(`
SELECT
dc.Id,
dc.DateDebut,
dc.DateFin,
dc.Statut,
dc.DateDemande,
dc.Commentaire,
dc.CollaborateurADId,
CONCAT(ca.prenom, ' ', ca.nom) as employee_name,
ca.email as employee_email,
STRING_AGG(tc.Nom, ', ') as types,
SUM(dct.NombreJours) as NombreJoursTotal
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE ca.ServiceId = @serviceId
AND ca.CampusId = @campusId
AND dc.Statut = 'En attente'
AND ca.id != @managerId
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande,
dc.Commentaire, dc.CollaborateurADId, ca.prenom, ca.nom, ca.email
ORDER BY dc.DateDemande ASC
`);
rows = result.recordset;
console.log(` â
${rows.length} demande(s) Ă valider (RH)`);
}
// ADMIN : Toutes les demandes en attente
else if (role === 'admin') {
const result = await pool.request().query(`
SELECT
dc.Id,
dc.DateDebut,
dc.DateFin,
dc.Statut,
dc.DateDemande,
dc.Commentaire,
dc.CollaborateurADId,
CONCAT(ca.prenom, ' ', ca.nom) as employee_name,
ca.email as employee_email,
STRING_AGG(tc.Nom, ', ') as types,
SUM(dct.NombreJours) as NombreJoursTotal
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.Statut = 'En attente'
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande,
dc.Commentaire, dc.CollaborateurADId, ca.prenom, ca.nom, ca.email
ORDER BY dc.DateDemande ASC
`);
rows = result.recordset;
console.log(` â
${rows.length} demande(s) Ă valider (Admin)`);
} else {
return res.json({ success: false, message: 'RÎle non autorisé pour la validation' });
}
const requests = rows.map(row => ({
id: row.Id,
employee_id: row.CollaborateurADId,
employee_name: row.employee_name,
employee_email: row.employee_email,
type: row.types || 'Non défini',
start_date: row.DateDebut,
end_date: row.DateFin,
date_display: row.DateDebut === row.DateFin
? formatDate(row.DateDebut)
: `${formatDate(row.DateDebut)} - ${formatDate(row.DateFin)}`,
days: row.NombreJoursTotal || getWorkingDays(row.DateDebut, row.DateFin),
status: row.Statut,
reason: row.Commentaire || '',
submitted_at: row.DateDemande,
submitted_display: formatDate(row.DateDemande)
}));
res.json({
success: true,
message: 'Demandes récupérées',
requests,
service_id: serviceId,
campus_id: campusId
});
} catch (error) {
console.error('â Erreur getPendingRequests:', error);
res.status(500).json({
success: false,
message: 'Erreur',
error: error.message
});
}
});
// Route getTeamMembers - Membres de l'équipe
app.get('/api/getTeamMembers', async (req, res) => {
try {
const managerId = req.query.manager_id;
if (!managerId) return res.json({ success: false, message: 'ID manager manquant' });
const managerRequest = pool.request();
managerRequest.input('managerId', managerId);
const managerResult = await managerRequest.query(`
SELECT TOP 1 ServiceId, CampusId, role, email
FROM CollaborateurAD
WHERE id = @managerId
`);
if (managerResult.recordset.length === 0) {
return res.json({ success: false, message: 'Manager non trouvé' });
}
const managerInfo = managerResult.recordset[0];
const serviceId = managerInfo.ServiceId;
const campusId = managerInfo.CampusId;
const role = normalizeRole(managerInfo.role);
console.log(`đ getTeamMembers - Manager: ${managerId}, Role: ${role}, Campus: ${campusId}`);
let members;
if (role === 'admin' || role === 'president' || role === 'rh') {
// CAS 1: Admin/President/RH - Vue globale
console.log("CAS 1: Admin/President/RH - Vue globale");
const membersRequest = pool.request();
membersRequest.input('managerId', managerId);
const membersResult = await membersRequest.query(`
SELECT
c.id,
c.nom,
c.prenom,
c.email,
c.role,
s.Nom as service_name,
c.CampusId,
camp.Nom as campus_name
FROM CollaborateurAD c
JOIN Services s ON c.ServiceId = s.Id
LEFT JOIN Campus camp ON c.CampusId = camp.Id
WHERE c.id != @managerId
AND (c.actif = 1 OR c.actif IS NULL)
ORDER BY c.prenom, c.nom
`);
members = membersResult.recordset;
console.log(` â
${members.length} collaborateur(s) au total`);
} else if (role === 'validateur' || role === 'directeur de campus') {
// CAS 2: Validateur/Directeur - Collaborateurs directs via hiérarchie
console.log("CAS 2: Validateur/Directeur - Collaborateurs directs via hiérarchie");
const membersRequest = pool.request();
membersRequest.input('managerId', managerId);
const membersResult = await membersRequest.query(`
SELECT DISTINCT
c.id,
c.nom,
c.prenom,
c.email,
c.role,
s.Nom as service_name,
c.CampusId,
camp.Nom as campus_name
FROM CollaborateurAD c
JOIN HierarchieValidationAD hv ON c.id = hv.CollaborateurId
JOIN Services s ON c.ServiceId = s.Id
LEFT JOIN Campus camp ON c.CampusId = camp.Id
WHERE hv.SuperieurId = @managerId
AND (c.actif = 1 OR c.actif IS NULL)
ORDER BY c.prenom, c.nom
`);
members = membersResult.recordset;
console.log(` â
${members.length} collaborateur(s) sous ${managerId}`);
} else if (role === 'collaborateur' || role === 'apprenti') {
// CAS 3: Collaborateur/Apprenti - CollĂšgues du mĂȘme service ET campus
console.log("CAS 3: Collaborateur/Apprenti - CollĂšgues du mĂȘme service et campus");
const membersRequest = pool.request();
membersRequest.input('managerId', managerId);
membersRequest.input('serviceId', serviceId);
membersRequest.input('campusId', campusId);
const membersResult = await membersRequest.query(`
SELECT
c.id,
c.nom,
c.prenom,
c.email,
c.role,
s.Nom as service_name,
c.CampusId,
camp.Nom as campus_name
FROM CollaborateurAD c
JOIN Services s ON c.ServiceId = s.Id
LEFT JOIN Campus camp ON c.CampusId = camp.Id
WHERE c.ServiceId = @serviceId
AND c.CampusId = @campusId
AND c.id != @managerId
AND (c.actif = 1 OR c.actif IS NULL)
ORDER BY c.prenom, c.nom
`);
members = membersResult.recordset;
console.log(` â
${members.length} collÚgue(s) trouvé(s)`);
} else {
return res.json({ success: false, message: 'RÎle non autorisé' });
}
res.json({
success: true,
team_members: members || [],
service_id: serviceId,
campus_id: campusId
});
} catch (error) {
console.error('â Erreur getTeamMembers:', error);
res.status(500).json({
success: false,
message: 'Erreur',
error: error.message
});
}
});
app.get('/api/getNotifications', async (req, res) => {
try {
const userIdParam = req.query.user_id;
if (!userIdParam) {
return res.json({ success: false, message: 'ID utilisateur manquant' });
}
const conn = await pool.getConnection();
// â
Déterminer si c'est un UUID ou un ID numérique
const isUUID = userIdParam.length > 10 && userIdParam.includes('-');
// â
Récupérer l'ID numérique si on a un UUID
let userId = userIdParam;
if (isUUID) {
const [userRows] = await conn.query(
'SELECT id, CampusId FROM CollaborateurAD WHERE entraUserId = ? AND (Actif = 1 OR Actif IS NULL)',
[userIdParam]
);
if (userRows.length === 0) {
conn.release();
return res.json({
success: false,
message: 'Utilisateur non trouvé ou compte désactivé'
});
}
userId = userRows[0].id;
} else {
userId = parseInt(userIdParam);
}
// â
Utiliser l'ID numĂ©rique pour la requĂȘte
const [notifications] = await conn.query(`
SELECT * FROM Notifications
WHERE CollaborateurADId = ?
ORDER BY DateCreation DESC
LIMIT 50
`, [userId]);
conn.release();
res.json({
success: true,
notifications: notifications || []
});
} catch (error) {
console.error('Erreur getNotifications:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
app.post('/api/markNotificationRead', async (req, res) => {
try {
const { notificationId } = req.body;
if (!notificationId || notificationId <= 0) return res.status(400).json({ success: false, message: 'ID notification invalide' });
await pool.query('UPDATE Notifications SET lu = 1 WHERE Id = ?', [notificationId]);
res.json({ success: true, message: 'Notification marquée comme lue' });
} catch (error) {
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
}
});
// Ă ajouter avant app.listen()
/**
* POST /saisirRecupJour
* Saisir une journée de récupération (samedi travaillé)
*/
app.post('/api/saisirRecupJour', async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const {
user_id,
date, // Date du samedi travaillé
nombre_heures = 1, // Par défaut 1 jour = 1 samedi
commentaire
} = req.body;
console.log('\nđ === SAISIE RĂCUP ===');
console.log('User ID:', user_id);
console.log('Date:', date);
console.log('Heures:', nombre_heures);
if (!user_id || !date) {
await conn.rollback();
conn.release();
return res.json({
success: false,
message: 'Données manquantes'
});
}
// Vérifier que c'est bien un samedi
const dateObj = new Date(date);
const dayOfWeek = dateObj.getDay();
if (dayOfWeek !== 6) {
await conn.rollback();
conn.release();
return res.json({
success: false,
message: 'La rĂ©cupĂ©ration ne peut ĂȘtre saisie que pour un samedi'
});
}
// Vérifier que ce samedi n'a pas déjà été saisi
const [existing] = await conn.query(`
SELECT dc.Id
FROM DemandeConge dc
JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.CollaborateurADId = ?
AND dc.DateDebut = ?
AND tc.Nom = 'Récupération'
`, [user_id, date]);
if (existing.length > 0) {
await conn.rollback();
conn.release();
return res.json({
success: false,
message: 'Ce samedi a déjà été déclaré'
});
}
// Récupérer infos utilisateur
const [userInfo] = await conn.query(
'SELECT prenom, nom, email, CampusId FROM CollaborateurAD WHERE id = ?',
[user_id]
);
if (userInfo.length === 0) {
await conn.rollback();
conn.release();
return res.json({
success: false,
message: 'Utilisateur non trouvé'
});
}
const user = userInfo[0];
const userName = `${user.prenom} ${user.nom}`;
const dateFormatted = dateObj.toLocaleDateString('fr-FR');
// Récupérer le type Récupération
const [recupType] = await conn.query(
'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1',
['Récupération']
);
if (recupType.length === 0) {
await conn.rollback();
conn.release();
return res.json({
success: false,
message: 'Type Récupération non trouvé'
});
}
const recupTypeId = recupType[0].Id;
const currentYear = dateObj.getFullYear();
// CRĂER LA DEMANDE (validĂ©e automatiquement)
const [result] = await conn.query(`
INSERT INTO DemandeConge
(CollaborateurADId, DateDebut, DateFin, TypeCongeId,
Statut, DateDemande, Commentaire, NombreJours)
VALUES (?, ?, ?, ?, 'Validée', GETDATE(), ?, ?)
`, [user_id, date, date, recupTypeId, commentaire || `Samedi travaillé - ${dateFormatted}`, nombre_heures]);
const demandeId = result.insertId;
// SAUVEGARDER DANS DemandeCongeType
await conn.query(`
INSERT INTO DemandeCongeType
(DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee, DateDebut, DateFin)
VALUES (?, ?, ?, 'Journée entiÚre',?, ?)
`, [demandeId, recupTypeId, nombre_heures]);
// ACCUMULER DANS LE COMPTEUR
const [compteur] = await conn.query(`
SELECT Id, Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [user_id, recupTypeId, currentYear]);
if (compteur.length > 0) {
await conn.query(`
UPDATE CompteurConges
SET Total = Total + ?,
Solde = Solde + ?,
DerniereMiseAJour = GETDATE()
WHERE Id = ?
`, [nombre_heures, nombre_heures, compteur[0].Id]);
console.log(`â
Compteur mis Ă jour: ${parseFloat(compteur[0].Solde) + nombre_heures}j`);
} else {
await conn.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, 0, GETDATE())
`, [user_id, recupTypeId, currentYear, nombre_heures, nombre_heures]);
console.log(`â
Compteur créé: ${nombre_heures}j`);
}
// ENREGISTRER L'ACCUMULATION
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Accum Récup', ?)
`, [demandeId, recupTypeId, currentYear, nombre_heures]);
// CRĂER NOTIFICATION
await conn.query(`
INSERT INTO Notifications
(CollaborateurADId, Type, Titre, Message, DemandeCongeId, DateCreation, Lu)
VALUES (?, 'Success', 'â
Récupération enregistrée', ?, ?, GETDATE(), 0)
`, [
user_id,
`Samedi ${dateFormatted} enregistré : +${nombre_heures}j de récupération`,
demandeId
]);
await conn.commit();
conn.release();
res.json({
success: true,
message: `Samedi ${dateFormatted} enregistré`,
jours_ajoutes: nombre_heures,
demande_id: demandeId
});
} catch (error) {
await conn.rollback();
if (conn) conn.release();
console.error('â Erreur saisie rĂ©cup:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
/**
* GET /getMesSamedis
* Récupérer les samedis déjà déclarés
*/
app.get('/api/getMesSamedis', async (req, res) => {
try {
const { user_id, annee } = req.query;
const conn = await pool.getConnection();
const [samedis] = await conn.query(`
SELECT
dc.Id,
DATE_FORMAT(dc.DateDebut, '%Y-%m-%d') as date,
dc.NombreJours as jours,
dc.Commentaire as commentaire,
DATE_FORMAT(dc.DateDemande, '%d/%m/%Y Ă %H:%i') as date_saisie
FROM DemandeConge dc
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
WHERE dc.CollaborateurADId = ?
AND tc.Nom = 'Récupération'
AND YEAR(dc.DateDebut) = ?
ORDER BY dc.DateDebut DESC
`, [user_id, annee]);
conn.release();
res.json({
success: true,
samedis: samedis
});
} catch (error) {
console.error('Erreur getMesSamedis:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
/**
* Déduit les jours d'un compteur avec gestion de l'anticipation
* Ordre de dĂ©duction : N-1 â N â N Anticip
*/
// ========================================
// đ° FONCTION DE DĂDUCTION AVEC ANTICIPATION (SANS ID MANUEL)
// ========================================
// ============================================================================
// đ FONCTION DE DĂDUCTION AVEC ORDRE CORRIGĂ : N-1 â N â ANTICIPĂ
// ============================================================================
// ============================================================================
// đ FONCTION DE DĂDUCTION CORRIGĂE POUR SQL SERVER
// Ordre : N-1 â N â ANTICIPĂ
// ============================================================================
// ============================================================================
// đ FONCTION DE DĂDUCTION CORRIGĂE POUR SQL SERVER (avec syntaxe ?)
// Ordre : N-1 â N â ANTICIPĂ
// ============================================================================
// FONCTION CORRIGĂE : deductLeaveBalanceWithAnticipation
// Ordre de dĂ©duction : N-1 â N â N AnticipĂ©
// FONCTION CORRIGĂE pour SQL Server
// FONCTION CORRIGĂE pour votre wrapper MySQL
// FONCTION CORRIGĂE POUR SQL SERVER - BASĂE SUR VOTRE ANCIEN CODE MYSQL
// â
VERSION SQL SERVER - CONVERSION DE VOTRE VERSION MYSQL QUI FONCTIONNE
async function deductLeaveBalanceWithAnticipation(conn, collaborateurId, typeCongeId, nombreJours, demandeCongeId, dateDebut) {
const today = new Date();
const currentYear = today.getFullYear();
const previousYear = currentYear - 1;
let joursRestants = nombreJours;
const deductions = [];
console.log("đ DĂDUCTION AVEC PRIORITĂ N-1");
console.log(`Jours à déduire: ${nombreJours}j`);
// ========================================
// đ VĂ©rifier si c'est un CP (pour gĂ©rer N-1)
// ========================================
const [typeInfo] = await conn.query(
`SELECT Nom FROM TypeConge WHERE Id = ?`,
[typeCongeId]
);
const isCP = typeInfo.length > 0 && typeInfo[0].Nom === 'Congé payé';
// ========================================
// 1ïžâŁ DĂ©duire du REPORT N-1 (CP uniquement)
// ========================================
if (isCP) {
const [compteurN1] = await conn.query(
`SELECT Id, Solde, SoldeReporte FROM CompteurConges
WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?`,
[collaborateurId, typeCongeId, previousYear]
);
if (compteurN1.length > 0) {
const soldeN1 = parseFloat(compteurN1[0].Solde || 0);
const aDeduireN1 = Math.min(soldeN1, joursRestants);
if (aDeduireN1 > 0) {
await conn.query(
`UPDATE CompteurConges
SET Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END,
SoldeReporte = CASE WHEN SoldeReporte - ? < 0 THEN 0 ELSE SoldeReporte - ? END,
DerniereMiseAJour = GETDATE()
WHERE Id=?`,
[aDeduireN1, aDeduireN1, aDeduireN1, aDeduireN1, compteurN1[0].Id]
);
await conn.query(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Année N-1', ?)`,
[demandeCongeId, typeCongeId, previousYear, aDeduireN1]
);
deductions.push({
annee: previousYear,
type: "Report N-1",
joursUtilises: aDeduireN1
});
joursRestants -= aDeduireN1;
console.log(`â
Report N-1: ${aDeduireN1.toFixed(2)}j déduits (reste ${joursRestants.toFixed(2)}j)`);
} else {
console.log(`â ïž Aucun solde N-1 disponible`);
}
} else {
console.log(`â ïž Pas de compteur N-1 trouvĂ©`);
}
}
// ========================================
// 2ïžâŁ DĂ©duire du SOLDE N (acquis actuel)
// ========================================
if (joursRestants > 0) {
const [compteurN] = await conn.query(
`SELECT Id, Solde, SoldeReporte FROM CompteurConges
WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?`,
[collaborateurId, typeCongeId, currentYear]
);
if (compteurN.length > 0) {
const soldeTotal = parseFloat(compteurN[0].Solde || 0);
const soldeReporte = parseFloat(compteurN[0].SoldeReporte || 0);
// â CORRECTION : Le solde N est le solde total MOINS le report
// Car le Solde contient déjà le SoldeReporte
const soldeNAcquis = isCP ? Math.max(0, soldeTotal - soldeReporte) : soldeTotal;
const aDeduireN = Math.min(soldeNAcquis, joursRestants);
if (aDeduireN > 0) {
await conn.query(
`UPDATE CompteurConges
SET Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END,
DerniereMiseAJour = GETDATE()
WHERE Id=?`,
[aDeduireN, aDeduireN, compteurN[0].Id]
);
await conn.query(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Année N', ?)`,
[demandeCongeId, typeCongeId, currentYear, aDeduireN]
);
deductions.push({
annee: currentYear,
type: "Année N",
joursUtilises: aDeduireN
});
joursRestants -= aDeduireN;
console.log(`â
Année N: ${aDeduireN.toFixed(2)}j déduits (reste ${joursRestants.toFixed(2)}j)`);
}
}
}
if (joursRestants > 0) {
console.log(`â ERREUR: ${joursRestants.toFixed(2)}j non couverts`);
}
console.log(`â
Déduction terminée - Total déduit: ${(nombreJours - joursRestants).toFixed(2)}j`);
return {
success: joursRestants === 0,
joursDeduitsTotal: nombreJours - joursRestants,
joursNonDeduits: joursRestants,
details: deductions
};
}
/**
* Calcule l'acquisition CP projetée à une date donnée
* RÚgle : 2.5j/mois travaillé depuis la date d'entrée (ou juin N-1 si déjà là )
* Plafond exercice : 25j (juin N â mai N+1)
*/
function calculerAcquisitionCPSmart(dateReference, dateEntree) {
if (!dateEntree) return 0;
const ref = new Date(dateReference);
const entree = new Date(dateEntree);
// Début de l'exercice en cours (1er juin de l'année N-1 ou N selon mois)
const moisRef = ref.getMonth() + 1; // 1-12
const anneeRef = ref.getFullYear();
const debutExercice = moisRef >= 6
? new Date(anneeRef, 5, 1) // 1er juin anneeRef
: new Date(anneeRef - 1, 5, 1); // 1er juin anneeRef-1
// Le décompte commence au plus tard entre entrée et début exercice
const dateDepart = entree > debutExercice ? entree : debutExercice;
if (ref <= dateDepart) return 0;
// Nombre de mois complets travaillés entre dateDepart et dateReference
let mois = 0;
const d = new Date(dateDepart);
while (true) {
const next = new Date(d.getFullYear(), d.getMonth() + 1, d.getDate());
if (next > ref) break;
mois++;
d.setMonth(d.getMonth() + 1);
}
// 2.5j par mois, plafonné à 25j
return Math.min(25, mois * 2.5);
}
/**
* Vérifie si le collaborateur a assez de CP pour la demande.
* Respecte les rĂšgles N-1 prioritaire (NORMAL) / N seulement (ANTICIPATION).
*/
// â
checkLeaveBalanceCP CORRIGĂE
// ==========================================
// FONCTION CORRIGĂE - deductLeaveBalanceCP
// ==========================================
async function deductLeaveBalanceCP(conn, collaborateurId, cpTypeId, nombreJours, demandeId, dateDebut) {
const today = new Date();
const currentYear = today.getFullYear();
const dateBascule = new Date(currentYear, 5, 1); // 01/06/currentYear
dateBascule.setHours(0, 0, 0, 0);
const apresBasculement = today >= dateBascule;
// Exercice en cours (Annee = année de FIN)
const anneeExCourant = apresBasculement ? currentYear + 1 : currentYear;
const anneeExPrecedent = anneeExCourant - 1;
// Exercice CP du congé demandé
const dateDebutConge = new Date(dateDebut);
const moisConge = dateDebutConge.getMonth() + 1;
const anneeConge = dateDebutConge.getFullYear();
const anneeExConge = moisConge >= 6 ? anneeConge + 1 : anneeConge;
// Cas anticipé : le congé est sur l'exercice SUIVANT
const isAnticipe = anneeExConge > anneeExCourant;
console.log(` CP â exerciceCourant=${anneeExCourant} | exerciceConge=${anneeExConge} | anticipĂ©=${isAnticipe}`);
let joursRestants = nombreJours;
const deductions = [];
if (!isAnticipe) {
// ââ CAS NORMAL ââââââââââââââââââââââââââââââââââââââââââââââââââ
// Priorité 1 : CP N-1
const [compteurN1] = await conn.query(
`SELECT Id, Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, cpTypeId, anneeExPrecedent]
);
if (compteurN1.length > 0) {
const soldeN1 = Math.max(0, parseFloat(compteurN1[0].Solde) || 0);
console.log(` đ° CP N-1 (Annee=${anneeExPrecedent}): ${soldeN1.toFixed(2)}j`);
const aDeduireN1 = Math.min(soldeN1, joursRestants);
if (aDeduireN1 > 0) {
await conn.query(
`UPDATE CompteurConges
SET Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END,
DerniereMiseAJour = GETDATE()
WHERE Id = ?`,
[aDeduireN1, aDeduireN1, compteurN1[0].Id]
);
await conn.query(
`INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Report N-1', ?)`,
[demandeId, cpTypeId, anneeExPrecedent, aDeduireN1]
);
deductions.push({ annee: anneeExPrecedent, type: 'Report N-1', joursUtilises: aDeduireN1 });
joursRestants -= aDeduireN1;
console.log(` â
CP N-1 déduit: ${aDeduireN1}j (reste ${joursRestants}j)`);
}
} else {
console.log(` âčïž Pas de compteur CP N-1 (Annee=${anneeExPrecedent})`);
}
// Priorité 2 : CP N courant
if (joursRestants > 0) {
const [compteurN] = await conn.query(
`SELECT Id, Solde, SoldeReporte FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, cpTypeId, anneeExCourant]
);
if (compteurN.length > 0) {
const soldeTotal = parseFloat(compteurN[0].Solde) || 0;
const soldeReporte = parseFloat(compteurN[0].SoldeReporte) || 0;
const soldeN = Math.max(0, soldeTotal - soldeReporte);
console.log(` đ° CP N (Annee=${anneeExCourant}): ${soldeTotal}j - ${soldeReporte}j reportĂ© = ${soldeN.toFixed(2)}j`);
const aDeduireN = Math.min(soldeN, joursRestants);
if (aDeduireN > 0) {
await conn.query(
`UPDATE CompteurConges
SET Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END,
DerniereMiseAJour = GETDATE()
WHERE Id = ?`,
[aDeduireN, aDeduireN, compteurN[0].Id]
);
await conn.query(
`INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Année N', ?)`,
[demandeId, cpTypeId, anneeExCourant, aDeduireN]
);
deductions.push({ annee: anneeExCourant, type: 'Année N', joursUtilises: aDeduireN });
joursRestants -= aDeduireN;
console.log(` â
CP N déduit: ${aDeduireN}j (reste ${joursRestants}j)`);
}
} else {
console.log(` â ïž Pas de compteur CP N (Annee=${anneeExCourant})`);
}
}
return {
success: joursRestants === 0,
mode: 'NORMAL',
joursDeduitsTotal: nombreJours - joursRestants,
joursNonDeduits: joursRestants,
details: deductions
};
} else {
// ââ CAS ANTICIPĂ âââââââââââââââââââââââââââââââââââââââââââââââââ
const TAUX_CP = 25 / 360;
const anneeN_anticipe = anneeExCourant;
// CORRECTION 2+3 : Lire Solde, SoldeReporte ET Total dans la mĂȘme requĂȘte
const [cNRows] = await conn.query(
`SELECT Id, Solde, COALESCE(SoldeReporte, 0) AS SoldeReporte, COALESCE(Total, 0) AS Total
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, cpTypeId, anneeN_anticipe]
);
// CORRECTION 2 : Solde N = Solde brut - SoldeReporte
const soldeNBrut = cNRows.length > 0 ? Math.max(0, parseFloat(cNRows[0].Solde || 0)) : 0;
const soldeNReporte = cNRows.length > 0 ? parseFloat(cNRows[0].SoldeReporte || 0) : 0;
const soldeNActuel = Math.max(0, soldeNBrut - soldeNReporte);
// CORRECTION 3 : Plafond calculé avec Total acquis (pas Solde)
const totalDejaAcquisN = cNRows.length > 0 ? parseFloat(cNRows[0].Total || 0) : 0;
// Projection jusqu'au 31/05/anneeN_anticipe
// Projection jusqu'Ă la DATE DU CONGĂ (pas la fin d'exercice)
const dateDebutCongeObj = new Date(dateDebut);
dateDebutCongeObj.setHours(0, 0, 0, 0);
const joursJusquAuConge = Math.max(0, Math.floor((dateDebutCongeObj - today) / (1000 * 60 * 60 * 24)));
const plafondRestantN = Math.max(0, 25 - totalDejaAcquisN);
const acquisJusquAuConge = parseFloat(Math.min(plafondRestantN, joursJusquAuConge * TAUX_CP).toFixed(2));
const soldeNProj = parseFloat(Math.min(25, soldeNActuel + acquisJusquAuConge).toFixed(2));
console.log(` đ Projection CP N jusqu'au ${dateDebut}: soldeActuel=${soldeNActuel}j, plafond=${plafondRestantN}j, jours d'ici le congĂ©=${joursJusquAuConge}j, acquis d'ici=${acquisJusquAuConge}j, solde projetĂ©=${soldeNProj}j`);
// Déjà consommé sur Annee=anneeN_anticipe (Anticip + AnticipFutur des demandes précédentes)
const dejaConsoNRows = await conn.query(
`SELECT COALESCE(SUM(dd.JoursUtilises), 0) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction IN ('Anticip', 'AnticipFutur')
AND dc.Statut NOT IN ('Refusé', 'Refusée', 'Annulé', 'Annulée')
AND dc.DateDebut <= ?`,
[collaborateurId, cpTypeId, anneeN_anticipe, dateDebut]
);
const dejaConsoN = parseFloat(dejaConsoNRows[0]?.total || 0); // â CETTE LIGNE
const dispoN = parseFloat(Math.max(0, soldeNProj - dejaConsoN).toFixed(2));
console.log(` đ° CP N (${anneeN_anticipe}): actuel=${soldeNActuel.toFixed(2)}j + acquis d'ici=${acquisJusquAuConge.toFixed(2)}j = projetĂ© ${soldeNProj.toFixed(2)}j - consommĂ© anticipĂ© ${dejaConsoN.toFixed(2)}j = dispo ${dispoN.toFixed(2)}j`);
// CP N+1 prorata depuis 01/06/anneeN_anticipe jusqu'Ă dateDebutConge
const debutNp1 = new Date(anneeN_anticipe, 5, 1); // 01/06/anneeN_anticipe
debutNp1.setHours(0, 0, 0, 0);
const diffJoursNp1 = Math.max(0, Math.floor((dateDebutConge - debutNp1) / (1000 * 60 * 60 * 24)));
const acquisNp1 = parseFloat(Math.min(25, diffJoursNp1 * TAUX_CP).toFixed(2));
const dejaConsoNp1Rows = await conn.query(
`SELECT COALESCE(SUM(dd.JoursUtilises), 0) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dc.Statut NOT IN ('Refusé', 'Refusée', 'Annulé', 'Annulée')
AND dc.DateDebut <= ?`,
[collaborateurId, cpTypeId, anneeExConge, dateDebut]
);
const dejaConsoNp1 = parseFloat(dejaConsoNp1Rows[0]?.total || 0);
const dispoNp1 = parseFloat(Math.max(0, acquisNp1 - dejaConsoNp1).toFixed(2));
console.log(` đ° CP N+1 (${anneeExConge}): ${diffJoursNp1}j Ă ${TAUX_CP.toFixed(4)} = ${acquisNp1}j - consommĂ© ${dejaConsoNp1}j = dispo ${dispoNp1}j`);
const totalDispo = parseFloat((dispoN + dispoNp1).toFixed(2));
console.log(` đ Total anticipĂ©: N=${dispoN.toFixed(2)}j + N+1=${dispoNp1.toFixed(2)}j = ${totalDispo.toFixed(2)}j | demandĂ©s=${nombreJours}j`);
if (totalDispo < nombreJours) {
console.warn(` â AnticipĂ© bloquĂ© â ${totalDispo.toFixed(2)}j dispo < ${nombreJours}j demandĂ©s`);
return {
success: false,
mode: 'ANTICIPĂ',
joursDeduitsTotal: 0,
joursNonDeduits: nombreJours,
soldeNProj, dejaConsoN, dispoN, acquisNp1, dejaConsoNp1, dispoNp1, totalDispo,
details: [],
message: `Solde CP anticipé insuffisant : ${totalDispo.toFixed(2)}j disponibles (N: ${dispoN.toFixed(2)}j + N+1: ${dispoNp1.toFixed(2)}j), ${nombreJours}j demandés`
};
}
// ââ DĂ©duction ââââââââââââââââââââââââââââââââââââââââââââââââââââ
let joursRestants = nombreJours;
// 1ïžâŁ DĂ©duire du solde rĂ©el actuel (ce qui est vraiment en base)
if (joursRestants > 0 && soldeNActuel > 0 && cNRows.length > 0) {
const aDeduire = parseFloat(Math.min(soldeNActuel, joursRestants).toFixed(2));
await conn.query(
`UPDATE CompteurConges SET Solde = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`,
[aDeduire, cNRows[0].Id]
);
await conn.query(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Anticip', ?)`,
[demandeId, cpTypeId, anneeN_anticipe, aDeduire]
);
deductions.push({ annee: anneeN_anticipe, type: 'Anticip', joursUtilises: aDeduire });
joursRestants = parseFloat((joursRestants - aDeduire).toFixed(2));
console.log(` â
CP N (${anneeN_anticipe}) Anticip: ${aDeduire.toFixed(2)}j sur solde réel (reste futur: ${joursRestants.toFixed(2)}j)`);
}
// 2ïžâŁ Jours restants dans CP N (acquis futurs jusqu'au 31/05) â AnticipFutur
if (joursRestants > 0 && dispoN > 0) {
const aTracerN = parseFloat(Math.min(dispoN, joursRestants).toFixed(2));
await conn.query(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'AnticipFutur', ?)`,
[demandeId, cpTypeId, anneeN_anticipe, aTracerN]
);
deductions.push({ annee: anneeN_anticipe, type: 'AnticipFutur', joursUtilises: aTracerN });
joursRestants = parseFloat((joursRestants - aTracerN).toFixed(2));
console.log(` â
CP N (${anneeN_anticipe}) AnticipFutur: ${aTracerN.toFixed(2)}j tracĂ© â reste: ${joursRestants.toFixed(2)}j`);
}
// 3ïžâŁ Jours restants = prorata CP N+1 â AnticipNp1
if (joursRestants > 0 && dispoNp1 > 0) {
const aTracerNp1 = parseFloat(Math.min(dispoNp1, joursRestants).toFixed(2));
const [cNp1Rows] = await conn.query(
`SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, cpTypeId, anneeExConge]
);
if (cNp1Rows.length === 0) {
const [newIdRows] = await conn.query(`SELECT ISNULL(MAX(Id), 0) + 1 AS nextId FROM CompteurConges`);
const newId = (newIdRows[0]?.nextId) || 1;
await conn.query(
`INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, 0, ?, 0, GETDATE())`,
[newId, collaborateurId, cpTypeId, anneeExConge, -aTracerNp1]
);
console.log(` â CompteurConges Annee=${anneeExConge} créé Solde=${-aTracerNp1}j`);
} else {
await conn.query(
`UPDATE CompteurConges SET Solde = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`,
[aTracerNp1, cNp1Rows[0].Id]
);
console.log(` âïž CompteurConges Annee=${anneeExConge} Solde - ${aTracerNp1}j`);
}
await conn.query(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'AnticipNp1', ?)`,
[demandeId, cpTypeId, anneeExConge, aTracerNp1]
);
deductions.push({ annee: anneeExConge, type: 'AnticipNp1', joursUtilises: aTracerNp1 });
joursRestants = parseFloat((joursRestants - aTracerNp1).toFixed(2));
console.log(` â
CP N+1 (${anneeExConge}) AnticipNp1: ${aTracerNp1.toFixed(2)}j tracé`);
}
return {
success: true, mode: 'ANTICIPĂ',
joursDeduitsTotal: nombreJours, joursNonDeduits: 0,
soldeNProj, dispoN, details: deductions
};
}
}
// ==========================================
// FONCTION CORRIGĂE - checkLeaveBalanceCP
// ==========================================
async function checkLeaveBalanceCP(conn, collaborateurId, joursNecessaires, dateDebut) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const currentYear = today.getFullYear();
const userIdInt = parseInt(collaborateurId, 10);
const dateDebutObj = new Date(dateDebut);
dateDebutObj.setHours(0, 0, 0, 0);
const TAUX_CP = 25 / 360;
const dateBascule = new Date(currentYear, 5, 1);
dateBascule.setHours(0, 0, 0, 0);
const apresBasculement = today >= dateBascule;
const anneeExCourant = apresBasculement ? currentYear + 1 : currentYear;
const anneeExPrecedent = anneeExCourant - 1;
const finExerciceCourant = new Date(anneeExCourant, 4, 31);
finExerciceCourant.setHours(23, 59, 59, 0);
const isAnticipe = dateDebutObj > finExerciceCourant;
const moisDebut = dateDebutObj.getMonth() + 1;
const anneeDebut = dateDebutObj.getFullYear();
const anneeExConge = moisDebut >= 6 ? anneeDebut + 1 : anneeDebut;
console.log(`\nđ CHECK CP BALANCE`);
console.log(` Collaborateur: ${userIdInt}`);
console.log(` Jours nécessaires: ${joursNecessaires}j`);
console.log(` Exercice courant: ${anneeExCourant} | Fin exercice: ${finExerciceCourant.toLocaleDateString('fr-FR')}`);
console.log(` Date congĂ©: ${dateDebut} | Mode: ${isAnticipe ? 'ANTICIPĂ đź' : 'NORMAL'}`);
const [cpTypeRows] = await conn.query(
`SELECT TOP 1 Id FROM TypeConge WHERE Nom = ?`,
['Congé payé']
);
if (cpTypeRows.length === 0) throw new Error('Type CP introuvable');
const cpTypeId = cpTypeRows[0].Id;
// ââ Lire solde ET total acquis âââââââââââââââââââââââââââââââââââââââââ
const [cNRows] = await conn.query(
`SELECT TOP 1 COALESCE(Solde, 0) AS Solde, COALESCE(SoldeReporte, 0) AS SoldeReporte,
COALESCE(Total, 0) AS Total
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
ORDER BY Id DESC`,
[userIdInt, cpTypeId, anneeExCourant]
);
const soldeNActuel = cNRows.length > 0
? Math.max(0, parseFloat(cNRows[0].Solde) - parseFloat(cNRows[0].SoldeReporte))
: 0;
const totalDejaAcquis = cNRows.length > 0 ? parseFloat(cNRows[0].Total) : 0;
// ââ Projection CP N jusqu'Ă la date du congĂ© ââââââââââââââââââââââââââ
const joursJusquAuDebut = Math.max(0, Math.floor((dateDebutObj - today) / (1000 * 60 * 60 * 24)));
const acquisDiciDebut = parseFloat(
Math.min(25 - totalDejaAcquis, joursJusquAuDebut * TAUX_CP).toFixed(2)
);
const soldeNProjecte = parseFloat((soldeNActuel + acquisDiciDebut).toFixed(2));
// ââ DĂ©jĂ consommĂ© Anticip+AnticipFutur sur anneeExCourant âââââââââââââ
const dejaConsoNRows = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction IN ('Anticip', 'AnticipFutur')
AND dc.Statut != 'Refusé'
AND dc.DateDebut <= ? -- â
AJOUT
`, [userIdInt, cpTypeId, anneeExCourant, dateDebut]);
const dejaConsoN = parseFloat(dejaConsoNRows[0]?.total || 0);
const soldeNNet = parseFloat(Math.max(0, soldeNProjecte - dejaConsoN).toFixed(2));
console.log(` CP N (${anneeExCourant}): actuel=${soldeNActuel}j + ${joursJusquAuDebut}j Ă ${TAUX_CP.toFixed(4)} = +${acquisDiciDebut}j â projetĂ© ${soldeNProjecte}j - consommĂ© ${dejaConsoN}j = net ${soldeNNet}j`);
if (isAnticipe) {
// ââ MODE ANTICIPĂ : CP N net + CP N+1 prorata jusqu'Ă dateDebutObj ââ
const debutNp1 = new Date(anneeExCourant, 5, 1);
debutNp1.setHours(0, 0, 0, 0);
const diffJoursNp1 = Math.max(0, Math.floor((dateDebutObj - debutNp1) / (1000 * 60 * 60 * 24)));
const acquisNp1 = parseFloat(Math.min(25, diffJoursNp1 * TAUX_CP).toFixed(2));
const dejaConsoNp1Rows = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dc.Statut != 'Refusé'
AND dc.DateDebut <= ? -- â
AJOUT
`, [userIdInt, cpTypeId, anneeExConge, dateDebut]);
const dejaConsoNp1 = parseFloat(dejaConsoNp1Rows[0]?.total || 0);
const dispoNp1 = parseFloat(Math.max(0, acquisNp1 - dejaConsoNp1).toFixed(2));
const soldeTotalDisponible = parseFloat((soldeNNet + dispoNp1).toFixed(2));
const suffisant = soldeTotalDisponible >= joursNecessaires;
const deficit = suffisant ? 0 : parseFloat((joursNecessaires - soldeTotalDisponible).toFixed(2));
console.log(` CP N+1 (${anneeExConge}): ${diffJoursNp1}j à ${TAUX_CP.toFixed(4)} = ${acquisNp1}j - consommé ${dejaConsoNp1}j = dispo ${dispoNp1}j`);
console.log(` đ Total anticipĂ©: ${soldeTotalDisponible}j | besoin=${joursNecessaires}j`);
console.log(` ${suffisant ? 'â
AUTORISĂ' : 'â BLOQUĂ'}`);
return {
valide: suffisant,
mode: 'ANTICIPATION',
isAnticipe: true,
anneeN: anneeExCourant,
anneeNp1: anneeExConge,
soldeNProjecte, dejaConsoN, soldeNNet,
acquisNp1, dejaConsoNp1, dispoNp1,
soldeTotalDisponible, deficit,
message: suffisant ? undefined
: `Solde CP insuffisant (â© AnticipĂ©) Maximum disponible : ${soldeTotalDisponible.toFixed(2)}j â DemandĂ© : ${joursNecessaires}j â DĂ©ficit : ${deficit.toFixed(2)}j`
};
} else {
// ââ MODE NORMAL : N-1 + N projetĂ© âââââââââââââââââââââââââââââââââ
const [cN1Rows] = await conn.query(
`SELECT TOP 1 COALESCE(Solde, 0) AS Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
ORDER BY Id DESC`,
[userIdInt, cpTypeId, anneeExPrecedent]
);
const soldeN1 = cN1Rows.length > 0 ? Math.max(0, parseFloat(cN1Rows[0].Solde)) : 0;
const soldeTotalDisponible = parseFloat((soldeN1 + soldeNNet).toFixed(2));
const suffisant = soldeTotalDisponible >= joursNecessaires;
const deficit = suffisant ? 0 : parseFloat((joursNecessaires - soldeTotalDisponible).toFixed(2));
console.log(` CP N-1 (${anneeExPrecedent}): ${soldeN1}j`);
console.log(` Total disponible: N-1=${soldeN1}j + N net=${soldeNNet}j = ${soldeTotalDisponible}j`);
console.log(` ${suffisant ? 'â
AUTORISĂ' : 'â BLOQUĂ'} â besoin=${joursNecessaires}j`);
return {
valide: suffisant,
mode: 'NORMAL',
isAnticipe: false,
soldeN1,
soldeNActuel,
soldeNProjecte,
soldeNNet,
soldeTotalDisponible,
deficit,
message: suffisant ? undefined
: `Solde CP insuffisant. Maximum disponible : ${soldeTotalDisponible.toFixed(2)}j â DemandĂ© : ${joursNecessaires}j â DĂ©ficit : ${deficit.toFixed(2)}j`
};
}
}
// ============================================================================
// đ FONCTION DE VĂRIFICATION DES SOLDES (sans anticipation)
// ============================================================================
async function checkLeaveBalanceWithAnticipation(conn, collaborateurId, repartition, dateDebut) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const currentYear = today.getFullYear();
const previousYear = currentYear - 1;
console.log('\nđ === CHECK SOLDES (ANTICIPATION DĂSACTIVĂE) ===');
const verification = [];
for (const rep of repartition) {
const typeCode = rep.TypeConge;
const joursNecessaires = parseFloat(rep.NombreJours || 0);
if (typeCode === 'ABS' || typeCode === 'Formation') {
continue;
}
const typeName = typeCode === 'CP' ? 'Congé payé' :
typeCode === 'RTT' ? 'RTT' :
typeCode === 'Récup' ? 'Récupération' : typeCode;
const [typeRow] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [typeName]);
if (typeRow.length === 0) continue;
const typeCongeId = typeRow[0].Id;
// ====================================
// CP - SANS ANTICIPATION
// ====================================
if (typeCode === 'CP') {
// Solde N-1 (reporté)
const [compteurN1] = await conn.query(`
SELECT Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, typeCongeId, previousYear]);
const soldeN1 = compteurN1.length > 0 ? parseFloat(compteurN1[0].Solde || 0) : 0;
// Solde N (actuel)
const [compteurN] = await conn.query(`
SELECT Solde, SoldeReporte FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, typeCongeId, currentYear]);
const soldeNTotal = compteurN.length > 0 ? parseFloat(compteurN[0].Solde || 0) : 0;
const soldeReporte = compteurN.length > 0 ? parseFloat(compteurN[0].SoldeReporte || 0) : 0;
const soldeN = Math.max(0, soldeNTotal - soldeReporte);
// â TOTAL SANS ANTICIPATION
const soldeTotal = soldeN1 + soldeN;
console.log(`đ° CP: N-1=${soldeN1.toFixed(2)}j + N=${soldeN.toFixed(2)}j = ${soldeTotal.toFixed(2)}j`);
const suffisant = soldeTotal >= joursNecessaires;
verification.push({
type: typeName,
joursNecessaires,
soldeN1,
soldeN,
soldeTotal,
anticipeDisponible: 0,
suffisant,
deficit: Math.max(0, joursNecessaires - soldeTotal)
});
if (!suffisant) {
return {
valide: false,
message: `Solde CP insuffisant : ${soldeTotal.toFixed(2)}j disponibles, ${joursNecessaires}j demandés`,
details: verification,
insuffisants: verification.filter(v => !v.suffisant)
};
}
}
// ====================================
// RTT - SANS ANTICIPATION
// ====================================
else if (typeCode === 'RTT') {
const [compteurN] = await conn.query(`
SELECT Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, typeCongeId, currentYear]);
const soldeN = compteurN.length > 0 ? parseFloat(compteurN[0].Solde || 0) : 0;
console.log(`đ° RTT: ${soldeN.toFixed(2)}j disponibles`);
const suffisant = soldeN >= joursNecessaires;
verification.push({
type: typeName,
joursNecessaires,
soldeN,
soldeTotal: soldeN,
anticipeDisponible: 0,
suffisant,
deficit: Math.max(0, joursNecessaires - soldeN)
});
if (!suffisant) {
return {
valide: false,
message: `Solde RTT insuffisant : ${soldeN.toFixed(2)}j disponibles, ${joursNecessaires}j demandés`,
details: verification,
insuffisants: verification.filter(v => !v.suffisant)
};
}
}
// ====================================
// RĂCUP
// ====================================
else if (typeCode === 'Récup') {
const [recupType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Récupération']);
if (recupType.length === 0) continue;
const [compteur] = await conn.query(`
SELECT Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, recupType[0].Id, currentYear]);
const soldeRecup = compteur.length > 0 ? parseFloat(compteur[0].Solde || 0) : 0;
const suffisant = soldeRecup >= joursNecessaires;
verification.push({
type: 'Récupération',
joursNecessaires,
soldeTotal: soldeRecup,
suffisant,
deficit: Math.max(0, joursNecessaires - soldeRecup)
});
if (!suffisant) {
return {
valide: false,
message: `Solde Récupération insuffisant : ${soldeRecup.toFixed(2)}j disponibles, ${joursNecessaires}j demandés`,
details: verification,
insuffisants: verification.filter(v => !v.suffisant)
};
}
}
}
return {
valide: true,
details: verification,
insuffisants: []
};
}
// ========================================
// đ§ FONCTION HELPER - GĂNĂRATION D'ID
// ========================================
// â
VERSION CORRIGĂE
// â
getNextId â NE PREND PLUS conn EN PARAMĂTRE
// â
getNextId â pool.request() direct, sans conn
async function getNextId(connection, tableName) {
const request = pool.request();
const result = await request.query(
`SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM [${tableName}]`
);
const id = result.recordset[0].NextId;
console.log(`đ getNextId(${tableName}) = ${id}`);
return id;
}
// ==========================================
// CODE CORRIGĂ - submitLeaveRequest
// ==========================================
// â
Helper à placer UNE FOIS en haut du fichier (aprÚs la définition du pool)
function extractValue(row, ...keys) {
for (const key of keys) {
const val = row[key] ?? row[key.toLowerCase()] ?? row[key.toUpperCase()];
if (val !== undefined && val !== null) {
if (typeof val === 'object') return Object.values(val)[0] ?? null;
return val;
}
}
for (const val of Object.values(row)) {
if (val !== null && val !== undefined && typeof val !== 'object') return val;
}
return null;
}
// â
Helper Ă ajouter UNE FOIS en haut du fichier
function getRows(result) {
// GĂšre : result, result.recordset, result[0], result.recordset[0]
if (!result) return [];
if (Array.isArray(result)) {
// Si c'est un tableau de tableaux [[{...}]]
if (result.length > 0 && Array.isArray(result[0])) return result[0];
return result;
}
if (result.recordset) {
const rs = result.recordset;
if (Array.isArray(rs) && rs.length > 0 && Array.isArray(rs[0])) return rs[0];
return rs;
}
return [];
}
// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
// POST /api/submitLeaveRequest
// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
app.post('/api/submitLeaveRequest', uploadMedical.array('medicalDocuments', 5), async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
console.log('đ Transaction dĂ©marrĂ©e');
const today = new Date();
const currentYear = today.getFullYear();
// ââ CP : basculement 1er juin ââââââââââââââââââââââââââââââââââ
const dateBasculeCP = new Date(`${currentYear}-06-01`);
const apresBasc = today >= dateBasculeCP;
const anneeN = apresBasc ? currentYear + 1 : currentYear;
const anneeN1 = apresBasc ? currentYear : currentYear - 1;
const uploadedFiles = req.files || [];
console.log('đ Fichiers mĂ©dicaux reçus:', uploadedFiles.length);
const DateDebut = req.body.DateDebut;
const DateFin = req.body.DateFin;
const NombreJours = parseFloat(req.body.NombreJours);
const Email = req.body.Email;
const Nom = req.body.Nom;
const Commentaire = req.body.Commentaire || '';
const statut = req.body.statut || null;
let Repartition;
try { Repartition = JSON.parse(req.body.Repartition); }
catch (e) { console.error('â Erreur parsing Repartition:', e); Repartition = []; }
// ââ Helpers âââââââââââââââââââââââââââââââââââââââââââââââââââ
const toTypeName = (code) => ({
'CP': 'Congé payé', 'RTT': 'RTT', 'ABS': 'Congé maladie',
'Formation': 'Formation', 'Récup': 'Récupération'
}[code] || code);
const getRows = (result) => {
if (!result) return [];
if (Array.isArray(result)) {
if (result.length > 0 && Array.isArray(result[0])) return result[0];
return result;
}
if (result.recordset) {
const rs = result.recordset;
if (Array.isArray(rs) && rs.length > 0 && Array.isArray(rs[0])) return rs[0];
return rs;
}
return [];
};
const readNum = (row, key) => {
if (!row) return null;
const v = row[key] ?? row[key.toLowerCase()] ?? row[key.toUpperCase()];
if (v === undefined || v === null) return null;
const n = Number(v);
return isNaN(n) ? null : n;
};
// ââ Validation PeriodeJournee âââââââââââââââââââââââââââââââââ
const validPeriodes = ['Journée entiÚre', 'Matin', 'AprÚs-midi'];
for (const rep of Repartition) {
const periodeDebut = rep.PeriodeJourneeDebut || 'Journée entiÚre';
const periodeFin = rep.PeriodeJourneeFin || 'Journée entiÚre';
if (!validPeriodes.includes(periodeDebut)) {
await conn.rollback(); conn.release();
return res.json({ success: false, message: `PeriodeJourneeDebut invalide: ${periodeDebut}` });
}
if (!validPeriodes.includes(periodeFin)) {
await conn.rollback(); conn.release();
return res.json({ success: false, message: `PeriodeJourneeFin invalide: ${periodeFin}` });
}
// VĂ©rification cohĂ©rence demi-journĂ©e (mĂȘme jour)
const isDemiJournee = periodeDebut === periodeFin && periodeDebut !== 'Journée entiÚre';
if (isDemiJournee && parseFloat(rep.NombreJours) !== 0.5) {
await conn.rollback(); conn.release();
return res.json({ success: false, message: `Demi-journée doit valoir 0.5j (reçu: ${rep.NombreJours}j)` });
}
}
if (!Repartition || Repartition.length === 0) {
await conn.rollback(); conn.release();
return res.json({ success: false, message: 'Répartition manquante' });
}
const totalJoursDemandes = Repartition.reduce((sum, r) => sum + parseFloat(r.NombreJours || 0), 0);
if (totalJoursDemandes > 25) {
await conn.rollback(); conn.release();
return res.json({
success: false,
message: `Vous ne pouvez pas poser plus de 25 jours ouvrés par demande (demandé : ${totalJoursDemandes}j)`
});
}
// ââ ĂTAPE 1 : Trouver le collaborateur ââââââââââââââââââââââââ
console.log('đ ĂTAPE 1 - Recherche collaborateur:', Email);
const collabADRaw = await conn.query(
`SELECT TOP 1 CAST(id AS INT) AS collab_id FROM CollaborateurAD WHERE email = ?`,
[Email]
);
const collabAD = getRows(collabADRaw);
const isAD = collabAD.length > 0;
const collaborateurId = isAD ? readNum(collabAD[0], 'collab_id') : null;
console.log('đ€ CollaborateurAD trouvĂ©:', isAD, '| ID:', collaborateurId);
if (!isAD || !collaborateurId) {
await conn.rollback(); conn.release();
return res.json({ success: false, message: `Collaborateur introuvable: ${Email}` });
}
// ââ Trouver le validateur âââââââââââââââââââââââââââââââââââââ
let validateurId = null;
let validateurADId = null;
const hierRaw = await conn.query(
`SELECT TOP 1 CAST(SuperieurId AS INT) AS sup_id
FROM HierarchieValidationAD WHERE CollaborateurId = ?`,
[collaborateurId]
);
const hier = getRows(hierRaw);
if (hier.length > 0) validateurADId = readNum(hier[0], 'sup_id');
console.log('đ ValidateurADId:', validateurADId);
const isFormationOnly = Repartition.every(r => r.TypeConge === 'Formation');
const statutDemande = isFormationOnly ? 'Validé' : (statut || 'En attente');
console.log('đ isFormationOnly:', isFormationOnly, '| Statut:', statutDemande);
// ââ TypeCongeId CSV âââââââââââââââââââââââââââââââââââââââââââ
const typeIds = [];
for (const rep of Repartition) {
const name = toTypeName(rep.TypeConge);
const tRaw = await conn.query(
`SELECT TOP 1 CAST(Id AS INT) AS type_id FROM TypeConge WHERE Nom = ?`, [name]
);
const tRow = getRows(tRaw);
if (tRow.length > 0) {
const tid = readNum(tRow[0], 'type_id');
if (tid && !typeIds.includes(tid)) typeIds.push(tid);
}
}
const typeCongeIdCsv = typeIds.join(',');
console.log('đ· TypeCongeIdCsv:', typeCongeIdCsv);
if (!typeCongeIdCsv) {
await conn.rollback(); conn.release();
return res.json({ success: false, message: 'Type de congé introuvable en base' });
}
// ââ ĂTAPE 2 : INSERT DemandeConge âââââââââââââââââââââââââââââ
console.log('đ ĂTAPE 2 - CrĂ©ation DemandeConge');
const nextDcRaw = await conn.query(
`SELECT CAST(ISNULL(MAX(Id), 0) + 1 AS INT) AS next_id FROM DemandeConge`
);
const demandeId = readNum(getRows(nextDcRaw)[0], 'next_id');
console.log('đ Prochain ID DemandeConge:', demandeId);
await conn.query(
`INSERT INTO DemandeConge
(Id, CollaborateurADId, DateDebut, DateFin, TypeCongeId, Statut, DateDemande,
Commentaire, Validateur, NombreJours, ValidateurId, ValidateurADId)
VALUES (?, ?, ?, ?, ?, ?, GETDATE(), ?, ?, ?, ?, ?)`,
[demandeId, collaborateurId, DateDebut, DateFin, typeCongeIdCsv,
statutDemande, Commentaire, '', NombreJours, validateurId, validateurADId]
);
console.log('â
Demande créée ID:', demandeId, '| Statut:', statutDemande);
// ââ ĂTAPE 3 : Fichiers mĂ©dicaux âââââââââââââââââââââââââââââââ
if (uploadedFiles.length > 0) {
console.log('đ ĂTAPE 3 - Sauvegarde fichiers mĂ©dicaux');
for (const file of uploadedFiles) {
const nextDocRaw = await conn.query(
`SELECT CAST(ISNULL(MAX(Id), 0) + 1 AS INT) AS next_id FROM DocumentsMedicaux`
);
const docId = readNum(getRows(nextDocRaw)[0], 'next_id');
await conn.query(
`INSERT INTO DocumentsMedicaux
(Id, DemandeCongeId, NomFichier, CheminFichier, TypeMime, TailleFichier, DateUpload)
VALUES (?, ?, ?, ?, ?, ?, GETDATE())`,
[docId, demandeId, file.originalname, file.path, file.mimetype, file.size]
);
console.log('đ Fichier sauvegardĂ©:', file.originalname);
}
}
// ââ ĂTAPE 4 : DemandeCongeType ââââââââââââââââââââââââââââââââ
// â
CORRECTION : on stocke PeriodeJournee (début) ET PeriodeJourneeFin séparément
// Prérequis SQL : ALTER TABLE DemandeCongeType ADD PeriodeJourneeFin NVARCHAR(50) NULL;
console.log('đ ĂTAPE 4 - Sauvegarde rĂ©partition');
for (const rep of Repartition) {
const name = toTypeName(rep.TypeConge);
const typeRowRaw = await conn.query(
`SELECT TOP 1 CAST(Id AS INT) AS type_id FROM TypeConge WHERE Nom = ?`, [name]
);
const typeRow = getRows(typeRowRaw);
if (typeRow.length > 0) {
const typeCongeId = readNum(typeRow[0], 'type_id');
const periodeDebut = rep.PeriodeJourneeDebut || 'Journée entiÚre';
const periodeFin = rep.PeriodeJourneeFin || 'Journée entiÚre';
const segmentDateDebut = rep.DateDebut
? new Date(rep.DateDebut).toISOString().split('T')[0]
: DateDebut;
const segmentDateFin = rep.DateFin
? new Date(rep.DateFin).toISOString().split('T')[0]
: DateFin;
const nextDctRaw = await conn.query(
`SELECT CAST(ISNULL(MAX(Id), 0) + 1 AS INT) AS next_id FROM DemandeCongeType`
);
const dctId = readNum(getRows(nextDctRaw)[0], 'next_id');
await conn.query(
`INSERT INTO DemandeCongeType
(Id, DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee, PeriodeJourneeFin, DateDebut, DateFin)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[dctId, demandeId, typeCongeId, rep.NombreJours, periodeDebut, periodeFin,
segmentDateDebut, segmentDateFin]
);
console.log(`â
${name} â ${rep.NombreJours}j | ${segmentDateDebut} â ${segmentDateFin} | dĂ©but:${periodeDebut} fin:${periodeFin}`);
} else {
console.warn(`â ïž TypeConge introuvable: ${name}`);
}
}
// ââ ĂTAPE 5 : DĂ©duction des compteurs âââââââââââââââââââââââââ
console.log('đ° ĂTAPE 5 - DĂ©duction compteurs');
if (isAD && collaborateurId && !isFormationOnly) {
for (const rep of Repartition) {
if (rep.TypeConge === 'ABS' || rep.TypeConge === 'Formation') {
console.log(`â Skip dĂ©duction: ${rep.TypeConge}`);
continue;
}
// ââ RĂ©cup âââââââââââââââââââââââââââââââââââââââââââââ
if (rep.TypeConge === 'Récup') {
const recupRaw = await conn.query(
`SELECT TOP 1 CAST(Id AS INT) AS type_id FROM TypeConge WHERE Nom = ?`,
['Récupération']
);
const recupRow = getRows(recupRaw);
if (!recupRow.length) throw new Error('Type Récupération introuvable');
const recupTypeId = readNum(recupRow[0], 'type_id');
await conn.query(
`UPDATE CompteurConges
SET Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END,
DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[rep.NombreJours, rep.NombreJours, collaborateurId, recupTypeId, currentYear]
);
await conn.query(
`INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Récup Posé', ?)`,
[demandeId, recupTypeId, currentYear, rep.NombreJours]
);
console.log(`â
Récup déduit: ${rep.NombreJours}j`);
continue;
}
// ââ CP ââââââââââââââââââââââââââââââââââââââââââââââââ
if (rep.TypeConge === 'CP') {
const cpRaw = await conn.query(
`SELECT TOP 1 CAST(Id AS INT) AS type_id FROM TypeConge WHERE Nom = ?`,
['Congé payé']
);
const cpRow = getRows(cpRaw);
if (!cpRow.length) throw new Error('Type Congé payé introuvable');
const cpTypeId = readNum(cpRow[0], 'type_id');
console.log('đ Appel deductLeaveBalanceCP...');
const result = await deductLeaveBalanceCP(
conn, collaborateurId, cpTypeId,
parseFloat(rep.NombreJours), demandeId, DateDebut
);
console.log('đ RĂ©sultat dĂ©duction CP:', result);
if (!result.success) {
if (result.mode === 'ANTICIPĂ POST-BASCULEMENT') {
console.warn(`â ïž AnticipĂ© post-basculement â ${result.joursNonDeduits.toFixed(2)}j non couverts`);
await conn.query(
`INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Anticipé Toléré', ?)`,
[demandeId, cpTypeId, anneeN, result.joursNonDeduits]
);
} else {
throw new Error(`DĂ©duction CP Ă©chouĂ©e â ${result.joursNonDeduits.toFixed(2)}j non couverts`);
}
}
console.log(`â
CP déduits: ${result.joursDeduitsTotal.toFixed(2)}j | Mode: ${result.mode}`);
continue;
}
// ââ RTT âââââââââââââââââââââââââââââââââââââââââââââââ
if (rep.TypeConge === 'RTT') {
const rttRaw = await conn.query(
`SELECT TOP 1 CAST(Id AS INT) AS type_id FROM TypeConge WHERE Nom = ?`,
['RTT']
);
const rttRow = getRows(rttRaw);
if (!rttRow.length) throw new Error('Type RTT introuvable');
const rttTypeId = readNum(rttRow[0], 'type_id');
const anneeRTT = new Date(DateDebut).getFullYear();
const isAnticipeRTT = anneeRTT > currentYear;
console.log(` RTT â anneeRTT=${anneeRTT} (${isAnticipeRTT ? 'anticipĂ©' : 'normal'})`);
if (!isAnticipeRTT) {
const existingRTT = await conn.query(
`SELECT TOP 1 Id FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, rttTypeId, anneeRTT]
);
if (!getRows(existingRTT).length) {
const nextRttIdRaw = await conn.query(
`SELECT CAST(ISNULL(MAX(Id), 0) + 1 AS INT) AS next_id FROM CompteurConges`
);
const nextRttId = readNum(getRows(nextRttIdRaw)[0], 'next_id');
const conn2 = await pool.getConnection();
let acquisRTT = 0;
try {
const finEx = new Date(anneeRTT, 11, 31);
const rttData = await calculerAcquisitionRTTSmart(conn2, collaborateurId, finEx);
acquisRTT = rttData.acquisition;
} finally { conn2.release(); }
await conn.query(
`INSERT INTO CompteurConges
(Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, 0, 0, GETDATE())`,
[nextRttId, collaborateurId, rttTypeId, anneeRTT, acquisRTT]
);
console.log(` â
Compteur RTT ${anneeRTT} créé (Total=${acquisRTT.toFixed(2)}j)`);
}
await conn.query(
`UPDATE CompteurConges
SET Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END,
DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[rep.NombreJours, rep.NombreJours, collaborateurId, rttTypeId, anneeRTT]
);
await conn.query(
`INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Année N', ?)`,
[demandeId, rttTypeId, anneeRTT, rep.NombreJours]
);
console.log(`â
RTT normal déduit: ${rep.NombreJours}j sur Annee=${anneeRTT}`);
} else {
const anneeRTTCourant = currentYear;
const nombreJoursRTT = parseFloat(rep.NombreJours);
const conn2rtt = await pool.getConnection();
let joursAnnuelsRTTN = 10;
try {
const cfgN = await getConfigurationRTT(conn2rtt, anneeRTTCourant, typeContrat);
joursAnnuelsRTTN = cfgN.joursAnnuels;
} finally { conn2rtt.release(); }
const tauxRTTN = joursAnnuelsRTTN / 360;
const conn3rtt = await pool.getConnection();
let joursAnnuelsRTTNp1 = 10;
try {
const cfgNp1 = await getConfigurationRTT(conn3rtt, anneeRTT, typeContrat);
joursAnnuelsRTTNp1 = cfgNp1.joursAnnuels;
} finally { conn3rtt.release(); }
const tauxRTTNp1 = joursAnnuelsRTTNp1 / 360;
const [cRTTNRows] = await conn.query(
`SELECT Id, Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, rttTypeId, anneeRTTCourant]
);
const soldeRTTNActuel = cRTTNRows.length > 0 ? Math.max(0, parseFloat(cRTTNRows[0].Solde || 0)) : 0;
const finExRTTN = new Date(anneeRTTCourant, 11, 31); finExRTTN.setHours(23, 59, 59, 0);
const joursRestantsRTTN = Math.max(0, Math.floor((finExRTTN - today) / (1000 * 60 * 60 * 24)));
const acquisRestantsRTTN = parseFloat(Math.min(joursAnnuelsRTTN - soldeRTTNActuel, joursRestantsRTTN * tauxRTTN).toFixed(2));
const soldeRTTNProj = parseFloat(Math.min(joursAnnuelsRTTN, soldeRTTNActuel + acquisRestantsRTTN).toFixed(2));
const [dejaConsoRTTNRows] = await conn.query(
`SELECT COALESCE(SUM(dd.JoursUtilises), 0) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ?
AND dd.TypeDeduction IN ('AnticipRTT', 'AnticipFuturRTT')
AND dc.Statut != 'Refusé'`,
[collaborateurId, rttTypeId, anneeRTTCourant]
);
const dejaConsoRTTN = parseFloat(dejaConsoRTTNRows[0]?.total || 0);
const dispoRTTN = parseFloat(Math.max(0, soldeRTTNProj - dejaConsoRTTN).toFixed(2));
const dateDebutCongeRTT = new Date(DateDebut); dateDebutCongeRTT.setHours(0, 0, 0, 0);
const debutNp1RTT = new Date(anneeRTT, 0, 1); debutNp1RTT.setHours(0, 0, 0, 0);
const diffJoursRTTNp1 = Math.max(0, Math.floor((dateDebutCongeRTT - debutNp1RTT) / (1000 * 60 * 60 * 24)));
const acquisRTTNp1 = parseFloat(Math.min(joursAnnuelsRTTNp1, diffJoursRTTNp1 * tauxRTTNp1).toFixed(2));
const [dejaConsoRTTNp1Rows] = await conn.query(
`SELECT COALESCE(SUM(dd.JoursUtilises), 0) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ?
AND dc.Statut != 'Refusé'`,
[collaborateurId, rttTypeId, anneeRTT]
);
const dejaConsoRTTNp1 = parseFloat(dejaConsoRTTNp1Rows[0]?.total || 0);
const dispoRTTNp1 = parseFloat(Math.max(0, acquisRTTNp1 - dejaConsoRTTNp1).toFixed(2));
const totalDispoRTT = parseFloat((dispoRTTN + dispoRTTNp1).toFixed(2));
console.log(` đ° RTT N (${anneeRTTCourant}): actuel=${soldeRTTNActuel.toFixed(2)}j + restants=${acquisRestantsRTTN.toFixed(2)}j = projetĂ© ${soldeRTTNProj.toFixed(2)}j - consommĂ© ${dejaConsoRTTN.toFixed(2)}j = dispo ${dispoRTTN.toFixed(2)}j`);
console.log(` đ° RTT N+1 (${anneeRTT}): ${diffJoursRTTNp1}j Ă ${tauxRTTNp1.toFixed(4)} = ${acquisRTTNp1.toFixed(2)}j - consommĂ© ${dejaConsoRTTNp1.toFixed(2)}j = dispo ${dispoRTTNp1.toFixed(2)}j`);
console.log(` đ RTT total anticipĂ©: ${totalDispoRTT.toFixed(2)}j | demandĂ©s=${nombreJoursRTT}j`);
if (totalDispoRTT < nombreJoursRTT) {
throw new Error(`Solde RTT anticipé insuffisant : ${totalDispoRTT.toFixed(2)}j disponibles (N: ${dispoRTTN.toFixed(2)}j + N+1: ${dispoRTTNp1.toFixed(2)}j), ${nombreJoursRTT}j demandés`);
}
let joursRestantsRTT2 = nombreJoursRTT;
if (joursRestantsRTT2 > 0 && soldeRTTNActuel > 0 && cRTTNRows.length > 0) {
const aDeduireRTT = parseFloat(Math.min(soldeRTTNActuel, joursRestantsRTT2).toFixed(2));
await conn.query(
`UPDATE CompteurConges SET Solde = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`,
[aDeduireRTT, cRTTNRows[0].Id]
);
await conn.query(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'AnticipRTT', ?)`,
[demandeId, rttTypeId, anneeRTTCourant, aDeduireRTT]
);
joursRestantsRTT2 = parseFloat((joursRestantsRTT2 - aDeduireRTT).toFixed(2));
console.log(` â
RTT AnticipRTT: ${aDeduireRTT.toFixed(2)}j dĂ©duit â reste: ${joursRestantsRTT2.toFixed(2)}j`);
}
if (joursRestantsRTT2 > 0 && dispoRTTN > 0) {
const aTracerRTTN = parseFloat(Math.min(dispoRTTN, joursRestantsRTT2).toFixed(2));
await conn.query(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'AnticipFuturRTT', ?)`,
[demandeId, rttTypeId, anneeRTTCourant, aTracerRTTN]
);
joursRestantsRTT2 = parseFloat((joursRestantsRTT2 - aTracerRTTN).toFixed(2));
console.log(` â
RTT AnticipFuturRTT: ${aTracerRTTN.toFixed(2)}j tracĂ© â reste: ${joursRestantsRTT2.toFixed(2)}j`);
}
if (joursRestantsRTT2 > 0 && dispoRTTNp1 > 0) {
const aTracerRTTNp1 = parseFloat(Math.min(dispoRTTNp1, joursRestantsRTT2).toFixed(2));
const [cRTTNp1Rows] = await conn.query(
`SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, rttTypeId, anneeRTT]
);
if (cRTTNp1Rows.length === 0) {
const [newIdRTTRows] = await conn.query(`SELECT ISNULL(MAX(Id), 0) + 1 AS nextId FROM CompteurConges`);
const newIdRTT = newIdRTTRows[0]?.nextId || 1;
await conn.query(
`INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, 0, ?, 0, GETDATE())`,
[newIdRTT, collaborateurId, rttTypeId, anneeRTT, -aTracerRTTNp1]
);
console.log(` â CompteurConges RTT Annee=${anneeRTT} créé Solde=${-aTracerRTTNp1}j`);
} else {
await conn.query(
`UPDATE CompteurConges SET Solde = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ?`,
[aTracerRTTNp1, cRTTNp1Rows[0].Id]
);
console.log(` âïž CompteurConges RTT Annee=${anneeRTT} Solde - ${aTracerRTTNp1}j`);
}
await conn.query(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'AnticipNp1RTT', ?)`,
[demandeId, rttTypeId, anneeRTT, aTracerRTTNp1]
);
joursRestantsRTT2 = parseFloat((joursRestantsRTT2 - aTracerRTTNp1).toFixed(2));
console.log(` â
RTT AnticipNp1RTT: ${aTracerRTTNp1.toFixed(2)}j tracé sur N+1`);
}
if (joursRestantsRTT2 > 0.01) {
throw new Error(`Déduction RTT anticipée incomplÚte: ${joursRestantsRTT2.toFixed(2)}j non couverts`);
}
console.log(`â
RTT anticipé déduit: ${nombreJoursRTT}j | AnticipRTT+AnticipFuturRTT+AnticipNp1RTT`);
}
continue;
}
}
console.log('â
Déductions compteurs terminées');
}
// ââ ĂTAPE 6 : Notifications âââââââââââââââââââââââââââââââââââ
console.log('đ ĂTAPE 6 - Notifications');
const dateDebutFmt = new Date(DateDebut).toLocaleDateString('fr-FR');
const dateFinFmt = new Date(DateFin).toLocaleDateString('fr-FR');
const datesPeriode = dateDebutFmt === dateFinFmt
? dateDebutFmt
: `du ${dateDebutFmt} au ${dateFinFmt}`;
if (isFormationOnly && collaborateurId) {
const maxNotifRaw = await conn.query(
`SELECT CAST(ISNULL(MAX(Id), 0) + 1 AS INT) AS next_id FROM Notifications`
);
const notifId = readNum(getRows(maxNotifRaw)[0], 'next_id');
await conn.query(
`INSERT INTO Notifications
(Id, CollaborateurADId, Type, Titre, Message, DemandeCongeId, DateCreation, Lu)
VALUES (?, ?, 'Success', 'Formation validée automatiquement', ?, ?, GETDATE(), 0)`,
[notifId, collaborateurId,
`Votre période de formation ${datesPeriode} a été validée automatiquement.`,
demandeId]
);
console.log('đ Notification formation créée, ID:', notifId);
}
// ââ ĂTAPE 7 : RĂ©cupĂ©rer les managers âââââââââââââââââââââââââ
console.log('đ ĂTAPE 7 - RĂ©cupĂ©ration managers');
const managersRaw = await conn.query(
`SELECT c.email AS manager_email
FROM HierarchieValidationAD hv
JOIN CollaborateurAD c ON hv.SuperieurId = c.id
WHERE hv.CollaborateurId = ?`,
[collaborateurId]
);
const managers = getRows(managersRaw)
.map(r => r?.manager_email ?? r?.MANAGER_EMAIL)
.filter(Boolean);
console.log('đ„ Managers trouvĂ©s:', managers);
// ââ COMMIT ââââââââââââââââââââââââââââââââââââââââââââââââââââ
await conn.commit();
console.log('â
Transaction validée avec succÚs');
// ââ ĂTAPE 8 : SSE âââââââââââââââââââââââââââââââââââââââââââââ
if (isFormationOnly && collaborateurId) {
notifyCollabClients({
type: 'demande-validated',
demandeId,
statut: 'Validé',
timestamp: new Date().toISOString()
}, collaborateurId);
}
// ââ ĂTAPE 9 : Emails (aprĂšs commit) âââââââââââââââââââââââââââ
console.log('đ§ Envoi des emails...');
const accessToken = await getGraphToken();
if (accessToken) {
const fromEmail = 'gtanoreply@ensup.eu';
const typesConges = Repartition.map(r =>
`${toTypeName(r.TypeConge)} (${r.NombreJours}j)`
).join(', ');
if (isFormationOnly) {
try {
await sendMailGraph(accessToken, fromEmail, Email,
'Formation enregistrée et validée',
`
â
Formation validée
Bonjour ${Nom},
Votre période de formation a été automatiquement validée.
Période : ${datesPeriode}
Durée : ${NombreJours} jours
`
);
console.log('đ§ Email formation envoyĂ©');
} catch (e) { console.error('â Email formation:', e.message); }
} else {
try {
await sendMailGraph(accessToken, fromEmail, Email,
'Confirmation de réception de votre demande de congé',
`
đ Demande enregistrĂ©e
Bonjour ${Nom},
Votre demande a bien été enregistrée et est en attente de validation.
Type : ${typesConges}
Période : ${datesPeriode}
Durée : ${NombreJours} jours
`
);
console.log('đ§ Email confirmation envoyĂ©');
} catch (e) { console.error('â Email confirmation:', e.message); }
}
for (const managerEmail of managers) {
const validationUrl = `https://mygta.ensup-adm.net/manager?demandeId=${demandeId}`;
try {
await sendMailGraph(accessToken, fromEmail, managerEmail,
`Nouvelle demande de congĂ© â ${Nom}`,
`
đ Nouvelle demande de congĂ©
${Nom} a soumis une nouvelle demande de congé.
| Type |
${typesConges} |
| Période |
${datesPeriode} |
| Durée |
${NombreJours} jours |
${Commentaire
? `| Commentaire |
${Commentaire} |
`
: ''}
`
);
console.log('đ§ Email manager envoyĂ©:', managerEmail);
} catch (e) { console.error('â Email manager:', managerEmail, e.message); }
}
} else {
console.log('â ïž Pas de token Graph â emails non envoyĂ©s');
}
res.json({
success: true,
message: isFormationOnly
? 'Formation enregistrée et validée automatiquement'
: 'Demande soumise avec succĂšs',
requestid: demandeId,
managers,
autovalidated: isFormationOnly,
filesuploaded: uploadedFiles.length
});
} catch (error) {
console.error('â ERREUR GLOBALE');
console.error('Message:', error.message);
console.error('Stack:', error.stack);
try { await conn.rollback(); console.log('đ Rollback effectuĂ©'); } catch (_) { }
if (req.files) {
req.files.forEach(f => {
try { if (fs.existsSync(f.path)) fs.unlinkSync(f.path); } catch (_) { }
console.log('đ Fichier supprimĂ©:', f.originalname);
});
}
res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message });
} finally {
conn.release();
console.log('đ Connexion libĂ©rĂ©e');
}
});
/**
* Appelée depuis le frontend dÚs que l'utilisateur saisit les dates.
* Retourne les soldes projetés + le mode (NORMAL/ANTICIPATION) avant soumission.
*/
/**
* ROUTE : /api/soldes-previsionnel
*
* Calcule le solde prévisionnel CP en fonction de la date de début du congé
* selon les rÚgles métier :
* - CongĂ©s normaux (mĂȘme exercice) : N-1 puis N
* - Congés anticipés (exercice suivant) : uniquement N
*/
// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
// ROUTE /api/simulation-solde-cp
// Retourne le solde CP projeté à une date donnée, en tenant compte :
// - des acquisitions journaliĂšres (0.0694j/j)
// - des demandes déjà posées (Anticip + AnticipFutur sur CP N)
// - du basculement 01/06
// - du compteur CP N+1 (2027) qui démarre le 01/06/2026
// Usage : GET /api/simulation-solde-cp?userid=10754&date=2026-09-01
// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
app.get('/api/simulation-solde-cp', async (req, res) => {
const { userid, date } = req.query;
if (!userid || !date) return res.json({ success: false, message: 'ParamĂštres requis: userid, date' });
const conn = await pool.getConnection();
try {
const today = new Date(); today.setHours(0, 0, 0, 0);
const dateSimul = new Date(date); dateSimul.setHours(0, 0, 0, 0);
const userIdInt = parseInt(userid, 10);
const currentYear = today.getFullYear();
const TAUX_CP = 25 / 360;
const dateBascule = new Date(currentYear, 5, 1); dateBascule.setHours(0, 0, 0, 0);
const apresBasculementAujourdhui = today >= dateBascule;
const anneeN = apresBasculementAujourdhui ? currentYear + 1 : currentYear;
const anneeN1 = anneeN - 1;
const anneeNp1 = anneeN + 1;
// apresBasculementSimul = true si la date du congé est aprÚs le 31/05/anneeN
// = mode anticipĂ© â exercice N-1 terminĂ©, non utilisable
const dateBasculeAnneeN = new Date(anneeN, 4, 31);
const apresBasculementSimul = dateSimul > dateBasculeAnneeN;
const [cpTypeRows] = await conn.query(`SELECT TOP 1 Id FROM TypeConge WHERE Nom = ?`, ['Congé payé']);
if (!cpTypeRows.length) throw new Error('Type CP introuvable');
const cpTypeId = cpTypeRows[0].Id;
// ââ Lire solde CP N-1 ââââââââââââââââââââââââââââââââââââââââââââââââââ
// Uniquement utile en mode normal (congé avant le 31/05)
const [cptN1Rows] = await conn.query(
`SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[userIdInt, cpTypeId, anneeN1]
);
const soldeN1Dispo = cptN1Rows.length ? Math.max(0, parseFloat(cptN1Rows[0].Solde || 0)) : 0;
// ââ Lire solde ET total acquis CP N ââââââââââââââââââââââââââââââââââââ
const [cptNRows] = await conn.query(
`SELECT Solde, Total FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[userIdInt, cpTypeId, anneeN]
);
const soldeNActuel = cptNRows.length ? Math.max(0, parseFloat(cptNRows[0].Solde || 0)) : 0;
const totalDejaAcquisN = cptNRows.length ? parseFloat(cptNRows[0].Total || 0) : 0;
// ââ Lire solde CP N+1 ââââââââââââââââââââââââââââââââââââââââââââââââââ
const [cptNp1Rows] = await conn.query(
`SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[userIdInt, cpTypeId, anneeNp1]
);
// ââ DĂ©jĂ consommĂ© AnticipFutur sur CP N uniquement ââââââââââââââââââââ
// Les 'Anticip' sont DĂJĂ dĂ©duits du Solde DB â ne pas les resoustraire
// Seuls les 'AnticipFutur' sont des engagements tracés non encore en Solde
console.log('anneeN:', anneeN, 'cpTypeId:', cpTypeId, 'userIdInt:', userIdInt);
const [dejaConsoNRows] = await conn.query(
`SELECT COALESCE(SUM(dd.JoursUtilises), 0) AS total
FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ?
AND dd.TypeDeduction = 'AnticipFutur'
AND dc.Statut NOT IN ('Refusé', 'Refusée', 'Annulé', 'Annulée')`,
[userIdInt, cpTypeId, anneeN]
);
const dejaConsoN = parseFloat(dejaConsoNRows[0]?.total || 0);
console.log('dejaConsoN result:', dejaConsoNRows);
// ââ DĂ©jĂ consommĂ© sur CP N+1 âââââââââââââââââââââââââââââââââââââââââââ
const [dejaConsoNp1Rows] = await conn.query(
`SELECT COALESCE(SUM(dd.JoursUtilises), 0) AS total
FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ?
AND dc.Statut NOT IN ('Refusé', 'Refusée', 'Annulé', 'Annulée')`,
[userIdInt, cpTypeId, anneeNp1]
);
const dejaConsoNp1 = parseFloat(dejaConsoNp1Rows[0]?.total || 0);
// ââ Projection CP N jusqu'Ă dateSimul âââââââââââââââââââââââââââââââââ
// soldeNActuel est déjà net des Anticip (déduits du Solde DB)
// on projette les acquisitions futures et on soustrait seulement les AnticipFutur
// Ligne actuelle â projette jusqu'Ă dateSimul sans limite
const diffJoursSimul = Math.max(0, Math.floor((dateSimul - today) / 86400000));
const acquisDiciSimul = parseFloat(
Math.min(25 - totalDejaAcquisN, diffJoursSimul * TAUX_CP).toFixed(2)
);
const plafondAbsolu = parseFloat(Math.min(25, soldeNActuel + (25 - totalDejaAcquisN)).toFixed(2));
const soldeNProjecte = parseFloat(Math.min(soldeNActuel + acquisDiciSimul, plafondAbsolu).toFixed(2));
// Dispo réelle CP N = solde projeté - AnticipFutur déjà engagés
const soldeNDispo = parseFloat(Math.max(0, soldeNProjecte - dejaConsoN).toFixed(2));
// ââ CP N+1 : prorata depuis 01/06/anneeN jusqu'Ă dateSimul âââââââââââ
let soldeNp1Dispo = 0;
let diffJoursNp1 = 0;
let acquisNp1 = 0;
const debutNp1 = new Date(anneeN, 5, 1);
debutNp1.setHours(0, 0, 0, 0);
if (dateSimul > debutNp1) {
diffJoursNp1 = Math.max(0, Math.floor((dateSimul - debutNp1) / 86400000));
acquisNp1 = parseFloat(Math.min(25, diffJoursNp1 * TAUX_CP).toFixed(2));
soldeNp1Dispo = parseFloat(Math.max(0, acquisNp1 - dejaConsoNp1).toFixed(2));
}
// ââ Total disponible âââââââââââââââââââââââââââââââââââââââââââââââââââ
// Mode anticipĂ© (aprĂšs 31/05/anneeN) : exercice N-1 terminĂ© â N + N+1 seulement
// Mode normal (avant 31/05/anneeN) : N-1 + N + N+1
const totalDisponible = apresBasculementSimul
? parseFloat((soldeNDispo + soldeNp1Dispo).toFixed(2))
: parseFloat((soldeN1Dispo + soldeNDispo + soldeNp1Dispo).toFixed(2));
const result = {
dateSimulation: date,
apresBasculement: apresBasculementSimul,
anneeN,
anneeN1,
anneeNp1,
cpN1: {
annee: anneeN1,
disponible: apresBasculementSimul ? 0 : soldeN1Dispo
},
cpN: {
annee: anneeN,
soldeActuel: soldeNActuel,
totalDejaAcquis: totalDejaAcquisN,
acquisDici: acquisDiciSimul,
soldeProjeté: soldeNProjecte,
dejaConsomme: dejaConsoN,
disponible: soldeNDispo
},
cpNp1: {
annee: anneeNp1,
debutExercice: `01/06/${anneeN}`,
joursDepuisDebut: diffJoursNp1,
acquisProrate: acquisNp1,
dejaConsomme: dejaConsoNp1,
disponible: soldeNp1Dispo
},
totalDisponible
};
console.log(`đ
Simulation CP userid=${userIdInt} date=${date}:`, result);
console.log('apresBasculementSimul:', apresBasculementSimul, 'dateSimul:', dateSimul, 'dateBasculeAnneeN:', dateBasculeAnneeN);
res.json({ success: true, ...result });
} catch (err) {
console.error('â simulation-solde-cp:', err);
res.json({ success: false, message: err.message });
} finally {
if (conn) conn.release?.();
}
});
// ROUTE /api/simulation-solde-rtt
// Retourne le solde RTT projeté à une date donnée :
// - Avant basculement 01/01/N+1 : RTT N projeté au 31/12/N - déjà consommé
// - AprÚs basculement : RTT N figé au 31/12 + RTT N+1 prorata depuis 01/01/N+1
// Usage : GET /api/simulation-solde-rtt?userid=10754&date=2027-03-01
// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
app.get('/api/simulation-solde-rtt', async (req, res) => {
const { userid, date } = req.query;
if (!userid || !date) return res.json({ success: false, message: 'ParamĂštres requis: userid, date' });
const conn = await pool.getConnection();
try {
const today = new Date(); today.setHours(0, 0, 0, 0);
const dateSimul = new Date(date); dateSimul.setHours(0, 0, 0, 0);
const userIdInt = parseInt(userid, 10);
const currentYear = today.getFullYear();
// Infos collaborateur (TypeContrat pour taux RTT)
const [collabRows] = await conn.query(
`SELECT TOP 1 TypeContrat, role FROM CollaborateurAD WHERE id = ?`, [userIdInt]
);
if (!collabRows.length) throw new Error('Collaborateur introuvable');
const typeContrat = collabRows[0].TypeContrat || '37h';
const isApprenti = collabRows[0].role === 'Apprenti';
if (isApprenti) {
return res.json({ success: true, totalDisponible: 0, note: 'Apprenti : pas de RTT' });
}
const [rttTypeRows] = await conn.query(`SELECT TOP 1 Id FROM TypeConge WHERE Nom = ?`, ['RTT']);
if (!rttTypeRows.length) throw new Error('Type RTT introuvable');
const rttTypeId = rttTypeRows[0].Id;
// Exercice RTT courant = année civile
const anneeRTTN = currentYear; // ex: 2026
const anneeRTTNp1 = currentYear + 1; // ex: 2027
// Basculement : 01/01/anneeRTTNp1 = fin exercice RTT N
const dateBasculeRTT = new Date(anneeRTTNp1, 0, 1); // 01/01/2027
dateBasculeRTT.setHours(0, 0, 0, 0);
const apresBasculementSimul = dateSimul >= dateBasculeRTT;
// Config RTT taux journalier
const cfgN = await getConfigurationRTT(conn, anneeRTTN, typeContrat);
const tauxRTTN = cfgN.joursAnnuels / 360;
const cfgNp1 = await getConfigurationRTT(conn, anneeRTTNp1, typeContrat);
const tauxRTTNp1 = cfgNp1.joursAnnuels / 360;
// Lire solde RTT N en base
const [cptNRows] = await conn.query(
`SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[userIdInt, rttTypeId, anneeRTTN]
);
const soldeRTTNActuel = cptNRows.length ? Math.max(0, parseFloat(cptNRows[0].Solde || 0)) : 0;
// Lire solde RTT N+1 en base (peut ĂȘtre nĂ©gatif si AnticipNp1RTT posĂ©)
const [cptNp1Rows] = await conn.query(
`SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[userIdInt, rttTypeId, anneeRTTNp1]
);
const soldeRTTNp1Actuel = cptNp1Rows.length ? parseFloat(cptNp1Rows[0].Solde || 0) : 0;
// Déjà consommé RTT N (AnticipRTT + AnticipFuturRTT)
const [dejaConsoNRows] = await conn.query(
`SELECT COALESCE(SUM(dd.JoursUtilises), 0) AS total
FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ?
AND dd.TypeDeduction IN ('AnticipRTT', 'AnticipFuturRTT') AND dc.Statut != 'Refusé'`,
[userIdInt, rttTypeId, anneeRTTN]
);
const dejaConsoRTTN = parseFloat(dejaConsoNRows[0]?.total || 0);
// Déjà consommé RTT N+1 (DeductionDetails anneeNp1)
const [dejaConsoNp1Rows] = await conn.query(
`SELECT COALESCE(SUM(dd.JoursUtilises), 0) AS total
FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ?
AND dc.Statut != 'Refusé'`,
[userIdInt, rttTypeId, anneeRTTNp1]
);
const dejaConsoRTTNp1 = parseFloat(dejaConsoNp1Rows[0]?.total || 0);
let result;
if (!apresBasculementSimul) {
// ââ AVANT basculement (date simulĂ©e < 01/01/N+1) âââââââââââââââââââââ
// RTT N : acquis depuis aujourd'hui jusqu'Ă dateSimul
const diffJoursSimul = Math.max(0, Math.floor((dateSimul - today) / 86400000));
const acquisDici = parseFloat(Math.min(cfgN.joursAnnuels - soldeRTTNActuel, diffJoursSimul * tauxRTTN).toFixed(2));
const soldeRTTNSimul = parseFloat(Math.min(cfgN.joursAnnuels, soldeRTTNActuel + acquisDici).toFixed(2));
const soldeRTTNDispo = parseFloat(Math.max(0, soldeRTTNSimul - dejaConsoRTTN).toFixed(2));
result = {
dateSimulation: date,
apresBasculement: false,
anneeRTTN,
anneeRTTNp1,
rttN: {
annee: anneeRTTN,
soldeActuel: soldeRTTNActuel,
acquisDici,
soldeProjeté: soldeRTTNSimul,
dejaConsomme: dejaConsoRTTN,
disponible: soldeRTTNDispo
},
rttNp1: {
annee: anneeRTTNp1,
note: `Pas encore commencé (démarre le 01/01/${anneeRTTNp1})`,
disponible: 0
},
totalDisponible: soldeRTTNDispo
};
} else {
// ââ APRĂS basculement (date simulĂ©e >= 01/01/N+1) ââââââââââââââââââââ
// RTT N : solde figé au 31/12/anneeRTTN - déjà consommé anticipé
const finExRTTN = new Date(anneeRTTN, 11, 31); finExRTTN.setHours(0, 0, 0, 0);
const joursRestantsAvantBascule = Math.max(0, Math.floor((finExRTTN - today) / 86400000));
const acquisAvantBascule = parseFloat(Math.min(cfgN.joursAnnuels - soldeRTTNActuel, joursRestantsAvantBascule * tauxRTTN).toFixed(2));
const soldeRTTNFinal = parseFloat(Math.min(cfgN.joursAnnuels, soldeRTTNActuel + acquisAvantBascule).toFixed(2));
const soldeRTTNDispo = parseFloat(Math.max(0, soldeRTTNFinal - dejaConsoRTTN).toFixed(2));
// RTT N+1 : prorata depuis 01/01/anneeRTTNp1 jusqu'Ă dateSimul
const debutNp1RTT = new Date(anneeRTTNp1, 0, 1); debutNp1RTT.setHours(0, 0, 0, 0);
const diffJoursNp1 = Math.max(0, Math.floor((dateSimul - debutNp1RTT) / 86400000));
const acquisRTTNp1 = parseFloat(Math.min(cfgNp1.joursAnnuels, diffJoursNp1 * tauxRTTNp1).toFixed(2));
const soldeRTTNp1Dispo = parseFloat(Math.max(0, acquisRTTNp1 - dejaConsoRTTNp1).toFixed(2));
result = {
dateSimulation: date,
apresBasculement: true,
anneeRTTN,
anneeRTTNp1,
rttN: {
annee: anneeRTTN,
soldeActuel: soldeRTTNActuel,
acquisAvantBascule,
soldeFinalAu31Dec: soldeRTTNFinal,
dejaConsomme: dejaConsoRTTN,
disponible: soldeRTTNDispo
},
rttNp1: {
annee: anneeRTTNp1,
debutExercice: `01/01/${anneeRTTNp1}`,
joursDepuisDebut: diffJoursNp1,
acquisProrate: acquisRTTNp1,
dejaConsomme: dejaConsoRTTNp1,
disponible: soldeRTTNp1Dispo
},
totalDisponible: parseFloat((soldeRTTNDispo + soldeRTTNp1Dispo).toFixed(2))
};
}
console.log(`đ
Simulation RTT userid=${userIdInt} date=${date}:`, result);
res.json({ success: true, ...result });
} catch (err) {
console.error('â simulation-solde-rtt:', err);
res.json({ success: false, message: err.message });
} finally {
if (conn) conn.release?.();
}
});
// Appelée depuis le frontend dÚs que l'utilisateur saisit les dates.
// Retourne les soldes projetés + le mode NORMAL/ANTICIPATION avant soumission.
// â
ROUTE /api/soldes-previsionnel AVEC DEBUG
app.get('/api/soldes-previsionnel', async (req, res) => {
const { userid, dateDebut, joursCP, joursRTT, demandeIdExclure } = req.query;
if (!userid || !dateDebut) return res.json({ success: false, message: 'ParamĂštres requis: userid, dateDebut' });
const userIdInt = parseInt(userid, 10);
if (isNaN(userIdInt)) return res.json({ success: false, message: 'userid invalide' });
const demandeIdExclureInt = demandeIdExclure ? parseInt(demandeIdExclure, 10) : null;
try {
const today = new Date();
today.setHours(0, 0, 0, 0);
const currentYear = today.getFullYear();
const dateDebutObj = new Date(dateDebut);
dateDebutObj.setHours(0, 0, 0, 0);
const moisDebut = dateDebutObj.getMonth() + 1;
const anneeDebut = dateDebutObj.getFullYear();
const dateBasculeCP = new Date(`${currentYear}-06-01`);
const apresBasculement = today >= dateBasculeCP;
const anneeN = apresBasculement ? currentYear + 1 : currentYear;
const anneeN1 = anneeN - 1;
const anneeExConge = moisDebut >= 6 ? anneeDebut + 1 : anneeDebut;
const finExerciceCourant = new Date(anneeN, 4, 31);
finExerciceCourant.setHours(23, 59, 59, 0);
const isAnticipe = dateDebutObj > finExerciceCourant;
const exerciceDemande = anneeN;
const exerciceConges = anneeExConge - 1;
const anneeRTT = dateDebutObj.getFullYear();
const anneeRTTPrev = anneeRTT - 1;
const isAnticipeRTT = anneeRTT > currentYear;
const TAUX_CP = 25 / 360;
console.log('đ PRĂVISIONNEL', {
userid: userIdInt, dateDebut, currentYear,
isAnticipe, exerciceDemande, exerciceConges,
apresBasculement, anneeN1, anneeN,
anneeRTT, anneeRTTPrev, isAnticipeRTT,
demandeIdExclure: demandeIdExclureInt
});
// ââ Infos collaborateur ââââââââââââââââââââââââââââââââââââââââ
const rCollabInfo = pool.request();
rCollabInfo.input('userId', userIdInt);
const resCollabInfo = await rCollabInfo.query(`
SELECT TOP 1 role, DateEntree, TypeContrat FROM CollaborateurAD WHERE id = @userId
`);
if (!resCollabInfo.recordset.length) throw new Error('Collaborateur introuvable');
const isApprenti = resCollabInfo.recordset[0].role === 'Apprenti';
const dateEntree = resCollabInfo.recordset[0].DateEntree ?? null;
const typeContrat = resCollabInfo.recordset[0].TypeContrat ?? '37h';
// ââ TypeConge CP âââââââââââââââââââââââââââââââââââââââââââââââ
const r0 = pool.request();
r0.input('nom', 'Congé payé');
const cpTypeResult = await r0.query(`SELECT TOP 1 Id FROM TypeConge WHERE Nom = @nom`);
if (!cpTypeResult.recordset.length) throw new Error('Type CP introuvable');
const cpTypeId = cpTypeResult.recordset[0].Id;
// ââ TypeConge RTT ââââââââââââââââââââââââââââââââââââââââââââââ
const rRTT = pool.request();
rRTT.input('nom', 'RTT');
const rttTypeResult = await rRTT.query(`SELECT TOP 1 Id FROM TypeConge WHERE Nom = @nom`);
const rttTypeId = rttTypeResult.recordset[0]?.Id ?? null;
// ââ Helper : lire un compteur CP âââââââââââââââââââââââââââââââ
const lireCompteurCP = async (annee) => {
const r = pool.request();
r.input('userId', userIdInt);
r.input('typeId', cpTypeId);
r.input('annee', annee);
const result = await r.query(`
SELECT TOP 1
CAST(COALESCE(Solde, 0) AS FLOAT) AS Solde,
CAST(COALESCE(SoldeReporte, 0) AS FLOAT) AS SoldeReporte,
CAST(COALESCE(Total, 0) AS FLOAT) AS Total
FROM CompteurConges
WHERE CollaborateurADId = @userId
AND TypeCongeId = @typeId
AND Annee = @annee
ORDER BY Id DESC
`);
if (!result.recordset.length) return null;
const row = result.recordset[0];
const solde = parseFloat(row.Solde);
const reporte = parseFloat(row.SoldeReporte);
return {
solde,
reporte,
total: parseFloat(row.Total),
dispoN1: Math.max(0, solde),
dispoN: Math.max(0, solde - reporte)
};
};
// ââ Helper : lire un compteur RTT ââââââââââââââââââââââââââââââ
const lireCompteurRTT = async (annee) => {
if (!rttTypeId) return null;
const r = pool.request();
r.input('userId', userIdInt);
r.input('typeId', rttTypeId);
r.input('annee', annee);
const result = await r.query(`
SELECT TOP 1
CAST(COALESCE(Solde, 0) AS FLOAT) AS Solde,
CAST(COALESCE(Total, 0) AS FLOAT) AS Total
FROM CompteurConges
WHERE CollaborateurADId = @userId
AND TypeCongeId = @typeId
AND Annee = @annee
ORDER BY Id DESC
`);
if (!result.recordset.length) return null;
return {
solde: parseFloat(result.recordset[0].Solde),
total: parseFloat(result.recordset[0].Total)
};
};
// ââ Helper : crĂ©er un compteur RTT si absent âââââââââââââââââââ
const creerCompteurRTTSiAbsent = async (annee, acquisProjectee) => {
const existant = await lireCompteurRTT(annee);
if (existant) return existant;
console.log(`đ Compteur RTT ${annee} absent â crĂ©ation automatique (Total: ${acquisProjectee.toFixed(2)}j)`);
const rNextId = pool.request();
const resNextId = await rNextId.query(`SELECT CAST(ISNULL(MAX(Id), 0) + 1 AS INT) AS nextid FROM CompteurConges`);
const nextId = resNextId.recordset[0].nextid;
const rInsert = pool.request();
rInsert.input('nextId', nextId);
rInsert.input('userId', userIdInt);
rInsert.input('typeId', rttTypeId);
rInsert.input('annee', annee);
rInsert.input('total', acquisProjectee);
await rInsert.query(`
INSERT INTO CompteurConges
(Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES
(@nextId, @userId, @typeId, @annee, @total, 0, 0, GETDATE())
`);
return await lireCompteurRTT(annee);
};
// ââ Lire les compteurs CP ââââââââââââââââââââââââââââââââââââââ
const cptN1 = await lireCompteurCP(anneeN1);
const cptN = await lireCompteurCP(anneeN);
console.log(`đ Compteur CP N-1 (${anneeN1}):`, cptN1);
console.log(`đ Compteur CP N (${anneeN}):`, cptN);
const cpN1Dispo = isAnticipe ? 0 : (cptN1 ? cptN1.dispoN1 : 0);
const cpNAcquisAujourdHui = cptN ? cptN.dispoN : 0;
const soldeNActuel = cptN ? Math.max(0, parseFloat(cptN.solde || 0)) : 0;
// ââ Calcul CP selon mode âââââââââââââââââââââââââââââââââââââââ
let totalCPDisponible;
let cpSoldeADateDebut;
let cpNAnticipeDispo;
let dispoNp1 = 0;
if (!isAnticipe) {
// ââ MODE NORMAL : projeter jusqu'Ă dateDebutObj âââââââââââ
const joursJusquAuDebut = Math.max(0, Math.floor((dateDebutObj - today) / 86400000));
const plafondRestant = Math.max(0, 25 - (cptN ? cptN.total : 0));
const acquisDiciDebut = parseFloat(Math.min(plafondRestant, joursJusquAuDebut * TAUX_CP).toFixed(2));
const soldeNProjecte = parseFloat(Math.min(25, soldeNActuel + acquisDiciDebut).toFixed(2));
// Déjà consommé AnticipFutur sur anneeN
const rDejaConsoN = pool.request();
rDejaConsoN.input('userId', userIdInt);
rDejaConsoN.input('typeId', cpTypeId);
rDejaConsoN.input('annee', anneeN);
let qN = `
SELECT COALESCE(SUM(dd.JoursUtilises), 0) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = @userId AND dd.TypeCongeId = @typeId AND dd.Annee = @annee
AND dd.TypeDeduction = 'AnticipFutur'
AND dc.Statut NOT IN ('Refusé', 'Refusée', 'Annulé', 'Annulée')`;
if (demandeIdExclureInt) {
rDejaConsoN.input('demandeIdExclure', demandeIdExclureInt);
qN += ` AND dc.Id != @demandeIdExclure`;
}
const resN = await rDejaConsoN.query(qN);
const dejaConsoAnticipFutur = parseFloat(resN.recordset[0]?.total ?? 0);
const soldeNNet = parseFloat(Math.max(0, soldeNProjecte - dejaConsoAnticipFutur).toFixed(2));
cpSoldeADateDebut = soldeNNet;
cpNAnticipeDispo = parseFloat(Math.max(0, soldeNNet - cpNAcquisAujourdHui).toFixed(2));
totalCPDisponible = parseFloat((cpN1Dispo + soldeNNet).toFixed(2));
console.log(`đ CP NORMAL: solde=${soldeNActuel}j + acq=${acquisDiciDebut}j = ${soldeNProjecte}j - AnticipFutur=${dejaConsoAnticipFutur}j = ${soldeNNet}j | N-1=${cpN1Dispo}j | Total=${totalCPDisponible}j`);
} else {
// ââ MODE ANTICIPĂ âââââââââââââââââââââââââââââââââââââââââ
// FIX 1 : CP N se projette toujours jusqu'au 31/05 (jamais jusqu'Ă dateDebutObj)
const finExN = new Date(currentYear, 4, 31); finExN.setHours(23, 59, 59, 0);
const debutNp1 = new Date(currentYear, 5, 1); debutNp1.setHours(0, 0, 0, 0);
// Jours depuis aujourd'hui jusqu'au 31/05 (borne fixe)
const joursJusquauFinExN = today <= finExN
? Math.max(0, Math.floor((finExN - today) / 86400000))
: 0;
const plafondRestantN = Math.max(0, 25 - (cptN?.total ?? 0));
const acquisJusquauFinExN = parseFloat(
Math.min(plafondRestantN, joursJusquauFinExN * TAUX_CP).toFixed(2)
);
const soldeNAFinEx = parseFloat(Math.min(25, soldeNActuel + acquisJusquauFinExN).toFixed(2));
// Déjà consommé via Anticip + AnticipFutur sur anneeN
const rDejaConsoN = pool.request();
rDejaConsoN.input('userId', userIdInt);
rDejaConsoN.input('typeId', cpTypeId);
rDejaConsoN.input('annee', anneeN);
let qDejaConsoN = `
SELECT COALESCE(SUM(dd.JoursUtilises), 0) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = @userId AND dd.TypeCongeId = @typeId AND dd.Annee = @annee
AND dd.TypeDeduction IN ('Anticip', 'AnticipFutur')
AND dc.Statut NOT IN ('Refusé', 'Refusée', 'Annulé', 'Annulée')`;
if (demandeIdExclureInt) {
rDejaConsoN.input('demandeIdExclure', demandeIdExclureInt);
qDejaConsoN += ` AND dc.Id != @demandeIdExclure`;
}
const resDejaConsoN = await rDejaConsoN.query(qDejaConsoN);
const dejaConsoAnticip = parseFloat(resDejaConsoN.recordset[0]?.total ?? 0);
const soldeNDispo = parseFloat(Math.max(0, soldeNAFinEx - dejaConsoAnticip).toFixed(2));
// FIX 2 : CP N+1 â condition >= pour inclure le 1er juin lui-mĂȘme
if (dateDebutObj >= debutNp1) {
// Nombre de jours depuis le 01/06 jusqu'Ă dateDebutObj INCLUS
const diffJoursNp1 = Math.max(0, Math.floor((dateDebutObj - debutNp1) / 86400000) + 1);
const acquisNp1 = parseFloat(Math.min(25, diffJoursNp1 * TAUX_CP).toFixed(2));
const rDejaConsoNp1 = pool.request();
rDejaConsoNp1.input('userId', userIdInt);
rDejaConsoNp1.input('typeId', cpTypeId);
rDejaConsoNp1.input('annee', anneeExConge);
let qNp1 = `
SELECT COALESCE(SUM(dd.JoursUtilises), 0) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = @userId AND dd.TypeCongeId = @typeId AND dd.Annee = @annee
AND dc.Statut NOT IN ('Refusé', 'Refusée', 'Annulé', 'Annulée')`;
if (demandeIdExclureInt) {
rDejaConsoNp1.input('demandeIdExclure', demandeIdExclureInt);
qNp1 += ` AND dc.Id != @demandeIdExclure`;
}
const resNp1 = await rDejaConsoNp1.query(qNp1);
const dejaConsoNp1 = parseFloat(resNp1.recordset[0]?.total ?? 0);
dispoNp1 = parseFloat(Math.max(0, acquisNp1 - dejaConsoNp1).toFixed(2));
console.log(`đ CP N+1 (${anneeExConge}): ${diffJoursNp1}j Ă ${TAUX_CP.toFixed(4)} = ${acquisNp1}j - consommĂ© ${dejaConsoNp1}j â dispo ${dispoNp1}j`);
}
cpSoldeADateDebut = soldeNDispo;
cpNAnticipeDispo = dispoNp1;
totalCPDisponible = parseFloat((soldeNDispo + dispoNp1).toFixed(2));
console.log(`đ CP ANTICIPĂ â N au 31/05: ${soldeNAFinEx}j (acq +${acquisJusquauFinExN}j) - consommĂ© ${dejaConsoAnticip}j = ${soldeNDispo}j | N+1 (01/06â${dateDebut}): ${dispoNp1}j | Total: ${totalCPDisponible}j`);
}
console.log(`đ CP â N-1: ${cpN1Dispo.toFixed(2)}j | Acquis aujourd'hui N: ${cpNAcquisAujourdHui.toFixed(2)}j | Part anticipĂ©e: ${cpNAnticipeDispo.toFixed(2)}j | Total: ${totalCPDisponible.toFixed(2)}j`);
// ââ RTT ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
let rttDispo = 0;
let acquisRTTADateDebut = 0;
let rttNAcquisAujourdHui = 0;
let rttAnticipeDispo = 0;
let simRTTAcquisUtilise = 0;
let simRTTAnticipeUtilise = 0;
if (rttTypeId && !isApprenti) {
const conn = await pool.getConnection();
try {
const rttData = await calculerAcquisitionRTTSmart(conn, userIdInt, dateDebutObj);
acquisRTTADateDebut = rttData.acquisition;
} finally {
conn.release();
}
let acquisRTTFinExercice = 0;
if (isAnticipeRTT) {
const conn2 = await pool.getConnection();
try {
const finEx = new Date(anneeRTT, 11, 31);
const rttDataFin = await calculerAcquisitionRTTSmart(conn2, userIdInt, finEx);
acquisRTTFinExercice = rttDataFin.acquisition;
} finally {
conn2.release();
}
await creerCompteurRTTSiAbsent(anneeRTT, acquisRTTFinExercice);
}
const cptRTT = await lireCompteurRTT(anneeRTT);
rttNAcquisAujourdHui = cptRTT ? cptRTT.solde : 0;
const rRTTConso = pool.request();
rRTTConso.input('userId', userIdInt);
rRTTConso.input('typeId', rttTypeId);
rRTTConso.input('annee', anneeRTT);
let queryRTTConso = `
SELECT CAST(COALESCE(SUM(dd.JoursUtilises), 0) AS FLOAT) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = @userId
AND dd.TypeCongeId = @typeId
AND dd.Annee = @annee
AND dc.Statut != 'Refusé'
`;
if (isAnticipeRTT) {
queryRTTConso += ` AND dd.TypeDeduction IN ('AnticipRTT', 'AnticipFuturRTT')`;
}
if (demandeIdExclureInt) {
rRTTConso.input('demandeIdExclure', demandeIdExclureInt);
queryRTTConso += ` AND dc.Id != @demandeIdExclure`;
}
const resRTTConso = await rRTTConso.query(queryRTTConso);
const rttConsomme = parseFloat(resRTTConso.recordset[0]?.total ?? 0);
if (!isAnticipeRTT) {
const rttSoldeADateDebut = Math.max(0, acquisRTTADateDebut - rttConsomme);
rttAnticipeDispo = Math.max(0, rttSoldeADateDebut - rttNAcquisAujourdHui);
rttDispo = Math.max(0, rttSoldeADateDebut);
} else {
const connRTTCfg = await pool.getConnection();
let cfgRTTPrev;
try { cfgRTTPrev = await getConfigurationRTT(connRTTCfg, anneeRTT, typeContrat); } finally { connRTTCfg.release(); }
const tauxRTTNPrev = cfgRTTPrev.joursAnnuels / 360;
const finExRTTNPrev = new Date(anneeRTT, 11, 31); finExRTTNPrev.setHours(23, 59, 59, 0);
const joursRestantsRTTNPrev = Math.max(0, Math.floor((finExRTTNPrev - today) / 86400000));
const acquisRestantsRTTNPrev = Math.min(cfgRTTPrev.joursAnnuels - rttNAcquisAujourdHui, joursRestantsRTTNPrev * tauxRTTNPrev);
const soldeRTTNProjPrev = Math.min(cfgRTTPrev.joursAnnuels, rttNAcquisAujourdHui + acquisRestantsRTTNPrev);
const dispoRTTNPrev = parseFloat(Math.max(0, soldeRTTNProjPrev - rttConsomme).toFixed(2));
const anneeRTTConge = anneeRTT + 1;
const connRTTCfgNp1 = await pool.getConnection();
let cfgRTTNp1Prev;
try { cfgRTTNp1Prev = await getConfigurationRTT(connRTTCfgNp1, anneeRTTConge, typeContrat); } finally { connRTTCfgNp1.release(); }
const tauxRTTNp1Prev = cfgRTTNp1Prev.joursAnnuels / 360;
const debutRTTNp1Prev = new Date(anneeRTTConge, 0, 1); debutRTTNp1Prev.setHours(0, 0, 0, 0);
const diffJoursRTTNp1Prev = Math.max(0, Math.floor((dateDebutObj - debutRTTNp1Prev) / 86400000));
const acquisRTTNp1Prev = parseFloat(Math.min(cfgRTTNp1Prev.joursAnnuels, diffJoursRTTNp1Prev * tauxRTTNp1Prev).toFixed(2));
const rRTTConsoNp1 = pool.request();
rRTTConsoNp1.input('userId', userIdInt);
rRTTConsoNp1.input('typeId', rttTypeId);
rRTTConsoNp1.input('annee', anneeRTTConge);
let qRTTConsoNp1 = `
SELECT CAST(COALESCE(SUM(dd.JoursUtilises), 0) AS FLOAT) AS total
FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = @userId AND dd.TypeCongeId = @typeId AND dd.Annee = @annee
AND dc.Statut != 'Refusé'`;
if (demandeIdExclureInt) {
rRTTConsoNp1.input('demandeIdExclure', demandeIdExclureInt);
qRTTConsoNp1 += ` AND dc.Id != @demandeIdExclure`;
}
const resRTTConsoNp1 = await rRTTConsoNp1.query(qRTTConsoNp1);
const rttConsoNp1Prev = parseFloat(resRTTConsoNp1.recordset[0]?.total ?? 0);
const dispoRTTNp1Prev = parseFloat(Math.max(0, acquisRTTNp1Prev - rttConsoNp1Prev).toFixed(2));
rttDispo = parseFloat((dispoRTTNPrev + dispoRTTNp1Prev).toFixed(2));
rttAnticipeDispo = dispoRTTNp1Prev;
console.log(`đ RTT ANTICIPĂ: N=${dispoRTTNPrev.toFixed(2)}j + N+1=${dispoRTTNp1Prev.toFixed(2)}j = total ${rttDispo.toFixed(2)}j`);
}
console.log(`đ RTT (${anneeRTT}) â Acquis Ă dateDebut: ${acquisRTTADateDebut.toFixed(2)}j | En base: ${rttNAcquisAujourdHui.toFixed(2)}j | Dispo: ${rttDispo.toFixed(2)}j`);
const joursRTTNecessaires = parseFloat(joursRTT) || 0;
let resteRTT = joursRTTNecessaires;
if (resteRTT > 0 && rttNAcquisAujourdHui > 0) {
simRTTAcquisUtilise = Math.max(0, Math.min(rttNAcquisAujourdHui - rttConsomme, resteRTT));
resteRTT -= simRTTAcquisUtilise;
}
if (resteRTT > 0 && rttAnticipeDispo > 0) {
simRTTAnticipeUtilise = Math.min(rttAnticipeDispo, resteRTT);
resteRTT -= simRTTAnticipeUtilise;
}
}
// ââ Mode label âââââââââââââââââââââââââââââââââââââââââââââââââ
let modeLabel = '';
if (!isAnticipe) {
modeLabel = apresBasculement ? 'NORMAL POST-BASCULEMENT' : 'NORMAL';
} else if (!apresBasculement) {
modeLabel = 'ANTICIPĂ CLASSIQUE';
} else {
modeLabel = 'ANTICIPĂ POST-BASCULEMENT';
}
// ââ VĂ©rification suffisance ââââââââââââââââââââââââââââââââââââ
const joursNecessairesCP = parseFloat(joursCP) || 0;
const joursNecessairesRTT = parseFloat(joursRTT) || 0;
const suffisantCP = joursNecessairesCP <= 0 || totalCPDisponible >= joursNecessairesCP;
const suffisantRTT = joursNecessairesRTT <= 0 || rttDispo >= joursNecessairesRTT;
const suffisant = suffisantCP && suffisantRTT;
const deficitCP = suffisantCP ? 0 : parseFloat((joursNecessairesCP - totalCPDisponible).toFixed(2));
const deficitRTT = suffisantRTT ? 0 : parseFloat((joursNecessairesRTT - rttDispo).toFixed(2));
// ââ Simulation prĂ©lĂšvement CP ââââââââââââââââââââââââââââââââââ
let simN1Utilise = 0;
let simNAcquisUtilise = 0;
let simNAnticipeUtilise = 0;
let reste = joursNecessairesCP;
if (!isAnticipe && reste > 0 && cpN1Dispo > 0) {
simN1Utilise = Math.min(cpN1Dispo, reste);
reste -= simN1Utilise;
}
if (reste > 0 && cpNAcquisAujourdHui > 0) {
simNAcquisUtilise = Math.min(cpNAcquisAujourdHui, reste);
reste -= simNAcquisUtilise;
}
if (reste > 0 && cpNAnticipeDispo > 0) {
simNAnticipeUtilise = Math.min(cpNAnticipeDispo, reste);
reste -= simNAnticipeUtilise;
}
console.log('đ PRĂVISIONNEL RĂSULTAT', {
modeLabel, cpN1Dispo, cpNAcquisAujourdHui, cpNAnticipeDispo,
cpSoldeADateDebut, dispoNp1,
totalCPDisponible, rttDispo,
suffisant, deficitCP, deficitRTT
});
res.json({
success: true,
previsionnel: {
mode: modeLabel,
isAnticipe,
apresBasculement,
exerciceDemande: `${exerciceDemande}-${exerciceDemande + 1}`,
exerciceConges: `${exerciceConges}-${exerciceConges + 1}`,
anneeN1,
anneeN,
// ââ CP âââââââââââââââââââââââââââââââââââââââââââââââââ
cpN1Reporte: isAnticipe ? 0 : parseFloat(cpN1Dispo.toFixed(2)),
cpNSolde: parseFloat(cpNAcquisAujourdHui.toFixed(2)),
cpNSoldeALaDate: parseFloat(cpSoldeADateDebut.toFixed(2)),
cpNAnticipeAutorise: parseFloat(cpNAnticipeDispo.toFixed(2)),
acquisCPADateDebut: parseFloat(cpSoldeADateDebut.toFixed(2)),
cpNAcquisAujourdHui: parseFloat(cpNAcquisAujourdHui.toFixed(2)),
cpNAnticipeDispo: parseFloat(cpNAnticipeDispo.toFixed(2)),
totalCPDisponible,
// ââ RTT ââââââââââââââââââââââââââââââââââââââââââââââââ
anneeRTT,
anneeRTTPrev,
isAnticipeRTT,
acquisRTTADateDebut: parseFloat(acquisRTTADateDebut.toFixed(2)),
rttNAcquisAujourdHui: parseFloat(rttNAcquisAujourdHui.toFixed(2)),
rttAnticipeDispo: parseFloat(rttAnticipeDispo.toFixed(2)),
rttDispo: parseFloat(rttDispo.toFixed(2)),
// ââ VĂ©rification âââââââââââââââââââââââââââââââââââââââ
joursNecessaires: joursNecessairesCP,
joursNecessairesCP,
joursNecessairesRTT,
totalDisponible: totalCPDisponible,
suffisant,
suffisantCP,
suffisantRTT,
deficit: deficitCP,
deficitCP,
deficitRTT,
// ââ Simulation CP ââââââââââââââââââââââââââââââââââââââ
simulation: {
n1Utilise: parseFloat(simN1Utilise.toFixed(2)),
nAcquisUtilise: parseFloat(simNAcquisUtilise.toFixed(2)),
nAnticipeUtilise: parseFloat(simNAnticipeUtilise.toFixed(2)),
rttAcquisUtilise: parseFloat(simRTTAcquisUtilise.toFixed(2)),
rttAnticipeUtilise: parseFloat(simRTTAnticipeUtilise.toFixed(2))
},
message: (() => {
const parts = [];
if (!isAnticipe && cpN1Dispo > 0) parts.push(`N-1(${anneeN1}): ${cpN1Dispo.toFixed(2)}j`);
if (cpNAcquisAujourdHui > 0) parts.push(`N acquis(${anneeN}): ${cpNAcquisAujourdHui.toFixed(2)}j`);
if (cpNAnticipeDispo > 0) parts.push(`N anticipé: ${cpNAnticipeDispo.toFixed(2)}j`);
if (dispoNp1 > 0) parts.push(`N+1(${anneeExConge}): ${dispoNp1.toFixed(2)}j`);
if (rttDispo > 0) parts.push(`RTT(${anneeRTT}): ${rttDispo.toFixed(2)}j`);
return `${modeLabel} â ${parts.join(' + ')} â CP total: ${totalCPDisponible.toFixed(2)}j`;
})()
}
});
} catch (error) {
console.error('â Erreur soldes-previsionnel:', error);
res.status(500).json({ success: false, message: error.message });
}
});
app.get('/api/download-medical/:documentId', async (req, res) => {
try {
const { documentId } = req.params;
const conn = await pool.getConnection();
const [docs] = await conn.query(
'SELECT TOP 1 * FROM DocumentsMedicaux WHERE Id = ?',
[documentId]
);
conn.release();
if (docs.length === 0) {
return res.status(404).json({ success: false, message: 'Document non trouvé' });
}
const doc = docs[0];
if (!fs.existsSync(doc.CheminFichier)) {
return res.status(404).json({ success: false, message: 'Fichier introuvable' });
}
res.download(doc.CheminFichier, doc.NomFichier);
} catch (error) {
console.error('Erreur téléchargement:', error);
res.status(500).json({ success: false, message: 'Erreur serveur' });
}
});
// Récupérer les documents d'une demande
app.get('/api/medical-documents/:demandeId', async (req, res) => {
try {
const { demandeId } = req.params;
const conn = await pool.getConnection();
const [docs] = await conn.query(
`SELECT Id, NomFichier, TypeMime, TailleFichier, DateUpload
FROM DocumentsMedicaux
WHERE DemandeCongeId = ?
ORDER BY DateUpload DESC`,
[demandeId]
);
conn.release();
res.json({
success: true,
documents: docs.map(doc => ({
id: doc.Id,
nom: doc.NomFichier,
type: doc.TypeMime,
taille: doc.TailleFichier,
date: doc.DateUpload,
downloadUrl: `/download-medical/${doc.Id}`
}))
});
} catch (error) {
console.error('Erreur récupération documents:', error);
res.status(500).json({ success: false, message: 'Erreur serveur' });
}
});
app.post('/api/validateRequest', async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const { request_id, action, validator_id, comment } = req.body;
console.log(`\nđ Validation demande #${request_id} - Action: ${action}`);
if (!request_id || !action || !validator_id) {
return res.json({ success: false, message: 'Données manquantes' });
}
// ========================================
// RĂCUPĂRER LE VALIDATEUR (AD ou User classique)
// ========================================
let validateurId = null;
let validateurADId = null;
let validatorData = null;
const [validatorAD] = await conn.query(
'SELECT TOP 1 Id, prenom, nom, email, CampusId FROM CollaborateurAD WHERE Id = ?',
[validator_id]
);
if (validatorAD.length > 0) {
validateurADId = validator_id;
validatorData = validatorAD[0];
console.log(`đ€ Validateur trouvĂ© dans CollaborateurAD: ${validatorData.prenom} ${validatorData.nom}`);
} else {
const [validatorUser] = await conn.query(
'SELECT TOP 1 ID, Prenom, Nom, Email FROM Users WHERE ID = ?',
[validator_id]
);
if (validatorUser.length > 0) {
validateurId = validator_id;
validatorData = {
Id: validatorUser[0].ID,
prenom: validatorUser[0].Prenom,
nom: validatorUser[0].Nom,
email: validatorUser[0].Email
};
console.log(`đ€ Validateur trouvĂ© dans Users: ${validatorData.prenom} ${validatorData.nom}`);
} else {
throw new Error('Validateur introuvable');
}
}
// ========================================
// RĂCUPĂRER LA DEMANDE (sans JOIN sur TypeCongeId car c'est un CSV)
// ========================================
const [requests] = await conn.query(
`SELECT TOP 1
dc.Id,
dc.CollaborateurADId,
dc.TypeCongeId,
dc.NombreJours,
dc.DateDebut,
dc.DateFin,
dc.Commentaire,
dc.Statut,
ca.prenom,
ca.nom,
ca.email as collaborateur_email
FROM DemandeConge dc
LEFT JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.Id
WHERE dc.Id = ?`,
[request_id]
);
if (requests.length === 0) {
throw new Error('Demande non trouvée');
}
const request = requests[0];
// ========================================
// RĂCUPĂRER LES TYPES DE CONGĂS
// ========================================
let typeCongeNom = 'Congé';
const [congeTypes] = await conn.query(
`SELECT tc.Nom, dct.NombreJours
FROM DemandeCongeType dct
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dct.DemandeCongeId = ?`,
[request_id]
);
if (congeTypes.length > 0) {
typeCongeNom = congeTypes.map(t => t.Nom).join(' + ');
} else if (request.TypeCongeId) {
const typeIds = request.TypeCongeId.toString().split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
if (typeIds.length > 0) {
const [types] = await conn.query(
`SELECT Nom FROM TypeConge WHERE Id IN (${typeIds.join(',')})`
);
if (types.length > 0) {
typeCongeNom = types.map(t => t.Nom).join(' + ');
}
}
}
request.TypeConge = typeCongeNom;
console.log('\n=== DONNĂES RĂCUPĂRĂES ===');
console.log('request:', JSON.stringify(request, null, 2));
console.log('TypeConge résolu:', typeCongeNom);
console.log('validateurId:', validateurId);
console.log('validateurADId:', validateurADId);
console.log('========================\n');
if (request.Statut !== 'En attente') {
throw new Error(`La demande a déjà été traitée (Statut: ${request.Statut})`);
}
const newStatus = action === 'approve' ? 'Validée' : 'Refusée';
// Si refus, restaurer les soldes
if (action === 'reject' && request.CollaborateurADId) {
console.log(`\nđ REFUS - Restauration des soldes...`);
const restoration = await restoreLeaveBalance(conn, request_id, request.CollaborateurADId);
console.log('Restauration:', restoration);
}
// Mettre Ă jour le statut
await conn.query(
`UPDATE DemandeConge
SET Statut = ?,
ValidateurId = ?,
ValidateurADId = ?,
DateValidation = GETDATE(),
CommentaireValidation = ?
WHERE Id = ?`,
[newStatus, validateurId, validateurADId, comment || '', request_id]
);
console.log(`â
Demande mise Ă jour - ValidateurId: ${validateurId}, ValidateurADId: ${validateurADId}`);
// ========================================
// CRĂER UNE NOTIFICATION (avec ID manuel)
// ========================================
const notifTitle = action === 'approve' ? 'Demande approuvĂ©e â
' : 'Demande refusĂ©e â';
let notifMessage = `Votre demande a été ${action === 'approve' ? 'approuvée' : 'refusée'}`;
if (comment) notifMessage += ` (Commentaire: ${comment})`;
const notifType = action === 'approve' ? 'Success' : 'Error';
// Générer l'ID manuellement
const [maxNotifId] = await conn.query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM Notifications');
const notifId = maxNotifId[0].NextId;
await conn.query(
`INSERT INTO Notifications
(Id, CollaborateurADId, Titre, Message, Type, DemandeCongeId, DateCreation, lu)
VALUES (?, ?, ?, ?, ?, ?, GETDATE(), 0)`,
[notifId, request.CollaborateurADId, notifTitle, notifMessage, notifType, request_id]
);
console.log(`đŹ Notification créée avec ID ${notifId}`);
await conn.commit();
// ========================================
// ENVOYER EMAIL VIA MICROSOFT GRAPH
// ========================================
console.log('\nđ§ === TENTATIVE ENVOI EMAIL ===');
const accessToken = await getGraphToken();
if (accessToken && request.collaborateur_email) {
const fromEmail = 'gtanoreply@ensup.eu';
const collaborateurNom = `${request.prenom} ${request.nom}`;
const validateurNom = `${validatorData.prenom} ${validatorData.nom}`;
const dateDebut = new Date(request.DateDebut).toLocaleDateString('fr-FR');
const dateFin = new Date(request.DateFin).toLocaleDateString('fr-FR');
const datesPeriode = dateDebut === dateFin ? dateDebut : `du ${dateDebut} au ${dateFin}`;
const subject = action === 'approve'
? 'â
Votre demande de congé a été approuvée'
: 'â Votre demande de congĂ© a Ă©tĂ© refusĂ©e';
const emailHtml = `
${subject}
Bonjour ${collaborateurNom},
Votre demande de ${request.TypeConge} pour ${request.NombreJours} jour(s) ${datesPeriode} a été ${action === 'approve' ? 'approuvée' : 'refusée'} par ${validateurNom}.
${comment ? `
Commentaire: ${comment}
` : ''}
Vous pouvez consulter les détails dans l'application GTA.
Ceci est un email automatique, merci de ne pas y répondre.
`;
const emailSent = await sendMailGraph(accessToken, fromEmail, request.collaborateur_email, subject, emailHtml);
console.log('đ§ Email envoyĂ© ?', emailSent ? 'OUI â
' : 'NON â');
}
res.json({
success: true,
message: `Demande ${action === 'approve' ? 'approuvée' : 'refusée'} avec succÚs`
});
} catch (error) {
await conn.rollback();
console.error('â Erreur validateRequest:', error);
res.status(500).json({ success: false, message: error.message });
} finally {
conn.release();
}
});
app.get('/api/testRestoration', async (req, res) => {
const conn = await pool.getConnection();
try {
const { demande_id, collab_id } = req.query;
if (!demande_id || !collab_id) {
return res.json({
success: false,
message: 'ParamĂštres manquants: demande_id et collab_id requis'
});
}
// 1. Voir les déductions enregistrées
const [deductions] = await conn.query(
`SELECT dd.*, tc.Nom as TypeNom
FROM DeductionDetails dd
JOIN TypeConge tc ON dd.TypeCongeId = tc.Id
WHERE dd.DemandeCongeId = ?`,
[demande_id]
);
// 2. Voir l'état actuel des compteurs
const [compteurs] = await conn.query(
`SELECT cc.*, tc.Nom as TypeNom
FROM CompteurConges cc
JOIN TypeConge tc ON cc.TypeCongeId = tc.Id
WHERE cc.CollaborateurADId = ?
ORDER BY tc.Nom, cc.Annee DESC`,
[collab_id]
);
// 3. Calculer ce que devrait ĂȘtre la restauration
const planRestauration = deductions.map(d => ({
type: d.TypeNom,
annee: d.Annee,
typeDeduction: d.TypeDeduction,
joursARestorer: d.JoursUtilises,
action: d.TypeDeduction === 'Reporté N-1'
? 'Ajouter au SoldeReporte ET au Solde'
: 'Ajouter au Solde uniquement'
}));
conn.release();
res.json({
success: true,
demande_id: demande_id,
collaborateur_id: collab_id,
deductions_enregistrees: deductions,
compteurs_actuels: compteurs,
plan_restauration: planRestauration,
instructions: planRestauration.length === 0
? "â Aucune dĂ©duction trouvĂ©e - La demande a Ă©tĂ© créée avant l'installation du tracking"
: "â
Déductions trouvées - La restauration devrait fonctionner"
});
} catch (error) {
conn.release();
res.status(500).json({
success: false,
error: error.message
});
}
});
function normalizeRole(role) {
if (!role) return null;
const roleLower = role.toLowerCase();
// Normaliser les variantes féminines et masculines
if (roleLower === 'collaboratrice') return 'collaborateur';
if (roleLower === 'validatrice') return 'validateur';
if (roleLower === 'directrice de campus') return 'directeur de campus';
if (roleLower === 'apprentie') return 'apprenti';
return roleLower;
}
app.get('/api/getSocietesByCampus', async (req, res) => {
try {
const { campusId } = req.query;
const conn = await pool.getConnection();
const [societes] = await conn.query(`
SELECT DISTINCT s.Id, s.Nom
FROM SocieteCampus sc
JOIN Societe s ON sc.SocieteId = s.Id
WHERE sc.CampusId = ?
ORDER BY
CASE WHEN s.Nom LIKE '%SOLUTION%' THEN 1 ELSE 2 END,
s.Nom
`, [campusId]);
conn.release();
res.json({
success: true,
societes: societes
});
} catch (error) {
console.error('Erreur getSocietesByCampus:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// â NOUVELLE ROUTE HELPER : RĂ©cupĂ©rer les campus d'une sociĂ©tĂ©
app.get('/api/getCampusBySociete', async (req, res) => {
try {
const { societeId } = req.query;
const conn = await pool.getConnection();
const [campus] = await conn.query(`
SELECT DISTINCT c.Id, c.Nom, sc.Principal
FROM SocieteCampus sc
JOIN Campus c ON sc.CampusId = c.Id
WHERE sc.SocieteId = ?
ORDER BY
sc.Principal DESC, -- Principal en premier
c.Nom
`, [societeId]);
conn.release();
res.json({
success: true,
campus: campus,
isMultiCampus: campus.length > 1
});
} catch (error) {
console.error('Erreur getCampusBySociete:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// ========================================
// ROUTE getTeamLeaves COMPLĂTE
// ========================================
// ========================================
// ROUTE getTeamLeaves COMPLĂTE ET OPTIMISĂE
// ========================================
app.get('/api/getTeamLeaves', async (req, res) => {
try {
let { user_id: userIdParam, role: roleParam, selectedCampus, selectedSociete, selectedService } = req.query;
console.log(`đ ParamĂštres reçus: user_id=${userIdParam}, role=${roleParam}, selectedCampus=${selectedCampus}`);
if (!userIdParam) {
return res.json({ success: false, message: 'ID utilisateur manquant' });
}
const isUUID = userIdParam.length > 10 && userIdParam.includes('-');
console.log(`đ Type ID dĂ©tectĂ©: ${isUUID ? 'UUID' : 'NumĂ©rique'}`);
const userRequest = pool.request();
userRequest.input('userIdParam', userIdParam);
const userQuery = `
SELECT
ca.id,
ca.ServiceId,
ca.CampusId,
ca.SocieteId,
ca.email,
s.Nom AS serviceNom,
c.Nom AS campusNom,
so.Nom AS societeNom
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Campus c ON ca.CampusId = c.Id
LEFT JOIN Societe so ON ca.SocieteId = so.Id
WHERE ${isUUID ? 'ca.entraUserId' : 'ca.id'} = @userIdParam
`;
const userResult = await userRequest.query(userQuery);
if (!userResult.recordset || userResult.recordset.length === 0) {
return res.json({ success: false, message: 'Collaborateur non trouvé' });
}
const userInfo = userResult.recordset[0];
const serviceId = userInfo.ServiceId;
const campusId = userInfo.CampusId;
const societeId = userInfo.SocieteId;
const userEmail = userInfo.email;
const campusNom = userInfo.campusNom;
const serviceNom = userInfo.serviceNom;
const societeNom = userInfo.societeNom;
function normalizeRole(role) {
if (!role) return null;
const roleLower = role.toLowerCase();
if (roleLower === 'collaboratrice') return 'collaborateur';
if (roleLower === 'validatrice') return 'validateur';
if (roleLower === 'directrice de campus') return 'directeur de campus';
if (roleLower === 'apprentie') return 'apprenti';
return roleLower;
}
const roleOriginal = roleParam?.toLowerCase();
const role = normalizeRole(roleOriginal);
console.log(`đ€ Utilisateur trouvĂ©:`);
console.log(` - ID: ${userInfo.id}`);
console.log(` - Email: ${userEmail}`);
console.log(` - ServiceId: ${serviceId}`);
console.log(` - CampusId: ${campusId}`);
console.log(` - CampusNom: ${campusNom}`);
console.log(` - ServiceNom: ${serviceNom}`);
console.log(` - SocieteId: ${societeId}`);
console.log(` - Role normalisé: ${role}`);
// â
Fragment SQL réutilisable pour le JSON detailsconges (tous les CAS)
const detailscongesSQL = `
CONCAT(
'[',
STRING_AGG(
CONCAT(
'{"type":"', tc.Nom,
'","jours":', dct.NombreJours,
',"periodeDebut":"', COALESCE(dct.PeriodeJournee, 'Journée entiÚre'),
'","periodeFin":"', COALESCE(dct.PeriodeJourneeFin, dct.PeriodeJournee, 'Journée entiÚre'),
'","dateDebut":"', CONVERT(VARCHAR(10), dct.DateDebut, 23),
'","dateFin":"', CONVERT(VARCHAR(10), dct.DateFin, 23),
'"}'
),
','
),
']'
) AS detailsconges`;
const filters = {};
// ========================================
// CAS 1: PRESIDENT, ADMIN, RH, DIRECTEUR DE CAMPUS
// ========================================
if (role === 'president' || role === 'admin' || role === 'directeur de campus') {
console.log("CAS 1: President/Admin/RH/Directeur de Campus - Vue globale");
console.log(` Filtres reçus: Société=${selectedSociete}, Campus=${selectedCampus}, Service=${selectedService}`);
// 1ïžâŁ SOCIĂTĂS
const societesRequest = pool.request();
const societesResult = await societesRequest.query(`
SELECT DISTINCT Nom
FROM Societe
ORDER BY Nom
`);
filters.societes = societesResult.recordset.map(s => s.Nom);
console.log('đ SociĂ©tĂ©s disponibles:', filters.societes);
// 2ïžâŁ CAMPUS
let campusRequest = pool.request();
let campusQuery;
if (selectedSociete && selectedSociete !== 'all') {
campusQuery = `
SELECT DISTINCT c.Nom
FROM Campus c
JOIN CollaborateurAD ca ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
WHERE so.Nom = @selectedSociete
AND (ca.actif = 1 OR ca.actif IS NULL)
ORDER BY c.Nom
`;
campusRequest.input('selectedSociete', selectedSociete);
} else {
campusQuery = `
SELECT DISTINCT Nom
FROM Campus
ORDER BY Nom
`;
}
const campusResult = await campusRequest.query(campusQuery);
filters.campus = campusResult.recordset.map(c => c.Nom);
console.log('đ Campus disponibles:', filters.campus);
if (role === 'directeur de campus') {
filters.defaultCampus = campusNom;
console.log('đą Campus par dĂ©faut pour directeur:', campusNom);
}
// 3ïžâŁ SERVICES
let servicesQuery = `
SELECT DISTINCT s.Nom
FROM Services s
JOIN CollaborateurAD ca ON ca.ServiceId = s.Id
`;
let servicesConditions = ['(ca.actif = 1 OR ca.actif IS NULL)'];
let servicesRequest = pool.request();
if (selectedSociete && selectedSociete !== 'all') {
servicesQuery += '\nJOIN Societe so ON ca.SocieteId = so.Id';
servicesConditions.push('so.Nom = @selectedSociete');
servicesRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
servicesQuery += '\nJOIN Campus c ON ca.CampusId = c.Id';
servicesConditions.push('c.Nom = @selectedCampus');
servicesRequest.input('selectedCampus', selectedCampus);
}
servicesQuery += `\nWHERE ${servicesConditions.join(' AND ')}\nORDER BY s.Nom`;
const servicesResult = await servicesRequest.query(servicesQuery);
filters.services = servicesResult.recordset.map(s => s.Nom);
// â LISTE DES EMPLOYĂS
let employeesQuery = `
SELECT
CONCAT(ca.prenom, ' ', ca.nom) AS fullname,
c.Nom AS campusnom,
so.Nom AS societenom,
s.Nom AS servicenom
FROM CollaborateurAD ca
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
`;
let employeesConditions = [];
let employeesRequest = pool.request();
if (selectedSociete && selectedSociete !== 'all') {
employeesConditions.push('so.Nom = @selectedSociete');
employeesRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
employeesConditions.push('c.Nom = @selectedCampus');
employeesRequest.input('selectedCampus', selectedCampus);
} else if (role === 'directeur de campus' && campusNom) {
employeesConditions.push('c.Nom = @campusNom');
employeesRequest.input('campusNom', campusNom);
}
if (selectedService && selectedService !== 'all') {
employeesConditions.push('s.Nom = @selectedService');
employeesRequest.input('selectedService', selectedService);
}
if (employeesConditions.length > 0) {
employeesQuery += ` AND ${employeesConditions.join(' AND ')}`;
}
employeesQuery += ` ORDER BY so.Nom, c.Nom, ca.prenom, ca.nom`;
const employeesResult = await employeesRequest.query(employeesQuery);
filters.employees = employeesResult.recordset.map(e => ({
name: e.fullname,
campus: e.campusnom,
societe: e.societenom,
service: e.servicenom
}));
console.log(`đ„ EmployĂ©s trouvĂ©s:`, filters.employees.length);
// QUERY DES CONGĂS
let whereConditions = [`dc.Statut IN ('Validé', 'Validée', 'Valide', 'En attente')`];
let queryRequest = pool.request();
if (selectedSociete && selectedSociete !== 'all') {
whereConditions.push('so.Nom = @selectedSociete');
queryRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
whereConditions.push('c.Nom = @selectedCampus');
queryRequest.input('selectedCampus', selectedCampus);
} else if (role === 'directeur de campus' && campusNom) {
whereConditions.push('c.Nom = @campusNom');
queryRequest.input('campusNom', campusNom);
}
if (selectedService && selectedService !== 'all') {
whereConditions.push('s.Nom = @selectedService');
queryRequest.input('selectedService', selectedService);
}
const query = `
SELECT
CONVERT(VARCHAR(10), dc.DateDebut, 23) AS startdate,
CONVERT(VARCHAR(10), dc.DateFin, 23) AS enddate,
CONCAT(ca.prenom, ' ', ca.nom) AS employeename,
(
SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom)
FROM (SELECT DISTINCT tc2.Nom
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id) AS DistinctTypes
) AS type,
${detailscongesSQL},
MAX(tc.CouleurHex) AS color,
dc.Statut AS statut,
s.Nom AS servicenom,
c.Nom AS campusnom,
so.Nom AS societenom,
dc.NombreJours AS nombrejoursouvres
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
WHERE ${whereConditions.join(' AND ')}
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, ca.prenom, ca.nom, dc.Statut, s.Nom, c.Nom, so.Nom, dc.NombreJours
ORDER BY c.Nom, dc.DateDebut ASC
`;
console.log(`đ Query finale WHERE:`, whereConditions.join(' AND '));
const leavesResult = await queryRequest.query(query);
const formattedLeaves = leavesResult.recordset.map(leave => ({ ...leave }));
console.log(`â
${formattedLeaves.length} congés trouvés`);
return res.json({
success: true,
role: role,
leaves: formattedLeaves,
filters: filters
});
}
// ========================================
// CAS 2: VALIDATEUR
// ========================================
else if (role === 'validateur') {
console.log("CAS 2: Validateur - Vue identique au collaborateur");
console.log(`đ Filtres reçus du frontend: SociĂ©tĂ©=${selectedSociete}, Campus=${selectedCampus}, Service=${selectedService}`);
const isFirstLoad = !selectedCampus && !selectedService && !selectedSociete;
if (isFirstLoad) {
console.log('đŻ Premier chargement validateur : initialisation avec valeurs par dĂ©faut');
selectedCampus = campusNom;
selectedService = serviceNom;
selectedSociete = societeNom;
}
console.log(`đ Filtres appliquĂ©s finaux: SociĂ©tĂ©=${selectedSociete}, Campus=${selectedCampus}, Service=${selectedService}`);
// Sociétés disponibles
const societesRequest = pool.request();
const societesResult = await societesRequest.query(`
SELECT DISTINCT so.Nom
FROM Societe so
JOIN CollaborateurAD ca ON ca.SocieteId = so.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
ORDER BY so.Nom
`);
filters.societes = societesResult.recordset.map(s => s.Nom);
// Campus disponibles
let campusQuery = `
SELECT DISTINCT c.Nom
FROM Campus c
JOIN CollaborateurAD ca ON ca.CampusId = c.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
`;
let campusRequest = pool.request();
if (selectedSociete && selectedSociete !== 'all') {
campusQuery += ` AND ca.SocieteId = (SELECT Id FROM Societe WHERE Nom = @selectedSociete)`;
campusRequest.input('selectedSociete', selectedSociete);
}
campusQuery += ` ORDER BY c.Nom`;
const campusResult = await campusRequest.query(campusQuery);
filters.campus = campusResult.recordset.map(c => c.Nom);
// Services disponibles
let servicesQuery = `
SELECT DISTINCT s.Nom
FROM Services s
JOIN CollaborateurAD ca ON ca.ServiceId = s.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
`;
let servicesRequest = pool.request();
if (selectedSociete && selectedSociete !== 'all') {
servicesQuery += ` AND ca.SocieteId = (SELECT Id FROM Societe WHERE Nom = @selectedSociete)`;
servicesRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
servicesQuery += ` AND ca.CampusId = (SELECT Id FROM Campus WHERE Nom = @selectedCampus)`;
servicesRequest.input('selectedCampus', selectedCampus);
}
servicesQuery += ` ORDER BY s.Nom`;
const servicesResult = await servicesRequest.query(servicesQuery);
filters.services = servicesResult.recordset.map(s => s.Nom);
filters.defaultCampus = campusNom;
filters.defaultService = serviceNom;
filters.defaultSociete = societeNom;
// â LISTE DES EMPLOYĂS
let employeesQuery = `
SELECT
CONCAT(ca.prenom, ' ', ca.nom) AS fullname,
c.Nom AS campusnom,
so.Nom AS societenom,
s.Nom AS servicenom
FROM CollaborateurAD ca
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
`;
let employeesRequest = pool.request();
let employeesConditions = [];
if (selectedSociete && selectedSociete !== 'all') {
employeesConditions.push('so.Nom = @selectedSociete');
employeesRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
employeesConditions.push('c.Nom = @selectedCampus');
employeesRequest.input('selectedCampus', selectedCampus);
}
if (selectedService && selectedService !== 'all') {
employeesConditions.push('s.Nom = @selectedService');
employeesRequest.input('selectedService', selectedService);
}
if (employeesConditions.length > 0) {
employeesQuery += ` AND ${employeesConditions.join(' AND ')}`;
}
employeesQuery += ` ORDER BY s.Nom, ca.prenom, ca.nom`;
const employeesResult = await employeesRequest.query(employeesQuery);
filters.employees = employeesResult.recordset.map(emp => ({
name: emp.fullname,
campus: emp.campusnom,
societe: emp.societenom,
service: emp.servicenom
}));
console.log(`đ„ EmployĂ©s trouvĂ©s: ${filters.employees.length}`);
// QUERY DES CONGĂS
let queryConditions = `WHERE dc.Statut IN ('Validé', 'Validée', 'Valide', 'En attente')`;
let queryRequest = pool.request();
let congesConditions = [];
if (selectedSociete && selectedSociete !== 'all') {
congesConditions.push('so.Nom = @selectedSociete');
queryRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
congesConditions.push('c.Nom = @selectedCampus');
queryRequest.input('selectedCampus', selectedCampus);
}
if (selectedService && selectedService !== 'all') {
congesConditions.push('s.Nom = @selectedService');
queryRequest.input('selectedService', selectedService);
}
if (congesConditions.length > 0) {
queryConditions += ` AND ${congesConditions.join(' AND ')}`;
}
const query = `
SELECT
CONVERT(VARCHAR(10), dc.DateDebut, 23) AS startdate,
CONVERT(VARCHAR(10), dc.DateFin, 23) AS enddate,
CONCAT(ca.prenom, ' ', ca.nom) AS employeename,
(
SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom)
FROM (SELECT DISTINCT tc2.Nom
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id) AS DistinctTypes
) AS type,
${detailscongesSQL},
MAX(tc.CouleurHex) AS color,
dc.Statut AS statut,
s.Nom AS servicenom,
c.Nom AS campusnom,
so.Nom AS societenom,
dc.NombreJours AS nombrejoursouvres
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
${queryConditions}
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, ca.prenom, ca.nom, dc.Statut, s.Nom, c.Nom, so.Nom, dc.NombreJours
ORDER BY s.Nom, dc.DateDebut ASC
`;
console.log(`đ Query WHERE final:`, queryConditions);
const leavesResult = await queryRequest.query(query);
const formattedLeaves = leavesResult.recordset.map(leave => ({ ...leave }));
console.log(`â
${formattedLeaves.length} congés trouvés`);
return res.json({
success: true,
role: role,
leaves: formattedLeaves,
filters: filters
});
}
// ========================================
// CAS 3: COLLABORATEUR / APPRENTI
// ========================================
else if (role === 'collaborateur' || role === 'apprenti' || role === 'rh') {
console.log("CAS 3: Collaborateur/Apprenti avec filtres avancés");
console.log(`đ Filtres reçus du frontend: SociĂ©tĂ©=${selectedSociete}, Campus=${selectedCampus}, Service=${selectedService}`);
const isFirstLoad = !selectedCampus && !selectedService && !selectedSociete;
if (isFirstLoad) {
console.log('đŻ Premier chargement : initialisation avec service par dĂ©faut');
selectedCampus = campusNom;
selectedService = serviceNom;
selectedSociete = societeNom;
}
console.log(`đ Filtres appliquĂ©s finaux: SociĂ©tĂ©=${selectedSociete}, Campus=${selectedCampus}, Service=${selectedService}`);
// Sociétés disponibles
const societesRequest = pool.request();
const societesResult = await societesRequest.query(`
SELECT DISTINCT so.Nom
FROM Societe so
JOIN CollaborateurAD ca ON ca.SocieteId = so.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
ORDER BY so.Nom
`);
filters.societes = societesResult.recordset.map(s => s.Nom);
// Campus disponibles
let campusQuery = `
SELECT DISTINCT c.Nom
FROM Campus c
JOIN CollaborateurAD ca ON ca.CampusId = c.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
`;
let campusRequest = pool.request();
if (selectedSociete && selectedSociete !== 'all') {
campusQuery += ` AND ca.SocieteId = (SELECT Id FROM Societe WHERE Nom = @selectedSociete)`;
campusRequest.input('selectedSociete', selectedSociete);
}
campusQuery += ` ORDER BY c.Nom`;
const campusResult = await campusRequest.query(campusQuery);
filters.campus = campusResult.recordset.map(c => c.Nom);
// Services disponibles
let servicesQuery = `
SELECT DISTINCT s.Nom
FROM Services s
JOIN CollaborateurAD ca ON ca.ServiceId = s.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
`;
let servicesRequest = pool.request();
if (selectedSociete && selectedSociete !== 'all') {
servicesQuery += ` AND ca.SocieteId = (SELECT Id FROM Societe WHERE Nom = @selectedSociete)`;
servicesRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
servicesQuery += ` AND ca.CampusId = (SELECT Id FROM Campus WHERE Nom = @selectedCampus)`;
servicesRequest.input('selectedCampus', selectedCampus);
}
servicesQuery += ` ORDER BY s.Nom`;
const servicesResult = await servicesRequest.query(servicesQuery);
filters.services = servicesResult.recordset.map(s => s.Nom);
filters.defaultCampus = campusNom;
filters.defaultService = serviceNom;
filters.defaultSociete = societeNom;
// â LISTE DES EMPLOYĂS
let employeesQuery = `
SELECT
CONCAT(ca.prenom, ' ', ca.nom) AS fullname,
c.Nom AS campusnom,
so.Nom AS societenom,
s.Nom AS servicenom
FROM CollaborateurAD ca
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
`;
let employeesRequest = pool.request();
let employeesConditions = [];
if (selectedSociete && selectedSociete !== 'all') {
employeesConditions.push('so.Nom = @selectedSociete');
employeesRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
employeesConditions.push('c.Nom = @selectedCampus');
employeesRequest.input('selectedCampus', selectedCampus);
}
if (selectedService && selectedService !== 'all') {
employeesConditions.push('s.Nom = @selectedService');
employeesRequest.input('selectedService', selectedService);
}
if (employeesConditions.length > 0) {
employeesQuery += ` AND ${employeesConditions.join(' AND ')}`;
}
employeesQuery += ` ORDER BY s.Nom, ca.prenom, ca.nom`;
const employeesResult = await employeesRequest.query(employeesQuery);
filters.employees = employeesResult.recordset.map(emp => ({
name: emp.fullname,
campus: emp.campusnom,
societe: emp.societenom,
service: emp.servicenom
}));
console.log(`đ„ EmployĂ©s trouvĂ©s: ${filters.employees.length}`);
// QUERY DES CONGĂS
let queryConditions = `WHERE dc.Statut IN ('Validé', 'Validée', 'Valide', 'En attente')`;
let queryRequest = pool.request();
let congesConditions = [];
if (selectedSociete && selectedSociete !== 'all') {
congesConditions.push('so.Nom = @selectedSociete');
queryRequest.input('selectedSociete', selectedSociete);
}
if (selectedCampus && selectedCampus !== 'all') {
congesConditions.push('c.Nom = @selectedCampus');
queryRequest.input('selectedCampus', selectedCampus);
}
if (selectedService && selectedService !== 'all') {
congesConditions.push('s.Nom = @selectedService');
queryRequest.input('selectedService', selectedService);
}
if (congesConditions.length > 0) {
queryConditions += ` AND ${congesConditions.join(' AND ')}`;
}
const query = `
SELECT
CONVERT(VARCHAR(10), dc.DateDebut, 23) AS startdate,
CONVERT(VARCHAR(10), dc.DateFin, 23) AS enddate,
CONCAT(ca.prenom, ' ', ca.nom) AS employeename,
(
SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom)
FROM (SELECT DISTINCT tc2.Nom
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id) AS DistinctTypes
) AS type,
${detailscongesSQL},
MAX(tc.CouleurHex) AS color,
dc.Statut AS statut,
s.Nom AS servicenom,
c.Nom AS campusnom,
so.Nom AS societenom,
dc.NombreJours AS nombrejoursouvres
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
${queryConditions}
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, ca.prenom, ca.nom, dc.Statut, s.Nom, c.Nom, so.Nom, dc.NombreJours
ORDER BY s.Nom, dc.DateDebut ASC
`;
console.log(`đ Query WHERE final:`, queryConditions);
const leavesResult = await queryRequest.query(query);
const formattedLeaves = leavesResult.recordset.map(leave => ({ ...leave }));
console.log(`â
${formattedLeaves.length} congés trouvés`);
return res.json({
success: true,
role: role,
leaves: formattedLeaves,
filters: filters
});
}
// ========================================
// CAS 4: AUTRES RĂLES (Fallback)
// ========================================
else {
console.log("CAS 4: Autres rĂŽles - Fallback service/campus");
if (!serviceId) {
return res.json({ success: false, message: 'ServiceId manquant' });
}
const checkServiceRequest = pool.request();
checkServiceRequest.input('serviceId', serviceId);
const checkServiceResult = await checkServiceRequest.query(`SELECT Nom FROM Services WHERE Id = @serviceId`);
const serviceNomCheck = checkServiceResult.recordset.length > 0 ? checkServiceResult.recordset[0].Nom : "Inconnu";
const isAdminFinancier = serviceNomCheck === "Administratif & Financier";
if (isAdminFinancier) {
const employeesRequest = pool.request();
employeesRequest.input('serviceId', serviceId);
const employeesResult = await employeesRequest.query(`
SELECT
CONCAT(ca.prenom, ' ', ca.nom) AS fullname,
c.Nom AS campusnom,
so.Nom AS societenom,
s.Nom AS servicenom
FROM CollaborateurAD ca
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
WHERE ca.ServiceId = @serviceId
AND (ca.actif = 1 OR ca.actif IS NULL)
ORDER BY ca.prenom, ca.nom
`);
filters.employees = employeesResult.recordset.map(e => ({
name: e.fullname,
campus: e.campusnom,
societe: e.societenom,
service: e.servicenom
}));
const queryRequest = pool.request();
queryRequest.input('serviceId', serviceId);
const query = `
SELECT
CONVERT(VARCHAR(10), dc.DateDebut, 23) AS startdate,
CONVERT(VARCHAR(10), dc.DateFin, 23) AS enddate,
CONCAT(ca.prenom, ' ', ca.nom) AS employeename,
(
SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom)
FROM (SELECT DISTINCT tc2.Nom
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id) AS DistinctTypes
) AS type,
${detailscongesSQL},
MAX(tc.CouleurHex) AS color,
dc.Statut AS statut,
s.Nom AS servicenom,
c.Nom AS campusnom,
so.Nom AS societenom,
dc.NombreJours AS nombrejoursouvres
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
WHERE dc.Statut IN ('Validé', 'Validée', 'Valide', 'En attente')
AND ca.ServiceId = @serviceId
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, ca.prenom, ca.nom, dc.Statut, s.Nom, c.Nom, so.Nom, dc.NombreJours
ORDER BY c.Nom, dc.DateDebut ASC
`;
const leavesResult = await queryRequest.query(query);
const formattedLeaves = leavesResult.recordset.map(leave => ({ ...leave }));
console.log(`â
${formattedLeaves.length} congés trouvés`);
return res.json({
success: true,
role: role,
leaves: formattedLeaves,
filters: filters
});
} else {
const employeesRequest = pool.request();
employeesRequest.input('serviceId', serviceId);
employeesRequest.input('campusId', campusId);
const employeesResult = await employeesRequest.query(`
SELECT
CONCAT(ca.prenom, ' ', ca.nom) AS fullname,
c.Nom AS campusnom,
so.Nom AS societenom,
s.Nom AS servicenom
FROM CollaborateurAD ca
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
WHERE ca.ServiceId = @serviceId
AND ca.CampusId = @campusId
AND (ca.actif = 1 OR ca.actif IS NULL)
ORDER BY ca.prenom, ca.nom
`);
filters.employees = employeesResult.recordset.map(e => ({
name: e.fullname,
campus: e.campusnom,
societe: e.societenom,
service: e.servicenom
}));
const queryRequest = pool.request();
queryRequest.input('serviceId', serviceId);
queryRequest.input('campusId', campusId);
const query = `
SELECT
CONVERT(VARCHAR(10), dc.DateDebut, 23) AS startdate,
CONVERT(VARCHAR(10), dc.DateFin, 23) AS enddate,
CONCAT(ca.prenom, ' ', ca.nom) AS employeename,
(
SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom)
FROM (SELECT DISTINCT tc2.Nom
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id) AS DistinctTypes
) AS type,
${detailscongesSQL},
MAX(tc.CouleurHex) AS color,
dc.Statut AS statut,
s.Nom AS servicenom,
c.Nom AS campusnom,
so.Nom AS societenom,
dc.NombreJours AS nombrejoursouvres
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON ca.CampusId = c.Id
JOIN Societe so ON ca.SocieteId = so.Id
WHERE dc.Statut IN ('Validé', 'Validée', 'Valide', 'En attente')
AND ca.ServiceId = @serviceId
AND ca.CampusId = @campusId
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, ca.prenom, ca.nom, dc.Statut, s.Nom, c.Nom, so.Nom, dc.NombreJours
ORDER BY c.Nom, dc.DateDebut ASC
`;
const leavesResult = await queryRequest.query(query);
const formattedLeaves = leavesResult.recordset.map(leave => ({ ...leave }));
console.log(`â
${formattedLeaves.length} congés trouvés`);
return res.json({
success: true,
role: role,
leaves: formattedLeaves,
filters: filters
});
}
}
} catch (error) {
console.error("â Erreur getTeamLeaves:", error);
res.status(500).json({
success: false,
message: "Erreur serveur",
error: error.message
});
}
});;
// ========================================
// SYNCHRONISATION ENTRA ID CORRIGĂE
// Ă remplacer dans server.js Ă partir de la ligne ~3700
// ========================================
app.post('/api/initial-sync', async (req, res) => {
let errorCount = 0;
const syncResults = {
processed: 0,
inserted: 0,
updated: 0,
deactivated: 0,
errors: []
};
try {
console.log('\nđ === DĂBUT SYNCHRONISATION ENTRA ID ===');
// 1ïžâŁ Obtenir le token Admin
const accessToken = await getGraphToken();
if (!accessToken) {
return res.json({
success: false,
message: 'â Impossible d\'obtenir le token Microsoft Graph'
});
}
console.log('â
Token Microsoft Graph obtenu');
// =============================================================================
// SCĂNARIO 1 : Synchronisation unitaire (Un seul utilisateur spĂ©cifique)
// =============================================================================
if (req.body.userPrincipalName || req.body.mail) {
const userEmail = (req.body.mail || req.body.userPrincipalName).toLowerCase().trim();
const entraUserId = req.body.id;
console.log(`\nđ Synchronisation utilisateur unique : ${userEmail}`);
// â VALIDATION : Email requis
if (!userEmail || userEmail === '') {
return res.json({
success: false,
message: 'â Email utilisateur manquant ou invalide'
});
}
// â VALIDATION : Format email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(userEmail)) {
return res.json({
success: false,
message: `â Format d'email invalide : ${userEmail}`
});
}
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// VĂ©rifier si l'utilisateur existe dĂ©jĂ
const [existing] = await conn.query(
'SELECT id, email, Actif FROM CollaborateurAD WHERE LOWER(email) = ?',
[userEmail]
);
if (existing.length > 0) {
// MISE Ă JOUR
await conn.query(`
UPDATE CollaborateurAD
SET
entraUserId = ?,
prenom = ?,
nom = ?,
service = ?,
description = ?,
Actif = 1
WHERE LOWER(email) = ?
`, [
entraUserId || existing[0].entraUserId,
req.body.givenName || 'Prénom',
req.body.surname || 'Nom',
req.body.department || '',
req.body.jobTitle || null,
userEmail
]);
console.log(` â
Utilisateur mis Ă jour : ${userEmail}`);
syncResults.updated++;
} else {
// INSERTION
await conn.query(`
INSERT INTO CollaborateurAD
(entraUserId, prenom, nom, email, service, description, role, SocieteId, Actif, DateEntree, TypeContrat)
VALUES (?, ?, ?, ?, ?, ?, 'Collaborateur', 1, 1, GETDATE(), '37h')
`, [
entraUserId,
req.body.givenName || 'Prénom',
req.body.surname || 'Nom',
userEmail,
req.body.department || '',
req.body.jobTitle || null
]);
console.log(` â
Nouvel utilisateur créé : ${userEmail}`);
syncResults.inserted++;
}
// Récupération des données fraßches
const [userRows] = await conn.query(`
SELECT
ca.id as localUserId,
ca.entraUserId,
ca.prenom,
ca.nom,
ca.email,
ca.role,
s.Nom as service,
ca.TypeContrat as typeContrat,
ca.DateEntree as dateEntree,
ca.description,
ca.CampusId,
ca.SocieteId,
so.Nom as societe_nom
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Societe so ON ca.SocieteId = so.Id
WHERE LOWER(ca.email) = ?
`, [userEmail]);
await conn.commit();
if (userRows.length === 0) {
await conn.rollback();
throw new Error('Utilisateur synchronisé mais introuvable en base');
}
const userData = userRows[0];
console.log(`â
Synchronisation réussie : ${userData.email}`);
return res.json({
success: true,
message: 'Utilisateur synchronisé avec succÚs',
localUserId: userData.localUserId,
role: userData.role,
service: userData.service,
typeContrat: userData.typeContrat,
dateEntree: userData.dateEntree,
societeId: userData.SocieteId,
user: userData
});
} catch (syncError) {
await conn.rollback();
console.error('â Erreur sync unitaire:', syncError);
return res.json({
success: false,
message: `â Erreur synchronisation: ${syncError.message}`
});
} finally {
conn.release();
}
}
// =============================================================================
// SCĂNARIO 2 : Full Sync (Tous les membres du groupe Azure)
// =============================================================================
console.log('\nđ === FULL SYNC - Tous les membres du groupe ===');
// A. Récupérer le nom du groupe
const groupResponse = await axios.get(
`https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
const groupName = groupResponse.data.displayName;
console.log(`đ Groupe : ${groupName}`);
// B. Récupérer TOUS les membres avec pagination
let allAzureMembers = [];
let nextLink = `https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}/members?$select=id,givenName,surname,mail,department,jobTitle,accountEnabled&$top=999`;
console.log('đ„ RĂ©cupĂ©ration des membres...');
while (nextLink) {
const membersResponse = await axios.get(nextLink, {
headers: { Authorization: `Bearer ${accessToken}` }
});
allAzureMembers = allAzureMembers.concat(membersResponse.data.value);
nextLink = membersResponse.data['@odata.nextLink'];
if (nextLink) {
console.log(` đ ${allAzureMembers.length} membres rĂ©cupĂ©rĂ©s... (suite)`);
}
}
console.log(`â
${allAzureMembers.length} membres trouvés dans Entra ID`);
// C. Filtrer et valider les emails
const validMembers = allAzureMembers.filter(m => {
// Ignorer si pas d'email
if (!m.mail || m.mail.trim() === '') {
console.log(` â ïž IgnorĂ© (pas d'email) : ${m.givenName} ${m.surname} (${m.id})`);
return false;
}
// Ignorer si compte désactivé
if (m.accountEnabled === false) {
console.log(` â ïž IgnorĂ© (compte dĂ©sactivĂ©) : ${m.mail}`);
return false;
}
// Valider format email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(m.mail)) {
console.log(` â ïž IgnorĂ© (format email invalide) : ${m.mail}`);
return false;
}
return true;
});
console.log(`â
${validMembers.length} membres valides Ă synchroniser`);
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// D. Construire la liste des emails valides
const azureEmails = new Set();
validMembers.forEach(m => {
azureEmails.add(m.mail.toLowerCase().trim());
});
console.log('\nđ Traitement des utilisateurs...');
// E. Traitement de chaque membre valide
for (const m of validMembers) {
try {
const emailClean = m.mail.toLowerCase().trim();
syncResults.processed++;
// VĂ©rifier si l'utilisateur existe dĂ©jĂ
const [existing] = await conn.query(
'SELECT id, email, entraUserId, Actif FROM CollaborateurAD WHERE LOWER(email) = ?',
[emailClean]
);
if (existing.length > 0) {
// MISE Ă JOUR si changements
await conn.query(`
UPDATE CollaborateurAD
SET
entraUserId = ?,
prenom = ?,
nom = ?,
service = ?,
description = ?,
Actif = 1
WHERE LOWER(email) = ?
`, [
m.id,
m.givenName || existing[0].prenom || 'Prénom',
m.surname || existing[0].nom || 'Nom',
m.department || '',
m.jobTitle || null,
emailClean
]);
syncResults.updated++;
console.log(` â Mis Ă jour : ${emailClean}`);
} else {
// INSERTION nouveau
await conn.query(`
INSERT INTO CollaborateurAD
(entraUserId, prenom, nom, email, service, description, role, SocieteId, Actif, DateEntree, TypeContrat)
VALUES (?, ?, ?, ?, ?, ?, 'Collaborateur', 1, 1, GETDATE(), '37h')
`, [
m.id,
m.givenName || 'Prénom',
m.surname || 'Nom',
emailClean,
m.department || '',
m.jobTitle || null
]);
syncResults.inserted++;
console.log(` â Créé : ${emailClean}`);
}
} catch (userError) {
errorCount++;
syncResults.errors.push({
email: m.mail,
error: userError.message
});
console.error(` â Erreur ${m.mail}:`, userError.message);
// Continuer avec les autres
}
}
// F. â DĂSACTIVATION SĂCURISĂE
console.log('\nđ DĂ©sactivation des comptes obsolĂštes...');
if (azureEmails.size > 0) {
const activeEmailsArray = Array.from(azureEmails);
const placeholders = activeEmailsArray.map(() => '?').join(',');
// â REQUĂTE PROTĂGĂE : Ne dĂ©sactive que les comptes qui :
// 1. Ont un email valide
// 2. Ne sont pas dans Entra ID
// 3. Ne sont pas RH/Admin/President
// 4. Sont actuellement actifs
const [resultDeactivate] = await conn.query(`
UPDATE CollaborateurAD
SET Actif = 0
WHERE
Email IS NOT NULL
AND Email != ''
AND Email NOT LIKE '%@noemail.local'
AND LOWER(Email) NOT IN (${placeholders})
AND Actif = 1
AND role NOT IN ('RH', 'Admin', 'President')
`, activeEmailsArray);
syncResults.deactivated = resultDeactivate.affectedRows;
console.log(` â ${syncResults.deactivated} compte(s) dĂ©sactivĂ©(s)`);
}
await conn.commit();
// G. Logging final
console.log('\nđ === RĂSUMĂ SYNCHRONISATION ===');
console.log(` Groupe Azure: ${groupName}`);
console.log(` Total membres Entra: ${allAzureMembers.length}`);
console.log(` Membres valides: ${validMembers.length}`);
console.log(` Traités: ${syncResults.processed}`);
console.log(` Créés: ${syncResults.inserted}`);
console.log(` Mis Ă jour: ${syncResults.updated}`);
console.log(` Désactivés: ${syncResults.deactivated}`);
console.log(` Erreurs: ${errorCount}`);
res.json({
success: true,
message: 'Synchronisation globale terminée',
groupe_sync: groupName,
stats: {
total_azure: allAzureMembers.length,
membres_valides: validMembers.length,
processed: syncResults.processed,
inserted: syncResults.inserted,
updated: syncResults.updated,
deactivated: syncResults.deactivated,
errors: errorCount,
error_details: syncResults.errors.length > 0 ? syncResults.errors : undefined
}
});
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
} catch (error) {
console.error('\nâ === ERREUR CRITIQUE SYNCHRONISATION ===');
console.error('Message:', error.message);
console.error('Stack:', error.stack);
res.status(500).json({
success: false,
message: 'Erreur lors de la synchronisation',
error: error.message,
stats: syncResults
});
}
});
''
// ========================================
// NOUVELLES ROUTES ADMINISTRATION RTT
// ========================================
app.get('/api/getAllCollaborateurs', async (req, res) => {
try {
const [collaborateurs] = await pool.query(`
SELECT
ca.id,
ca.prenom,
ca.nom,
ca.email,
ca.role,
ca.TypeContrat,
ca.DateEntree,
s.Nom as service,
ca.CampusId,
ca.SocieteId,
so.Nom as societe_nom
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Societe so ON ca.SocieteId = so.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
ORDER BY ca.nom, ca.prenom
`);
res.json({
success: true,
collaborateurs: collaborateurs,
total: collaborateurs.length
});
} catch (error) {
console.error('Erreur getAllCollaborateurs:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
app.post('/api/updateTypeContrat', async (req, res) => {
try {
const { collaborateur_id, type_contrat } = req.body;
if (!collaborateur_id || !type_contrat) {
return res.json({
success: false,
message: 'Données manquantes'
});
}
const typesValides = ['37h', 'forfait_jour', 'temps_partiel'];
if (!typesValides.includes(type_contrat)) {
return res.json({
success: false,
message: 'Type de contrat invalide'
});
}
const [collab] = await pool.query(
'SELECT prenom, nom, CampusId FROM CollaborateurAD WHERE id = ?',
[collaborateur_id]
);
if (collab.length === 0) {
return res.json({
success: false,
message: 'Collaborateur non trouvé'
});
}
await pool.query(
'UPDATE CollaborateurAD SET TypeContrat = ? WHERE id = ?',
[type_contrat, collaborateur_id]
);
res.json({
success: true,
message: 'Type de contrat mis Ă jour',
nom: `${collab[0].prenom} ${collab[0].nom}`,
nouveau_type: type_contrat
});
} catch (error) {
console.error('Erreur updateTypeContrat:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
app.get('/api/getConfigurationRTT', async (req, res) => {
try {
const annee = parseInt(req.query.annee || new Date().getFullYear());
const [configs] = await pool.query(
`SELECT Annee, TypeContrat, JoursAnnuels, AcquisitionMensuelle, Description
FROM ConfigurationRTT
WHERE Annee = ?
ORDER BY TypeContrat`,
[annee]
);
res.json({ success: true, configs });
} catch (error) {
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
}
});
app.post('/api/updateConfigurationRTT', async (req, res) => {
try {
const { annee, typeContrat, joursAnnuels } = req.body;
if (!annee || !typeContrat || !joursAnnuels) {
return res.json({ success: false, message: 'Données manquantes' });
}
const acquisitionMensuelle = joursAnnuels / 12;
await pool.query(
`INSERT INTO ConfigurationRTT (Annee, TypeContrat, JoursAnnuels, AcquisitionMensuelle)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
JoursAnnuels = ?, AcquisitionMensuelle = ?`,
[annee, typeContrat, joursAnnuels, acquisitionMensuelle, joursAnnuels, acquisitionMensuelle]
);
res.json({ success: true, message: 'Configuration mise Ă jour' });
} catch (error) {
res.status(500).json({ success: false, message: 'Erreur', error: error.message });
}
});
app.post('/api/updateRequest', upload.array('medicalDocuments', 5), async (req, res) => {
let connection;
try {
console.log('\nđ„ === MODIFICATION DEMANDE ===');
console.log('Body reçu:', req.body);
console.log('Fichiers reçus:', req.files?.length || 0);
const {
requestId, leaveType, startDate, endDate,
reason, businessDays, userId, userEmail, userName, accessToken
} = req.body;
let Repartition;
try {
Repartition = JSON.parse(req.body.Repartition || '[]');
console.log('đ RĂ©partition parsĂ©e:', JSON.stringify(Repartition, null, 2));
} catch (parseError) {
console.error('â Erreur parsing Repartition:', parseError);
req.files?.forEach(file => { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); });
return res.status(400).json({ success: false, message: 'Erreur de format de la répartition' });
}
if (!requestId || !leaveType || !startDate || !endDate || !businessDays || !userId) {
req.files?.forEach(file => { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); });
return res.status(400).json({ success: false, message: 'â DonnĂ©es manquantes' });
}
connection = await pool.getConnection();
const q = async (sqlText, params = {}, usePoolDirectly = false) => {
const request = (usePoolDirectly || !connection || typeof connection.request !== 'function')
? pool.request()
: connection.request();
for (const [key, value] of Object.entries(params)) request.input(key, value);
const result = await request.query(sqlText);
return result.recordset || [];
};
await connection.beginTransaction();
const uploadedFiles = req.files || [];
console.log(`Demande ID: ${requestId}, User ID: ${userId}`);
// ââ Calcul anneeN dynamique (CP et RTT) ââââââââââââââââââââââ
const today = new Date();
const currentYear = today.getFullYear();
const dateBascule = new Date(`${currentYear}-06-01`);
const apresBasculement = today >= dateBascule;
const anneeN = apresBasculement ? currentYear + 1 : currentYear;
const anneeN1 = apresBasculement ? currentYear : currentYear - 1;
// TypeContrat du collaborateur (nécessaire pour taux RTT anticipé)
const collabInfoUpdate = await q(
'SELECT TOP 1 TypeContrat FROM CollaborateurAD WHERE id = @userId',
{ userId }
);
const typeContrat = collabInfoUpdate[0]?.TypeContrat || '37h';
// 1ïžâŁ RĂCUPĂRER LA DEMANDE ORIGINALE ââââââââââââââââââââââââââ
const originalRequest = await q(
'SELECT * FROM DemandeConge WHERE Id = @requestId AND CollaborateurADId = @userId',
{ requestId, userId }
);
if (!originalRequest || originalRequest.length === 0) {
await connection.rollback();
req.files?.forEach(file => { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); });
return res.status(404).json({ success: false, message: 'â Demande introuvable ou non autorisĂ©e' });
}
const original = originalRequest[0];
const oldStatus = original.Statut;
console.log(`đ Demande originale: Statut=${oldStatus}`);
const aujourdhui = new Date();
aujourdhui.setHours(0, 0, 0, 0);
const dateDebutOriginale = original.DateDebut ? new Date(original.DateDebut) : null;
if (dateDebutOriginale) dateDebutOriginale.setHours(0, 0, 0, 0);
if (dateDebutOriginale && dateDebutOriginale <= aujourdhui) {
await connection.rollback();
req.files?.forEach(file => { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); });
return res.status(400).json({
success: false,
message: 'â Impossible de modifier : la date de dĂ©but est dĂ©jĂ passĂ©e ou c\'est aujourd\'hui',
dateDebut: dateDebutOriginale.toISOString().split('T')[0]
});
}
const hasABS = Repartition.some(r => r.TypeConge === 'ABS');
if (hasABS && uploadedFiles.length === 0) {
await connection.rollback();
return res.json({ success: false, message: 'Un justificatif mĂ©dical est obligatoire pour un arrĂȘt maladie' });
}
const sommeRepartition = Repartition.reduce((sum, r) => {
if (['CP', 'RTT', 'Récup'].includes(r.TypeConge)) return sum + parseFloat(r.NombreJours || 0);
return sum;
}, 0);
const hasCountableLeave = Repartition.some(r => ['CP', 'RTT', 'Récup'].includes(r.TypeConge));
if (hasCountableLeave && Math.abs(sommeRepartition - businessDays) > 0.01) {
req.files?.forEach(file => { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); });
await connection.rollback();
return res.json({
success: false,
message: `Erreur de répartition : la somme (${sommeRepartition.toFixed(2)}j) ne correspond pas au total (${businessDays}j)`
});
}
// 2ïžâŁ REMBOURSER L'ANCIENNE DEMANDE âââââââââââââââââââââââââââ
// â
Plus de SoldeAnticipe â on rembourse uniquement Solde
// â
Pour 'Anticipé N+1' : juste suppression DeductionDetails (rien n'avait été déduit du Solde)
let restorationStats = { count: 0, details: [] };
if (oldStatus !== 'Refusée' && oldStatus !== 'Annulée' && original.TypeCongeId !== 3) {
console.log(`đ Remboursement de l'ancienne demande...`);
const oldDeductions = await q(
'SELECT * FROM DeductionDetails WHERE DemandeCongeId = @requestId',
{ requestId }
);
if (oldDeductions && oldDeductions.length > 0) {
for (const d of oldDeductions) {
// â
AnticipĂ© TolĂ©rĂ© â skip (jamais dĂ©duit du Solde)
if (d.TypeDeduction === 'Anticipé Toléré') {
console.log(` âïž SKIP: ${d.JoursUtilises}j "AnticipĂ© TolĂ©rĂ©" (non acquis)`);
continue;
}
// â
AnticipĂ© N+1 (nouvelle logique) â rien Ă rembourser en Solde
// La ligne DeductionDetails sera supprimée en bloc plus bas
if (d.TypeDeduction === 'Anticipé N+1') {
console.log(` âïž SKIP Solde pour AnticipĂ© N+1 (${d.JoursUtilises}j) â sera supprimĂ© avec DELETE`);
restorationStats.count++;
restorationStats.details.push({
typeCongeId: d.TypeCongeId,
annee: d.Annee,
joursRendus: parseFloat(d.JoursUtilises),
isAnticipe: true,
typeDeduction: d.TypeDeduction
});
continue;
}
// â
AnticipFutur â jamais dĂ©duit du Solde, juste suppression DeductionDetails
if (d.TypeDeduction === 'AnticipFutur') {
console.log(` âïž SKIP Solde pour AnticipFutur (${d.JoursUtilises}j) â tracĂ© uniquement, sera supprimĂ© avec DELETE`);
restorationStats.count++;
restorationStats.details.push({
typeCongeId: d.TypeCongeId,
annee: d.Annee,
joursRendus: 0,
isAnticipe: true,
typeDeduction: d.TypeDeduction
});
continue;
}
// â
AnticipNp1 (CP) â dĂ©duit du Solde CompteurConges N+1 â remettre
if (d.TypeDeduction === 'AnticipNp1') {
const compteurNp1 = await q(
`SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`,
{ userId, typeCongeId: d.TypeCongeId, annee: d.Annee }
);
if (compteurNp1?.length > 0) {
const newSolde = parseFloat((parseFloat(compteurNp1[0].Solde) + parseFloat(d.JoursUtilises)).toFixed(2));
await q(
'UPDATE CompteurConges SET Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id',
{ solde: newSolde, id: compteurNp1[0].Id }
);
console.log(` â
AnticipNp1 restaurĂ©: Solde N+1 +${d.JoursUtilises}j â ${newSolde}j`);
}
restorationStats.count++;
restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: parseFloat(d.JoursUtilises), typeDeduction: d.TypeDeduction });
continue;
}
// â
AnticipRTT â solde RTT N rĂ©el dĂ©duit â remettre
if (d.TypeDeduction === 'AnticipRTT') {
const compteur = await q(
`SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`,
{ userId, typeCongeId: d.TypeCongeId, annee: d.Annee }
);
if (compteur?.length > 0) {
const newSolde = parseFloat((parseFloat(compteur[0].Solde) + parseFloat(d.JoursUtilises)).toFixed(2));
await q('UPDATE CompteurConges SET Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id', { solde: newSolde, id: compteur[0].Id });
console.log(` â
AnticipRTT restaurĂ©: Solde +${d.JoursUtilises}j â ${newSolde}j`);
}
restorationStats.count++;
restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: parseFloat(d.JoursUtilises), typeDeduction: d.TypeDeduction });
continue;
}
// â
AnticipFuturRTT â tracĂ© uniquement, aucun solde Ă remettre
if (d.TypeDeduction === 'AnticipFuturRTT') {
console.log(` âïž SKIP Solde pour AnticipFuturRTT (${d.JoursUtilises}j) â tracĂ© uniquement`);
restorationStats.count++;
restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: 0, typeDeduction: d.TypeDeduction });
continue;
}
// â
AnticipNp1RTT (RTT N+1) â dĂ©duit du Solde CompteurConges RTT N+1 â remettre
if (d.TypeDeduction === 'AnticipNp1RTT') {
const compteur = await q(
`SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`,
{ userId, typeCongeId: d.TypeCongeId, annee: d.Annee }
);
if (compteur?.length > 0) {
const newSolde = parseFloat((parseFloat(compteur[0].Solde) + parseFloat(d.JoursUtilises)).toFixed(2));
await q('UPDATE CompteurConges SET Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id', { solde: newSolde, id: compteur[0].Id });
console.log(` â
AnticipNp1RTT restaurĂ©: Solde RTT N+1 +${d.JoursUtilises}j â ${newSolde}j`);
}
restorationStats.count++;
restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: parseFloat(d.JoursUtilises), typeDeduction: d.TypeDeduction });
continue;
}
// â
Legacy 'N Anticipé' / 'N+1 Anticipé' (ancienne logique)
// Le Solde avait Ă©tĂ© dĂ©duit â on le rembourse
const isAncienAnticipe = d.TypeDeduction && (
d.TypeDeduction === 'N Anticipé' ||
d.TypeDeduction === 'N+1 Anticipé'
);
const compteur = await q(
`SELECT Id, Solde FROM CompteurConges
WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`,
{ userId, typeCongeId: d.TypeCongeId, annee: d.Annee }
);
if (compteur && compteur.length > 0) {
const joursUtilises = parseFloat(d.JoursUtilises);
const newSolde = parseFloat(compteur[0].Solde) + joursUtilises;
await q(
'UPDATE CompteurConges SET Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id',
{ solde: newSolde, id: compteur[0].Id }
);
console.log(` â
RemboursĂ© ${joursUtilises}j (Solde) â Type=${d.TypeDeduction}, AnnĂ©e=${d.Annee}${isAncienAnticipe ? ' [legacy]' : ''}`);
restorationStats.count++;
restorationStats.details.push({
typeCongeId: d.TypeCongeId,
annee: d.Annee,
joursRendus: joursUtilises,
isAnticipe: isAncienAnticipe,
typeDeduction: d.TypeDeduction
});
}
}
// Supprime toutes les déductions de cette demande en une fois
await q(
'DELETE FROM DeductionDetails WHERE DemandeCongeId = @requestId',
{ requestId }
);
console.log(` đ§č ${oldDeductions.length} dĂ©duction(s) supprimĂ©e(s)`);
}
}
// 3ïžâŁ METTRE Ă JOUR LA DEMANDE ââââââââââââââââââââââââââââââââ
console.log('đ Mise Ă jour de la demande...');
const newStatus = (oldStatus === 'Validée' || oldStatus === 'Validé') ? 'En attente' : oldStatus;
await q(
`UPDATE DemandeConge
SET TypeCongeId = @leaveType,
DateDebut = @startDate,
DateFin = @endDate,
Commentaire = @reason,
NombreJours = @businessDays,
Statut = @newStatus,
DateValidation = GETDATE(),
CommentaireValidation = COALESCE(CommentaireValidation, '') +
CHAR(10) + '[Modifiée le ' +
FORMAT(GETDATE(), 'dd/MM/yyyy Ă HH:mm', 'fr-FR') + ']'
WHERE Id = @requestId`,
{ leaveType, startDate, endDate, reason: reason || '', businessDays, newStatus, requestId }
);
console.log(`â
Demande ${requestId} modifiée - Statut: ${newStatus}`);
// 4ïžâŁ SUPPRIMER L'ANCIENNE RĂPARTITION ââââââââââââââââââââââââ
await q('DELETE FROM DemandeCongeType WHERE DemandeCongeId = @requestId', { requestId });
// 5ïžâŁ SAUVEGARDER LA NOUVELLE RĂPARTITION âââââââââââââââââââââ
console.log('\nđ Sauvegarde de la nouvelle rĂ©partition en base...');
for (const rep of Repartition) {
const code = rep.TypeConge;
const name = code === 'CP' ? 'Congé payé' :
code === 'RTT' ? 'RTT' :
code === 'ABS' ? 'Congé maladie' :
code === 'Formation' ? 'Formation' :
code === 'Récup' ? 'Récupération' : code;
const typeRows = await q('SELECT TOP 1 Id FROM TypeConge WHERE Nom = @nom', { nom: name });
if (!typeRows || typeRows.length === 0) { console.warn(` â ïž Type introuvable: ${name}`); continue; }
const typeCongeId = typeRows[0].Id;
const dctId = await getNextId(connection, 'DemandeCongeType');
await q(
`INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee, DateDebut, DateFin)
VALUES (@id, @demandeCongeId, @typeCongeId, @nbJours, @periode, @dateDebut, @dateFin)`,
{
id: dctId, demandeCongeId: requestId, typeCongeId,
nbJours: rep.NombreJours, periode: rep.PeriodeJournee || 'Journée entiÚre',
dateDebut: rep.DateDebut || startDate, dateFin: rep.DateFin || endDate
}
);
console.log(` â ${name}: ${rep.NombreJours}j (${rep.PeriodeJournee || 'JournĂ©e entiĂšre'})`);
}
// 6ïžâŁ CALCULER ET APPLIQUER LA NOUVELLE DĂDUCTION âââââââââââââ
let newRepartition = [];
const isFormationOnly = Repartition.length === 1 && Repartition[0].TypeConge === 'Formation';
if (!isFormationOnly) {
console.log('đ DĂ©duction des compteurs...');
for (const rep of Repartition) {
if (rep.TypeConge === 'ABS' || rep.TypeConge === 'Formation') {
console.log(` ⩠${rep.TypeConge} ignoré (pas de déduction)`);
continue;
}
// ââ RĂ©cup âââââââââââââââââââââââââââââââââââââââââââââ
if (rep.TypeConge === 'Récup') {
const recupTypeRows = await q('SELECT TOP 1 Id FROM TypeConge WHERE Nom = @nom', { nom: 'Récupération' });
if (!recupTypeRows?.length) { console.warn(' â ïž Type RĂ©cupĂ©ration introuvable'); continue; }
const recupTypeId = recupTypeRows[0].Id;
const nbJours = parseFloat(rep.NombreJours || 0);
await q(
`UPDATE CompteurConges
SET Solde = CASE WHEN Solde - @nbJours < 0 THEN 0 ELSE Solde - @nbJours END,
DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`,
{ nbJours, userId, typeCongeId: recupTypeId, annee: currentYear }
);
await q(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (@requestId, @typeCongeId, @annee, 'Récup Posée', @jours)`,
{ requestId, typeCongeId: recupTypeId, annee: currentYear, jours: nbJours }
);
newRepartition.push({ typeCongeId: recupTypeId, annee: currentYear, jours: rep.NombreJours, typeDeduction: 'Récup Posée' });
console.log(` â RĂ©cup: ${nbJours}j dĂ©duits`);
continue;
}
// ââ CP ââââââââââââââââââââââââââââââââââââââââââââââââ
if (rep.TypeConge === 'CP') {
const cpTypeRows = await q('SELECT TOP 1 Id FROM TypeConge WHERE Nom = @nom', { nom: 'Congé payé' });
if (!cpTypeRows?.length) { console.warn(' â ïž Type CongĂ© payĂ© introuvable'); continue; }
const cpTypeId = cpTypeRows[0].Id;
console.log('đ Appel deductLeaveBalanceCP pour modification...');
const result = await deductLeaveBalanceCP(
connection, userId, cpTypeId,
parseFloat(rep.NombreJours), requestId, startDate
);
console.log('đ RĂ©sultat dĂ©duction CP:', result);
if (!result.success) {
// â
Tolérance uniquement pour anticipé post-bascule
// (les jours seront acquis avant la date de départ)
if (result.mode === 'ANTICIPĂ POST-BASCULEMENT') {
console.warn(`â ïž AnticipĂ© post-basculement â ${result.joursNonDeduits.toFixed(2)}j non couverts (tolĂ©rĂ©)`);
await q(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (@requestId, @typeCongeId, @annee, 'Anticipé Toléré', @jours)`,
{ requestId, typeCongeId: cpTypeId, annee: anneeN, jours: result.joursNonDeduits }
);
} else {
throw new Error(`DĂ©duction CP Ă©chouĂ©e â ${result.joursNonDeduits.toFixed(2)}j non couverts`);
}
}
console.log(`â
CP déduits: ${result.joursDeduitsTotal.toFixed(2)}j | Mode: ${result.mode}`);
continue;
}
// ââ RTT âââââââââââââââââââââââââââââââââââââââââââââââ
// â
MĂȘme logique anticipĂ© que le submit : AnticipRTT / AnticipFuturRTT / AnticipNp1RTT
if (rep.TypeConge === 'RTT') {
const rttTypeRows = await q('SELECT TOP 1 Id FROM TypeConge WHERE Nom = @nom', { nom: 'RTT' });
if (!rttTypeRows?.length) { console.warn(' â ïž Type RTT introuvable'); continue; }
const rttTypeId = rttTypeRows[0].Id;
const nbJours = parseFloat(rep.NombreJours || 0);
// anneeRTT = année civile de la date de début du congé
const anneeRTT = new Date(startDate).getFullYear();
const isAnticipeRTT = anneeRTT > currentYear;
console.log(` RTT â anneeRTT=${anneeRTT} (${isAnticipeRTT ? 'anticipĂ©' : 'normal'})`);
if (!isAnticipeRTT) {
// ââ MODE NORMAL ââââââââââââââââââââââââââââââââââââââââââ
const existingRTT = await q(
`SELECT Id FROM CompteurConges WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`,
{ userId, typeCongeId: rttTypeId, annee: anneeRTT }
);
if (!existingRTT?.length) {
const nextId = await getNextId(connection, 'CompteurConges');
const conn2 = await pool.getConnection();
let acquisRTT = 0;
try {
const finEx = new Date(anneeRTT, 11, 31);
const rttData = await calculerAcquisitionRTTSmart(conn2, userId, finEx);
acquisRTT = rttData.acquisition;
} finally { conn2.release(); }
await q(
`INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (@id, @userId, @typeCongeId, @annee, @total, 0, 0, GETDATE())`,
{ id: nextId, userId, typeCongeId: rttTypeId, annee: anneeRTT, total: acquisRTT }
);
console.log(` â
Compteur RTT ${anneeRTT} créé (Total=${acquisRTT.toFixed(2)}j)`);
}
await q(
`UPDATE CompteurConges SET Solde = CASE WHEN Solde - @nbJours < 0 THEN 0 ELSE Solde - @nbJours END, DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`,
{ nbJours, userId, typeCongeId: rttTypeId, annee: anneeRTT }
);
await q(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@requestId, @typeCongeId, @annee, 'Année N', @jours)`,
{ requestId, typeCongeId: rttTypeId, annee: anneeRTT, jours: nbJours }
);
console.log(` â RTT normal: ${nbJours}j dĂ©duits sur Annee=${anneeRTT}`);
} else {
// ââ MODE ANTICIPĂ RTT âââââââââââââââââââââââââââââââââââââ
const anneeRTTCourant = currentYear;
// Taux RTT N et N+1
const conn2rtt = await pool.getConnection();
let joursAnnuelsRTTN = 10;
try { const cfg = await getConfigurationRTT(conn2rtt, anneeRTTCourant, typeContrat || '37h'); joursAnnuelsRTTN = cfg.joursAnnuels; } finally { conn2rtt.release(); }
const conn3rtt = await pool.getConnection();
let joursAnnuelsRTTNp1 = 10;
try { const cfg = await getConfigurationRTT(conn3rtt, anneeRTT, typeContrat || '37h'); joursAnnuelsRTTNp1 = cfg.joursAnnuels; } finally { conn3rtt.release(); }
const tauxRTTN = joursAnnuelsRTTN / 360;
const tauxRTTNp1 = joursAnnuelsRTTNp1 / 360;
// Solde RTT N réel
const cRTTN = await q(
`SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`,
{ userId, typeCongeId: rttTypeId, annee: anneeRTTCourant }
);
const soldeRTTNActuel = cRTTN?.length > 0 ? Math.max(0, parseFloat(cRTTN[0].Solde || 0)) : 0;
// Projection RTT N jusqu'au 31/12
const finExRTTN = new Date(anneeRTTCourant, 11, 31); finExRTTN.setHours(23, 59, 59, 0);
const joursRestantsRTTN = Math.max(0, Math.floor((finExRTTN - today) / 86400000));
const acquisRestantsRTTN = parseFloat(Math.min(joursAnnuelsRTTN - soldeRTTNActuel, joursRestantsRTTN * tauxRTTN).toFixed(2));
const soldeRTTNProj = parseFloat(Math.min(joursAnnuelsRTTN, soldeRTTNActuel + acquisRestantsRTTN).toFixed(2));
// Déjà consommé RTT N (AnticipRTT + AnticipFuturRTT)
const dejaConsoRTTNRows = await q(
`SELECT CAST(COALESCE(SUM(dd.JoursUtilises), 0) AS FLOAT) AS total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = @userId AND dd.TypeCongeId = @typeCongeId AND dd.Annee = @annee AND dd.TypeDeduction IN ('AnticipRTT', 'AnticipFuturRTT') AND dc.Statut != 'Refusé'`,
{ userId, typeCongeId: rttTypeId, annee: anneeRTTCourant }
);
const dejaConsoRTTN = parseFloat(dejaConsoRTTNRows[0]?.total || 0);
const dispoRTTN = parseFloat(Math.max(0, soldeRTTNProj - dejaConsoRTTN).toFixed(2));
// Prorata RTT N+1 depuis 01/01/anneeRTT
const dateDebutCongeRTT = new Date(startDate); dateDebutCongeRTT.setHours(0, 0, 0, 0);
const debutNp1RTT = new Date(anneeRTT, 0, 1); debutNp1RTT.setHours(0, 0, 0, 0);
const diffJoursRTTNp1 = Math.max(0, Math.floor((dateDebutCongeRTT - debutNp1RTT) / 86400000));
const acquisRTTNp1 = parseFloat(Math.min(joursAnnuelsRTTNp1, diffJoursRTTNp1 * tauxRTTNp1).toFixed(2));
const dejaConsoRTTNp1Rows = await q(
`SELECT CAST(COALESCE(SUM(dd.JoursUtilises), 0) AS FLOAT) AS total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = @userId AND dd.TypeCongeId = @typeCongeId AND dd.Annee = @annee AND dc.Statut != 'Refusé'`,
{ userId, typeCongeId: rttTypeId, annee: anneeRTT }
);
const dejaConsoRTTNp1 = parseFloat(dejaConsoRTTNp1Rows[0]?.total || 0);
const dispoRTTNp1 = parseFloat(Math.max(0, acquisRTTNp1 - dejaConsoRTTNp1).toFixed(2));
const totalDispoRTT = parseFloat((dispoRTTN + dispoRTTNp1).toFixed(2));
console.log(` đ° RTT N (${anneeRTTCourant}): projetĂ© ${soldeRTTNProj.toFixed(2)}j - consommĂ© ${dejaConsoRTTN.toFixed(2)}j = dispo ${dispoRTTN.toFixed(2)}j`);
console.log(` đ° RTT N+1 (${anneeRTT}): ${acquisRTTNp1.toFixed(2)}j - consommĂ© ${dejaConsoRTTNp1.toFixed(2)}j = dispo ${dispoRTTNp1.toFixed(2)}j`);
console.log(` đ RTT total: ${totalDispoRTT.toFixed(2)}j | demandĂ©s=${nbJours}j`);
if (totalDispoRTT < nbJours) {
throw new Error(`Solde RTT anticipé insuffisant : ${totalDispoRTT.toFixed(2)}j disponibles, ${nbJours}j demandés`);
}
let joursRestantsRTT = nbJours;
// 1ïžâŁ Solde RTT N rĂ©el â AnticipRTT
if (joursRestantsRTT > 0 && soldeRTTNActuel > 0 && cRTTN?.length > 0) {
const aDeduireRTT = parseFloat(Math.min(soldeRTTNActuel, joursRestantsRTT).toFixed(2));
await q('UPDATE CompteurConges SET Solde = Solde - @jours, DerniereMiseAJour = GETDATE() WHERE Id = @id', { jours: aDeduireRTT, id: cRTTN[0].Id });
await q(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@requestId, @typeCongeId, @annee, 'AnticipRTT', @jours)`, { requestId, typeCongeId: rttTypeId, annee: anneeRTTCourant, jours: aDeduireRTT });
joursRestantsRTT = parseFloat((joursRestantsRTT - aDeduireRTT).toFixed(2));
console.log(` â
AnticipRTT: ${aDeduireRTT.toFixed(2)}j â reste ${joursRestantsRTT.toFixed(2)}j`);
}
// 2ïžâŁ Acquisition future RTT N (jusqu'au 31/12) â AnticipFuturRTT
if (joursRestantsRTT > 0 && dispoRTTN > 0) {
const aTracerRTTN = parseFloat(Math.min(dispoRTTN, joursRestantsRTT).toFixed(2));
await q(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@requestId, @typeCongeId, @annee, 'AnticipFuturRTT', @jours)`, { requestId, typeCongeId: rttTypeId, annee: anneeRTTCourant, jours: aTracerRTTN });
joursRestantsRTT = parseFloat((joursRestantsRTT - aTracerRTTN).toFixed(2));
console.log(` â
AnticipFuturRTT: ${aTracerRTTN.toFixed(2)}j tracĂ© â reste ${joursRestantsRTT.toFixed(2)}j`);
}
// 3ïžâŁ Prorata RTT N+1 â AnticipNp1RTT
if (joursRestantsRTT > 0 && dispoRTTNp1 > 0) {
const aTracerRTTNp1 = parseFloat(Math.min(dispoRTTNp1, joursRestantsRTT).toFixed(2));
const cRTTNp1 = await q(`SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = @userId AND TypeCongeId = @typeCongeId AND Annee = @annee`, { userId, typeCongeId: rttTypeId, annee: anneeRTT });
if (!cRTTNp1?.length) {
const nextId = await getNextId(connection, 'CompteurConges');
await q(`INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (@id, @userId, @typeCongeId, @annee, 0, @solde, 0, GETDATE())`, { id: nextId, userId, typeCongeId: rttTypeId, annee: anneeRTT, solde: -aTracerRTTNp1 });
console.log(` â CompteurConges RTT Annee=${anneeRTT} créé Solde=${-aTracerRTTNp1}j`);
} else {
await q('UPDATE CompteurConges SET Solde = Solde - @jours, DerniereMiseAJour = GETDATE() WHERE Id = @id', { jours: aTracerRTTNp1, id: cRTTNp1[0].Id });
}
await q(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@requestId, @typeCongeId, @annee, 'AnticipNp1RTT', @jours)`, { requestId, typeCongeId: rttTypeId, annee: anneeRTT, jours: aTracerRTTNp1 });
joursRestantsRTT = parseFloat((joursRestantsRTT - aTracerRTTNp1).toFixed(2));
console.log(` â
AnticipNp1RTT: ${aTracerRTTNp1.toFixed(2)}j tracé sur RTT ${anneeRTT}`);
}
if (joursRestantsRTT > 0.01) {
throw new Error(`Déduction RTT anticipée incomplÚte: ${joursRestantsRTT.toFixed(2)}j non couverts`);
}
console.log(` â RTT anticipĂ©: ${nbJours}j dĂ©duits | AnticipRTT+AnticipFuturRTT+AnticipNp1RTT`);
}
continue;
}
}
// â
updateSoldeAnticipe supprimĂ© â plus nĂ©cessaire
console.log('â
Déductions appliquées');
}
await connection.commit();
console.log(`â
Demande ${requestId} modifiée avec succÚs`);
// 7ïžâŁ ENVOI DES EMAILS (aprĂšs commit) âââââââââââââââââââââââââ
const graphToken = await getGraphToken();
if (graphToken) {
const managerInfo = await q(
`SELECT m.Email, m.Prenom, m.Nom
FROM CollaborateurAD c
JOIN HierarchieValidationAD h ON c.id = h.CollaborateurId
JOIN CollaborateurAD m ON h.SuperieurId = m.id
WHERE c.id = @userId`,
{ userId },
true // usePoolDirectly aprĂšs commit
);
if (managerInfo?.length > 0) {
const manager = managerInfo[0];
sendMailGraph(graphToken, 'gtanoreply@ensup.eu', manager.Email,
'đ Modification de demande de congĂ©',
`
đ Modification de demande
${userName} a modifié sa demande de congé :
| Type : | ${getLeaveTypeName(leaveType)} |
| Dates : | du ${formatDateFR(startDate)} au ${formatDateFR(endDate)} |
| Durée : | ${businessDays} jour(s) |
${restorationStats.count > 0 ? `
â
Compteurs recalculés (${restorationStats.count} opération(s)).
` : ''}
`
).catch(err => console.error('â Erreur email manager:', err));
sendMailGraph(graphToken, 'gtanoreply@ensup.eu', userEmail,
'â
Confirmation de modification',
`
â
Demande modifiée
Bonjour ${userName.split(' ')[0]}, votre demande a bien été modifiée :
| Type : | ${getLeaveTypeName(leaveType)} |
| Dates : | du ${formatDateFR(startDate)} au ${formatDateFR(endDate)} |
| Durée : | ${businessDays} jour(s) |
Elle est maintenant en attente de validation.
`
).catch(err => console.error('â Erreur email collaborateur:', err));
}
}
res.json({
success: true,
message: 'â
Demande modifiée avec succÚs',
newStatus,
restoration: restorationStats,
repartition: newRepartition
});
} catch (error) {
if (connection) await connection.rollback();
console.error('â Erreur updateRequest:', error);
res.status(500).json({ success: false, message: error.message || 'Erreur lors de la modification' });
} finally {
if (connection) connection.release();
}
});
// â Fonction helper pour calculer la rĂ©partition CP
function calculateCPRepartition(joursNecessaires, soldeN1, soldeN) {
const repartition = [];
let reste = joursNecessaires;
// D'abord utiliser N-1
if (reste > 0 && soldeN1 > 0) {
const joursN1 = Math.min(reste, soldeN1);
repartition.push({
type: 'CP',
annee: 'N-1',
jours: joursN1
});
reste -= joursN1;
}
// Puis utiliser N
if (reste > 0 && soldeN > 0) {
const joursN = Math.min(reste, soldeN);
repartition.push({
type: 'CP',
annee: 'N',
jours: joursN
});
reste -= joursN;
}
return repartition;
}
// â Fonction helper pour obtenir le champ de compteur
function getCounterField(type, annee) {
if (type === 'CP' && annee === 'N-1') return 'SoldeCP_N1';
if (type === 'CP' && annee === 'N') return 'SoldeCP_N';
if (type === 'RTT' && annee === 'N') return 'SoldeRTT_N';
return null;
}
// â Fonction helper pour le nom du type de congĂ©
function getLeaveTypeName(typeId) {
const types = {
1: 'Congé payé',
2: 'RTT',
3: 'ArrĂȘt maladie',
4: 'Formation',
5: 'Récupération'
};
return types[typeId] || 'Inconnu';
}
// â Fonction helper pour formater les dates
function formatDateFR(dateStr) {
const date = new Date(dateStr);
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
/**
* Route pour SUPPRIMER une demande de congé
* POST /deleteRequest
*/
app.post('/api/deleteRequest', async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const { requestId, userId, userEmail, userName } = req.body;
if (!requestId || !userId) {
await conn.rollback();
conn.release();
return res.status(400).json({ success: false, message: 'ParamĂštres manquants (requestId ou userId)' });
}
console.log('\nđïž === ANNULATION DEMANDE ===');
console.log(`Demande ID: ${requestId}, User ID: ${userId}`);
// 1ïžâŁ VĂ©rifier que la demande existe ââââââââââââââââââââââââââ
const [existingRequest] = await conn.query(
`SELECT d.Id, d.DateDebut, d.DateFin, d.Statut, d.NombreJours,
d.CollaborateurADId, d.Commentaire
FROM DemandeConge d
WHERE d.Id = ? AND d.CollaborateurADId = ?`,
[requestId, userId]
);
if (existingRequest.length === 0) {
await conn.rollback();
conn.release();
return res.status(404).json({ success: false, message: 'Demande introuvable ou non autorisée' });
}
const request = existingRequest[0];
const requestStatus = request.Statut;
const aujourdhui = new Date();
aujourdhui.setHours(0, 0, 0, 0);
const dateDebut = request.DateDebut ? new Date(request.DateDebut) : null;
if (dateDebut) dateDebut.setHours(0, 0, 0, 0);
if (!dateDebut || isNaN(dateDebut.getTime())) {
console.warn('â ïž Date invalide, on autorise l\'annulation');
} else {
console.log(`đ Demande: Statut=${requestStatus}, Date dĂ©but=${dateDebut.toLocaleDateString('fr-FR')}`);
if (dateDebut <= aujourdhui && requestStatus === 'Validée') {
await conn.rollback();
conn.release();
return res.status(400).json({
success: false,
message: 'â Impossible d\'annuler : la date de dĂ©but est dĂ©jĂ passĂ©e ou c\'est aujourd\'hui',
dateDebut: dateDebut.toISOString().split('T')[0]
});
}
}
// 2ïžâŁ RĂ©cupĂ©rer la rĂ©partition (pour l'email) âââââââââââââââââ
const [repartition] = await conn.query(`
SELECT dct.*, tc.Nom as TypeNom
FROM DemandeCongeType dct
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dct.DemandeCongeId = ?
ORDER BY tc.Nom
`, [requestId]);
// 3ïžâŁ RESTAURER LES COMPTEURS ââââââââââââââââââââââââââââââââââ
let restorationStats = { count: 0, details: [] };
if (requestStatus !== 'Refusée' && requestStatus !== 'Annulée') {
console.log(`đ Restauration des compteurs...`);
const [deductions] = await conn.query(
'SELECT * FROM DeductionDetails WHERE DemandeCongeId = ?',
[requestId]
);
if (deductions.length > 0) {
for (const d of deductions) {
// ââ AnticipĂ© TolĂ©rĂ© â skip âââââââââââââââââââââââââ
if (d.TypeDeduction === 'Anticipé Toléré') {
console.log(` âïž SKIP: ${d.JoursUtilises}j "AnticipĂ© TolĂ©rĂ©" (non acquis, ne pas rembourser)`);
continue;
}
// â
Anticipé N+1 (nouvelle logique)
// Le Solde du compteur n'a jamais Ă©tĂ© touchĂ© â DELETE seul suffit
if (d.TypeDeduction === 'Anticipé N+1') {
console.log(` âïž SKIP Solde pour AnticipĂ© N+1 (${d.JoursUtilises}j) â supprimĂ© avec DELETE global`);
restorationStats.count++;
restorationStats.details.push({
typeCongeId: d.TypeCongeId,
annee: d.Annee,
joursRendus: parseFloat(d.JoursUtilises),
isAnticipe: true,
typeDeduction: d.TypeDeduction
});
continue;
}
// â
AnticipFutur â jamais dĂ©duit du Solde, juste suppression DeductionDetails
if (d.TypeDeduction === 'AnticipFutur') {
console.log(` âïž SKIP Solde pour AnticipFutur (${d.JoursUtilises}j) â tracĂ© uniquement, supprimĂ© avec DELETE`);
restorationStats.count++;
restorationStats.details.push({
typeCongeId: d.TypeCongeId,
annee: d.Annee,
joursRendus: 0,
isAnticipe: true,
typeDeduction: d.TypeDeduction
});
continue;
}
// â
AnticipNp1 â dĂ©duit du Solde CompteurConges N+1 â remettre
if (d.TypeDeduction === 'AnticipNp1') {
const [compteurNp1] = await conn.query(
'SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?',
[userId, d.TypeCongeId, d.Annee]
);
if (compteurNp1?.length > 0) {
const newSolde = parseFloat((parseFloat(compteurNp1[0].Solde) + parseFloat(d.JoursUtilises)).toFixed(2));
await conn.query(
'UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?',
[newSolde, compteurNp1[0].Id]
);
console.log(` â
AnticipNp1 restaurĂ©: Solde N+1 +${d.JoursUtilises}j â ${newSolde}j`);
}
restorationStats.count++;
restorationStats.details.push({ typeCongeId: d.TypeCongeId, annee: d.Annee, joursRendus: parseFloat(d.JoursUtilises), typeDeduction: d.TypeDeduction });
continue;
}
// â
AnticipFuturRTT â tracĂ© uniquement, jamais dĂ©duit du Solde RTT â DELETE seul
// MĂȘme logique qu'AnticipFutur pour les CP
if (d.TypeDeduction === 'AnticipFuturRTT') {
console.log(` âïž SKIP Solde pour AnticipFuturRTT (${d.JoursUtilises}j) â tracĂ© uniquement, supprimĂ© avec DELETE`);
restorationStats.count++;
restorationStats.details.push({
typeCongeId: d.TypeCongeId,
annee: d.Annee,
joursRendus: 0,
isAnticipe: true,
typeDeduction: d.TypeDeduction
});
continue;
}
// â
AnticipRTT â solde RTT N rĂ©ellement dĂ©duit â remettre MAIS plafonnĂ© au Total acquis aujourd'hui
if (d.TypeDeduction === 'AnticipRTT') {
const [compteurRTT] = await conn.query(
'SELECT Id, Solde, Total FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?',
[userId, d.TypeCongeId, d.Annee]
);
if (compteurRTT?.length > 0) {
const soldeActuel = parseFloat(compteurRTT[0].Solde) || 0;
const totalAcquis = parseFloat(compteurRTT[0].Total) || 0;
// đ Plafonner au Total acquis aujourd'hui (pas de surplus non encore acquis)
const newSolde = parseFloat(
Math.min(soldeActuel + parseFloat(d.JoursUtilises), totalAcquis).toFixed(2)
);
await conn.query(
'UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?',
[newSolde, compteurRTT[0].Id]
);
console.log(` â
AnticipRTT restaurĂ©: Solde RTT +${d.JoursUtilises}j â ${newSolde}j (plafonnĂ© Ă Total=${totalAcquis}j)`);
}
restorationStats.count++;
restorationStats.details.push({
typeCongeId: d.TypeCongeId,
annee: d.Annee,
joursRendus: parseFloat(d.JoursUtilises),
isAnticipe: true,
typeDeduction: d.TypeDeduction
});
continue;
}
// â
AnticipNp1RTT â solde RTT N+1 rĂ©ellement dĂ©duit â remettre MAIS plafonnĂ© au Total acquis aujourd'hui
if (d.TypeDeduction === 'AnticipNp1RTT') {
const [compteurRTTNp1] = await conn.query(
'SELECT Id, Solde, Total FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?',
[userId, d.TypeCongeId, d.Annee]
);
if (compteurRTTNp1?.length > 0) {
const soldeActuel = parseFloat(compteurRTTNp1[0].Solde) || 0;
const totalAcquis = parseFloat(compteurRTTNp1[0].Total) || 0;
// đ Plafonner au Total acquis aujourd'hui (idem AnticipRTT)
const newSolde = parseFloat(
Math.min(soldeActuel + parseFloat(d.JoursUtilises), totalAcquis).toFixed(2)
);
await conn.query(
'UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?',
[newSolde, compteurRTTNp1[0].Id]
);
console.log(` â
AnticipNp1RTT restaurĂ©: Solde RTT N+1 +${d.JoursUtilises}j â ${newSolde}j (plafonnĂ© Ă Total=${totalAcquis}j)`);
}
restorationStats.count++;
restorationStats.details.push({
typeCongeId: d.TypeCongeId,
annee: d.Annee,
joursRendus: parseFloat(d.JoursUtilises),
isAnticipe: true,
typeDeduction: d.TypeDeduction
});
continue;
}
// â
Legacy 'N Anticipé' / 'N+1 Anticipé' (ancienne logique)
// Le Solde avait Ă©tĂ© dĂ©duit â on le rembourse
const isLegacyAnticipe = (
d.TypeDeduction === 'N Anticipé' ||
d.TypeDeduction === 'N+1 Anticipé'
);
const [compteur] = await conn.query(
'SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?',
[userId, d.TypeCongeId, d.Annee]
);
if (compteur.length > 0) {
const joursUtilises = parseFloat(d.JoursUtilises);
const newSolde = parseFloat(compteur[0].Solde) + joursUtilises;
await conn.query(
'UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ?',
[newSolde, compteur[0].Id]
);
console.log(` â
RemboursĂ© ${joursUtilises}j (Solde) â Type=${d.TypeDeduction}, AnnĂ©e=${d.Annee}${isLegacyAnticipe ? ' [legacy]' : ''}`);
restorationStats.count++;
restorationStats.details.push({
typeCongeId: d.TypeCongeId,
annee: d.Annee,
joursRendus: joursUtilises,
isAnticipe: isLegacyAnticipe,
typeDeduction: d.TypeDeduction
});
} else {
console.warn(`â ïž Compteur introuvable (Type ${d.TypeCongeId}, AnnĂ©e ${d.Annee})`);
}
}
// Supprime toutes les déductions de cette demande en une fois
await conn.query('DELETE FROM DeductionDetails WHERE DemandeCongeId = ?', [requestId]);
console.log(` đ§č ${deductions.length} ligne(s) DeductionDetails supprimĂ©e(s)`);
} else {
console.log(' âčïž Aucune dĂ©duction Ă rembourser');
}
}
// 4ïžâŁ METTRE Ă JOUR LE STATUT âââââââââââââââââââââââââââââââââ
await conn.query(
`UPDATE DemandeConge
SET Statut = 'Annulée',
DateValidation = GETDATE(),
CommentaireValidation = COALESCE(CommentaireValidation, '') +
CHAR(10) + '[Annulée par le collaborateur le ' +
FORMAT(GETDATE(), 'dd/MM/yyyy Ă HH:mm', 'fr-FR') + ']'
WHERE Id = ?`,
[requestId]
);
console.log(`â
Demande ${requestId} marquée comme Annulée`);
await conn.commit();
// 5ïžâŁ ENVOI DES EMAILS (aprĂšs commit) âââââââââââââââââââââââââ
let emailsSent = { collaborateur: false, manager: false };
const graphToken = await getGraphToken();
if (graphToken) {
const [collabInfo] = await pool.query(
'SELECT email, prenom, nom FROM CollaborateurAD WHERE id = ?',
[userId]
);
const collabEmail = collabInfo.length > 0 ? collabInfo[0].email : userEmail;
const collabName = collabInfo.length > 0
? `${collabInfo[0].prenom} ${collabInfo[0].nom}`
: userName;
const dateDebutFormatted = request.DateDebut
? new Date(request.DateDebut).toLocaleDateString('fr-FR')
: 'Date inconnue';
const dateFinFormatted = request.DateFin
? new Date(request.DateFin).toLocaleDateString('fr-FR')
: 'Date inconnue';
const datesPeriode = dateDebutFormatted === dateFinFormatted
? dateDebutFormatted
: `du ${dateDebutFormatted} au ${dateFinFormatted}`;
const repartitionText = repartition.map(r =>
`
| ${r.TypeNom} : |
${r.NombreJours}j ${r.PeriodeJournee !== 'Journée entiÚre' ? `(${r.PeriodeJournee})` : ''} |
`
).join('');
// đ§ Email collaborateur
if (collabEmail) {
try {
await sendMailGraph(
graphToken, 'gtanoreply@ensup.eu', collabEmail,
'â
Confirmation d\'annulation de votre demande',
`
â
Demande annulée
Bonjour ${collabName},
Votre demande de congé a bien été annulée.
đ Demande annulĂ©e
| Période : | ${datesPeriode} |
| Durée totale : | ${request.NombreJours} jour(s) |
| Répartition : |
${repartitionText}
${restorationStats.count > 0 ? `
â
Vos compteurs ont été restaurés
${restorationStats.count} opération(s) effectuée(s).
` : ''}
đ§ Cet email est envoyĂ© automatiquement, merci de ne pas y rĂ©pondre.
`
);
emailsSent.collaborateur = true;
console.log('â
Email envoyé au collaborateur');
} catch (e) { console.error('â Erreur email collaborateur:', e.message); }
}
// đ§ Email manager
const [hierarchie] = await pool.query(
`SELECT h.SuperieurId, m.email as managerEmail,
m.prenom as managerPrenom, m.nom as managerNom
FROM HierarchieValidationAD h
LEFT JOIN CollaborateurAD m ON h.SuperieurId = m.id
WHERE h.CollaborateurId = ?`,
[userId]
);
const managerEmail = hierarchie[0]?.managerEmail;
const managerName = hierarchie[0]
? `${hierarchie[0].managerPrenom} ${hierarchie[0].managerNom}`
: 'Manager';
if (managerEmail && requestStatus !== 'Refusée' && requestStatus !== 'Annulée') {
try {
const isValidated = requestStatus === 'Validée' || requestStatus === 'Validé';
const couleur = isValidated ? '#ef4444' : '#f59e0b';
const couleurDark = isValidated ? '#991b1b' : '#d97706';
const subjectManager = isValidated
? `đïž Annulation de congĂ© validĂ© - ${collabName}`
: `đïž Annulation de demande - ${collabName}`;
await sendMailGraph(
graphToken, 'gtanoreply@ensup.eu', managerEmail, subjectManager,
`
đïž Annulation de ${isValidated ? 'congĂ©' : 'demande'}
Bonjour ${managerName},
${collabName} a annulé ${isValidated ? 'son congé validé' : 'sa demande de congé'}.
đ Demande annulĂ©e
| Statut initial : | ${requestStatus} |
| Période : | ${datesPeriode} |
| Durée totale : | ${request.NombreJours} jour(s) |
| Répartition : |
${repartitionText}
${restorationStats.count > 0 ? `
â
Les compteurs ont été automatiquement restaurés (${restorationStats.count} opération(s)).
` : ''}
đ§ Cet email est envoyĂ© automatiquement, merci de ne pas y rĂ©pondre.
`
);
emailsSent.manager = true;
console.log('â
Email envoyé au manager');
} catch (e) { console.error('â Erreur email manager:', e.message); }
}
}
res.json({
success: true,
message: 'Demande annulée avec succÚs',
restoration: restorationStats,
emailsSent
});
} catch (error) {
await conn.rollback();
console.error('â Erreur deleteRequest:', error);
res.status(500).json({ success: false, message: 'Erreur lors de l\'annulation', error: error.message });
} finally {
if (conn) conn.release();
}
});
app.get('/api/exportCompteurs', async (req, res) => {
try {
const dateRef = req.query.dateRef || new Date().toISOString().split('T')[0];
const conn = await pool.getConnection();
const [collaborateurs] = await pool.query(`
SELECT
ca.id,
ca.prenom,
ca.nom,
ca.email,
ca.role,
ca.TypeContrat,
ca.DateEntree,
s.Nom as service,
ca.CampusId,
ca.SocieteId,
so.Nom as societe_nom
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Societe so ON ca.SocieteId = so.Id
WHERE (ca.actif = 1 OR ca.actif IS NULL)
ORDER BY ca.nom, ca.prenom
`);
const rapport = [];
for (const collab of collaborateurs) {
const dateEntree = collab.DateEntree;
const dateReference = new Date(dateRef);
const acquisCP = calculerAcquisitionCP_Smart(dateReference, dateEntree);
let acquisRTT = 0;
if (collab.role !== 'Apprenti') {
const rttData = await calculerAcquisitionRTT_Smart(conn, collab.id, dateReference);
acquisRTT = rttData.acquisition;
}
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
let soldeCP = 0;
let soldeRTT = 0;
if (cpType.length > 0) {
const [compteurCP] = await conn.query(
'SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?',
[collab.id, cpType[0].Id, dateReference.getFullYear()]
);
soldeCP = compteurCP.length > 0 ? parseFloat(compteurCP[0].Solde) : 0;
}
if (rttType.length > 0 && collab.role !== 'Apprenti') {
const [compteurRTT] = await conn.query(
'SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?',
[collab.id, rttType[0].Id, dateReference.getFullYear()]
);
soldeRTT = compteurRTT.length > 0 ? parseFloat(compteurRTT[0].Solde) : 0;
}
rapport.push({
id: collab.id,
prenom: collab.prenom,
nom: collab.nom,
email: collab.email,
role: collab.role,
service: collab.service,
societe_id: collab.SocieteId,
societe_nom: collab.societe_nom,
type_contrat: collab.TypeContrat,
date_entree: dateEntree ? formatDateWithoutUTC(dateEntree) : null,
cp_acquis: parseFloat(acquisCP.toFixed(2)),
cp_solde: parseFloat(soldeCP.toFixed(2)),
rtt_acquis: parseFloat(acquisRTT.toFixed(2)),
rtt_solde: parseFloat(soldeRTT.toFixed(2)),
date_reference: dateRef
});
}
conn.release();
res.json({
success: true,
date_reference: dateRef,
total_collaborateurs: rapport.length,
rapport: rapport
});
} catch (error) {
console.error('Erreur exportCompteurs:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
function isInPeriodeAnticipation(dateDebut, typeConge) {
const date = new Date(dateDebut);
const year = date.getFullYear();
const month = date.getMonth() + 1; // 1-12
if (typeConge === 'CP') {
// CP : 01/06 annĂ©e N â 31/05 annĂ©e N+1
// Période anticipation : du 01/06 de l'année suivante
return month >= 6; // Si >= juin, c'est pour l'exercice N+1
} else if (typeConge === 'RTT') {
// RTT : 01/01 annĂ©e N â 31/12 annĂ©e N
// Pas d'anticipation possible car année civile
return month >= 1 && month <= 12;
}
return false;
}
function getAnneeCompteur(dateDebut, typeConge) {
const date = new Date(dateDebut);
const year = date.getFullYear();
const month = date.getMonth() + 1;
if (typeConge === 'CP') {
// Si date entre 01/06 et 31/12 â annĂ©e N
// Si date entre 01/01 et 31/05 â annĂ©e N-1 (exercice prĂ©cĂ©dent)
return month >= 6 ? year : year - 1;
} else {
// RTT : toujours année civile
return year;
}
}
/**
* Vérifie la disponibilité des soldes pour une demande
* Retourne : { available: boolean, details: {}, useN1: boolean }
*/
async function checkSoldesDisponiblesMixte(conn, collaborateurId, repartition, dateDebut, isApprenti) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const currentYear = today.getFullYear();
const dateDemandeObj = new Date(dateDebut);
dateDemandeObj.setHours(0, 0, 0, 0);
const demandeYear = dateDemandeObj.getFullYear();
const demandeMonth = dateDemandeObj.getMonth() + 1;
console.log('\nđ === CHECK SOLDES MIXTE (AVEC ANTICIPATION) ===');
console.log('đ
Date AUJOURD\'HUI:', today.toISOString().split('T')[0]);
console.log('đ
Date DEMANDE:', dateDebut);
console.log('đ
Année demande:', demandeYear, '/ Mois:', demandeMonth);
console.log('đ
Année actuelle:', currentYear);
let totalDisponible = 0;
let totalNecessaire = 0;
const details = {};
for (const rep of repartition) {
const typeCode = rep.TypeConge;
const joursNecessaires = parseFloat(rep.NombreJours || 0);
// Ignorer ABS et Formation
if (typeCode === 'ABS' || typeCode === 'Formation') {
continue;
}
totalNecessaire += joursNecessaires;
if (typeCode === 'CP') {
// â RĂCUPĂRER LES INFOS COLLABORATEUR
const [collabInfo] = await conn.query(
`SELECT DateEntree FROM CollaborateurAD WHERE id = ?`,
[collaborateurId]
);
const dateEntree = collabInfo[0]?.DateEntree;
// â CALCULER L'ACQUISITION JUSQU'Ă LA DATE DEMANDĂE
const acquisALaDate = calculerAcquisitionCP_Smart(dateDemandeObj, dateEntree);
console.log('đ° Acquisition CP Ă la date', dateDebut, ':', acquisALaDate.toFixed(2), 'j');
// â RĂCUPĂRER LE REPORTĂ N-1
const previousYear = currentYear - 1;
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
const cpTypeId = cpType[0].Id;
const [compteurN1] = await conn.query(`
SELECT SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, cpTypeId, previousYear]);
const reporteN1 = compteurN1.length > 0 ? parseFloat(compteurN1[0].SoldeReporte || 0) : 0;
// â RĂCUPĂRER CE QUI A DĂJĂ ĂTĂ POSĂ (toutes demandes validĂ©es ou en attente)
const [totalPose] = await conn.query(`
SELECT COALESCE(SUM(dct.NombreJours), 0) as totalPose
FROM DemandeConge dc
JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.CollaborateurADId = ?
AND tc.Nom = 'Congé payé'
AND dc.Statut IN ('Validée', 'En attente')
AND dc.DateDebut <= ?
`, [collaborateurId, dateDebut]);
const dejaPose = parseFloat(totalPose[0].totalPose || 0);
// â SOLDE RĂEL = ReportĂ© N-1 + Acquisition - DĂ©jĂ posĂ©
const soldeReel = reporteN1 + acquisALaDate - dejaPose;
console.log('đ° Soldes CP dĂ©taillĂ©s:', {
reporteN1: reporteN1.toFixed(2),
acquisALaDate: acquisALaDate.toFixed(2),
dejaPose: dejaPose.toFixed(2),
soldeReel: soldeReel.toFixed(2)
});
details.CP = {
reporteN1: reporteN1,
acquisALaDate: acquisALaDate,
dejaPose: dejaPose,
soldeReel: soldeReel,
necessaire: joursNecessaires
};
totalDisponible += Math.max(0, soldeReel);
if (soldeReel < joursNecessaires) {
return {
available: false,
message: `Solde CP insuffisant (${Math.max(0, soldeReel).toFixed(2)}j disponibles avec anticipation, ${joursNecessaires}j demandés)`,
details,
manque: joursNecessaires - soldeReel
};
}
} else if (typeCode === 'RTT') {
if (isApprenti) {
return {
available: false,
message: 'Les apprentis ne peuvent pas poser de RTT',
details
};
}
// â CALCUL RTT (utiliser la fonction existante)
const rttData = await calculerAcquisitionRTT_Smart(conn, collaborateurId, dateDemandeObj);
const acquisALaDate = rttData.acquisition;
console.log('đ° Acquisition RTT Ă la date', dateDebut, ':', acquisALaDate.toFixed(2), 'j');
// â RĂCUPĂRER CE QUI A DĂJĂ ĂTĂ POSĂ
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
const rttTypeId = rttType[0].Id;
const [totalPose] = await conn.query(`
SELECT COALESCE(SUM(dct.NombreJours), 0) as totalPose
FROM DemandeConge dc
JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.CollaborateurADId = ?
AND tc.Nom = 'RTT'
AND dc.Statut IN ('Validée', 'En attente')
AND dc.DateDebut <= ?
`, [collaborateurId, dateDebut]);
const dejaPose = parseFloat(totalPose[0].totalPose || 0);
// â SOLDE RĂEL = Acquisition - DĂ©jĂ posĂ©
const soldeReel = acquisALaDate - dejaPose;
console.log('đ° Soldes RTT dĂ©taillĂ©s:', {
acquisALaDate: acquisALaDate.toFixed(2),
dejaPose: dejaPose.toFixed(2),
soldeReel: soldeReel.toFixed(2)
});
details.RTT = {
acquisALaDate: acquisALaDate,
dejaPose: dejaPose,
soldeReel: soldeReel,
necessaire: joursNecessaires
};
totalDisponible += Math.max(0, soldeReel);
if (soldeReel < joursNecessaires) {
return {
available: false,
message: `Solde RTT insuffisant (${Math.max(0, soldeReel).toFixed(2)}j disponibles avec anticipation, ${joursNecessaires}j demandés)`,
details,
manque: joursNecessaires - soldeReel
};
}
} else if (typeCode === 'Récup') {
const [recupType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Récupération']);
if (recupType.length === 0) continue;
const recupTypeId = recupType[0].Id;
const [compteur] = await conn.query(
`SELECT Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, recupTypeId, currentYear]
);
const soldeRecup = compteur.length > 0 ? parseFloat(compteur[0].Solde || 0) : 0;
console.log('đ° Solde RĂ©cup:', soldeRecup.toFixed(2), 'j');
details.Recup = {
soldeN: soldeRecup,
total: soldeRecup,
necessaire: joursNecessaires
};
totalDisponible += Math.min(joursNecessaires, soldeRecup);
if (soldeRecup < joursNecessaires) {
return {
available: false,
message: `Solde Récupération insuffisant (${soldeRecup.toFixed(2)}j disponibles, ${joursNecessaires}j demandés)`,
details,
manque: joursNecessaires - soldeRecup
};
}
}
}
console.log('\nâ
Check final:', {
totalDisponible: totalDisponible.toFixed(2),
totalNecessaire: totalNecessaire.toFixed(2),
available: totalDisponible >= totalNecessaire
});
return {
available: totalDisponible >= totalNecessaire,
details,
totalDisponible,
totalNecessaire
};
}
/**
* đ DĂ©termine si une demande de CP utilise l'anticipation
*
* @param {Date|string} dateDebut - Date de début des congés
* @param {Date|string} dateDemande - Date de la demande (défaut: aujourd'hui)
* @returns {Object} Informations sur les années de compteurs à utiliser
*/
function getAnneesCompteursCP(dateDebut, dateDemande = new Date()) {
const debut = new Date(dateDebut);
debut.setHours(0, 0, 0, 0);
const demande = new Date(dateDemande);
demande.setHours(0, 0, 0, 0);
const moisDebut = debut.getMonth() + 1; // 1-12
const anneeDebut = debut.getFullYear();
const moisDemande = demande.getMonth() + 1;
const anneeDemande = demande.getFullYear();
// ========================================
// 1ïžâŁ DĂ©terminer l'exercice de la DEMANDE
// ========================================
let exerciceDemande;
if (moisDemande >= 6) {
// Demande entre juin et dĂ©cembre N â exercice commence en N
exerciceDemande = anneeDemande;
} else {
// Demande entre janvier et mai N â exercice a commencĂ© en N-1
exerciceDemande = anneeDemande - 1;
}
// ========================================
// 2ïžâŁ DĂ©terminer l'exercice des CONGĂS
// ========================================
let exerciceConges;
if (moisDebut >= 6) {
exerciceConges = anneeDebut;
} else {
exerciceConges = anneeDebut - 1;
}
// ========================================
// 3ïžâŁ Calculer les annĂ©es de compteurs
// ========================================
const isAnticipe = exerciceConges > exerciceDemande;
if (isAnticipe) {
// MODE ANTICIPATION : congés sur exercice suivant
return {
mode: 'ANTICIPATION',
isAnticipe: true,
exerciceDemande: exerciceDemande,
exerciceConges: exerciceConges,
anneeN: exerciceDemande, // L'année N actuelle
anneeN1: null, // Pas de N-1 en anticipation
description: `Anticipation: CP ${exerciceDemande} pour congés ${exerciceConges}-${exerciceConges + 1}`
};
} else {
// MODE NORMAL : congés sur exercice en cours
return {
mode: 'NORMAL',
isAnticipe: false,
exerciceDemande: exerciceDemande,
exerciceConges: exerciceConges,
anneeN1: exerciceDemande - 1, // Le reporté de l'exercice précédent
anneeN: exerciceDemande, // L'exercice en cours
description: `Normal: N-1 (${exerciceDemande - 1}) prioritaire puis N (${exerciceDemande})`
};
}
}
/**
* đ° DĂ©duit les CP avec gestion du glissement d'exercice
*
* RĂGLES MĂTIER :
* - MODE NORMAL : N-1 (reporté) prioritaire puis N
* - MODE ANTICIPATION : N uniquement (pas de report)
*
* @param {Connection} conn - Connexion BDD
* @param {number} collaborateurId - ID du collaborateur
* @param {number} typeCongeId - ID du type de congé CP
* @param {number} nombreJours - Nombre de jours à déduire
* @param {number} demandeCongeId - ID de la demande
* @param {Date|string} dateDebut - Date de début des congés
* @param {Date|string} dateDemande - Date de la demande
* @returns {Object} Résultat de la déduction
async function deductLeaveBalanceCP(conn, collaborateurId, typeCongeId, nombreJours, demandeCongeId, dateDebut, dateDemande = new Date()) {
let joursRestants = nombreJours;
const deductions = [];
console.log(`\nđ
=== DĂDUCTION CP ===`);
console.log(` Jours à déduire: ${nombreJours}j`);
console.log(` Date congés: ${dateDebut}`);
console.log(` Date demande: ${dateDemande}`);
// â CALCULER LES ANNĂES DE COMPTEURS
const infos = getAnneesCompteursCP(dateDebut, dateDemande);
console.log(` Mode: ${infos.mode}`);
console.log(` ${infos.description}`);
if (infos.isAnticipe) {
// ========================================
// MODE ANTICIPATION : CP N uniquement
// ========================================
console.log(` đž Utilisation CP ${infos.anneeN} uniquement`);
const [compteurN] = await conn.query(`
SELECT Id, Solde, SoldeReporte, Total
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, typeCongeId, infos.anneeN]);
if (compteurN.length > 0) {
const soldeTotal = parseFloat(compteurN[0].Solde || 0);
const soldeReporte = parseFloat(compteurN[0].SoldeReporte || 0);
// Solde N = Solde Total - Reporté
const soldeN = Math.max(0, soldeTotal - soldeReporte);
console.log(` đ° Solde CP ${infos.anneeN}: ${soldeN.toFixed(2)}j disponible (Total: ${soldeTotal.toFixed(2)}j, ReportĂ©: ${soldeReporte.toFixed(2)}j)`);
const aDeduire = Math.min(soldeN, joursRestants);
if (aDeduire > 0) {
await conn.query(`
UPDATE CompteurConges
SET Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END,
DerniereMiseAJour = GETDATE()
WHERE Id = ?
`, [aDeduire, aDeduire, compteurN[0].Id]);
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, ?, ?)
`, [demandeCongeId, typeCongeId, infos.anneeN, `CP ${infos.anneeN} (Anticipé)`, aDeduire]);
deductions.push({
annee: infos.anneeN,
type: `CP ${infos.anneeN} (Anticipé)`,
joursUtilises: aDeduire,
soldeAvant: soldeN
});
joursRestants -= aDeduire;
console.log(` â
Déduit de CP ${infos.anneeN}: ${aDeduire.toFixed(2)}j (reste: ${joursRestants.toFixed(2)}j)`);
} else {
console.log(` â ïž Solde CP ${infos.anneeN} insuffisant`);
}
} else {
console.log(` â ïž Compteur CP ${infos.anneeN} non trouvĂ©`);
}
} else {
// ========================================
// MODE NORMAL : N-1 prioritaire puis N
// ========================================
console.log(` đž PrioritĂ© 1: CP ${infos.anneeN1} (reportĂ©)`);
console.log(` đž PrioritĂ© 2: CP ${infos.anneeN} (actuel)`);
// 1ïžâŁ Utiliser N-1 (reportĂ©) en prioritĂ©
const [compteurN1] = await conn.query(`
SELECT Id, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, typeCongeId, infos.anneeN1]);
if (compteurN1.length > 0) {
const soldeN1 = parseFloat(compteurN1[0].SoldeReporte || 0);
console.log(` đ° Solde CP ${infos.anneeN1} (reportĂ©): ${soldeN1.toFixed(2)}j`);
const aDeduire = Math.min(soldeN1, joursRestants);
if (aDeduire > 0) {
await conn.query(`
UPDATE CompteurConges
SET SoldeReporte = CASE WHEN SoldeReporte - ? < 0 THEN 0 ELSE SoldeReporte - ? END,
Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END,
DerniereMiseAJour = GETDATE()
WHERE Id = ?
`, [aDeduire, aDeduire, aDeduire, aDeduire, compteurN1[0].Id]);
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, ?, ?)
`, [demandeCongeId, typeCongeId, infos.anneeN1, `CP ${infos.anneeN1} (Reporté)`, aDeduire]);
deductions.push({
annee: infos.anneeN1,
type: `CP ${infos.anneeN1} (Reporté)`,
joursUtilises: aDeduire,
soldeAvant: soldeN1
});
joursRestants -= aDeduire;
console.log(` â
Déduit de CP ${infos.anneeN1} (reporté): ${aDeduire.toFixed(2)}j (reste: ${joursRestants.toFixed(2)}j)`);
}
} else {
console.log(` âčïž Pas de CP ${infos.anneeN1} reportĂ©`);
}
// 2ïžâŁ Si N-1 insuffisant, utiliser N
if (joursRestants > 0) {
const [compteurN] = await conn.query(`
SELECT Id, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, typeCongeId, infos.anneeN]);
if (compteurN.length > 0) {
const soldeTotal = parseFloat(compteurN[0].Solde || 0);
const soldeReporte = parseFloat(compteurN[0].SoldeReporte || 0);
const soldeN = Math.max(0, soldeTotal - soldeReporte);
console.log(` đ° Solde CP ${infos.anneeN}: ${soldeN.toFixed(2)}j disponible`);
const aDeduire = Math.min(soldeN, joursRestants);
if (aDeduire > 0) {
await conn.query(`
UPDATE CompteurConges
SET Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END,
DerniereMiseAJour = GETDATE()
WHERE Id = ?
`, [aDeduire, aDeduire, compteurN[0].Id]);
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, ?, ?)
`, [demandeCongeId, typeCongeId, infos.anneeN, `CP ${infos.anneeN}`, aDeduire]);
deductions.push({
annee: infos.anneeN,
type: `CP ${infos.anneeN}`,
joursUtilises: aDeduire,
soldeAvant: soldeN
});
joursRestants -= aDeduire;
console.log(` â
Déduit de CP ${infos.anneeN}: ${aDeduire.toFixed(2)}j (reste: ${joursRestants.toFixed(2)}j)`);
} else {
console.log(` â ïž Solde CP ${infos.anneeN} insuffisant`);
}
}
}
}
console.log(` đ RĂ©sultat: ${deductions.length} dĂ©duction(s), ${joursRestants.toFixed(2)}j non couverts`);
return {
success: joursRestants === 0,
joursDeduitsTotal: nombreJours - joursRestants,
joursNonDeduits: joursRestants,
details: deductions,
mode: infos.mode,
exerciceDemande: infos.exerciceDemande,
exerciceConges: infos.exerciceConges
};
}*/
// ========================================
// FONCTIONS HELPER
// ========================================
async function getSoldesCP(conn, collaborateurId, dateEntree, includeN1Anticipe = false) {
const currentYear = new Date().getFullYear();
const previousYear = currentYear - 1;
console.log(`\nđ getSoldesCP - includeN1Anticipe: ${includeN1Anticipe}`);
const [cpType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = 'Congé payé' LIMIT 1`);
const typeCongeId = cpType[0].Id;
// N-1 (reporté)
const [compteursN1] = await conn.query(
`SELECT Solde, SoldeReporte FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, previousYear]
);
const soldeN1 = compteursN1.length > 0 ? parseFloat(compteursN1[0].SoldeReporte || 0) : 0;
// N (actuel)
const [compteursN] = await conn.query(
`SELECT Solde, SoldeReporte, Total FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, currentYear]
);
const soldeN = compteursN.length > 0
? parseFloat(compteursN[0].Solde || 0) - parseFloat(compteursN[0].SoldeReporte || 0)
: 0;
const totalAcquisN = compteursN.length > 0 ? parseFloat(compteursN[0].Total || 0) : 0;
// Anticipation N
const finExerciceN = new Date(currentYear + 1, 4, 31); // 31 mai N+1
const acquisTotaleN = calculerAcquisitionCP_Smart(finExerciceN, dateEntree);
const soldeAnticipeN = Math.max(0, acquisTotaleN - totalAcquisN);
console.log(' N-1:', soldeN1);
console.log(' N:', soldeN);
console.log(' Anticipé N:', soldeAnticipeN);
// â Anticipation N+1 (si demandĂ©)
let soldeAnticipeN1 = 0;
if (includeN1Anticipe) {
const debutExerciceN1 = new Date(currentYear + 1, 5, 1); // 01 juin N+1
const finExerciceN1 = new Date(currentYear + 2, 4, 31); // 31 mai N+2
let dateCalculN1 = debutExerciceN1;
if (dateEntree && new Date(dateEntree) > debutExerciceN1) {
dateCalculN1 = new Date(dateEntree);
}
const acquisTotaleN1 = calculerAcquisitionCP_Smart(finExerciceN1, dateCalculN1);
soldeAnticipeN1 = acquisTotaleN1;
console.log(' Anticipé N+1:', soldeAnticipeN1);
}
return { soldeN1, soldeN, soldeAnticipeN, soldeAnticipeN1 };
}
async function getSoldesRTT(conn, collaborateurId, typeContrat, dateEntree) {
const currentYear = new Date().getFullYear();
const rttType = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = 'RTT' LIMIT 1`);
const typeCongeId = rttType[0].Id;
const compteursN = await conn.query(
`SELECT Solde, Total FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, currentYear]
);
const soldeN = compteursN.length > 0 ? parseFloat(compteursN[0].Solde || 0) : 0;
const totalAcquisN = compteursN.length > 0 ? parseFloat(compteursN[0].Total || 0) : 0;
// Calcul anticipation N
const finAnneeN = new Date(currentYear, 11, 31); // 31 déc N
const rttDataTotalN = await calculerAcquisitionRTT_Smart(conn, collaborateurId, finAnneeN);
const soldeAnticipeN = Math.max(0, rttDataTotalN.acquisition - totalAcquisN);
return { soldeN, soldeAnticipeN };
}
async function getSoldesRecup(conn, collaborateurId) {
const currentYear = new Date().getFullYear();
const recupType = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = 'Récupération' LIMIT 1`);
if (recupType.length === 0) return 0;
const typeCongeId = recupType[0].Id;
const compteur = await conn.query(
`SELECT Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, currentYear]
);
return compteur.length > 0 ? parseFloat(compteur[0].Solde || 0) : 0;
}
app.get('/api/getAnticipationDisponible', async (req, res) => {
try {
const { userid, dateDebut } = req.query;
if (!userid || !dateDebut) {
return res.json({ success: false, message: 'ParamĂštres manquants' });
}
const conn = await pool.getConnection();
const [collabInfo] = await conn.query(
`SELECT DateEntree, TypeContrat, role FROM CollaborateurAD WHERE id = ?`,
[userid]
);
const dateEntree = collabInfo.DateEntree;
const isApprenti = collabInfo.role === 'Apprenti';
// Déterminer si c'est une demande N+1
const dateDemandeObj = new Date(dateDebut);
const currentYear = new Date().getFullYear();
const demandeYear = dateDemandeObj.getFullYear();
const demandeMonth = dateDemandeObj.getMonth() + 1;
const isN1 = (demandeYear === currentYear + 1 && demandeMonth >= 6) ||
(demandeYear === currentYear + 2 && demandeMonth <= 5);
// Calculer les soldes avec anticipation
const soldesCP = await getSoldesCP(conn, userid, dateEntree, isN1);
const soldesRTT = isApprenti ? { soldeN: 0, soldeAnticipeN: 0 } :
await getSoldesRTT(conn, userid, collabInfo.TypeContrat, dateEntree);
const soldesRecup = await getSoldesRecup(conn, userid);
conn.release();
res.json({
success: true,
isN1Request: isN1,
CP: {
actuel: soldesCP.soldeN1 + soldesCP.soldeN,
anticipeN: soldesCP.soldeAnticipeN,
anticipeN1: isN1 ? soldesCP.soldeAnticipeN1 : 0,
total: soldesCP.soldeN1 + soldesCP.soldeN + soldesCP.soldeAnticipeN + (isN1 ? soldesCP.soldeAnticipeN1 : 0)
},
RTT: {
actuel: soldesRTT.soldeN,
anticipeN: soldesRTT.soldeAnticipeN,
total: soldesRTT.soldeN + soldesRTT.soldeAnticipeN
},
Recup: {
actuel: soldesRecup,
total: soldesRecup
}
});
} catch (error) {
console.error('Erreur getAnticipationDisponible:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// FONCTION CORRIGĂE : deductLeaveBalanceWithN1
async function deductLeaveBalanceWithN1(conn, collaborateurId, typeCongeId, nombreJours, demandeCongeId, dateDebut) {
const currentYear = new Date().getFullYear();
const previousYear = currentYear - 1;
const nextYear = currentYear + 1;
let joursRestants = nombreJours;
const deductions = [];
const dateDemandeObj = new Date(dateDebut);
const demandeYear = dateDemandeObj.getFullYear();
const demandeMonth = dateDemandeObj.getMonth() + 1;
// Déterminer le type de congé
const typeRow = await conn.query(`SELECT Nom FROM TypeConge WHERE Id=?`, [typeCongeId]);
const typeNom = typeRow[0].Nom;
const isCP = typeNom === "Congé payé";
// Vérifier si demande pour N+1
let useN1 = false;
if (isCP) {
useN1 = (demandeYear === nextYear) ||
(demandeYear === nextYear - 1 && demandeMonth >= 6);
} else {
useN1 = (demandeYear === nextYear);
}
console.log(`Déduction ${typeNom}: ${nombreJours}j - useN1=${useN1}`);
if (useN1) {
// ===== ORDRE POUR N+1 : N+1 anticip â N anticip â N actuel â N-1 =====
// 1. N+1 Anticip (priorité absolue)
const compteurN1Anticipe = await conn.query(
`SELECT Id, SoldeAnticipe FROM CompteurConges
WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?`,
[collaborateurId, typeCongeId, nextYear]
);
if (compteurN1Anticipe.length > 0) {
const soldeN1A = parseFloat(compteurN1Anticipe[0].SoldeAnticipe || 0);
const aDeduire = Math.min(soldeN1A, joursRestants);
if (aDeduire > 0) {
await conn.query(
`UPDATE CompteurConges
SET SoldeAnticipe = CASE WHEN SoldeAnticipe - ? < 0 THEN 0 ELSE SoldeAnticipe - ? END
WHERE Id=?`,
[aDeduire, aDeduire, compteurN1Anticipe[0].Id]
);
await conn.query(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'N+1 Anticip', ?)`,
[demandeCongeId, typeCongeId, nextYear, aDeduire]
);
deductions.push({
annee: nextYear,
type: "N+1 Anticip",
joursUtilises: aDeduire,
soldeAvant: soldeN1A
});
joursRestants -= aDeduire;
console.log(`â
N+1 Anticip: ${aDeduire}j - reste ${joursRestants}j`);
}
}
// 2. N Anticip
if (joursRestants > 0) {
const compteurNAnticipe = await conn.query(
`SELECT Id, SoldeAnticipe FROM CompteurConges
WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?`,
[collaborateurId, typeCongeId, currentYear]
);
if (compteurNAnticipe.length > 0) {
const soldeNA = parseFloat(compteurNAnticipe[0].SoldeAnticipe || 0);
const aDeduire = Math.min(soldeNA, joursRestants);
if (aDeduire > 0) {
await conn.query(
`UPDATE CompteurConges
SET SoldeAnticipe = CASE WHEN SoldeAnticipe - ? < 0 THEN 0 ELSE SoldeAnticipe - ? END
WHERE Id=?`,
[aDeduire, aDeduire, compteurNAnticipe[0].Id]
);
await conn.query(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'N Anticip', ?)`,
[demandeCongeId, typeCongeId, currentYear, aDeduire]
);
deductions.push({
annee: currentYear,
type: "N Anticip",
joursUtilises: aDeduire,
soldeAvant: soldeNA
});
joursRestants -= aDeduire;
console.log(`â
N Anticip: ${aDeduire}j - reste ${joursRestants}j`);
}
}
}
// 3. N actuel
if (joursRestants > 0) {
const compteurN = await conn.query(
`SELECT Id, Solde, SoldeReporte FROM CompteurConges
WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?`,
[collaborateurId, typeCongeId, currentYear]
);
if (compteurN.length > 0) {
const soldeN = parseFloat(compteurN[0].Solde - compteurN[0].SoldeReporte || 0);
const aDeduire = Math.min(soldeN, joursRestants);
if (aDeduire > 0) {
await conn.query(
`UPDATE CompteurConges
SET Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END
WHERE Id=?`,
[aDeduire, aDeduire, compteurN[0].Id]
);
await conn.query(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Anne N', ?)`,
[demandeCongeId, typeCongeId, currentYear, aDeduire]
);
deductions.push({
annee: currentYear,
type: "Anne N",
joursUtilises: aDeduire,
soldeAvant: soldeN
});
joursRestants -= aDeduire;
console.log(`â
Anne N: ${aDeduire}j - reste ${joursRestants}j`);
}
}
}
// 4. N-1 report (en dernier pour N+1)
if (joursRestants > 0) {
const compteurN1 = await conn.query(
`SELECT Id, SoldeReporte FROM CompteurConges
WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?`,
[collaborateurId, typeCongeId, previousYear]
);
if (compteurN1.length > 0) {
const soldeN1 = parseFloat(compteurN1[0].SoldeReporte || 0);
const aDeduire = Math.min(soldeN1, joursRestants);
if (aDeduire > 0) {
await conn.query(
`UPDATE CompteurConges
SET SoldeReporte = CASE WHEN SoldeReporte - ? < 0 THEN 0 ELSE SoldeReporte - ? END,
Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END
WHERE Id=?`,
[aDeduire, aDeduire, aDeduire, aDeduire, compteurN1[0].Id]
);
await conn.query(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Report N-1', ?)`,
[demandeCongeId, typeCongeId, previousYear, aDeduire]
);
deductions.push({
annee: previousYear,
type: "Report N-1",
joursUtilises: aDeduire,
soldeAvant: soldeN1
});
joursRestants -= aDeduire;
console.log(`â
Report N-1: ${aDeduire}j - reste ${joursRestants}j`);
}
}
}
} else {
// ===== ORDRE NORMAL POUR N : N-1 d'abord â puis N seulement si N-1 vide â puis N anticip =====
// 1. Report N-1 (PRIORITĂ ABSOLUE)
const compteurN1 = await conn.query(
`SELECT Id, SoldeReporte, Solde FROM CompteurConges
WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?`,
[collaborateurId, typeCongeId, previousYear]
);
if (compteurN1.length > 0) {
const soldeN1 = parseFloat(compteurN1[0].SoldeReporte || 0);
const aDeduire = Math.min(soldeN1, joursRestants);
if (aDeduire > 0) {
await conn.query(
`UPDATE CompteurConges
SET SoldeReporte = CASE WHEN SoldeReporte - ? < 0 THEN 0 ELSE SoldeReporte - ? END,
Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END
WHERE Id=?`,
[aDeduire, aDeduire, aDeduire, aDeduire, compteurN1[0].Id]
);
await conn.query(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Report N-1', ?)`,
[demandeCongeId, typeCongeId, previousYear, aDeduire]
);
deductions.push({
annee: previousYear,
type: "Report N-1",
joursUtilises: aDeduire,
soldeAvant: soldeN1
});
joursRestants -= aDeduire;
console.log(`â
Report N-1: ${aDeduire}j - reste ${joursRestants}j`);
}
}
// 2. Anne N (SEULEMENT SI N-1 est vide ou insuffisant)
if (joursRestants > 0) {
const compteurN = await conn.query(
`SELECT Id, Solde, SoldeReporte FROM CompteurConges
WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?`,
[collaborateurId, typeCongeId, currentYear]
);
if (compteurN.length > 0) {
const soldeN = parseFloat(compteurN[0].Solde - compteurN[0].SoldeReporte || 0);
const aDeduire = Math.min(soldeN, joursRestants);
if (aDeduire > 0) {
await conn.query(
`UPDATE CompteurConges
SET Solde = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END
WHERE Id=?`,
[aDeduire, aDeduire, compteurN[0].Id]
);
await conn.query(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Anne N', ?)`,
[demandeCongeId, typeCongeId, currentYear, aDeduire]
);
deductions.push({
annee: currentYear,
type: "Anne N",
joursUtilises: aDeduire,
soldeAvant: soldeN
});
joursRestants -= aDeduire;
console.log(`â
Anne N: ${aDeduire}j - reste ${joursRestants}j`);
}
}
}
// 3. N Anticip (uniquement si N-1 ET N sont épuisés)
if (joursRestants > 0) {
const compteurNAnticipe = await conn.query(
`SELECT Id, SoldeAnticipe FROM CompteurConges
WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?`,
[collaborateurId, typeCongeId, currentYear]
);
if (compteurNAnticipe.length > 0) {
const soldeNA = parseFloat(compteurNAnticipe[0].SoldeAnticipe || 0);
const aDeduire = Math.min(soldeNA, joursRestants);
if (aDeduire > 0) {
await conn.query(
`UPDATE CompteurConges
SET SoldeAnticipe = CASE WHEN SoldeAnticipe - ? < 0 THEN 0 ELSE SoldeAnticipe - ? END
WHERE Id=?`,
[aDeduire, aDeduire, compteurNAnticipe[0].Id]
);
await conn.query(
`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'N Anticip', ?)`,
[demandeCongeId, typeCongeId, currentYear, aDeduire]
);
deductions.push({
annee: currentYear,
type: "N Anticip",
joursUtilises: aDeduire,
soldeAvant: soldeNA
});
joursRestants -= aDeduire;
console.log(`â
N Anticip: ${aDeduire}j - reste ${joursRestants}j`);
}
}
}
}
return {
success: joursRestants === 0,
joursDeduitsTotal: nombreJours - joursRestants,
joursNonDeduits: joursRestants,
details: deductions,
useN1: useN1
};
}
/**
* Met à jour les soldes anticipés pour un collaborateur
* Appelée aprÚs chaque mise à jour de compteur ou soumission de demande
*/
/**
* Met à jour les soldes anticipés pour un collaborateur
* Appelée aprÚs chaque mise à jour de compteur ou soumission de demande
*/
async function updateSoldeAnticipe(conn, collaborateurId) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const currentYear = today.getFullYear();
console.log(`đ Mise Ă jour soldes anticipĂ©s pour collaborateur ${collaborateurId}`);
const collab = await conn.query(`
SELECT DateEntree, TypeContrat, role
FROM CollaborateurAD
WHERE id = ?
`, [collaborateurId]);
if (collab.length === 0) {
console.log(`â Collaborateur non trouvĂ©`);
return;
}
const dateEntree = collab[0].DateEntree;
const typeContrat = collab[0].TypeContrat || '37h';
const isApprenti = collab[0].role === 'Apprenti';
// ========================================
// CP ANTICIPĂ
// ========================================
const cpType = await conn.query(`SELECT TOP 1 Id FROM TypeConge WHERE Nom = ?`, ['Congé payé']);
if (cpType.length > 0) {
const cpAnticipe = calculerAcquisitionCPAnticipee(today, dateEntree);
// Vérifier si le compteur existe
const compteurCP = await conn.query(`
SELECT Id FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, cpType[0].Id, currentYear]);
if (compteurCP.length > 0) {
await conn.query(`
UPDATE CompteurConges
SET SoldeAnticipe = ?,
DerniereMiseAJour = GETDATE()
WHERE Id = ?
`, [cpAnticipe, compteurCP[0].Id]);
} else {
// Créer le compteur s'il n'existe pas
const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree);
await conn.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, SoldeAnticipe, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, 0, ?, GETDATE())
`, [collaborateurId, cpType[0].Id, currentYear, acquisCP, acquisCP, cpAnticipe]);
}
console.log(` â CP AnticipĂ©: ${cpAnticipe.toFixed(2)}j`);
}
// ========================================
// RTT ANTICIPĂ
// ========================================
if (!isApprenti) {
const rttType = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['RTT']);
if (rttType.length > 0) {
const rttAnticipe = await calculerAcquisitionRTTAnticipee(conn, collaborateurId, today);
// Vérifier si le compteur existe
const compteurRTT = await conn.query(`
SELECT Id FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, rttType[0].Id, currentYear]);
if (compteurRTT.length > 0) {
await conn.query(`
UPDATE CompteurConges
SET SoldeAnticipe = ?,
DerniereMiseAJour = GETDATE()
WHERE Id = ?
`, [rttAnticipe, compteurRTT[0].Id]);
} else {
// Créer le compteur s'il n'existe pas
const rttData = await calculerAcquisitionRTT_Smart(conn, collaborateurId, today);
await conn.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, SoldeAnticipe, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, 0, ?, GETDATE())
`, [collaborateurId, rttType[0].Id, currentYear, rttData.acquisition, rttData.acquisition, rttAnticipe]);
}
console.log(` â RTT AnticipĂ©: ${rttAnticipe.toFixed(2)}j`);
}
}
console.log(` â
Soldes anticipés mis à jour`);
}
/**
* GET /getSoldesAnticipes
* Retourne les soldes actuels ET anticipés pour un collaborateur
*/
app.get('/api/getSoldesAnticipes', async (req, res) => {
try {
const userIdParam = req.query.user_id;
const dateRefParam = req.query.date_reference;
if (!userIdParam) {
return res.json({ success: false, message: 'ID utilisateur manquant' });
}
const conn = await pool.getConnection();
// Déterminer l'ID
const isUUID = userIdParam.length > 10 && userIdParam.includes('-');
const userQuery = `
SELECT ca.id, ca.prenom, ca.nom, ca.DateEntree, ca.TypeContrat, ca.role
FROM CollaborateurAD ca
WHERE ${isUUID ? 'ca.entraUserId' : 'ca.id'} = ?
AND (ca.Actif = 1 OR ca.Actif IS NULL)
`;
const [userInfo] = await conn.query(userQuery, [userIdParam]);
if (userInfo.length === 0) {
conn.release();
return res.json({ success: false, message: 'Utilisateur non trouvé' });
}
const user = userInfo[0];
const userId = user.id;
const dateEntree = user.DateEntree;
const typeContrat = user.TypeContrat || '37h';
const isApprenti = user.role === 'Apprenti';
const dateReference = dateRefParam ? new Date(dateRefParam) : new Date();
dateReference.setHours(0, 0, 0, 0);
const currentYear = dateReference.getFullYear();
// ===== CP =====
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
let cpData = {
acquis: 0,
solde: 0,
anticipe: 0,
totalDisponible: 0
};
if (cpType.length > 0) {
// Acquisition actuelle
const acquisCP = calculerAcquisitionCP_Smart(dateReference, dateEntree);
// Anticipé
const anticipeCP = calculerAcquisitionCPAnticipee(dateReference, dateEntree);
// Solde en base (avec consommations déduites)
const [compteurCP] = await conn.query(`
SELECT Solde, SoldeReporte, SoldeAnticipe
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [userId, cpType[0].Id, currentYear]);
// Reporté N-1
const [compteurCPN1] = await conn.query(`
SELECT Solde as SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [userId, cpType[0].Id, currentYear - 1]);
const soldeN1 = compteurCPN1.length > 0 ? parseFloat(compteurCPN1[0].SoldeReporte || 0) : 0;
const soldeN = compteurCP.length > 0 ? parseFloat(compteurCP[0].Solde || 0) : acquisCP;
cpData = {
acquis: parseFloat(acquisCP.toFixed(2)),
soldeN1: parseFloat(soldeN1.toFixed(2)),
soldeN: parseFloat((soldeN - soldeN1).toFixed(2)),
soldeTotal: parseFloat(soldeN.toFixed(2)),
anticipe: parseFloat(anticipeCP.toFixed(2)),
totalDisponible: parseFloat((soldeN + anticipeCP).toFixed(2))
};
}
// ===== RTT =====
let rttData = {
acquis: 0,
solde: 0,
anticipe: 0,
totalDisponible: 0,
isApprenti: isApprenti
};
if (!isApprenti) {
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
if (rttType.length > 0) {
// Acquisition actuelle
const rttCalc = await calculerAcquisitionRTT_Smart(conn, userId, dateReference);
const acquisRTT = rttCalc.acquisition;
// Anticipé
const anticipeRTT = await calculerAcquisitionRTTAnticipee(conn, userId, dateReference);
// Solde en base
const [compteurRTT] = await conn.query(`
SELECT Solde, SoldeAnticipe
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [userId, rttType[0].Id, currentYear]);
const soldeRTT = compteurRTT.length > 0 ? parseFloat(compteurRTT[0].Solde || 0) : acquisRTT;
rttData = {
acquis: parseFloat(acquisRTT.toFixed(2)),
solde: parseFloat(soldeRTT.toFixed(2)),
anticipe: parseFloat(anticipeRTT.toFixed(2)),
totalDisponible: parseFloat((soldeRTT + anticipeRTT).toFixed(2)),
config: rttCalc.config,
typeContrat: typeContrat
};
}
}
// ===== RĂCUP =====
const [recupType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Récupération']);
let recupData = { solde: 0 };
if (recupType.length > 0) {
const [compteurRecup] = await conn.query(`
SELECT Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [userId, recupType[0].Id, currentYear]);
recupData.solde = compteurRecup.length > 0 ? parseFloat(compteurRecup[0].Solde || 0) : 0;
}
conn.release();
res.json({
success: true,
user: {
id: user.id,
nom: `${user.prenom} ${user.nom}`,
typeContrat: typeContrat,
dateEntree: dateEntree ? formatDateWithoutUTC(dateEntree) : null
},
dateReference: dateReference.toISOString().split('T')[0],
cp: cpData,
rtt: rttData,
recup: recupData,
totalGeneral: {
disponibleActuel: parseFloat((cpData.soldeTotal + rttData.solde + recupData.solde).toFixed(2)),
disponibleAvecAnticipe: parseFloat((cpData.totalDisponible + rttData.totalDisponible + recupData.solde).toFixed(2))
}
});
} catch (error) {
console.error('Erreur getSoldesAnticipes:', error);
res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message });
}
});
/**
* GET /getCongesAnticipes
* Calcule les congés anticipés disponibles pour un collaborateur
*/
app.get('/api/getCongesAnticipes', async (req, res) => {
try {
const userIdParam = req.query.user_id;
if (!userIdParam) {
return res.json({ success: false, message: 'ID utilisateur manquant' });
}
const conn = await pool.getConnection();
// Déterminer l'ID (UUID ou numérique)
const isUUID = userIdParam.length > 10 && userIdParam.includes('-');
const userQuery = `
SELECT
ca.id,
ca.prenom,
ca.nom,
ca.email,
ca.DateEntree,
ca.TypeContrat,
ca.role,
ca.CampusId
FROM CollaborateurAD ca
WHERE ${isUUID ? 'ca.entraUserId' : 'ca.id'} = ?
AND (ca.Actif = 1 OR ca.Actif IS NULL)
`;
const [userInfo] = await conn.query(userQuery, [userIdParam]);
if (userInfo.length === 0) {
conn.release();
return res.json({ success: false, message: 'Utilisateur non trouvé' });
}
const user = userInfo[0];
const userId = user.id;
const dateEntree = user.DateEntree;
const typeContrat = user.TypeContrat || '37h';
const today = new Date();
const currentYear = today.getFullYear();
const finAnnee = new Date(currentYear, 11, 31); // 31 décembre
// ========================================
// CALCUL CP (Congés Payés)
// ========================================
// Acquisition actuelle
const acquisActuelleCP = calculerAcquisitionCP_Smart(today, dateEntree);
// Acquisition prévue à la fin de l'exercice (31 mai N+1)
const finExerciceCP = new Date(currentYear + 1, 4, 31); // 31 mai N+1
const acquisTotaleCP = calculerAcquisitionCP_Smart(finExerciceCP, dateEntree);
// Récupérer le solde actuel
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
let soldeActuelCP = 0;
let dejaPrisCP = 0;
if (cpType.length > 0) {
const [compteurCP] = await conn.query(`
SELECT Total, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [userId, cpType[0].Id, currentYear]);
if (compteurCP.length > 0) {
const total = parseFloat(compteurCP[0].Total || 0);
soldeActuelCP = parseFloat(compteurCP[0].Solde || 0);
dejaPrisCP = total - (soldeActuelCP - parseFloat(compteurCP[0].SoldeReporte || 0));
}
}
// Calculer le potentiel anticipé pour CP
const acquisRestanteCP = acquisTotaleCP - acquisActuelleCP;
const anticipePossibleCP = Math.max(0, acquisRestanteCP);
const limiteAnticipeCP = Math.min(anticipePossibleCP, 25 - dejaPrisCP);
// ========================================
// CALCUL RTT
// ========================================
let anticipePossibleRTT = 0;
let limiteAnticipeRTT = 0;
let soldeActuelRTT = 0;
let dejaPrisRTT = 0;
let acquisActuelleRTT = 0;
let acquisTotaleRTT = 0;
if (user.role !== 'Apprenti') {
// Acquisition actuelle
const rttDataActuel = await calculerAcquisitionRTT_Smart(conn, userId, today);
acquisActuelleRTT = rttDataActuel.acquisition;
// Acquisition prévue à la fin de l'année
const rttDataTotal = await calculerAcquisitionRTT_Smart(conn, userId, finAnnee);
acquisTotaleRTT = rttDataTotal.acquisition;
// Récupérer le solde actuel
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
if (rttType.length > 0) {
const [compteurRTT] = await conn.query(`
SELECT Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [userId, rttType[0].Id, currentYear]);
if (compteurRTT.length > 0) {
const total = parseFloat(compteurRTT[0].Total || 0);
soldeActuelRTT = parseFloat(compteurRTT[0].Solde || 0);
dejaPrisRTT = total - soldeActuelRTT;
}
}
// Calculer le potentiel anticipé pour RTT
const acquisRestanteRTT = acquisTotaleRTT - acquisActuelleRTT;
anticipePossibleRTT = Math.max(0, acquisRestanteRTT);
const maxRTT = typeContrat === 'forfait_jour' ? 12 : 10;
limiteAnticipeRTT = Math.min(anticipePossibleRTT, maxRTT - dejaPrisRTT);
}
conn.release();
res.json({
success: true,
user: {
id: user.id,
nom: `${user.prenom} ${user.nom}`,
email: user.email,
typeContrat: typeContrat,
dateEntree: dateEntree ? formatDateWithoutUTC(dateEntree) : null
},
dateReference: today.toISOString().split('T')[0],
congesPayes: {
acquisActuelle: parseFloat(acquisActuelleCP.toFixed(2)),
acquisTotalePrevu: parseFloat(acquisTotaleCP.toFixed(2)),
acquisRestante: parseFloat((acquisTotaleCP - acquisActuelleCP).toFixed(2)),
soldeActuel: parseFloat(soldeActuelCP.toFixed(2)),
dejaPris: parseFloat(dejaPrisCP.toFixed(2)),
anticipePossible: parseFloat(anticipePossibleCP.toFixed(2)),
limiteAnticipe: parseFloat(limiteAnticipeCP.toFixed(2)),
totalDisponible: parseFloat((soldeActuelCP + limiteAnticipeCP).toFixed(2)),
message: limiteAnticipeCP > 0
? `Vous pouvez poser jusqu'à ${limiteAnticipeCP.toFixed(1)} jours de CP en anticipé`
: "Vous avez atteint la limite d'anticipation pour les CP"
},
rtt: user.role !== 'Apprenti' ? {
acquisActuelle: parseFloat(acquisActuelleRTT.toFixed(2)),
acquisTotalePrevu: parseFloat(acquisTotaleRTT.toFixed(2)),
acquisRestante: parseFloat((acquisTotaleRTT - acquisActuelleRTT).toFixed(2)),
soldeActuel: parseFloat(soldeActuelRTT.toFixed(2)),
dejaPris: parseFloat(dejaPrisRTT.toFixed(2)),
anticipePossible: parseFloat(anticipePossibleRTT.toFixed(2)),
limiteAnticipe: parseFloat(limiteAnticipeRTT.toFixed(2)),
totalDisponible: parseFloat((soldeActuelRTT + limiteAnticipeRTT).toFixed(2)),
message: limiteAnticipeRTT > 0
? `Vous pouvez poser jusqu'à ${limiteAnticipeRTT.toFixed(1)} jours de RTT en anticipé`
: "Vous avez atteint la limite d'anticipation pour les RTT"
} : null,
regles: {
cpMaxAnnuel: 25,
rttMaxAnnuel: typeContrat === 'forfait_jour' ? 12 : 10,
description: "Les congés anticipés sont basés sur l'acquisition prévue jusqu'à la fin de l'exercice/année"
}
});
} catch (error) {
console.error('Erreur getCongesAnticipes:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
/**
* Calcule l'acquisition CP ANTICIPĂE (ce qui reste Ă acquĂ©rir jusqu'Ă fin d'exercice)
*/
function calculerAcquisitionCPAnticipee(dateReference = new Date(), dateEntree = null) {
const d = new Date(dateReference);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
const mois = d.getMonth() + 1;
// 1ïžâŁ DĂ©terminer la fin de l'exercice CP (31/05)
let finExercice;
if (mois >= 6) {
finExercice = new Date(annee + 1, 4, 31); // 31/05/N+1
} else {
finExercice = new Date(annee, 4, 31); // 31/05/N
}
finExercice.setHours(0, 0, 0, 0);
// 2ïžâŁ Calculer l'acquisition actuelle
const acquisActuelle = calculerAcquisitionCP_Smart(d, dateEntree);
// 3ïžâŁ Calculer l'acquisition totale Ă fin d'exercice
const acquisTotaleFinExercice = calculerAcquisitionCP_Smart(finExercice, dateEntree);
// 4ïžâŁ AnticipĂ©e = Totale - Actuelle (plafonnĂ©e Ă 25)
const acquisAnticipee = Math.min(25, acquisTotaleFinExercice) - acquisActuelle;
return Math.max(0, Math.round(acquisAnticipee * 100) / 100);
}
/**
* Calcule l'acquisition RTT ANTICIPĂE (ce qui reste Ă acquĂ©rir jusqu'Ă fin d'annĂ©e)
*/
async function calculerAcquisitionRTTAnticipee(conn, collaborateurId, dateReference = new Date()) {
const d = new Date(dateReference);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
// 1ïžâŁ RĂ©cupĂ©rer les infos du collaborateur
const [collabInfo] = await conn.query(
`SELECT TypeContrat, DateEntree, role FROM CollaborateurAD WHERE id = ?`,
[collaborateurId]
);
if (collabInfo.length === 0) {
return 0;
}
const typeContrat = collabInfo[0].TypeContrat || '37h';
const isApprenti = collabInfo[0].role === 'Apprenti';
// 2ïžâŁ Apprentis = pas de RTT
if (isApprenti) {
return 0;
}
// 3ïžâŁ RĂ©cupĂ©rer la configuration RTT
const config = await getConfigurationRTT(conn, annee, typeContrat);
// 4ïžâŁ Calculer l'acquisition actuelle
const rttActuel = await calculerAcquisitionRTT_Smart(conn, collaborateurId, d);
const acquisActuelle = rttActuel.acquisition;
// 5ïžâŁ Calculer l'acquisition totale Ă fin d'annĂ©e (31/12)
const finAnnee = new Date(annee, 11, 31);
finAnnee.setHours(0, 0, 0, 0);
const rttFinAnnee = await calculerAcquisitionRTT_Smart(conn, collaborateurId, finAnnee);
const acquisTotaleFinAnnee = rttFinAnnee.acquisition;
// 6ïžâŁ AnticipĂ©e = Totale - Actuelle (plafonnĂ©e au max annuel)
const acquisAnticipee = Math.min(config.joursAnnuels, acquisTotaleFinAnnee) - acquisActuelle;
return Math.max(0, Math.round(acquisAnticipee * 100) / 100);
}
app.get('/api/getStatistiquesCompteurs', async (req, res) => {
try {
const conn = await pool.getConnection();
const currentYear = new Date().getFullYear();
const [totalCollabs] = await conn.query(
'SELECT COUNT(*) as total FROM CollaborateurAD WHERE actif = 1 OR actif IS NULL'
);
const [statsTypeContrat] = await conn.query(`
SELECT
TypeContrat,
COUNT(*) as nombre,
GROUP_CONCAT(CONCAT(prenom, ' ', nom) SEPARATOR ', ') as noms
FROM CollaborateurAD
WHERE actif = 1 OR actif IS NULL
GROUP BY TypeContrat
`);
const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']);
let statsCP = { total_acquis: 0, total_solde: 0, moyenne_utilisation: 0 };
if (cpType.length > 0) {
const [cpStats] = await conn.query(`
SELECT
SUM(Total) as total_acquis,
SUM(Solde) as total_solde,
AVG(CASE WHEN Total > 0 THEN ((Total - Solde) / Total) * 100 ELSE 0 END) as moyenne_utilisation
FROM CompteurConges
WHERE TypeCongeId = ? AND Annee = ?
`, [cpType[0].Id, currentYear]);
if (cpStats.length > 0) {
statsCP = {
total_acquis: parseFloat((cpStats[0].total_acquis || 0).toFixed(2)),
total_solde: parseFloat((cpStats[0].total_solde || 0).toFixed(2)),
moyenne_utilisation: parseFloat((cpStats[0].moyenne_utilisation || 0).toFixed(1))
};
}
}
const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']);
let statsRTT = { total_acquis: 0, total_solde: 0, moyenne_utilisation: 0 };
if (rttType.length > 0) {
const [rttStats] = await conn.query(`
SELECT
SUM(Total) as total_acquis,
SUM(Solde) as total_solde,
AVG(CASE WHEN Total > 0 THEN ((Total - Solde) / Total) * 100 ELSE 0 END) as moyenne_utilisation
FROM CompteurConges
WHERE TypeCongeId = ? AND Annee = ?
`, [rttType[0].Id, currentYear]);
if (rttStats.length > 0) {
statsRTT = {
total_acquis: parseFloat((rttStats[0].total_acquis || 0).toFixed(2)),
total_solde: parseFloat((rttStats[0].total_solde || 0).toFixed(2)),
moyenne_utilisation: parseFloat((rttStats[0].moyenne_utilisation || 0).toFixed(1))
};
}
}
conn.release();
res.json({
success: true,
annee: currentYear,
statistiques: {
collaborateurs: {
total: totalCollabs[0].total,
par_type_contrat: statsTypeContrat
},
conges_payes: statsCP,
rtt: statsRTT
}
});
} catch (error) {
console.error('Erreur getStatistiquesCompteurs:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
async function hasCompteRenduAccess(userId) {
try {
const conn = await pool.getConnection();
const [user] = await conn.query(`
SELECT TypeContrat, role
FROM CollaborateurAD
WHERE id = ?
`, [userId]);
conn.release();
if (!user.length) return false;
const userInfo = user[0];
// AccĂšs si :
// 1. TypeContrat = 'forfait_jour'
// 2. role = 'Directeur Campus' ou 'Directrice Campus'
// 3. role = 'RH' ou 'Admin'
return (
userInfo.TypeContrat === 'forfait_jour' ||
userInfo.role === 'Directeur Campus' ||
userInfo.role === 'Directrice Campus' ||
userInfo.role === 'RH' ||
userInfo.role === 'Admin'
);
} catch (error) {
console.error('Erreur vérification accÚs:', error);
return false;
}
}
function genererJoursJpos(jpos, mois, annee) {
const jours = [];
const dateDebut = new Date(jpos.DateDebut);
const dateFin = new Date(jpos.DateFin);
const premierJourMois = new Date(annee, mois - 1, 1);
const dernierJourMois = new Date(annee, mois, 0);
const debutEffectif = dateDebut > premierJourMois ? dateDebut : premierJourMois;
const finEffective = dateFin < dernierJourMois ? dateFin : dernierJourMois;
for (let d = new Date(debutEffectif); d <= finEffective; d.setDate(d.getDate() + 1)) {
if (d.getMonth() + 1 === mois && d.getFullYear() === annee) {
const jourNum = d.getDate();
const jourSemaine = d.getDay();
const typeJour = jourSemaine === 0 ? 'Dimanche' :
jourSemaine === 6 ? 'Samedi' : 'Férié';
const labelPeriode = jpos.PeriodeJournee === 'Matin' ? '[Matin] ' :
jpos.PeriodeJournee === 'AprĂšs-midi' ? '[AprĂšs-midi] ' : '';
jours.push({
JourDate: jourNum,
JourTravaille: 1,
CommentaireRepos: `[${jpos.TypeCongeNom}] Jour travaillé (${typeJour}) - ${labelPeriode}${jpos.Commentaire || ''}`.trim(),
Source: 'JPOSF',
PeriodeJournee: jpos.PeriodeJournee || 'Journée entiÚre',
saisi_par_rh: true
});
}
}
return jours;
}
function fusionnerDemiJournees(existing, nouveau) {
if (!existing) return nouveau;
const periodeExisting = existing.PeriodeJournee || 'Journée entiÚre';
const periodeNouveau = nouveau.PeriodeJournee || 'Journée entiÚre';
if ((periodeExisting === 'Matin' && periodeNouveau === 'AprĂšs-midi') ||
(periodeExisting === 'AprĂšs-midi' && periodeNouveau === 'Matin')) {
return {
...existing,
JourTravaille: 0,
CommentaireRepos: `${existing.CommentaireRepos} + ${nouveau.CommentaireRepos}`,
PeriodeJournee: 'Journée entiÚre'
};
}
return existing;
}
async function jourNecessiteSaisieCRA(pool, collaborateurId, dateJour) {
const request = new sql.Request(pool);
request.input('collaborateurId', sql.Int, collaborateurId);
request.input('dateJour', sql.Date, dateJour);
// Vérifier s'il y a un congé ce jour-là via DemandeConge et DemandeCongeType
const congeResult = await request.query(`
SELECT
dct.Id,
dct.DemandeCongeId,
dct.TypeCongeId,
dct.NombreJours,
dct.PeriodeJournee,
dc.DateDebut,
dc.DateFin,
dc.Statut,
tc.Code AS TypeCongeCode,
tc.Libelle AS TypeCongeLibelle
FROM DemandeCongeType dct
INNER JOIN DemandeConge dc ON dct.DemandeCongeId = dc.Id
INNER JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.CollaborateurADId = @collaborateurId
AND @dateJour BETWEEN dc.DateDebut AND dc.DateFin
AND dc.Statut = 'Validee'
`);
// Si pas de congé validé, c'est un jour potentiellement travaillé
if (congeResult.recordset.length === 0) {
return {
necessiteSaisie: true,
typeJournee: 'Journee',
valeurJour: 1,
raison: 'jour_travaille',
congeInfo: null
};
}
const conge = congeResult.recordset[0];
// Vérifier si c'est un JPOSF
if (conge.TypeCongeCode === 'JPOSF') {
return {
necessiteSaisie: true,
typeJournee: 'Journee',
valeurJour: 1,
raison: 'jposf',
congeInfo: {
type: conge.TypeCongeLibelle,
code: conge.TypeCongeCode
}
};
}
// Vérifier si c'est une demi-journée de congé via PeriodeJournee
// PeriodeJournee peut ĂȘtre : 'Matin', 'Apres-midi', 'Journee' (ou null pour journĂ©e complĂšte)
if (conge.PeriodeJournee === 'Matin' || conge.PeriodeJournee === 'Apres-midi') {
// Demi-journée de congé = l'autre demi-journée est travaillée
const periodeTravaillee = conge.PeriodeJournee === 'Matin' ? 'Apres-midi' : 'Matin';
return {
necessiteSaisie: true,
typeJournee: periodeTravaillee,
valeurJour: 0.5,
raison: 'demi_journee_travaillee',
congeInfo: {
type: conge.TypeCongeLibelle,
periodeConge: conge.PeriodeJournee,
periodeTravaillee: periodeTravaillee
}
};
}
// On peut aussi vérifier via NombreJours si c'est 0.5
if (conge.NombreJours === 0.5) {
// Demi-journĂ©e mais PeriodeJournee non spĂ©cifiĂ©e, on demande quand mĂȘme la saisie
return {
necessiteSaisie: true,
typeJournee: 'Demi-journee',
valeurJour: 0.5,
raison: 'demi_journee_travaillee',
congeInfo: {
type: conge.TypeCongeLibelle,
nombreJours: conge.NombreJours
}
};
}
// Journée complÚte de congé (sauf JPOSF) = pas de saisie CRA nécessaire
return {
necessiteSaisie: false,
typeJournee: null,
valeurJour: 0,
raison: 'journee_conge_complete',
congeInfo: {
type: conge.TypeCongeLibelle,
code: conge.TypeCongeCode
}
};
}
async function jourNecessiteSaisieCRATransaction(transaction, collaborateurId, dateJour) {
const request = new sql.Request(transaction);
request.input('collaborateurId', sql.Int, collaborateurId);
request.input('dateJour', sql.Date, dateJour);
const congeResult = await request.query(`
SELECT
dct.Id,
dct.PeriodeJournee,
dct.NombreJours,
tc.Code AS TypeCongeCode,
tc.Libelle AS TypeCongeLibelle
FROM DemandeCongeType dct
INNER JOIN DemandeConge dc ON dct.DemandeCongeId = dc.Id
INNER JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.CollaborateurADId = @collaborateurId
AND @dateJour BETWEEN dc.DateDebut AND dc.DateFin
AND dc.Statut = 'Validee'
`);
if (congeResult.recordset.length === 0) {
return {
necessiteSaisie: true,
typeJournee: 'Journee',
valeurJour: 1,
raison: 'jour_travaille',
congeInfo: null
};
}
const conge = congeResult.recordset[0];
if (conge.TypeCongeCode === 'JPOSF') {
return {
necessiteSaisie: true,
typeJournee: 'Journee',
valeurJour: 1,
raison: 'jposf',
congeInfo: {
type: conge.TypeCongeLibelle,
code: conge.TypeCongeCode
}
};
}
if (conge.PeriodeJournee === 'Matin' || conge.PeriodeJournee === 'Apres-midi') {
const periodeTravaillee = conge.PeriodeJournee === 'Matin' ? 'Apres-midi' : 'Matin';
return {
necessiteSaisie: true,
typeJournee: periodeTravaillee,
valeurJour: 0.5,
raison: 'demi_journee_travaillee',
congeInfo: {
type: conge.TypeCongeLibelle,
periodeConge: conge.PeriodeJournee,
periodeTravaillee: periodeTravaillee
}
};
}
if (conge.NombreJours === 0.5) {
return {
necessiteSaisie: true,
typeJournee: 'Demi-journee',
valeurJour: 0.5,
raison: 'demi_journee_travaillee',
congeInfo: {
type: conge.TypeCongeLibelle,
nombreJours: conge.NombreJours
}
};
}
return {
necessiteSaisie: false,
typeJournee: null,
valeurJour: 0,
raison: 'journee_conge_complete',
congeInfo: {
type: conge.TypeCongeLibelle,
code: conge.TypeCongeCode
}
};
}
app.get('/api/getJoursSaisieObligatoire', async (req, res) => {
try {
const { collaborateurId, annee, mois, user_id } = req.query; // â
Ajouter user_id
const parsedCollaborateurId = parseInt(collaborateurId);
const parsedAnnee = parseInt(annee);
const parsedMois = parseInt(mois);
const parsedUserId = parseInt(user_id); // â
Parser user_id
if (isNaN(parsedCollaborateurId) || isNaN(parsedAnnee) || isNaN(parsedMois)) {
return res.status(400).json({ success: false, message: 'ParamĂštres invalides' });
}
// â
Vérifier que user_id est fourni
if (!user_id || isNaN(parsedUserId)) {
return res.status(401).json({ success: false, message: 'Authentification requise' });
}
// â
Utiliser parsedUserId au lieu de req.user.id
const hasAccess = await hasCompteRenduAccess(parsedUserId);
if (!hasAccess && parsedUserId !== parsedCollaborateurId) {
return res.status(403).json({ success: false, message: 'AccÚs non autorisé' });
}
// â SUPPRIMER: const pool = await sql.connect();
// â
Le pool global est déjà disponible
// Calculer le premier et dernier jour du mois
const premierJour = new Date(parsedAnnee, parsedMois - 1, 1);
const dernierJour = new Date(parsedAnnee, parsedMois, 0);
const joursObligatoires = [];
// Parcourir chaque jour du mois
for (let d = new Date(premierJour); d <= dernierJour; d.setDate(d.getDate() + 1)) {
const jourSemaine = d.getDay();
// â
Utiliser pool.request() au lieu de new sql.Request(pool)
const requestJF = pool.request();
requestJF.input('dateJour', sql.Date, new Date(d));
const jourFerieResult = await requestJF.query(`
SELECT * FROM JoursFeries
WHERE DateJour = @dateJour
`);
const estJourFerie = jourFerieResult.recordset.length > 0;
// Vérifier si c'est un samedi (6) ou dimanche (0) ou jour férié
if (jourSemaine !== 0 && jourSemaine !== 6 && !estJourFerie) {
joursObligatoires.push({
date: formatDateWithoutUTC(new Date(d)),
jourSemaine: jourSemaine
});
}
}
res.json({
success: true,
data: joursObligatoires
});
} catch (error) {
console.error('Erreur getJoursSaisieObligatoire:', error);
res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message });
}
});
app.get('/api/getCompteRenduActivites', async (req, res) => {
const { user_id, annee, mois } = req.query;
try {
// Vérifier l'accÚs
const hasAccess = await hasCompteRenduAccess(user_id);
if (!hasAccess) {
return res.json({
success: false,
message: 'AccÚs réservé aux collaborateurs en forfait jour et aux directeurs de campus'
});
}
// Validation des paramĂštres
const parsedUserId = parseInt(user_id);
const parsedAnnee = parseInt(annee);
const parsedMois = parseInt(mois);
if (isNaN(parsedUserId) || isNaN(parsedAnnee) || isNaN(parsedMois)) {
return res.json({ success: false, message: 'ParamĂštres invalides' });
}
// RequĂȘte pour les jours
const joursRequest = new sql.Request(pool);
joursRequest.input('userid', sql.Int, parsedUserId);
joursRequest.input('annee', sql.Int, parsedAnnee);
joursRequest.input('mois', sql.Int, parsedMois);
const joursResult = await joursRequest.query(`
SELECT
Id,
CollaborateurADId,
Annee,
Mois,
CONVERT(VARCHAR(10), JourDate, 23) as JourDate,
JourTravaille,
ReposQuotidienRespect,
ReposHebdomadaireRespect,
CommentaireRepos,
Verrouille,
DateSaisie,
SaisiePar
FROM CompteRenduActivites
WHERE CollaborateurADId = @userid
AND Annee = @annee
AND Mois = @mois
ORDER BY JourDate
`);
console.log('đ Backend - Jours trouvĂ©s:', joursResult.recordset.length);
// RequĂȘte pour le mensuel
const mensuelRequest = new sql.Request(pool);
mensuelRequest.input('userid', sql.Int, parsedUserId);
mensuelRequest.input('annee', sql.Int, parsedAnnee);
mensuelRequest.input('mois', sql.Int, parsedMois);
const mensuelResult = await mensuelRequest.query(`
SELECT * FROM CompteRenduMensuel
WHERE CollaborateurADId = @userid
AND Annee = @annee
AND Mois = @mois
`);
res.json({
success: true,
jours: joursResult.recordset,
mensuel: mensuelResult.recordset[0] || null
});
} catch (error) {
console.error('â Erreur getCompteRenduActivites:', error);
res.status(500).json({ success: false, message: error.message });
}
});
function genererJoursConge(conge, mois, annee) {
const jours = [];
const dateDebut = new Date(conge.DateDebut);
const dateFin = new Date(conge.DateFin);
// Premier et dernier jour du mois demandé
const premierJourMois = new Date(annee, mois - 1, 1);
const dernierJourMois = new Date(annee, mois, 0);
// Ajuster les bornes
const debutEffectif = dateDebut > premierJourMois ? dateDebut : premierJourMois;
const finEffective = dateFin < dernierJourMois ? dateFin : dernierJourMois;
// Itérer sur chaque jour
for (let d = new Date(debutEffectif); d <= finEffective; d.setDate(d.getDate() + 1)) {
if (d.getMonth() + 1 === mois && d.getFullYear() === annee) {
const jourNum = d.getDate();
const dateStr = d.toISOString().split('T')[0];
const estPremierJour = d.getTime() === dateDebut.getTime();
const estDernierJour = d.getTime() === dateFin.getTime();
const estJourUnique = estPremierJour && estDernierJour;
let periodeJournee = 'Journée entiÚre';
let jourTravaille = 0;
if (estJourUnique) {
periodeJournee = conge.PeriodeJournee || 'Journée entiÚre';
jourTravaille = (periodeJournee === 'Matin' || periodeJournee === 'AprĂšs-midi') ? 1 : 0;
} else if (estPremierJour && conge.PeriodeJournee === 'AprĂšs-midi') {
periodeJournee = 'AprĂšs-midi';
jourTravaille = 1;
} else if (estDernierJour && conge.PeriodeJournee === 'Matin') {
periodeJournee = 'Matin';
jourTravaille = 1;
}
const labelPeriode = periodeJournee === 'Matin' ? '[Congé Matin] ' :
periodeJournee === 'AprÚs-midi' ? '[Congé AprÚs-midi] ' :
'[Congé Journée] ';
jours.push({
JourDate: jourNum,
DateComplete: dateStr,
JourTravaille: jourTravaille,
CommentaireRepos: `[${conge.TypeCongeNom}] ${labelPeriode}${conge.Commentaire || ''}`.trim(),
Source: `Congé-${conge.TypeCongeNom}`,
PeriodeJournee: periodeJournee,
DemandeCongeId: conge.Id,
TypeCongeId: conge.TypeCongeId,
TypeCongeNom: conge.TypeCongeNom
});
}
}
return jours;
}
async function getCongesAvecMultiJours(conn, userId, mois, annee) {
// Calculer les dates de dĂ©but et fin de mois pour la requĂȘte
const debutMois = new Date(annee, mois - 1, 1);
const finMois = new Date(annee, mois, 0);
// ExĂ©cuter la requĂȘte avec les nouveaux paramĂštres
const [congesRaw] = await conn.query(`
SELECT
dc.Id,
dc.DateDebut,
dc.DateFin,
dc.CollaborateurADId,
tc.Nom as TypeCongeNom,
tc.Id as TypeCongeId,
dct.PeriodeJournee,
dct.NombreJours,
dc.Commentaire,
dc.Statut
FROM DemandeConge dc
JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.CollaborateurADId = ?
AND tc.Nom NOT IN ('Récupération', 'JPOSF')
AND dc.Statut IN ('Validée', 'Validé')
AND (
(YEAR(dc.DateDebut) = ? AND MONTH(dc.DateDebut) = ?)
OR
(YEAR(dc.DateFin) = ? AND MONTH(dc.DateFin) = ?)
OR
(dc.DateDebut < ? AND dc.DateFin > ?)
)
`, [userId, annee, mois, annee, mois, debutMois, finMois]);
// Générer tous les jours pour chaque congé
const tousLesJours = [];
for (const conge of congesRaw) {
const joursConge = genererJoursConge(conge, mois, annee);
tousLesJours.push(...joursConge);
}
return tousLesJours;
}
// â
Route GET CRA pour EmployeeDetails (CORRIGĂE - avec statut saisie)
app.get('/api/getCra', async (req, res) => {
try {
const { user_id, month, year } = req.query;
if (!user_id || !month || !year) {
return res.json({ success: false, message: 'ParamĂštres manquants' });
}
const parsedUserId = parseInt(user_id);
const parsedMonth = parseInt(month);
const parsedYear = parseInt(year);
// Récupérer les jours du CRA
const requestCra = new sql.Request(pool);
requestCra.input('userid', sql.Int, parsedUserId);
requestCra.input('mois', sql.Int, parsedMonth);
requestCra.input('annee', sql.Int, parsedYear);
const craResult = await requestCra.query(`
SELECT
Id,
JourDate,
JourTravaille,
ReposQuotidienRespect,
ReposHebdomadaireRespect,
CommentaireRepos,
Verrouille
FROM CompteRenduActivites
WHERE CollaborateurADId = @userid
AND Mois = @mois
AND Annee = @annee
ORDER BY JourDate
`);
// Récupérer tous les types de congés
const typesResult = await new sql.Request(pool).query(`
SELECT Id, Nom FROM TypeConge WHERE Actif = 1
`);
const typesCongeMap = {};
for (const tc of typesResult.recordset) {
typesCongeMap[tc.Id] = tc.Nom;
}
// Récupérer les congés validés pour ce mois
const requestConges = new sql.Request(pool);
requestConges.input('userid', sql.Int, parsedUserId);
requestConges.input('mois', sql.Int, parsedMonth);
requestConges.input('annee', sql.Int, parsedYear);
const congesResult = await requestConges.query(`
SELECT
Id,
DateDebut,
DateFin,
NombreJours,
Statut,
TypeCongeId
FROM DemandeConge
WHERE CollaborateurADId = @userid
AND Statut = 'Validée'
AND (
(MONTH(DateDebut) = @mois AND YEAR(DateDebut) = @annee)
OR (MONTH(DateFin) = @mois AND YEAR(DateFin) = @annee)
OR (DateDebut <= DATEFROMPARTS(@annee, @mois, 1) AND DateFin >= EOMONTH(DATEFROMPARTS(@annee, @mois, 1)))
)
`);
// Récupérer le résumé mensuel
const requestMensuel = new sql.Request(pool);
requestMensuel.input('userid', sql.Int, parsedUserId);
requestMensuel.input('mois', sql.Int, parsedMonth);
requestMensuel.input('annee', sql.Int, parsedYear);
const mensuelResult = await requestMensuel.query(`
SELECT
NbJoursTravailles,
NbJoursNonRespectsReposQuotidien,
NbJoursNonRespectsReposHebdo,
Statut,
Verrouille
FROM CompteRenduMensuel
WHERE CollaborateurADId = @userid
AND Mois = @mois
AND Annee = @annee
`);
const daysInMonth = new Date(parsedYear, parsedMonth, 0).getDate();
const days = [];
const extractPeriode = (comm) => {
if (!comm) return 'Journée complÚte';
const match = comm.match(/\[PERIODE:(.*?)\]/);
return match ? match[1] : 'Journée complÚte';
};
const getTypeCongeNom = (typeCongeId) => {
if (!typeCongeId) return 'Congé';
const firstId = String(typeCongeId).split(',')[0].trim();
const id = parseInt(firstId);
return typesCongeMap[id] || 'Congé';
};
const getTypeCra = (typeConge) => {
if (!typeConge) return 'leave';
const typeLower = typeConge.toLowerCase();
if (typeLower.includes('rtt') || typeLower.includes('récup')) return 'rtt';
if (typeLower.includes('maladie')) return 'sick';
if (typeLower.includes('télétravail') || typeLower.includes('remote')) return 'remote';
return 'leave';
};
// Créer un map des jours CRA saisis
const craMap = new Map();
for (const jour of craResult.recordset) {
const dayNum = new Date(jour.JourDate).getDate();
const periode = extractPeriode(jour.CommentaireRepos);
if (!craMap.has(dayNum)) {
craMap.set(dayNum, []);
}
craMap.get(dayNum).push({
...jour,
periode
});
}
// Créer un map des congés par jour
const congesMap = new Map();
for (const conge of congesResult.recordset) {
const start = new Date(conge.DateDebut);
const end = new Date(conge.DateFin);
const typeNom = getTypeCongeNom(conge.TypeCongeId);
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
if (d.getMonth() + 1 === parsedMonth && d.getFullYear() === parsedYear) {
const dayNum = d.getDate();
congesMap.set(dayNum, {
typeCongeId: conge.TypeCongeId,
typeNom: typeNom,
typeCra: getTypeCra(typeNom),
statut: conge.Statut
});
}
}
}
// Jours fériés français
const joursFeries = getJoursFeries(parsedYear);
const feriesSet = new Set(joursFeries
.filter(jf => jf.month === parsedMonth)
.map(jf => jf.day)
);
// Date du jour pour savoir si le jour est passé
const today = new Date();
today.setHours(0, 0, 0, 0);
// Construire les jours
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(parsedYear, parsedMonth - 1, day);
const dayOfWeek = date.getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const isFerie = feriesSet.has(day);
const isPast = date < today;
const isToday = date.getTime() === today.getTime();
let type = null;
let label = '';
let value = 0;
let saisi = false; // â
Nouveau : indique si le jour a été saisi
if (isWeekend) {
type = 'weekend';
label = dayOfWeek === 0 ? 'Dimanche' : 'Samedi';
saisi = true; // Weekend = pas besoin de saisie
} else if (isFerie) {
type = 'holiday';
label = 'Jour férié';
saisi = true; // Férié = pas besoin de saisie
} else if (congesMap.has(day) && congesMap.get(day).typeCra !== 'remote') {
const conge = congesMap.get(day);
type = conge.typeCra;
label = conge.typeNom;
saisi = true;
// Autres congés = jour NON travaillé (absence)
value = 1;
} else if (craMap.has(day)) {
// â
Jour SAISI dans le CRA
saisi = true;
const craJours = craMap.get(day);
const totalValue = craJours.reduce((sum, j) => {
return sum + (j.periode === 'Matin' || j.periode === 'AprĂšs-midi' ? 0.5 : 1);
}, 0);
if (craJours.some(j => j.JourTravaille)) {
type = 'worked';
label = totalValue === 0.5 ? 'Demi-journée travaillée' : 'Journée travaillée';
value = totalValue;
} else {
type = 'not_worked';
label = 'Non travaillé (saisi)';
value = 0;
}
} else {
// â
Jour NON SAISI
saisi = false;
if (isPast) {
type = 'missing'; // Jour passé non saisi = manquant
label = 'Non saisi';
} else {
type = 'pending'; // Jour futur = en attente
label = 'Ă saisir';
}
}
days.push({
day,
date: date.toISOString().split('T')[0],
type,
label,
value,
isWeekend,
isFerie,
isPast,
isToday,
saisi // â
Nouveau champ
});
}
// Calculer le résumé avec les nouveaux statuts
const joursOuvrables = days.filter(d => !d.isWeekend && !d.isFerie);
const joursPasses = joursOuvrables.filter(d => d.isPast || d.isToday);
const summary = {
worked: days.filter(d => d.type === 'worked').reduce((sum, d) => sum + d.value, 0),
leave: days.filter(d => d.type === 'leave').length,
rtt: days.filter(d => d.type === 'rtt').length,
sick: days.filter(d => d.type === 'sick').length,
remote: days.filter(d => d.type === 'remote').length,
holiday: days.filter(d => d.type === 'holiday').length,
not_worked: days.filter(d => d.type === 'not_worked').length,
// â
Nouveaux indicateurs
missing: days.filter(d => d.type === 'missing').length, // Jours passés non saisis
pending: days.filter(d => d.type === 'pending').length, // Jours futurs Ă saisir
totalOuvrables: joursOuvrables.length,
totalSaisis: days.filter(d => d.saisi && !d.isWeekend && !d.isFerie).length,
tauxSaisie: joursPasses.length > 0
? Math.round((joursPasses.filter(d => d.saisi).length / joursPasses.length) * 100)
: 100
};
// Utiliser les données mensuelles si disponibles
if (mensuelResult.recordset.length > 0) {
const mensuel = mensuelResult.recordset[0];
summary.worked = mensuel.NbJoursTravailles || summary.worked;
}
res.json({
success: true,
cra: {
month: parsedMonth,
year: parsedYear,
days,
summary,
mensuel: mensuelResult.recordset[0] || null
}
});
} catch (error) {
console.error('â Erreur getCra:', error);
res.status(500).json({
success: false,
message: 'Erreur lors de la récupération du CRA',
error: error.message
});
}
});
// â
Fonction helper pour les jours fériés français
function getJoursFeries(year) {
const feries = [
{ day: 1, month: 1, label: 'Jour de l\'an' },
{ day: 1, month: 5, label: 'FĂȘte du travail' },
{ day: 8, month: 5, label: 'Victoire 1945' },
{ day: 14, month: 7, label: 'FĂȘte nationale' },
{ day: 15, month: 8, label: 'Assomption' },
{ day: 1, month: 11, label: 'Toussaint' },
{ day: 11, month: 11, label: 'Armistice' },
{ day: 25, month: 12, label: 'Noël' }
];
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
const paques = new Date(year, month - 1, day);
const lundiPaques = new Date(paques);
lundiPaques.setDate(lundiPaques.getDate() + 1);
feries.push({ day: lundiPaques.getDate(), month: lundiPaques.getMonth() + 1, label: 'Lundi de PĂąques' });
const ascension = new Date(paques);
ascension.setDate(ascension.getDate() + 39);
feries.push({ day: ascension.getDate(), month: ascension.getMonth() + 1, label: 'Ascension' });
const pentecote = new Date(paques);
pentecote.setDate(pentecote.getDate() + 50);
feries.push({ day: pentecote.getDate(), month: pentecote.getMonth() + 1, label: 'Lundi de PentecĂŽte' });
return feries;
}
/**
* GET /api/getMesCongesPersonnels
* RécupÚre les congés personnels d'un utilisateur (pour le compte-rendu d'activités)
* Utilisé notamment par les validateurs qui ne voient pas leurs propres congés via getTeamLeaves
*/
/**
* GET /api/getMesCongesPersonnels
* RécupÚre les congés personnels d'un utilisateur (pour le compte-rendu d'activités)
*/
app.get('/api/getMesCongesPersonnels', async (req, res) => {
try {
const { user_id } = req.query;
if (!user_id) {
return res.json({ success: false, message: 'ID utilisateur manquant' });
}
const isUUID = user_id.length > 10 && user_id.includes('-');
let numericUserId = user_id;
if (isUUID) {
const userRequest = pool.request();
userRequest.input('entraUserId', user_id);
const userResult = await userRequest.query(`
SELECT id FROM CollaborateurAD WHERE entraUserId = @entraUserId
`);
if (userResult.recordset.length === 0) {
return res.json({ success: false, message: 'Utilisateur non trouvé' });
}
numericUserId = userResult.recordset[0].id;
}
const queryRequest = pool.request();
queryRequest.input('userId', sql.Int, parseInt(numericUserId));
const query = `
SELECT
dc.Id,
CONVERT(VARCHAR(10), dc.DateDebut, 23) AS startdate,
CONVERT(VARCHAR(10), dc.DateFin, 23) AS enddate,
CONCAT(ca.prenom, ' ', ca.nom) AS employeename,
(
SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom)
FROM (SELECT DISTINCT tc2.Nom
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id) AS DistinctTypes
) AS type,
CONCAT(
'[',
STRING_AGG(
CONCAT(
'{"type":"', tc.Nom,
'","typeId":', tc.Id,
',"jours":', dct.NombreJours,
',"periode":"', COALESCE(dct.PeriodeJournee, 'Journée entiÚre'), '"}'
),
','
),
']'
) AS detailsconges,
MAX(tc.CouleurHex) AS color,
dc.Statut AS statut,
dc.NombreJours AS nombrejoursouvres,
CASE
WHEN EXISTS (
SELECT 1 FROM DemandeCongeType dct3
JOIN TypeConge tc3 ON dct3.TypeCongeId = tc3.Id
WHERE dct3.DemandeCongeId = dc.Id AND tc3.Nom = 'JPOSF'
) AND NOT EXISTS (
SELECT 1 FROM DemandeCongeType dct4
JOIN TypeConge tc4 ON dct4.TypeCongeId = tc4.Id
WHERE dct4.DemandeCongeId = dc.Id AND tc4.Nom != 'JPOSF'
) THEN 1
ELSE 0
END AS isJPOSFOnly
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.CollaborateurADId = @userId
AND dc.Statut IN ('Validé', 'Validée', 'Valide')
GROUP BY dc.Id, dc.DateDebut, dc.DateFin, ca.prenom, ca.nom, dc.Statut, dc.NombreJours
ORDER BY dc.DateDebut DESC
`;
const result = await queryRequest.query(query);
console.log(`â
getMesCongesPersonnels: ${result.recordset.length} congés trouvés pour user ${numericUserId}`);
return res.json({
success: true,
conges: result.recordset
});
} catch (error) {
console.error('â Erreur getMesCongesPersonnels:', error);
res.status(500).json({ success: false, message: error.message });
}
});
/**
* Envoie un email au N+1 direct quand les repos quotidiens/hebdomadaires ne sont pas respectés
*/
async function notifierN1ReposNonRespecte(pool, collaborateurId, date, commentaire) {
// Validation
if (!collaborateurId || collaborateurId === 'undefined' || collaborateurId === null) {
console.error('â collaborateurId NULL dans notification');
return;
}
const parsedCollabId = parseInt(collaborateurId);
if (isNaN(parsedCollabId)) {
console.error('â collaborateurId non numĂ©rique:', collaborateurId);
return;
}
try {
// Récupérer info collaborateur
const collabRequest = new sql.Request(pool);
collabRequest.input('collabId', sql.Int, parsedCollabId);
const collabResult = await collabRequest.query(`
SELECT prenom, nom, email, ServiceId, CampusId
FROM CollaborateurAD
WHERE id = @collabId
`);
if (collabResult.recordset.length === 0) {
console.error(`â Collaborateur ${parsedCollabId} introuvable`);
return;
}
const collab = collabResult.recordset[0];
const collabName = `${collab.prenom} ${collab.nom}`;
console.log(`đ§ Notification repos non respectĂ©s - Collaborateur: ${collabName}, Date: ${date}`);
// Récupérer le manager via hiérarchie
const managerRequest = new sql.Request(pool);
managerRequest.input('collabId', sql.Int, parsedCollabId);
const managerResult = await managerRequest.query(`
SELECT ca.id, ca.email, ca.prenom, ca.nom
FROM HierarchieValidationAD hv
JOIN CollaborateurAD ca ON hv.SuperieurId = ca.id
WHERE hv.CollaborateurId = @collabId
`);
if (managerResult.recordset.length === 0) {
console.error(`â Manager introuvable pour ${collabName}`);
return;
}
const manager = managerResult.recordset[0];
const managerName = `${manager.prenom} ${manager.nom}`;
const managerEmail = manager.email;
console.log(`â
Manager trouvé: ${managerName} (${managerEmail})`);
// Formater la date
const dateObj = new Date(date);
const dateFormatted = dateObj.toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
});
// Obtenir le token Graph
const graphToken = await getGraphToken();
if (!graphToken) {
console.error('â Impossible d\'obtenir le token Graph');
return;
}
// Construire l'email
const subject = `â ïž Alerte repos non respectĂ©s - ${collabName}`;
const bodyHTML = `
â ïž Alerte Temps de Repos
Bonjour ${managerName},
Un non-respect des temps de repos obligatoires a été déclaré par ${collabName}.
đ
Détails du signalement
| Collaborateur : |
${collabName} |
| Date : |
${dateFormatted} |
${commentaire ? `
đŹ Commentaire :
${commentaire}
` : ''}
âčïž Rappel : Le Code du travail impose un repos quotidien minimum de 11 heures consĂ©cutives
et un repos hebdomadaire de 35 heures consécutives (24h + 11h).
Cet email est envoyé automatiquement depuis l'application GTA.
`;
// Envoyer l'email
const emailSent = await sendMailGraph(graphToken, 'gtanoreply@ensup.eu', managerEmail, subject, bodyHTML);
if (emailSent) {
console.log(`â
Email envoyé au N+1: ${managerEmail}`);
// Créer une notification en base
const notifRequest = new sql.Request(pool);
const maxNotifIdResult = await notifRequest.query(`
SELECT ISNULL(MAX(Id), 0) + 1 AS nextId FROM Notifications
`);
const notifId = maxNotifIdResult.recordset[0].nextId;
const insertNotifRequest = new sql.Request(pool);
insertNotifRequest.input('id', sql.Int, notifId);
insertNotifRequest.input('collabId', sql.Int, manager.id);
insertNotifRequest.input('type', sql.NVarChar(50), 'Warning');
insertNotifRequest.input('titre', sql.NVarChar(200), 'Repos non respectés');
insertNotifRequest.input('message', sql.NVarChar(sql.MAX), `${collabName} a déclaré un non-respect des temps de repos le ${dateFormatted}`);
await insertNotifRequest.query(`
INSERT INTO Notifications (Id, CollaborateurADId, Type, Titre, Message, DateCreation, Lu)
VALUES (@id, @collabId, @type, @titre, @message, GETDATE(), 0)
`);
} else {
console.error('â Ăchec envoi email au N+1');
}
} catch (error) {
console.error('â Erreur notification N+1:', error);
}
}
async function recalculerStatsMensuel(transaction, userId, annee, mois) {
const statsRequest = new sql.Request(transaction);
statsRequest.input('userid', sql.Int, userId);
statsRequest.input('annee', sql.Int, annee);
statsRequest.input('mois', sql.Int, mois);
// Récupérer tous les enregistrements pour calculer manuellement
const allJours = await statsRequest.query(`
SELECT
Id,
JourTravaille,
ReposQuotidienRespect,
ReposHebdomadaireRespect,
CommentaireRepos
FROM CompteRenduActivites
WHERE CollaborateurADId = @userid
AND Annee = @annee
AND Mois = @mois
`);
let totalJoursSaisis = 0;
let joursTravailles = 0;
let nonRespectQuotidien = 0;
let nonRespectHebdo = 0;
for (const jour of allJours.recordset) {
const periode = extractPeriodeFromCommentaire(jour.CommentaireRepos);
const valeurJour = (periode === 'Matin' || periode === 'AprĂšs-midi') ? 0.5 : 1;
totalJoursSaisis += valeurJour;
if (jour.JourTravaille) {
joursTravailles += valeurJour;
}
if (!jour.ReposQuotidienRespect) {
nonRespectQuotidien++;
}
if (!jour.ReposHebdomadaireRespect) {
nonRespectHebdo++;
}
}
console.log(`đ Stats calculĂ©es: ${totalJoursSaisis}j saisis, ${joursTravailles}j travaillĂ©s`);
// VĂ©rifier si un enregistrement mensuel existe dĂ©jĂ
const checkRequest = new sql.Request(transaction);
checkRequest.input('userid', sql.Int, userId);
checkRequest.input('annee', sql.Int, annee);
checkRequest.input('mois', sql.Int, mois);
const existing = await checkRequest.query(`
SELECT Id
FROM CompteRenduMensuel
WHERE CollaborateurADId = @userid AND Annee = @annee AND Mois = @mois
`);
if (existing.recordset.length > 0) {
// UPDATE
const updateRequest = new sql.Request(transaction);
updateRequest.input('mensuelId', sql.Int, existing.recordset[0].Id);
updateRequest.input('totalJours', sql.Float, joursTravailles);
updateRequest.input('nonRespectQuotidien', sql.Int, nonRespectQuotidien);
updateRequest.input('nonRespectHebdo', sql.Int, nonRespectHebdo);
await updateRequest.query(`
UPDATE CompteRenduMensuel
SET NbJoursTravailles = @totalJours,
NbJoursNonRespectsReposQuotidien = @nonRespectQuotidien,
NbJoursNonRespectsReposHebdo = @nonRespectHebdo,
DateModification = GETDATE()
WHERE Id = @mensuelId
`);
console.log(`â
Stats mensuelles mises Ă jour`);
} else {
// INSERT - Générer l'ID manuellement
const newIdRequest = new sql.Request(transaction);
const maxIdResult = await newIdRequest.query(`
SELECT ISNULL(MAX(Id), 0) + 1 AS nextId FROM CompteRenduMensuel
`);
const mensuelId = maxIdResult.recordset[0].nextId;
const insertRequest = new sql.Request(transaction);
insertRequest.input('mensuelId', sql.Int, mensuelId);
insertRequest.input('userid', sql.Int, userId);
insertRequest.input('annee', sql.Int, annee);
insertRequest.input('mois', sql.Int, mois);
insertRequest.input('totalJours', sql.Float, joursTravailles);
insertRequest.input('nonRespectQuotidien', sql.Int, nonRespectQuotidien);
insertRequest.input('nonRespectHebdo', sql.Int, nonRespectHebdo);
await insertRequest.query(`
INSERT INTO CompteRenduMensuel (
Id, CollaborateurADId, Annee, Mois,
NbJoursTravailles,
NbJoursNonRespectsReposQuotidien,
NbJoursNonRespectsReposHebdo,
Statut, Verrouille,
DateCreation, DateModification
) VALUES (
@mensuelId, @userid, @annee, @mois,
@totalJours,
@nonRespectQuotidien,
@nonRespectHebdo,
'Brouillon', 0,
GETDATE(), GETDATE()
)
`);
console.log(`â
Stats mensuelles créées`);
}
}
function extractPeriodeFromCommentaire(commentaire) {
if (!commentaire) return 'Journée complÚte';
const match = commentaire.match(/\[PERIODE:(.*?)\]/);
return match ? match[1] : 'Journée complÚte';
}
// â
FONCTION HELPER : Extraire le commentaire sans le préfixe période
function extractCommentaireSansPeriode(commentaire) {
if (!commentaire) return null;
return commentaire.replace(/\[PERIODE:.*?\]\s*/, '').trim() || null;
}
app.post('/api/saveCompteRenduJour', async (req, res) => {
const { user_id, date, jour_travaille, repos_quotidien, repos_hebdo, commentaire, rh_override, periode } = req.body;
const transaction = new sql.Transaction(pool);
try {
await transaction.begin();
const parsedUserId = parseInt(user_id);
if (isNaN(parsedUserId)) {
await transaction.rollback();
return res.json({ success: false, message: 'ID utilisateur invalide' });
}
const dateJour = new Date(date);
const aujourdhui = new Date();
aujourdhui.setHours(0, 0, 0, 0);
dateJour.setHours(0, 0, 0, 0);
// Bloquer saisie du jour actuel (il faut attendre le lendemain)
if (dateJour >= aujourdhui && !rh_override) {
await transaction.rollback();
return res.json({
success: false,
message: 'Vous ne pouvez pas saisir le jour actuel. Veuillez attendre demain.'
});
}
const annee = dateJour.getFullYear();
const mois = dateJour.getMonth() + 1;
// Vérifier si le JOUR est déjà verrouillé
const checkRequest = new sql.Request(transaction);
checkRequest.input('userid', sql.Int, parsedUserId);
checkRequest.input('date', sql.Date, dateJour);
const existingResult = await checkRequest.query(`
SELECT Verrouille
FROM CompteRenduActivites
WHERE CollaborateurADId = @userid AND JourDate = @date
`);
if (existingResult.recordset.length > 0 && existingResult.recordset[0].Verrouille && !rh_override) {
await transaction.rollback();
return res.json({
success: false,
message: 'Ce jour est verrouillé - Contactez les RH pour modification'
});
}
// Vérifier commentaire obligatoire si non-respect des repos
if ((!repos_quotidien || !repos_hebdo) && (!commentaire || commentaire.trim() === '')) {
await transaction.rollback();
return res.json({
success: false,
message: 'Commentaire obligatoire en cas de non-respect des repos'
});
}
// Construire le commentaire avec la période
let commentaireComplet = '';
if (periode && periode !== 'Journée complÚte') {
commentaireComplet = `[PERIODE:${periode}] `;
}
if (commentaire) {
commentaireComplet += commentaire;
}
// VĂ©rifier si l'enregistrement existe dĂ©jĂ
if (existingResult.recordset.length > 0) {
// UPDATE
const updateRequest = new sql.Request(transaction);
updateRequest.input('userid', sql.Int, parsedUserId);
updateRequest.input('date', sql.Date, dateJour);
updateRequest.input('jourTravaille', sql.Bit, jour_travaille ? 1 : 0);
updateRequest.input('reposQuotidien', sql.Bit, repos_quotidien ? 1 : 0);
updateRequest.input('reposHebdo', sql.Bit, repos_hebdo ? 1 : 0);
updateRequest.input('commentaire', sql.NVarChar(sql.MAX), commentaireComplet || null);
updateRequest.input('saisiePar', sql.Int, parsedUserId);
await updateRequest.query(`
UPDATE CompteRenduActivites
SET JourTravaille = @jourTravaille,
ReposQuotidienRespect = @reposQuotidien,
ReposHebdomadaireRespect = @reposHebdo,
CommentaireRepos = @commentaire,
SaisiePar = @saisiePar,
DateModification = GETDATE(),
Verrouille = 1
WHERE CollaborateurADId = @userid AND JourDate = @date
`);
} else {
// INSERT - Générer l'ID manuellement
const maxIdRequest = new sql.Request(transaction);
const maxIdResult = await maxIdRequest.query(`
SELECT ISNULL(MAX(Id), 0) + 1 AS nextId FROM CompteRenduActivites
`);
const newId = maxIdResult.recordset[0].nextId;
const insertRequest = new sql.Request(transaction);
insertRequest.input('id', sql.Int, newId);
insertRequest.input('userid', sql.Int, parsedUserId);
insertRequest.input('annee', sql.Int, annee);
insertRequest.input('mois', sql.Int, mois);
insertRequest.input('date', sql.Date, dateJour);
insertRequest.input('jourTravaille', sql.Bit, jour_travaille ? 1 : 0);
insertRequest.input('reposQuotidien', sql.Bit, repos_quotidien ? 1 : 0);
insertRequest.input('reposHebdo', sql.Bit, repos_hebdo ? 1 : 0);
insertRequest.input('commentaire', sql.NVarChar(sql.MAX), commentaireComplet || null);
insertRequest.input('saisiePar', sql.Int, parsedUserId);
await insertRequest.query(`
INSERT INTO CompteRenduActivites
(Id, CollaborateurADId, Annee, Mois, JourDate, JourTravaille,
ReposQuotidienRespect, ReposHebdomadaireRespect, CommentaireRepos,
DateCreation, DateModification, SaisiePar, Verrouille)
VALUES
(@id, @userid, @annee, @mois, @date, @jourTravaille,
@reposQuotidien, @reposHebdo, @commentaire,
GETDATE(), GETDATE(), @saisiePar, 1)
`);
}
// Recalculer les stats mensuelles
await recalculerStatsMensuel(transaction, parsedUserId, annee, mois);
// Notification N+1 si repos non respectés
if (!repos_quotidien || !repos_hebdo) {
// Appel asynchrone (ne pas bloquer la réponse)
setImmediate(() => {
notifierN1ReposNonRespecte(pool, parsedUserId, date, commentaire)
.catch(err => console.error('Erreur notification N+1:', err));
});
}
await transaction.commit();
res.json({
success: true,
message: 'Jour enregistré et verrouillé',
verrouille: true
});
} catch (error) {
await transaction.rollback();
console.error('â Erreur saveCompteRenduJour:', error);
res.status(500).json({ success: false, message: error.message });
}
});
/**
* POST /api/saveCompteRenduMasse
* Sauvegarde plusieurs jours de compte-rendu en une fois
*/
app.post('/api/saveCompteRenduMasse', async (req, res) => {
const { user_id, annee, mois, jours, rh_override } = req.body;
const transaction = new sql.Transaction(pool);
try {
await transaction.begin();
const parsedUserId = parseInt(user_id);
const parsedAnnee = parseInt(annee);
const parsedMois = parseInt(mois);
if (isNaN(parsedUserId) || isNaN(parsedAnnee) || isNaN(parsedMois)) {
await transaction.rollback();
return res.json({ success: false, message: 'ParamĂštres invalides' });
}
let count = 0;
let blocked = 0;
const notificationsRepos = [];
const aujourdhui = new Date();
aujourdhui.setHours(0, 0, 0, 0);
for (const jour of jours) {
const dateJour = new Date(jour.date);
dateJour.setHours(0, 0, 0, 0);
// Bloquer le jour actuel (sauf override RH)
if (dateJour >= aujourdhui && !rh_override) {
blocked++;
continue;
}
// Vérifier si déjà verrouillé
const checkRequest = new sql.Request(transaction);
checkRequest.input('userid', sql.Int, parsedUserId);
checkRequest.input('date', sql.Date, dateJour);
const existingResult = await checkRequest.query(`
SELECT Id, Verrouille
FROM CompteRenduActivites
WHERE CollaborateurADId = @userid AND JourDate = @date
`);
if (existingResult.recordset.length > 0 && existingResult.recordset[0].Verrouille && !rh_override) {
blocked++;
continue;
}
// Construire le commentaire avec la période
let commentaireComplet = '';
if (jour.periode && jour.periode !== 'Journée complÚte') {
commentaireComplet = `[PERIODE:${jour.periode}] `;
}
if (jour.commentaire) {
commentaireComplet += jour.commentaire;
}
if (existingResult.recordset.length > 0) {
// UPDATE
const updateRequest = new sql.Request(transaction);
updateRequest.input('userid', sql.Int, parsedUserId);
updateRequest.input('date', sql.Date, dateJour);
updateRequest.input('jourTravaille', sql.Bit, jour.jour_travaille ? 1 : 0);
updateRequest.input('reposQuotidien', sql.Bit, jour.repos_quotidien ? 1 : 0);
updateRequest.input('reposHebdo', sql.Bit, jour.repos_hebdo ? 1 : 0);
updateRequest.input('commentaire', sql.NVarChar(sql.MAX), commentaireComplet || null);
updateRequest.input('saisiePar', sql.Int, parsedUserId);
await updateRequest.query(`
UPDATE CompteRenduActivites
SET JourTravaille = @jourTravaille,
ReposQuotidienRespect = @reposQuotidien,
ReposHebdomadaireRespect = @reposHebdo,
CommentaireRepos = @commentaire,
SaisiePar = @saisiePar,
DateModification = GETDATE(),
Verrouille = 1
WHERE CollaborateurADId = @userid AND JourDate = @date
`);
} else {
// INSERT - Générer l'ID manuellement
const maxIdRequest = new sql.Request(transaction);
const maxIdResult = await maxIdRequest.query(`
SELECT ISNULL(MAX(Id), 0) + 1 AS nextId FROM CompteRenduActivites
`);
const newId = maxIdResult.recordset[0].nextId;
const insertRequest = new sql.Request(transaction);
insertRequest.input('id', sql.Int, newId);
insertRequest.input('userid', sql.Int, parsedUserId);
insertRequest.input('annee', sql.Int, parsedAnnee);
insertRequest.input('mois', sql.Int, parsedMois);
insertRequest.input('date', sql.Date, dateJour);
insertRequest.input('jourTravaille', sql.Bit, jour.jour_travaille ? 1 : 0);
insertRequest.input('reposQuotidien', sql.Bit, jour.repos_quotidien ? 1 : 0);
insertRequest.input('reposHebdo', sql.Bit, jour.repos_hebdo ? 1 : 0);
insertRequest.input('commentaire', sql.NVarChar(sql.MAX), commentaireComplet || null);
insertRequest.input('saisiePar', sql.Int, parsedUserId);
await insertRequest.query(`
INSERT INTO CompteRenduActivites
(Id, CollaborateurADId, Annee, Mois, JourDate, JourTravaille,
ReposQuotidienRespect, ReposHebdomadaireRespect, CommentaireRepos,
DateCreation, DateModification, SaisiePar, Verrouille)
VALUES
(@id, @userid, @annee, @mois, @date, @jourTravaille,
@reposQuotidien, @reposHebdo, @commentaire,
GETDATE(), GETDATE(), @saisiePar, 1)
`);
}
count++;
// Collecter les jours avec repos non respectés pour notification
if (!jour.repos_quotidien || !jour.repos_hebdo) {
notificationsRepos.push({
date: jour.date,
commentaire: jour.commentaire
});
}
}
// Recalculer les stats mensuelles
await recalculerStatsMensuel(transaction, parsedUserId, parsedAnnee, parsedMois);
await transaction.commit();
// Envoyer les notifications N+1 (asynchrone)
if (notificationsRepos.length > 0) {
setImmediate(() => {
for (const notif of notificationsRepos) {
notifierN1ReposNonRespecte(pool, parsedUserId, notif.date, notif.commentaire)
.catch(err => console.error('Erreur notification N+1:', err));
}
});
}
res.json({
success: true,
count: count,
blocked: blocked,
message: `${count} jours enregistrés${blocked > 0 ? `, ${blocked} ignorés (jour actuel ou déjà verrouillés)` : ''}`
});
} catch (error) {
await transaction.rollback();
console.error('â Erreur saveCompteRenduMasse:', error);
res.status(500).json({ success: false, message: error.message });
}
});
app.get('/api/getCompteRenduMois/:userId/:annee/:mois', async (req, res) => {
try {
const { userId, annee, mois } = req.params;
const parsedUserId = parseInt(userId);
const request = new sql.Request(pool);
request.input('userid', sql.Int, parsedUserId);
request.input('annee', sql.Int, parseInt(annee));
request.input('mois', sql.Int, parseInt(mois));
const result = await request.query(`
SELECT
Id,
JourDate,
JourTravaille,
ReposQuotidienRespect,
ReposHebdomadaireRespect,
CommentaireRepos,
Verrouille,
DateCreation,
DateModification
FROM CompteRenduActivites
WHERE CollaborateurADId = @userid
AND Annee = @annee
AND Mois = @mois
ORDER BY JourDate
`);
// Transformer les résultats pour extraire la période
const joursAvecPeriode = result.recordset.map(jour => {
const periode = extractPeriodeFromCommentaire(jour.CommentaireRepos);
const commentaireSansPeriode = extractCommentaireSansPeriode(jour.CommentaireRepos);
return {
...jour,
PeriodeJournee: periode,
Commentaire: commentaireSansPeriode,
ValeurJour: (periode === 'Matin' || periode === 'AprĂšs-midi') ? 0.5 : 1
};
});
res.json({
success: true,
jours: joursAvecPeriode
});
} catch (error) {
console.error('â Erreur rĂ©cupĂ©ration mois:', error);
res.status(500).json({
success: false,
message: 'Erreur lors de la récupération',
error: error.message
});
}
});
app.post('/api/deverrouillerJour', async (req, res) => {
const { user_id, date, rh_user_id } = req.body;
try {
const conn = await pool.getConnection();
const [rhUser] = await conn.query(
'SELECT role FROM CollaborateurAD WHERE id = ?',
[rh_user_id]
);
if (!rhUser.length || (rhUser[0].role !== 'RH' && rhUser[0].role !== 'Admin')) {
conn.release();
return res.json({ success: false, message: 'Action réservée aux RH' });
}
await conn.query(`
UPDATE CompteRenduActivites
SET Verrouille = FALSE
WHERE CollaborateurADId = ? AND JourDate = ?
`, [user_id, date]);
conn.release();
res.json({ success: true });
} catch (error) {
console.error('â Erreur dĂ©verrouillage jour:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// POST - Verrouiller (RH uniquement)
app.post('/api/verrouillerCompteRendu', async (req, res) => {
const { user_id, annee, mois, rh_user_id } = req.body;
try {
const conn = await pool.getConnection();
// Vérifier que l'utilisateur est RH
const [rhUser] = await conn.query(
'SELECT role FROM CollaborateurAD WHERE id = ?',
[rh_user_id]
);
if (!rhUser.length || (rhUser[0].role !== 'RH' && rhUser[0].role !== 'Admin')) {
conn.release();
return res.json({ success: false, message: 'Action réservée aux RH' });
}
await conn.query(`
UPDATE CompteRenduMensuel
SET Verrouille = TRUE,
DateModification = GETDATE()
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ?
`, [user_id, annee, mois]);
conn.release();
res.json({ success: true });
} catch (error) {
console.error('Erreur verrouillage:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// POST - Déverrouiller (RH uniquement)
app.post('/api/deverrouillerCompteRendu', async (req, res) => {
const { user_id, annee, mois, rh_user_id } = req.body;
try {
const conn = await pool.getConnection();
// Vérifier que l'utilisateur est RH
const [rhUser] = await conn.query(
'SELECT role FROM CollaborateurAD WHERE id = ?',
[rh_user_id]
);
if (!rhUser.length || (rhUser[0].role !== 'RH' && rhUser[0].role !== 'Admin')) {
conn.release();
return res.json({ success: false, message: 'Action réservée aux RH' });
}
await conn.query(`
UPDATE CompteRenduMensuel
SET Verrouille = FALSE,
DateDeverrouillage = GETDATE(),
DeverrouillePar = ?
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ?
`, [rh_user_id, user_id, annee, mois]);
conn.release();
res.json({ success: true });
} catch (error) {
console.error('Erreur déverrouillage:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// GET - Stats annuelles
app.get('/api/getStatsAnnuelles', async (req, res) => {
const { user_id, annee } = req.query;
try {
const parsedUserId = parseInt(user_id);
const parsedAnnee = parseInt(annee);
if (isNaN(parsedUserId) || isNaN(parsedAnnee)) {
return res.json({ success: false, message: 'ParamĂštres invalides' });
}
const request = new sql.Request(pool);
request.input('userid', sql.Int, parsedUserId);
request.input('annee', sql.Int, parsedAnnee);
const result = await request.query(`
SELECT
ISNULL(SUM(NbJoursTravailles), 0) as totalJoursTravailles,
ISNULL(SUM(NbJoursNonRespectsReposQuotidien), 0) as totalNonRespectQuotidien,
ISNULL(SUM(NbJoursNonRespectsReposHebdo), 0) as totalNonRespectHebdo
FROM CompteRenduMensuel
WHERE CollaborateurADId = @userid
AND Annee = @annee
`);
res.json({
success: true,
stats: result.recordset[0] || {
totalJoursTravailles: 0,
totalNonRespectQuotidien: 0,
totalNonRespectHebdo: 0
}
});
} catch (error) {
console.error('â Erreur getStatsAnnuelles:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// GET - Export PDF (RH uniquement)
app.get('/api/exportCompteRenduPDF', async (req, res) => {
const { user_id, annee, mois } = req.query;
try {
const conn = await pool.getConnection();
// Récupérer les données du collaborateur
const [collab] = await conn.query(
'SELECT prenom, nom, email FROM CollaborateurAD WHERE id = ?',
[user_id]
);
// Récupérer les jours du mois
const [jours] = await conn.query(`
SELECT * FROM CompteRenduActivites
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ?
ORDER BY JourDate
`, [user_id, annee, mois]);
// Récupérer le mensuel
const [mensuel] = await conn.query(`
SELECT * FROM CompteRenduMensuel
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ?
`, [user_id, annee, mois]);
conn.release();
// TODO: Générer le PDF avec une bibliothÚque comme pdfkit ou puppeteer
// Pour l'instant, retourner les données JSON
res.json({
success: true,
collaborateur: collab[0],
jours: jours,
mensuel: mensuel[0],
message: 'Export PDF à implémenter'
});
} catch (error) {
console.error('Erreur export PDF:', error);
res.status(500).json({ success: false, message: error.message });
}
});
app.post('/api/addUserFromEntra', async (req, res) => {
try {
const { email } = req.body;
if (!email) {
return res.json({ success: false, message: 'Email requis' });
}
console.log(`\nđ Recherche utilisateur Entra ID: ${email}`);
// 1ïžâŁ Obtenir le token
const accessToken = await getGraphToken();
if (!accessToken) {
return res.json({ success: false, message: 'Impossible d\'obtenir le token Microsoft' });
}
// 2ïžâŁ Rechercher l'utilisateur dans Entra ID
const searchUrl = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(email)}?$select=id,givenName,surname,mail,userPrincipalName,department,jobTitle,accountEnabled`;
let userData;
try {
const response = await axios.get(searchUrl, {
headers: { Authorization: `Bearer ${accessToken}` }
});
userData = response.data;
} catch (error) {
return res.json({
success: false,
message: `Utilisateur ${email} non trouvé dans Entra ID`
});
}
// 3ïžâŁ VĂ©rifier si compte actif
if (userData.accountEnabled === false) {
return res.json({
success: false,
message: 'Ce compte est désactivé dans Entra ID'
});
}
// 4ïžâŁ VĂ©rifier s'il existe dĂ©jĂ en base
const conn = await pool.getConnection();
const [existing] = await conn.query(
'SELECT id, email FROM CollaborateurAD WHERE LOWER(email) = ?',
[email.toLowerCase()]
);
if (existing.length > 0) {
conn.release();
return res.json({
success: false,
message: 'Cet utilisateur existe déjà en base',
userId: existing[0].id
});
}
// 5ïžâŁ InsĂ©rer l'utilisateur (SANS DateEntree pour Ă©viter GETDATE())
await conn.query(`
INSERT INTO CollaborateurAD
(entraUserId, prenom, nom, email, service, description, role, SocieteId, Actif, TypeContrat)
VALUES (?, ?, ?, ?, ?, ?, 'Collaborateur', 1, 1, '37h')
`, [
userData.id,
userData.givenName || 'Prénom',
userData.surname || 'Nom',
email.toLowerCase(),
userData.department || '',
userData.jobTitle || ''
]);
// 6ïžâŁ RĂ©cupĂ©rer l'utilisateur créé
const [newUser] = await conn.query(
'SELECT id, prenom, nom, email FROM CollaborateurAD WHERE LOWER(email) = ?',
[email.toLowerCase()]
);
conn.release();
console.log(`â
Utilisateur créé: ${newUser[0].prenom} ${newUser[0].nom} (ID: ${newUser[0].id})`);
res.json({
success: true,
message: 'Utilisateur ajouté avec succÚs',
user: newUser[0]
});
} catch (error) {
console.error('â Erreur addUserFromEntra:', error);
res.status(500).json({
success: false,
message: error.message
});
}
});
async function syncEntraIdUsers() {
const syncResults = {
processed: 0,
inserted: 0,
updated: 0,
deactivated: 0,
errors: []
};
try {
console.log('\nđ === DĂBUT SYNCHRONISATION ENTRA ID ===');
// 1ïžâŁ Obtenir le token
const accessToken = await getGraphToken();
if (!accessToken) {
console.error('â Impossible d\'obtenir le token');
return syncResults;
}
console.log('â
Token obtenu');
// 2ïžâŁ RĂ©cupĂ©rer le groupe
const groupResponse = await axios.get(
`https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
const groupName = groupResponse.data.displayName;
console.log(`đ Groupe : ${groupName}`);
// 3ïžâŁ RĂ©cupĂ©rer tous les membres avec pagination
let allAzureMembers = [];
let nextLink = `https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}/members?$select=id,givenName,surname,mail,department,jobTitle,officeLocation,accountEnabled&$top=999`;
console.log('đ„ RĂ©cupĂ©ration des membres...');
while (nextLink) {
const membersResponse = await axios.get(nextLink, {
headers: { Authorization: `Bearer ${accessToken}` }
});
allAzureMembers = allAzureMembers.concat(membersResponse.data.value);
nextLink = membersResponse.data['@odata.nextLink'];
if (nextLink) {
console.log(` đ ${allAzureMembers.length} membres rĂ©cupĂ©rĂ©s...`);
}
}
console.log(`â
${allAzureMembers.length} membres trouvés`);
// 4ïžâŁ Filtrer les membres valides
const validMembers = allAzureMembers.filter(m => {
if (!m.mail || m.mail.trim() === '') return false;
if (m.accountEnabled === false) return false;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(m.mail);
});
console.log(`â
${validMembers.length} membres valides`);
// 5ïžâŁ Traitement avec transaction
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
const azureEmails = new Set();
validMembers.forEach(m => {
azureEmails.add(m.mail.toLowerCase().trim());
});
console.log('\nđ Traitement des utilisateurs...');
// 6ïžâŁ Pour chaque membre
for (const m of validMembers) {
try {
const emailClean = m.mail.toLowerCase().trim();
syncResults.processed++;
// â
CORRIGĂ : Utiliser entraUserId et Actif
const request = new sql.Request(transaction);
request.input('email', sql.NVarChar, emailClean);
const result = await request.query(`
SELECT id, email, entraUserId, Actif
FROM CollaborateurAD
WHERE LOWER(email) = LOWER(@email)
`);
if (result.recordset.length > 0) {
// â
MISE Ă JOUR avec bonnes colonnes
const updateRequest = new sql.Request(transaction);
updateRequest.input('entraUserId', sql.NVarChar, m.id);
updateRequest.input('prenom', sql.NVarChar, m.givenName || '');
updateRequest.input('nom', sql.NVarChar, m.surname || '');
updateRequest.input('service', sql.NVarChar, m.department || '');
updateRequest.input('description', sql.NVarChar, m.jobTitle || '');
updateRequest.input('email', sql.NVarChar, emailClean);
await updateRequest.query(`
UPDATE CollaborateurAD
SET
entraUserId = @entraUserId,
prenom = @prenom,
nom = @nom,
service = @service,
description = @description,
Actif = 1
WHERE LOWER(email) = LOWER(@email)
`);
syncResults.updated++;
console.log(` â Mis Ă jour : ${emailClean}`);
} else {
// â
INSERTION avec bonnes colonnes
const insertRequest = new sql.Request(transaction);
insertRequest.input('entraUserId', sql.NVarChar, m.id);
insertRequest.input('prenom', sql.NVarChar, m.givenName || '');
insertRequest.input('nom', sql.NVarChar, m.surname || '');
insertRequest.input('email', sql.NVarChar, emailClean);
insertRequest.input('service', sql.NVarChar, m.department || '');
insertRequest.input('description', sql.NVarChar, m.jobTitle || '');
await insertRequest.query(`
INSERT INTO CollaborateurAD
(entraUserId, prenom, nom, email, service, description, role, SocieteId, Actif, TypeContrat)
VALUES (@entraUserId, @prenom, @nom, @email, @service, @description, 'Collaborateur', 1, 1, '37h')
`);
syncResults.inserted++;
console.log(` â Créé : ${emailClean}`);
}
} catch (userError) {
syncResults.errors.push({
email: m.mail,
error: userError.message
});
console.error(` â Erreur ${m.mail}:`, userError.message);
}
}
// 7ïžâŁ â
DĂSACTIVATION avec bonne colonne (Actif, pas dateMiseAJour)
console.log('\nđ DĂ©sactivation des comptes obsolĂštes...');
if (azureEmails.size > 0) {
const activeEmailsList = Array.from(azureEmails).map(e => `'${e}'`).join(',');
const deactivateRequest = new sql.Request(transaction);
const deactivateResult = await deactivateRequest.query(`
UPDATE CollaborateurAD
SET Actif = 0
WHERE
email IS NOT NULL
AND email != ''
AND LOWER(email) NOT IN (${activeEmailsList})
AND Actif = 1
`);
syncResults.deactivated = deactivateResult.rowsAffected[0];
console.log(` â ${syncResults.deactivated} compte(s) dĂ©sactivĂ©(s)`);
}
await transaction.commit();
console.log('\nđ === RĂSUMĂ ===');
console.log(` Groupe: ${groupName}`);
console.log(` Total Entra: ${allAzureMembers.length}`);
console.log(` Valides: ${validMembers.length}`);
console.log(` Traités: ${syncResults.processed}`);
console.log(` Créés: ${syncResults.inserted}`);
console.log(` Mis Ă jour: ${syncResults.updated}`);
console.log(` Désactivés: ${syncResults.deactivated}`);
console.log(` Erreurs: ${syncResults.errors.length}`);
} catch (error) {
await transaction.rollback();
throw error;
}
} catch (error) {
console.error('\nâ ERREUR SYNCHRONISATION:', error.message);
}
return syncResults;
}
app.post('/api/sync-all', async (req, res) => {
try {
console.log('đ Sync complĂšte manuelle...');
const results = await syncEntraIdUsers();
res.json({
success: true,
message: 'Sync terminée',
stats: results
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message
});
}
});
// Route diagnostic
app.get('/api/diagnostic-sync', async (req, res) => {
try {
const totalDB = await pool.query(
'SELECT COUNT(*) as total, SUM(CASE WHEN actif = 1 THEN 1 ELSE 0 END) as actifs FROM CollaborateurAD',
[]
);
const sansEmail = await pool.query(
'SELECT COUNT(*) as total FROM CollaborateurAD WHERE email IS NULL OR email = \'\'',
[]
);
const derniers = await pool.query(
'SELECT TOP 10 id, prenom, nom, email, CollaborateurADId, actif FROM CollaborateurAD ORDER BY id DESC',
[]
);
// Test Entra
let entraStatus = { connected: false };
try {
const token = await getGraphToken();
if (token) {
const groupResponse = await axios.get(
`https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`,
{ headers: { Authorization: `Bearer ${token}` } }
);
entraStatus = {
connected: true,
groupName: groupResponse.data.displayName
};
}
} catch (err) {
entraStatus.error = err.message;
}
res.json({
success: true,
database: {
total: totalDB[0]?.total || 0,
actifs: totalDB[0]?.actifs || 0,
sansEmail: sansEmail[0]?.total || 0
},
entraId: entraStatus,
derniers_utilisateurs: derniers
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
// GET - Compteur de demandes en attente pour le badge
app.get('/api/getPendingRequestsCount', async (req, res) => {
try {
const userId = req.query.user_id;
const userRole = req.query.role;
const userEmail = req.query.email;
if (!userId) {
return res.json({ success: false, message: 'ID utilisateur manquant' });
}
const conn = await pool.getConnection();
let count = 0;
// Normaliser le rĂŽle
const role = normalizeRole(userRole);
console.log(`đ Comptage demandes pour: ${userEmail}, Role: ${role}`);
if (role === 'rh' || role === 'admin' || role === 'president' || role === 'directeur de campus') {
// Pour RH/Admin/President/Directeur : toutes les demandes en attente de leur périmÚtre
const [userInfo] = await conn.query(
'SELECT ServiceId, CampusId, SocieteId FROM CollaborateurAD WHERE id = ?',
[userId]
);
if (userInfo.length === 0) {
conn.release();
return res.json({ success: false, message: 'Utilisateur non trouvé' });
}
const serviceId = userInfo[0].ServiceId;
const campusId = userInfo[0].CampusId;
// â VĂ©rifier accĂšs transversal
const accesTransversal = getUserAccesTransversal(userEmail);
if (accesTransversal) {
// AccĂšs service multi-campus
const [result] = await conn.query(`
SELECT COUNT(DISTINCT dc.Id) as count
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
JOIN Services s ON ca.ServiceId = s.Id
WHERE dc.Statut = 'En attente'
AND s.Nom = ?
`, [accesTransversal.serviceNom]);
count = result[0].count;
} else if (role === 'directeur de campus') {
// Directeur de campus : toutes les demandes de son campus
const [result] = await conn.query(`
SELECT COUNT(DISTINCT dc.Id) as count
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
WHERE dc.Statut = 'En attente'
AND ca.CampusId = ?
`, [campusId]);
count = result[0].count;
} else {
// RH : son service sur son campus uniquement
const [result] = await conn.query(`
SELECT COUNT(DISTINCT dc.Id) as count
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
WHERE dc.Statut = 'En attente'
AND ca.ServiceId = ?
AND ca.CampusId = ?
`, [serviceId, campusId]);
count = result[0].count;
}
} else if (role === 'validateur' || role === 'validatrice') {
// Pour validateurs : demandes de leur service
const [userInfo] = await conn.query(
'SELECT ServiceId, CampusId FROM CollaborateurAD WHERE id = ?',
[userId]
);
if (userInfo.length === 0) {
conn.release();
return res.json({ success: false, message: 'Utilisateur non trouvé' });
}
const serviceId = userInfo[0].ServiceId;
const campusId = userInfo[0].CampusId;
const [result] = await conn.query(`
SELECT COUNT(DISTINCT dc.Id) as count
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
WHERE dc.Statut = 'En attente'
AND ca.ServiceId = ?
AND ca.CampusId = ?
AND ca.id != ?
`, [serviceId, campusId, userId]);
count = result[0].count;
}
conn.release();
console.log(`â
Nombre de demandes en attente: ${count}`);
res.json({
success: true,
count: count,
role: role
});
} catch (error) {
console.error('â Erreur getPendingRequestsCount:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
// ============================================
// đ BASCULE CP 31 MAI â ROUTE MANUELLE
// ============================================
app.post('/api/admin/basculement-cp-2026', async (req, res) => {
const { secret } = req.body;
if (secret !== 'ENSUP_BASCULE_2026') {
return res.status(403).json({ message: 'Non autorisé' });
}
const conn = await pool.getConnection();
const rapport = [];
try {
await conn.beginTransaction();
// Récupérer le TypeCongeId CP
const [cpType] = await conn.query(
`SELECT TOP 1 Id FROM TypeConge WHERE Nom = 'Congé payé'`
);
if (!cpType.length) {
throw new Error('Type Congé payé introuvable');
}
const cpTypeId = cpType[0].Id;
console.log(`\nđ === BASCULE CP 2026â2027 ===`);
console.log(` TypeCongeId CP: ${cpTypeId}`);
// Récupérer tous les collaborateurs actifs
const [collabs] = await conn.query(`
SELECT id, prenom, nom
FROM CollaborateurAD
WHERE Actif = 1 OR Actif IS NULL
ORDER BY nom, prenom
`);
console.log(` Collaborateurs Ă traiter: ${collabs.length}`);
for (const collab of collabs) {
const userId = collab.id;
const nom = `${collab.prenom} ${collab.nom}`;
// âââââââââââââââââââââââââââââââââââââ
// ĂTAPE A : ClĂŽturer CP 2025
// Les jours restants sont perdus
// âââââââââââââââââââââââââââââââââââââ
const [cpt2025] = await conn.query(`
SELECT Id, Solde
FROM CompteurConges
WHERE CollaborateurADId = ?
AND TypeCongeId = ?
AND Annee = 2025
`, [userId, cpTypeId]);
if (cpt2025.length > 0) {
const solde2025 = parseFloat(cpt2025[0].Solde || 0);
if (solde2025 > 0) {
await conn.query(`
UPDATE CompteurConges
SET Solde = 0,
DerniereMiseAJour = GETDATE()
WHERE Id = ?
`, [cpt2025[0].Id]);
rapport.push({
collaborateur: nom,
etape: 'A - ClĂŽture 2025',
detail: `${solde2025.toFixed(2)}j perdus (non reportables)`
});
console.log(` [A] ${nom}: ${solde2025.toFixed(2)}j perdus (2025)`);
}
}
// âââââââââââââââââââââââââââââââââââââ
// ĂTAPE B : Calculer le report depuis 2026
// Solde 2026 â devient SoldeReporte dans 2027
// Si nĂ©gatif â on reporte 0 (jamais un nĂ©gatif)
// âââââââââââââââââââââââââââââââââââââ
const [cpt2026] = await conn.query(`
SELECT Id, Solde, SoldeReporte, Total
FROM CompteurConges
WHERE CollaborateurADId = ?
AND TypeCongeId = ?
AND Annee = 2026
`, [userId, cpTypeId]);
let montantReport = 0;
if (cpt2026.length > 0) {
const solde2026 = parseFloat(cpt2026[0].Solde || 0);
// Report = solde restant, minimum 0, maximum 25
montantReport = Math.max(0, Math.min(solde2026, 25));
console.log(` [B] ${nom}: Solde 2026=${solde2026.toFixed(2)}j â Report=${montantReport.toFixed(2)}j`);
}
// âââââââââââââââââââââââââââââââââââââ
// ĂTAPE C : Mettre Ă jour le compteur 2027
// 2027 devient le nouveau CP N
// âââââââââââââââââââââââââââââââââââââ
const [cpt2027] = await conn.query(`
SELECT Id, Solde, SoldeReporte, Total
FROM CompteurConges
WHERE CollaborateurADId = ?
AND TypeCongeId = ?
AND Annee = 2027
`, [userId, cpTypeId]);
if (cpt2027.length > 0) {
// Compteur 2027 existe (créé par les anticipés)
const soldeActuel2027 = parseFloat(cpt2027[0].Solde || 0);
const nouveauSolde = parseFloat((soldeActuel2027 + montantReport).toFixed(2));
await conn.query(`
UPDATE CompteurConges
SET Solde = ?,
SoldeReporte = ?,
DerniereMiseAJour = GETDATE()
WHERE Id = ?
`, [nouveauSolde, montantReport, cpt2027[0].Id]);
rapport.push({
collaborateur: nom,
etape: 'C - Bascule 2027',
detail: `SoldeAvant=${soldeActuel2027.toFixed(2)}j + Report=${montantReport.toFixed(2)}j = SoldeApres=${nouveauSolde.toFixed(2)}j`
});
console.log(` [C] ${nom}: 2027 ${soldeActuel2027.toFixed(2)}j + ${montantReport.toFixed(2)}j report = ${nouveauSolde.toFixed(2)}j`);
} else if (montantReport > 0) {
// Compteur 2027 n'existe pas encore â le crĂ©er
const [nextIdRow] = await conn.query(
`SELECT ISNULL(MAX(Id), 0) + 1 AS nextId FROM CompteurConges`
);
const nextId = nextIdRow[0].nextId;
await conn.query(`
INSERT INTO CompteurConges
(Id, CollaborateurADId, TypeCongeId, Annee,
Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, 2027, 0, ?, ?, GETDATE())
`, [nextId, userId, cpTypeId, montantReport, montantReport]);
rapport.push({
collaborateur: nom,
etape: 'C - Création 2027',
detail: `Compteur créé avec Report=${montantReport.toFixed(2)}j`
});
console.log(` [C] ${nom}: Compteur 2027 créé avec ${montantReport.toFixed(2)}j report`);
}
}
await conn.commit();
console.log(`\nâ
Bascule terminée: ${rapport.length} opérations`);
res.json({
success: true,
message: `Bascule CP 2026â2027 effectuĂ©e â ${rapport.length} opĂ©rations`,
total_collaborateurs: collabs.length,
rapport
});
} catch (error) {
await conn.rollback();
console.error('â Erreur bascule CP:', error);
res.status(500).json({
success: false,
message: error.message
});
} finally {
conn.release();
}
});
// ============================================
// đ VĂRIFICATION AVANT ET APRĂS BASCULE
// ============================================
app.get('/api/admin/verif-bascule-cp', async (req, res) => {
try {
const [result] = await pool.query(`
SELECT
ca.prenom + ' ' + ca.nom AS collaborateur,
cc.Annee,
CAST(cc.Total AS DECIMAL(10,2)) AS Total,
CAST(cc.Solde AS DECIMAL(10,2)) AS Solde,
CAST(cc.SoldeReporte AS DECIMAL(10,2)) AS SoldeReporte,
CASE
WHEN cc.Solde < 0 THEN 'NEGATIF'
WHEN cc.Solde = 0 AND cc.Total = 0 THEN 'VIDE'
ELSE 'OK'
END AS etat
FROM CompteurConges cc
JOIN CollaborateurAD ca ON cc.CollaborateurADId = ca.id
JOIN TypeConge tc ON cc.TypeCongeId = tc.Id
WHERE tc.Nom = 'Congé payé'
AND cc.Annee IN (2025, 2026, 2027)
ORDER BY
CASE WHEN cc.Solde < 0 THEN 0 ELSE 1 END,
ca.nom,
cc.Annee
`);
// Statistiques rapides
const negatifs = result.filter(r => r.etat === 'NEGATIF');
const annee2025 = result.filter(r => r.Annee === 2025);
const annee2026 = result.filter(r => r.Annee === 2026);
const annee2027 = result.filter(r => r.Annee === 2027);
res.json({
success: true,
stats: {
total_lignes: result.length,
negatifs: negatifs.length,
annee_2025: annee2025.length,
annee_2026: annee2026.length,
annee_2027: annee2027.length
},
attention: negatifs,
data: result
});
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});
app.get('/test-bascule', (req, res) => {
res.sendFile(path.join(__dirname, 'test-bascule.html'));
});
app.listen(PORT, "0.0.0.0", async () => {
console.log("â
==========================================");
console.log(" SERVEUR PRINCIPAL DĂMARRĂ");
console.log(" Port:", PORT);
console.log(` Base: ${dbConfig.database}@${dbConfig.server}`);
console.log("==========================================");
// ⥠Synchronisation Entra ID au démarrage (aprÚs 5 secondes)
setTimeout(async () => {
console.log("đ Lancement synchronisation Entra ID...");
await syncEntraIdUsers();
}, 5000);
});